DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
hanfuit committed 2026-11-04 14:31:55 +08:00
commit 645b64fe21
4388 files changed
+1405457

No files matched your search

@@ -0,0 +1,219 @@
package adventuregroup
import (
"errors"
"testing"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
func TestRuntimeStateMonthlyResetAccrualPurchaseAndCapsule(t *testing.T) {
config := RuntimeConfig{
ShopCategories: []ShopCategory{
{
Index: ShopPointBrave,
MaxPoint: 250,
ExperiencePerPoint: 15_000_000,
PointPerExperience: 1,
Products: []ShopProduct{{
ItemID: 10, Cost: 1, MonthlyPurchaseLimit: 2,
}},
},
{Index: ShopPointGlory, MaxPoint: 9999, ResetPointMonthly: true},
{Index: ShopPointPure},
},
Capsule: CapsuleConfig{
MinimumExperience: 10,
MaximumExperience: 100,
MaximumCount: 10,
MinimumLevel: 50,
MaximumLevel: 86,
GrantedExperience: 1,
},
}
account := dnfrepo.AccountRecord{
AccountID: "dnf:1",
Metadata: map[string]string{
RuntimeStateMetadataKey: `{"version":1,"shop_points":[7,80,0],"month":"2026-06","purchases":{"0:10":2}}`,
},
}
now := time.Date(2026, time.July, 28, 12, 0, 0, 0, time.FixedZone("UTC+8", 8*60*60))
state, err := ParseRuntimeState(account, config, now)
if err != nil {
t.Fatal(err)
}
if state.ShopPoints != [ShopPointTypeCount]uint32{7, 0, 0} || len(state.Purchases) != 0 {
t.Fatalf("monthly normalized state=%+v", state)
}
// 满级角色的整笔经验:全额进结晶,同样全额(= 越过上限的部分)进胶囊槽。
state.AddExperience(config, 30_000_005, 30_000_005)
// 胶囊总容量 = [capsule max exp] 100 × [capsule max count] 10 = 1000
// (PVF 真值:116,914,950 × 10;界面写"累积数上限 : 10个")。
// 早先这里按"单格 100 就是总上限"写,会把累计值卡死在正好 100%,
// 客户端进度分子(累计值 mod 单格)恒 0 —— 现场就是"胶囊不累计"。
if state.ShopPoints[ShopPointBrave] != 9 || state.BraveExperience != 5 || state.GrowthExperience != 1000 {
t.Fatalf("maximum-level accrual=%+v", state)
}
category, _ := config.Shop(ShopPointBrave)
if err := state.Spend(category, category.Products[0]); err != nil {
t.Fatal(err)
}
if state.ShopPoints[ShopPointBrave] != 8 || state.PurchaseCount(ShopPointBrave, 10) != 1 {
t.Fatalf("purchase state=%+v", state)
}
if !state.ConsumeCapsule(config) || state.GrowthExperience != 990 {
t.Fatalf("capsule state=%+v", state)
}
}
// 未满级经验只进骁勇结晶:胶囊槽与名誉经验都是满级专属(2026-09-24 口径)。
func TestRuntimeStateAddExperienceKeepsSubCapExperienceOutOfCapsule(t *testing.T) {
config := creditTestConfig()
state := RuntimeState{Version: 1}
state.AddExperience(config, 30_000_005, 0)
if state.ShopPoints[ShopPointBrave] != 2 || state.BraveExperience != 5 {
t.Fatalf("sub-cap crystals=%+v", state)
}
if state.GrowthExperience != 0 {
t.Fatalf("sub-cap experience entered the capsule slot: %+v", state)
}
}
// 购买被拒的两种业务原因必须是可区分的错误:客户端 op1414 的结果码要按原因回
// (19 = 超过购买限制、22 = 购买材料不足),回一个笼统失败码客户端什么都不弹。
func TestRuntimeStateSpendDistinguishesLimitAndInsufficientPoints(t *testing.T) {
product := ShopProduct{ItemID: 10, Cost: 5, MonthlyPurchaseLimit: 1}
category := ShopCategory{Index: ShopPointBrave, MaxPoint: 250, Products: []ShopProduct{product}}
state := RuntimeState{Version: 1}
// 点数不足:0 点买 5 点的商品。
if err := state.Spend(category, product); !errors.Is(err, ErrInsufficientShopPoints) {
t.Fatalf("insufficient points error=%v", err)
}
// 给够点数后先买一次成功,再用完本月次数。
state.ShopPoints[ShopPointBrave] = 10
if err := state.Spend(category, product); err != nil {
t.Fatalf("first purchase=%v", err)
}
if err := state.Spend(category, product); !errors.Is(err, ErrPurchaseLimitReached) {
t.Fatalf("limit error=%v", err)
}
// 分类号越界仍然是"状态非法",不是业务拒绝。
bad := ShopCategory{Index: ShopPointTypeCount, Products: []ShopProduct{product}}
if err := state.Spend(bad, product); !errors.Is(err, ErrRuntimeStateInvalid) {
t.Fatalf("invalid category error=%v", err)
}
}
// 成长胶囊槽按周重置:界面写明"成长胶囊于每周四凌晨6点重置"。
// 两条关键契约:① 第一次读到旧存档(没有 capsule_week 字段)**不许清零**;
// ② 跨越周四 06:00 之后才清零。
func TestRuntimeStateGrowthCapsuleResetsWeeklyOnThursdaySix(t *testing.T) {
// 上限取真实 PVF 值(116,914,950 = 10 × [capsule need minimum exp]),
// 免得归一化里的封顶把"是否被周重置"这件事掩盖掉。
config := RuntimeConfig{Capsule: CapsuleConfig{MaximumExperience: 116_914_950}}
shanghai := time.FixedZone("UTC+8", 8*60*60)
// 2026-09-23 是周三。周四 06:00 之前仍属上一周(2026-09-17)。
wednesday := time.Date(2026, time.September, 23, 12, 0, 0, 0, shanghai)
state, err := ParseRuntimeState(
dnfrepo.AccountRecord{AccountID: "dnf:1", Metadata: map[string]string{
RuntimeStateMetadataKey: `{"version":1,"growth_experience":116914950,"month":"2026-09"}`,
}},
config,
wednesday,
)
if err != nil {
t.Fatal(err)
}
// 旧存档首次归一化:认领当前值,绝不清零。
if state.GrowthExperience != 116_914_950 || state.CapsuleWeek != "2026-09-17" {
t.Fatalf("first observation reset the capsule: %+v", state)
}
// 同一周内再读一次:不动。
sameWeek, err := ParseRuntimeState(
dnfrepo.AccountRecord{AccountID: "dnf:1", Metadata: map[string]string{
RuntimeStateMetadataKey: `{"version":1,"growth_experience":500,"capsule_week":"2026-09-17"}`,
}},
config,
time.Date(2026, time.September, 23, 23, 59, 0, 0, shanghai),
)
if err != nil {
t.Fatal(err)
}
if sameWeek.GrowthExperience != 500 {
t.Fatalf("same-week state reset: %+v", sameWeek)
}
// 周四 05:59 仍是上一周;06:00 起进入新的一周 → 清零。
beforeBoundary, err := ParseRuntimeState(
dnfrepo.AccountRecord{AccountID: "dnf:1", Metadata: map[string]string{
RuntimeStateMetadataKey: `{"version":1,"growth_experience":500,"capsule_week":"2026-09-17"}`,
}},
config,
time.Date(2026, time.September, 24, 5, 59, 0, 0, shanghai),
)
if err != nil {
t.Fatal(err)
}
if beforeBoundary.GrowthExperience != 500 {
t.Fatalf("reset happened before Thursday 06:00: %+v", beforeBoundary)
}
afterBoundary, err := ParseRuntimeState(
dnfrepo.AccountRecord{AccountID: "dnf:1", Metadata: map[string]string{
RuntimeStateMetadataKey: `{"version":1,"growth_experience":500,"capsule_week":"2026-09-17"}`,
}},
config,
time.Date(2026, time.September, 24, 6, 0, 0, 0, shanghai),
)
if err != nil {
t.Fatal(err)
}
if afterBoundary.GrowthExperience != 0 || afterBoundary.CapsuleWeek != "2026-09-24" {
t.Fatalf("weekly capsule reset missing: %+v", afterBoundary)
}
// 骁勇结晶一辈子不重置,跨周也不许动。
if afterBoundary.BraveExperience != 0 || afterBoundary.ShopPoints[ShopPointBrave] != 0 {
t.Fatalf("weekly reset touched crystals: %+v", afterBoundary)
}
}
func TestRuntimeConfigExpeditionRewardUsesRotatedPVFAttributes(t *testing.T) {
config := RuntimeConfig{
ExpeditionAreas: []ExpeditionArea{{
Index: 1,
RewardRates: map[uint32]float64{
6 * 60 * 60: 2,
},
DesignatedGroups: []string{"[group A]"},
}},
AttributeIDs: map[string]byte{
"[a]": 1,
},
AttributeGroups: map[string][]string{
"[group a]": {"[a]"},
},
CharacterAttributes: map[[2]byte][]string{
{1, 2}: {"[a]"},
},
AttributeRewardRates: []float64{1, 1.5},
PartyMemberRewardRates: []float64{1, 1, 2, 3, 4},
ExpeditionExtraRate: 0.5,
RotationDays: 1,
}
// Native formula: rate 2 x party rate 1 x attribute bonus 1.5 = 3, rounded,
// then x extra rate 0.5 and truncated -> 1.
reward, ok := config.ExpeditionReward(
1,
6*60*60,
[]ExpeditionMemberInput{{Level: 90, Job: 1, GrowType: 2}},
config.AreaAttributes(1, time.Unix(1_700_000_000, 0)),
)
if !ok || reward != 1 {
t.Fatalf("reward=%d ok=%v", reward, ok)
}
}