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

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

220 lines
7.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}