Files
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

201 lines
8.3 KiB
Go
Raw Permalink 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"
dnfcerashop "longheng.io/server/internal/modules/dnf/cerashop"
dnfenum "longheng.io/server/internal/modules/dnf/dnfenum"
dnfproto "longheng.io/server/internal/modules/dnf/protocol"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
// 现场 2026-09-19 的抱怨:「代币券和欢乐代币券会作为材料放进背包,这很不合理」。
//
// 根因:这两件道具在道具 PVF 里就是普通 `[stackable type] [material]`,
// **没有 `[action type]`,客户端根本没有任何途径把它们变成钱包余额**。
// 所以只要它们被当普通道具发下去,玩家拿到的就是一件永远用不掉的「材料」。
//
// 礼盒(`[booster]`,PVF 里有 40 个盒子会开出欢乐代币券)是主要来源之一。
// 本组测试守住:礼盒开出的券**直接进账号钱包、不占背包格**,并且当场推给客户端。
// voucherTestSource 在共用礼盒夹具上挂一个会开出两种券的盒子(道具 512)。
func voucherTestSource() dungeonDropCatalogTestSource {
source := currentBoosterTestSource()
source["stackable/stackable.lst"] += "512 `cash/voucher_box.stk`\n" +
"2681762 `cash/chn_Trade_Gold/trade_point.stk`\n" +
"2681917 `cash/chn_Trade_Gold/trade_point_event.stk`\n"
// 与真机 PVF 一致:两件券都是普通 [material],且**没有** [action type]。
// 每个奖励行单独一个 section —— 真机 booster PVF(contracts.stk 里连续的多个
// [cera])就是这么写的;多行塞进同一个 [etc] 只会解析出一行。
source["stackable/cash/voucher_box.stk"] = "[stackable type]\n`[booster]` 0\n[stack limit]\n1\n" +
"[booster info]\n[etc]\n1 2681762 10000 100\n[/etc]\n[etc]\n1 2681917 10000 100\n[/etc]\n[/booster info]\n"
source["stackable/cash/chn_Trade_Gold/trade_point.stk"] =
"[name]\n`代币券`\n[stackable type]\n`[material]`\n[stack limit]\n1000000000\n[attach type]\n`[free]`\n"
source["stackable/cash/chn_Trade_Gold/trade_point_event.stk"] =
"[name]\n`欢乐代币券`\n[stackable type]\n`[material]`\n[attach type]\n`[free]`\n"
return source
}
// currentVoucherStreamPackets 把礼盒开启写出的整条流拆成包,供"这一包在不在"这类断言用。
func currentVoucherStreamPackets(t *testing.T, data []byte) []dnfproto.ChannelPacket {
t.Helper()
packets := make([]dnfproto.ChannelPacket, 0, 4)
for len(data) > 0 {
var packet dnfproto.ChannelPacket
packet, data = splitGameServerUpperPacket(t, data)
packets = append(packets, packet)
}
return packets
}
func TestCurrentBoosterVoucherRewardCreditsWalletInsteadOfBackpack(t *testing.T) {
catalog, err := newPVFDungeonDropCatalog(voucherTestSource())
if err != nil {
t.Fatal(err)
}
repositories := dnfrepomemory.NewMemoryGroup()
ctx := context.Background()
if err := repositories.Account.Save(ctx, dnfrepo.AccountRecord{
AccountID: "account-1",
Metadata: map[string]string{"account_cera": "700", "account_token_cera": "5"},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "19", AccountID: "account-1"}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "19",
Slots: map[string]dnfrepo.ItemStack{"0:76": {ItemID: 512, Count: 1}},
}); err != nil {
t.Fatal(err)
}
connection := &bufferConn{}
service := currentBoosterTestService(catalog, repositories)
session := &gameSession{conn: connection, connID: "booster-voucher-test", selectedCharacterID: 19}
if err := service.handleGameCommand(
session,
byte(dnfenum.GameCmdCommand),
uint16(dnfenum.CmdPacketUseBoosterItem),
[]byte{76, 0},
); err != nil {
t.Fatal(err)
}
// 1) 券进了账号钱包:代币券 5+100、欢乐代币券 0+100,点券不受影响。
account, found, err := repositories.Account.Load(ctx, "account-1")
if err != nil || !found {
t.Fatalf("load account found=%t err=%v", found, err)
}
wallet := dnfcerashop.WalletBalance(account)
if wallet.Cera != 700 || wallet.TokenCera != 105 || wallet.HappyTokenCera != 100 {
t.Fatalf("钱包=%+v want cera=700 token=105 happy=100", wallet)
}
// 2) 券**没有**落进背包(这正是现场的抱怨)。
inventory, found, err := repositories.Inventory.Load(ctx, "19")
if err != nil || !found {
t.Fatalf("load inventory found=%t err=%v", found, err)
}
for key, stack := range inventory.Slots {
for _, voucherID := range dnfcerashop.VoucherItemIDs() {
if stack.ItemID == voucherID {
t.Fatalf("券漏进背包 key=%s stack=%+v", key, stack)
}
}
}
// 3) 源盒子照常被消耗(不能因为改了发奖路径就白送)。
if stack, stillThere := inventory.Slots["0:76"]; stillThere {
t.Fatalf("源盒子没被消耗: %+v", stack)
}
// 4) 新钱包值当场推给客户端(不推的话玩家看不到到账)。
// 礼盒开启本来还会发道具列表等若干包,所以扫描整条流找钱包通知,
// 而不是假设它是第一个包。
var walletBody []byte
for _, packet := range currentVoucherStreamPackets(t, connection.write.Bytes()) {
if packet.Header.Classification != 0 || packet.Header.MsgID != currentCeraShopWalletMsgID {
continue
}
walletBody = packet.Body
if len(walletBody) > 3 && walletBody[0] == 0 && walletBody[1] == 0 && walletBody[2] == 0 {
walletBody = walletBody[3:]
}
break
}
if walletBody == nil {
t.Fatal("礼盒开出券之后没有推送钱包,玩家看不到到账")
}
if len(walletBody) != 13 || int32(binary.LittleEndian.Uint32(walletBody[1:5])) != 700 ||
int32(binary.LittleEndian.Uint32(walletBody[5:9])) != 105 ||
int32(binary.LittleEndian.Uint32(walletBody[9:13])) != 100 {
t.Fatalf("钱包通知体=%x want 700/105/100", walletBody)
}
}
// 钱包已经到上限时,整笔开启必须失败(盒子不能被消耗掉白扣),
// 而不是静默截断——客户端把这三个字段按 i32 读,溢出会显示成负数。
func TestCurrentBoosterVoucherRewardRefusesWalletOverflow(t *testing.T) {
catalog, err := newPVFDungeonDropCatalog(voucherTestSource())
if err != nil {
t.Fatal(err)
}
repositories := dnfrepomemory.NewMemoryGroup()
ctx := context.Background()
if err := repositories.Account.Save(ctx, dnfrepo.AccountRecord{
AccountID: "account-1",
Metadata: map[string]string{"account_token_cera": "2147483647"},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "19", AccountID: "account-1"}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "19",
Slots: map[string]dnfrepo.ItemStack{"0:76": {ItemID: 512, Count: 1}},
}); err != nil {
t.Fatal(err)
}
service := currentBoosterTestService(catalog, repositories)
connection := &bufferConn{}
session := &gameSession{conn: connection, connID: "booster-voucher-overflow", selectedCharacterID: 19}
// handler 会把事务错误转成"失败 ACK"发回客户端并返回 nil(它既有的契约),
// 所以这里断言的是**效果**而不是返回值。
if err := service.handleGameCommand(
session,
byte(dnfenum.GameCmdCommand),
uint16(dnfenum.CmdPacketUseBoosterItem),
[]byte{76, 0},
); err != nil {
t.Fatalf("handler 不该把事务错误往上抛: %v", err)
}
// 客户端绝不能收到"钱包已到账"的通知(那会让它显示一个溢出的值)。
for _, packet := range currentVoucherStreamPackets(t, connection.write.Bytes()) {
if packet.Header.Classification == 0 && packet.Header.MsgID == currentCeraShopWalletMsgID {
t.Fatalf("溢出失败时不该推送钱包: %x", packet.Body)
}
}
// 盒子还在(没有白扣)。
inventory, _, err := repositories.Inventory.Load(ctx, "19")
if err != nil {
t.Fatal(err)
}
if stack, ok := inventory.Slots["0:76"]; !ok || stack.Count != 1 {
t.Fatalf("溢出失败后盒子应原样保留: %+v ok=%t", stack, ok)
}
// 全有或全无:代币券溢出时,欢乐代币券也不能被写进去。
account, _, err := repositories.Account.Load(ctx, "account-1")
if err != nil {
t.Fatal(err)
}
if wallet := dnfcerashop.WalletBalance(account); wallet.HappyTokenCera != 0 || wallet.TokenCera != 2147483647 {
t.Fatalf("溢出失败后钱包不该被改动: %+v", wallet)
}
}