服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
220 lines
7.9 KiB
Go
220 lines
7.9 KiB
Go
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)
|
||
}
|
||
}
|