Files
90CNServer/server/internal/services/dnfbridge/item_cooltime_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

331 lines
13 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 dnfbridge
import (
"context"
"encoding/binary"
"testing"
"time"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfitemcooldown "longheng.io/server/internal/modules/dnf/itemcooldown"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
// 冷却道具的测试夹具:512 = 首日度假礼盒(`[cool time] 86400000` = 1 天、
// `[cooltime group] 66`、`[cooltime maintenance] 1 1 1`),513 = 同组 66 的另一件礼盒,
// 514 = 只有 `[cool time] 10000` 而没有 maintenance 的对照组。
//
// 现场 2026-09-17 运营报"冷却时间为1天的消耗品 小退重上 冷却时间就消失了":客户端的冷却门
// 只是进程内内存,重上之后它会照常发 op160,服务端必须自己拦住。
func currentBoosterCooltimeTestSource() dungeonDropCatalogTestSource {
source := currentBoosterTestSource()
source["stackable/stackable.lst"] += "512 `cash/daily_box.stk`\n513 `cash/daily_box_next.stk`\n514 `cash/local_cooltime_box.stk`\n"
source["stackable/cash/daily_box.stk"] = "[stackable type]\n`[booster]` 0\n[stack limit]\n5\n" +
"[cool time]\n86400000\n[cooltime group]\n66\n[cooltime maintenance]\n1 1 1\n" +
"[booster info]\n[etc]\n1 600 10000 1\n[/etc]\n[/booster info]\n"
source["stackable/cash/daily_box_next.stk"] = "[stackable type]\n`[booster]` 0\n[stack limit]\n5\n" +
"[cool time]\n86400000\n[cooltime group]\n66\n[cooltime maintenance]\n1 1 1\n" +
"[booster info]\n[etc]\n1 600 10000 1\n[/etc]\n[/booster info]\n"
source["stackable/cash/local_cooltime_box.stk"] = "[stackable type]\n`[booster]` 0\n[stack limit]\n5\n" +
"[cool time]\n10000\n[cooltime group]\n5\n" +
"[booster info]\n[etc]\n1 600 10000 1\n[/etc]\n[/booster info]\n"
return source
}
func mustCurrentBoosterCooltimeCatalog(t *testing.T) *pvfDungeonDropCatalog {
t.Helper()
catalog, err := newPVFDungeonDropCatalog(currentBoosterCooltimeTestSource())
if err != nil {
t.Fatal(err)
}
return catalog
}
// 一次带冷却的开启必须在角色身上落下持久窗口,并且重上之后(客户端会照常再发一次)被拦住。
func TestCurrentBoosterCooltimeRefusesReuseAfterRelog(t *testing.T) {
ctx := context.Background()
catalog := mustCurrentBoosterCooltimeCatalog(t)
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "19", AccountID: "account-1", Level: 90,
Stats: map[string]int64{},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "19",
Slots: map[string]dnfrepo.ItemStack{
"0:80": {ItemID: 512, Count: 3},
"0:81": {ItemID: 513, Count: 1},
},
}); err != nil {
t.Fatal(err)
}
connection := &bufferConn{}
service := currentBoosterTestService(catalog, repositories)
session := &gameSession{conn: connection, connID: "booster-cooltime", selectedCharacterID: 19}
open := func(slot byte) (byte, []byte) {
t.Helper()
connection.write.Reset()
if err := service.handleGameCommand(
session,
byte(dnfenum.GameCmdCommand),
uint16(dnfenum.CmdPacketUseBoosterItem),
[]byte{slot, 0},
); err != nil {
t.Fatal(err)
}
packet, _ := splitGameServerUpperPacket(t, connection.write.Bytes())
if packet.Header.MsgID != uint16(dnfenum.CmdPacketUseBoosterItem) || len(packet.Body) == 0 {
t.Fatalf("booster open header=%+v body=%x", packet.Header, packet.Body)
}
return packet.Body[0], packet.Body
}
// ① 第一次开启成功,并落下一份 1 天的持久冷却。
if ok, body := open(80); ok != 1 {
t.Fatalf("first booster open body=%x", body)
}
character, _, err := repositories.Character.Load(ctx, "19")
if err != nil {
t.Fatal(err)
}
expireAt, found := dnfitemcooldown.ExpireAt(character.Stats, 512)
if !found {
t.Fatalf("no durable cooltime written: %+v", character.Stats)
}
if delta := expireAt - time.Now().Unix(); delta < 86_300 || delta > 86_400 {
t.Fatalf("cooltime window = %d s, want one day", delta)
}
inventory, _, _ := repositories.Inventory.Load(ctx, "19")
if got := inventory.Slots["0:80"].Count; got != 2 {
t.Fatalf("first open did not consume one: %+v", inventory.Slots["0:80"])
}
// ② 小退重上之后客户端照常再发一次(它的冷却记忆没了):必须被服务端拦住,
// 而且源道具一个都不能少。
if ok, body := open(80); ok != 0 {
t.Fatalf("reuse after relog was not refused: body=%x", body)
}
inventory, _, _ = repositories.Inventory.Load(ctx, "19")
if got := inventory.Slots["0:80"].Count; got != 2 {
t.Fatalf("refused reuse consumed the source: %+v", inventory.Slots["0:80"])
}
// ③ 同 [cooltime group] 66 的另一件礼盒共用同一个冷却窗口。
if ok, body := open(81); ok != 0 {
t.Fatalf("group sibling was not refused: body=%x", body)
}
inventory, _, _ = repositories.Inventory.Load(ctx, "19")
if got := inventory.Slots["0:81"].Count; got != 1 {
t.Fatalf("refused group sibling consumed the source: %+v", inventory.Slots["0:81"])
}
// ④ 冷却走完之后同一件道具可以再用(把到期时间挪到过去,模拟一天之后)。
character, _, err = repositories.Character.Load(ctx, "19")
if err != nil {
t.Fatal(err)
}
character.Stats[dnfitemcooldown.StatKey(512)] = time.Now().Unix() - 1
if err := repositories.Character.Save(ctx, character); err != nil {
t.Fatal(err)
}
if ok, body := open(80); ok != 1 {
t.Fatalf("expired cooltime still blocked the item: body=%x", body)
}
inventory, _, _ = repositories.Inventory.Load(ctx, "19")
if got := inventory.Slots["0:80"].Count; got != 1 {
t.Fatalf("expired cooltime did not allow the use: %+v", inventory.Slots["0:80"])
}
}
// op44 的冷却契约完全来自 PVF:`[cool time]`(毫秒)+ `[cooltime group]` +
// `[cooltime maintenance]`。这里验证解析器真的把这三项交给了 owner,并且
// [cooltime group] 解析器能回答**已经在冷却表里的**道具(同组共享窗口必须先知道
// 上一次那件道具属于哪一组)。
func TestAlignedStackableUseResolverReportsPVFCooltimeContract(t *testing.T) {
source := dungeonDropCatalogTestSource{
"monster/monster.lst": "",
"equipment/equipment.lst": "",
"stackable/stackable.lst": "70001 `cash/daily_box.stk`\n70002 `cash/daily_box_next.stk`\n" +
"70003 `cash/local_cooltime_box.stk`\n70004 `cash/zero_maintenance_box.stk`\n",
"stackable/cash/daily_box.stk": "[stackable type] `[waste]`\n[cool time]\n86400000\n" +
"[cooltime group]\n66\n[cooltime maintenance]\n1 1 1\n",
"stackable/cash/daily_box_next.stk": "[stackable type] `[waste]`\n[cool time]\n86400000\n" +
"[cooltime group]\n66\n[cooltime maintenance]\n1 1 1\n",
// 只有 [cool time]:红蓝药那类纯客户端冷却,服务端不记也不拦。
"stackable/cash/local_cooltime_box.stk": "[stackable type] `[waste]`\n[cool time]\n10000\n[cooltime group]\n5\n",
// [cooltime maintenance] 段存在但值全 0 = 不跨会话保留。
"stackable/cash/zero_maintenance_box.stk": "[stackable type] `[waste]`\n[cool time]\n60000\n" +
"[cooltime group]\n7\n[cooltime maintenance]\n0 0 0\n",
}
catalog, err := newPVFDungeonDropCatalog(source)
if err != nil {
t.Fatal(err)
}
service := &Service{pvfItemCatalog: catalog}
resolver, err := service.alignedStackableUseResolverForCommand(dnfenum.CmdPacketUseStackable)
if err != nil {
t.Fatal(err)
}
maintained, err := resolver(70001)
if err != nil {
t.Fatal(err)
}
if !maintained.MaintainsCooltime || maintained.CooltimeSeconds != 86_400 || maintained.CooltimeGroup != 66 {
t.Fatalf("1 天冷却的消耗品解析=%+v", maintained)
}
local, err := resolver(70003)
if err != nil {
t.Fatal(err)
}
if local.MaintainsCooltime || local.CooltimeSeconds != 0 || local.CooltimeGroup != 0 {
t.Fatalf("无 [cooltime maintenance] 的道具解析=%+v", local)
}
zero, err := resolver(70004)
if err != nil {
t.Fatal(err)
}
if zero.MaintainsCooltime {
t.Fatalf("[cooltime maintenance] 全 0 的道具解析=%+v", zero)
}
// 同组解析:冷却表里的 70001 必须被认成 66 组,70003/70004 不参与共享。
if got := service.currentPVFItemCooltimeGroup(70001); got != 66 {
t.Fatalf("已冷却道具的 [cooltime group]=%d want=66", got)
}
if got := service.currentPVFItemCooltimeGroup(70002); got != 66 {
t.Fatalf("同组第二件道具的 [cooltime group]=%d want=66", got)
}
if got := service.currentPVFItemCooltimeGroup(70003); got != 0 {
t.Fatalf("无 maintenance 的道具不应参与共享: group=%d", got)
}
if got := service.currentPVFItemCooltimeGroup(70004); got != 0 {
t.Fatalf("maintenance 全 0 的道具不应参与共享: group=%d", got)
}
}
// 选角阶段必须把仍在冷却的道具重放给客户端(类 0 通知 172),否则 1 天冷却的礼盒重上后
// 图标就是"可用",玩家只会收到一次静默失败。
func TestSendCurrentItemCooltimeProjectionRestoresActiveWindows(t *testing.T) {
ctx := context.Background()
now := time.Now().UTC()
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "19", AccountID: "account-1", Level: 90,
Stats: map[string]int64{
dnfitemcooldown.StatKey(490702504): now.Add(3 * time.Hour).Unix(),
dnfitemcooldown.StatKey(490702505): now.Add(-time.Minute).Unix(),
},
}); err != nil {
t.Fatal(err)
}
connection := &bufferConn{}
service := &Service{
options: options{
accountID: "account-1",
gameUpperHeader: gameUpperHeaderServer16,
gameUpperBodyCodec: gameUpperBodyCodecPlain,
},
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
session := &gameSession{
conn: connection, connID: "cooltime-restore",
selectedCharacterID: 19,
}
if err := service.sendCurrentItemCooltimeProjection(session, "unit_test"); err != nil {
t.Fatal(err)
}
packet, trailing := splitGameServerUpperPacket(t, connection.write.Bytes())
if len(trailing) != 0 || packet.Header.Classification != 0 ||
packet.Header.MsgID != currentLoadCooltimeItemInfoMsgID {
t.Fatalf("cooltime restore header=%+v trailing=%x", packet.Header, trailing)
}
// server16 头模式会在包体前带固定的 3 个 0 字节;通知体本身是最后 1+8 字节。
body := packet.Body
if len(body) == 12 && body[0] == 0 && body[1] == 0 && body[2] == 0 {
body = body[3:]
}
if len(body) != 9 || body[0] != 1 {
t.Fatalf("cooltime restore body=%x", packet.Body)
}
if itemID := binary.LittleEndian.Uint32(body[1:5]); itemID != 490702504 {
t.Fatalf("cooltime restore item=%d", itemID)
}
if remaining := binary.LittleEndian.Uint32(body[5:9]); remaining < 10_700 || remaining > 10_800 {
t.Fatalf("cooltime restore remaining=%d s, want ~3h", remaining)
}
// 过期的那一行顺手清掉,不留垃圾 stat。
character, _, err := repositories.Character.Load(ctx, "19")
if err != nil {
t.Fatal(err)
}
if _, found := character.Stats[dnfitemcooldown.StatKey(490702505)]; found {
t.Fatalf("expired cooltime row survived: %+v", character.Stats)
}
// 每个选中角色只重放一次。
connection.write.Reset()
if err := service.sendCurrentItemCooltimeProjection(session, "unit_test_duplicate"); err != nil {
t.Fatal(err)
}
if connection.write.Len() != 0 {
t.Fatalf("duplicate cooltime restore sent %x", connection.write.Bytes())
}
}
// 没有 [cooltime maintenance] 的道具(例如 10 秒冷却的红蓝药)保持纯客户端语义:
// 服务端既不允许被拦,也不落 stat。
func TestCurrentBoosterCooltimeIgnoresItemsWithoutMaintenance(t *testing.T) {
ctx := context.Background()
catalog := mustCurrentBoosterCooltimeCatalog(t)
definition, err := catalog.ResolveItem(514)
if err != nil {
t.Fatal(err)
}
if definition.HasCooltimeMaintenance {
t.Fatalf("item 514 must not carry [cooltime maintenance]: %+v", definition)
}
if definition.CooltimeMS != 10_000 || definition.CooltimeGroup != 5 {
t.Fatalf("item 514 cooltime fields = %d/%d", definition.CooltimeMS, definition.CooltimeGroup)
}
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "19", AccountID: "account-1", Level: 90, Stats: map[string]int64{},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "19",
Slots: map[string]dnfrepo.ItemStack{"0:80": {ItemID: 514, Count: 3}},
}); err != nil {
t.Fatal(err)
}
connection := &bufferConn{}
service := currentBoosterTestService(catalog, repositories)
session := &gameSession{conn: connection, connID: "booster-local-cooltime", selectedCharacterID: 19}
for attempt := range 2 {
connection.write.Reset()
if err := service.handleGameCommand(
session,
byte(dnfenum.GameCmdCommand),
uint16(dnfenum.CmdPacketUseBoosterItem),
[]byte{80, 0},
); err != nil {
t.Fatal(err)
}
packet, _ := splitGameServerUpperPacket(t, connection.write.Bytes())
if len(packet.Body) == 0 || packet.Body[0] != 1 {
t.Fatalf("attempt %d refused a client-local cooltime item: body=%x", attempt, packet.Body)
}
}
character, _, err := repositories.Character.Load(ctx, "19")
if err != nil {
t.Fatal(err)
}
if entries := dnfitemcooldown.Entries(character.Stats); len(entries) != 0 {
t.Fatalf("a client-local cooldown was persisted: %+v", entries)
}
}