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,753 @@
package dnfbridge
import (
"bytes"
"context"
"os"
"testing"
dnfproto "longheng.io/server/internal/modules/dnf/protocol"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
func TestParseCurrentPVFHotkeyVer2KeepsIdentifiersAndPhysicalKeys(t *testing.T) {
text := "[key] `move up` `I01` `system` `creator` 87\n" +
"[key] `move left` `I02` `system` `creator` 65\n" +
"[key] `change skill bar` `K00` `skill` `creator` 67\n"
entries, err := parseCurrentPVFHotkeyVer2Entries(text)
if err != nil {
t.Fatal(err)
}
if len(entries) != 3 || entries[0].identifier != "I01" || entries[0].externalKey != 87 ||
entries[1].identifier != "I02" || entries[1].externalKey != 65 ||
entries[2].identifier != "K00" || entries[2].externalKey != 67 {
t.Fatalf("creator ver2 entries=%+v", entries)
}
}
func TestBuildCurrentCreatorHotkeyProfileUsesWASDAndDigitSkills(t *testing.T) {
normal := []currentPVFHotkeyEntry{
{identifier: "I01", externalKey: 38},
{identifier: "I02", externalKey: 37},
{identifier: "I03", externalKey: 40},
{identifier: "I04", externalKey: 39},
{identifier: "I05", externalKey: 32},
{identifier: "I28", externalKey: 87},
{identifier: "I06", externalKey: 67},
{identifier: "I21", externalKey: 65},
{identifier: "I22", externalKey: 83},
{identifier: "I23", externalKey: 68},
{identifier: "I33", externalKey: 49},
{identifier: "I34", externalKey: 50},
{identifier: "I35", externalKey: 51},
{identifier: "I36", externalKey: 52},
{identifier: "I37", externalKey: 53},
{identifier: "K02", externalKey: 9},
}
creator := []currentPVFHotkeyEntry{
{identifier: "I01", externalKey: 87},
{identifier: "I02", externalKey: 65},
{identifier: "I03", externalKey: 83},
{identifier: "I04", externalKey: 68},
{identifier: "I05", externalKey: 32},
{identifier: "I21", externalKey: 81},
{identifier: "I22", externalKey: 69},
{identifier: "I23", externalKey: 82},
{identifier: "I24", externalKey: 70},
{identifier: "I25", externalKey: 71},
{identifier: "I33", externalKey: 49},
{identifier: "I34", externalKey: 50},
{identifier: "I35", externalKey: 51},
{identifier: "I36", externalKey: 52},
{identifier: "I37", externalKey: 53},
{identifier: "K02", externalKey: 9},
}
profile, err := buildCurrentCreatorHotkeyProfile(normal, creator, csharpDefaultHotkeySlots)
if err != nil {
t.Fatal(err)
}
want := map[string]uint16{
"I01": 77,
"I02": 55,
"I03": 73,
"I04": 58,
"I05": 78,
"I21": 46,
"I22": 47,
"I23": 48,
"I24": 49,
"I25": 50,
}
for identifier, wantKey := range want {
got, ok := currentDefaultInternalHotkey(profile.slots, identifier)
if !ok || got != wantKey {
t.Fatalf("creator %s=%d ok=%t, want %d", identifier, got, ok, wantKey)
}
}
for _, row := range [][]byte{
[]byte("I01\t77\n"), []byte("I02\t55\n"), []byte("I03\t73\n"), []byte("I04\t58\n"), []byte("I05\t78\n"),
[]byte("I21\t46\n"), []byte("I22\t47\n"), []byte("I23\t48\n"), []byte("I24\t49\n"), []byte("I25\t50\n"),
} {
if !bytes.Contains(profile.keymap, row) {
t.Fatalf("creator keymap missing %q in %q", row, profile.keymap)
}
}
}
func TestCurrentCharacterHotkeySlotsReadsCreatorRealPVF(t *testing.T) {
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("DNFBRIDGE_REAL_PVF_SMOKE is not set")
}
service := &Service{options: options{pvfPath: pvfPath}}
slots, keyType, source, err := service.currentCharacterHotkeySlots(context.Background(), csharpCreatorMageJob)
if err != nil {
t.Fatal(err)
}
if keyType != 1 || source != "pvf_clientonly_hotkeysystemforcreator_ver2_indexed" || bytes.Equal(slots, csharpDefaultHotkeySlots) {
t.Fatalf("creator hotkeys type=%d source=%q slots=%d", keyType, source, len(slots)/2)
}
want := map[string]uint16{
"I01": 77,
"I02": 55,
"I03": 73,
"I04": 58,
"I05": 78,
"I21": 46,
"I22": 47,
"I23": 48,
"I24": 49,
"I25": 50,
}
for identifier, wantKey := range want {
got, ok := currentDefaultInternalHotkey(slots, identifier)
if !ok || got != wantKey {
t.Fatalf("creator real PVF %s=%d ok=%t, want %d", identifier, got, ok, wantKey)
}
}
}
// 缔造者的 keymap 分两种情况,两者都必须成立:
// 1. 玩家在游戏里改过技能快捷键(存进角色作用域的是一份**完整的缔造者表**)
// → 登录必须下发**玩家改过的那份**。活体 2026-09-15:库里
// `character:13:game_option_keymap` = I21=30/I22=25,而登录时被 PVF 默认表
// (I21..I25 = 46..50)覆盖,玩家看到"小退重上恢复原样"。
// 2. 教学期回显进来的临时普通 ASDFG 表(缺 I21..I25 这些缔造者行)
// → 继续下发 PVF 表,绝不能让那份快照变成权威。
func TestCurrentGameOptionKeymapForCreatorHonoursAStoredCreatorRebind(t *testing.T) {
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("DNFBRIDGE_REAL_PVF_SMOKE is not set")
}
ctx := context.Background()
// 玩家改过的绑定:I21 指到内部键 30、I22 指到 25(就是库里那份)。
// I01..I05 必须保持缔造者那组(77/55/73/58/78)—— 玩家改技能键不会动它们。
playerRebind := []byte("I01\t77\nI02\t55\nI03\t73\nI04\t58\nI05\t78\n" +
"I21\t30\nI22\t25\nI23\t48\nI24\t49\nI25\t50\n")
t.Run("player rebind wins over the pvf default", func(t *testing.T) {
repositories := testRepositoryGroup()
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "13",
AccountID: "account-a",
Job: "10",
}); err != nil {
t.Fatal(err)
}
if err := saveCurrentGameOptionKeymapToScope(
ctx,
repositories.Settings,
currentCharacterGameOptionKeymapScope("13"),
playerRebind,
); err != nil {
t.Fatal(err)
}
service := &Service{options: options{pvfPath: pvfPath, accountID: "account-a"}}
session := &gameSession{accountID: "account-a", selectedCharacterID: 13}
keymap, source, err := service.currentGameOptionKeymapForSelectedCharacter(
ctx, repositories, session, []byte("I01\t2\nI21\t40\n"),
)
if err != nil {
t.Fatal(err)
}
if source != "character_scoped_creator_type198_keymap" {
t.Fatalf("creator keymap source=%q want the stored character-scoped rebind", source)
}
if !bytes.Equal(keymap, playerRebind) {
t.Fatalf("creator keymap=%q want the player's own rebind %q", keymap, playerRebind)
}
})
t.Run("tutorial echo still falls back to the pvf table", func(t *testing.T) {
repositories := testRepositoryGroup()
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "13",
AccountID: "account-a",
Job: "10",
}); err != nil {
t.Fatal(err)
}
// 直进教学副本的缔造者会把这个临时普通表回显进 op198:没有 I21..I25。
if err := saveCurrentGameOptionKeymapToScope(
ctx,
repositories.Settings,
currentCharacterGameOptionKeymapScope("13"),
[]byte("I01\t2\nI02\t0\n"),
); err != nil {
t.Fatal(err)
}
service := &Service{options: options{pvfPath: pvfPath, accountID: "account-a"}}
session := &gameSession{accountID: "account-a", selectedCharacterID: 13}
keymap, source, err := service.currentGameOptionKeymapForSelectedCharacter(
ctx, repositories, session, []byte("I01\t2\nI21\t40\n"),
)
if err != nil {
t.Fatal(err)
}
if source != "pvf_creator_ver2_wasd_space_digit_skill_keymap_locked" {
t.Fatalf("creator keymap source=%q want the pvf profile for a tutorial echo", source)
}
for _, row := range [][]byte{
[]byte("I21\t46\n"), []byte("I22\t47\n"), []byte("I23\t48\n"), []byte("I24\t49\n"), []byte("I25\t50\n"),
} {
if !bytes.Contains(keymap, row) {
t.Fatalf("creator keymap missing %q in %q", row, keymap)
}
}
})
}
// validStoredCreatorKeymap 只认形状:必须同时有 I01(普攻)和 I21..I25(五个技能选择键)。
// 这份判定决定了"玩家自己的绑定"和"教学期临时表"谁有权威地位,所以逐条钉住。
//
// 注意:光有形状还不够 —— 线上那张客户端"恢复默认"的普通表形状也是完整的
// (I01..I25 全都有),所以还要再过 keymapHasCreatorInputModel(I01..I05 是不是
// 缔造者那组 77/55/73/58/78)。本用例只钉形状这一层。
func TestValidStoredCreatorKeymapOnlyAcceptsACompleteCreatorTable(t *testing.T) {
cases := []struct {
name string
keymap string
want bool
}{
{"player rebind", "I01\t77\nI21\t30\nI22\t25\nI23\t48\nI24\t49\nI25\t50\n", true},
{"full table with extras", "I01\t77\nI02\t55\nI21\t46\nI22\t47\nI23\t48\nI24\t49\nI25\t50\nK02\t31\n", true},
{"crlf rows", "I01\t77\r\nI21\t30\r\nI22\t25\r\nI23\t48\r\nI24\t49\r\nI25\t50\r\n", true},
{"tutorial echo (no skill selectors)", "I01\t2\nI02\t0\n", false},
{"missing attack row", "I21\t30\nI22\t25\nI23\t48\nI24\t49\nI25\t50\n", false},
{"missing one selector", "I01\t77\nI21\t30\nI22\t25\nI23\t48\nI24\t49\n", false},
{"empty", "", false},
{"whitespace only", "\n\n", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := validStoredCreatorKeymap([]byte(tc.keymap)); got != tc.want {
t.Fatalf("validStoredCreatorKeymap = %t want %t for %q", got, tc.want, tc.keymap)
}
})
}
}
// 玩家在游戏里点"恢复默认按键"时,客户端会把**普通角色的**键位表原样 op198 发上来。
// 对缔造者那份表是错的输入模型(技能键落到普通角色的位置上),既不能存、也不能当成
// "玩家自己的绑定"在下次登录发下去。
//
// 判据是"与账号作用域的普通表逐行相同"。下面这段正文就是线上库里真实存下来的那张表
// (2026-09-15,角色 13 的角色作用域 = 该账号的账号作用域表,客户端只发了前 150 字节)。
const currentCreatorOrdinaryDefaultKeymapFixture = "I01\t2\nI02\t0\nI03\t3\nI04\t1\nI05\t78\n" +
"I06\t57\nI07\t80\nI08\t13\nI09\t76\nI10\t67\nI11\t65\nI12\t63\nI13\t69\nI14\t82\nI15\t75\n" +
"I16\t85\nI17\t68\nI18\t86\nI19\t84\nI20\t81\n" +
"I21\t55\nI22\t73\n"
// 同一账号里的普通表更长(后面还有 I23..I25 等行):截断副本必须照样被认出来。
const currentOrdinaryAccountKeymapFixture = currentCreatorOrdinaryDefaultKeymapFixture +
"I23\t58\nI24\t60\nI25\t61\nI26\t62\nI27\t71\n"
// 判据分两层:①"客户端默认指纹"(移动组 2/0/3/1 + 跳跃 57);②与账号作用域表逐行相同。
// 老数据里存下来的那张 150 字节截断副本满足 ②,线上完整那张两层都满足。
func TestCurrentCreatorSaveIsOrdinaryKeymapDefaultRecognisesTheResetButton(t *testing.T) {
playerRebind := "I01\t77\nI02\t55\nI03\t73\nI04\t58\nI05\t78\n" +
"I21\t30\nI22\t25\nI23\t48\nI24\t49\nI25\t50\n"
cases := []struct {
name string
keymap string
account string
want bool
}{
{
name: "client restore-default (real live row, truncated copy)",
keymap: currentCreatorOrdinaryDefaultKeymapFixture,
account: currentOrdinaryAccountKeymapFixture,
want: true,
},
{
name: "same table without truncation",
keymap: currentOrdinaryAccountKeymapFixture,
account: currentOrdinaryAccountKeymapFixture,
want: true,
},
{
name: "player rebind is neither the fingerprint nor the account table",
keymap: playerRebind,
account: currentOrdinaryAccountKeymapFixture,
want: false,
},
{
name: "intentionally unbound is not a default request",
keymap: "I01\t134\nI02\t134\nI03\t134\nI04\t134\nI06\t134\n",
account: currentOrdinaryAccountKeymapFixture,
want: false,
},
{
name: "empty save",
keymap: "",
account: currentOrdinaryAccountKeymapFixture,
want: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := currentCreatorSaveIsClientDefaultTable([]byte(tc.keymap)) ||
(len(tc.account) != 0 && keymapBodyMatchesPrefix([]byte(tc.keymap), []byte(tc.account)))
if got != tc.want {
t.Fatalf("default-request detection = %t want %t", got, tc.want)
}
})
}
}
// 真正决定"这份表能不能给缔造者用"的判据:I01..I05 是不是缔造者那组
// (77/55/73/58/78)。玩家改技能键只动 I21..I25,依然满足;客户端"恢复默认"给的普通表
// (2/0/3/1/78)怎么都不满足。用线上真实两张表钉住。
func TestKeymapHasCreatorInputModelUsesTheAttackRowsAsFingerprint(t *testing.T) {
// 职业默认表(PVF 缔造者表)里的 I01..I05 就是那五个值。
profile := []byte("I01\t77\nI02\t55\nI03\t73\nI04\t58\nI05\t78\n" +
"I21\t46\nI22\t47\nI23\t48\nI24\t49\nI25\t50\n")
cases := []struct {
name string
keymap string
want bool
}{
{
name: "pvf creator table",
keymap: string(profile),
want: true,
},
{
name: "player rebind keeps the creator attack rows",
keymap: "I01\t77\nI02\t55\nI03\t73\nI04\t58\nI05\t78\n" +
"I21\t30\nI22\t25\nI23\t48\nI24\t49\nI25\t50\n",
want: true,
},
{
name: "live client restore-default table (94 rows, real)",
keymap: currentCreatorOrdinaryDefaultKeymapFixture + "I23\t58\nI24\t60\nI25\t61\nI26\t62\nI27\t71\n",
want: false,
},
{
name: "truncated ordinary table",
keymap: currentCreatorOrdinaryDefaultKeymapFixture,
want: false,
},
{
name: "tutorial echo without the creator rows",
keymap: "I01\t2\nI02\t0\n",
want: false,
},
{
name: "empty",
keymap: "",
want: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := keymapHasCreatorInputModel([]byte(tc.keymap), profile); got != tc.want {
t.Fatalf("creator input model = %t want %t", got, tc.want)
}
})
}
}
// 缔造者点"恢复默认按键":客户端本地已经换成普通角色的表了,服务端必须
//
// 1. 丢掉角色作用域里存的自定义绑定("恢复默认"就该把改动一并丢掉),
// 2. 把**这个职业自己的** PVF 默认表沿 op454 推回客户端。
//
// 只拒绝保存是不够的 —— 那样界面仍是错的(现场 2026-09-15:点完没反应)。
func TestCreatorRestoreDefaultPushesThePVFTableAndDropsTheStoredBinding(t *testing.T) {
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("DNFBRIDGE_REAL_PVF_SMOKE is not set")
}
ctx := context.Background()
repositories := testRepositoryGroup()
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "13",
AccountID: "account-a",
Job: "10",
}); err != nil {
t.Fatal(err)
}
// 账号作用域里放一份普通角色的键位表:这就是客户端"恢复默认"会照抄的那张。
if err := saveCurrentGameOptionKeymap(
ctx, repositories.Settings, "account-a", []byte(currentOrdinaryAccountKeymapFixture),
); err != nil {
t.Fatal(err)
}
playerRebind := []byte("I01\t77\nI02\t55\nI03\t73\nI04\t58\nI05\t78\n" +
"I21\t30\nI22\t25\nI23\t48\nI24\t49\nI25\t50\n")
if err := saveCurrentGameOptionKeymapToScope(
ctx, repositories.Settings, currentCharacterGameOptionKeymapScope("13"), playerRebind,
); err != nil {
t.Fatal(err)
}
connection := &bufferConn{}
service := &Service{
options: options{
pvfPath: pvfPath,
accountID: "account-a",
gameUpperHeader: gameUpperHeaderServer16,
gameUpperBodyCodec: gameUpperBodyCodecPlain,
},
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
session := &gameSession{
conn: connection,
accountID: "account-a",
selectedCharacterID: 13,
}
if err := service.handleCurrentSaveGameOptionKeymap(
session,
buildCurrentGameOptionKeymapSaveBody([]byte(currentCreatorOrdinaryDefaultKeymapFixture)),
); err != nil {
t.Fatal(err)
}
// (1) 客户端必须收到一条 op454,正文是 PVF 的缔造者表(回推),不是那张普通表。
packet, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
if packet.Header.MsgID != currentGameOptionKeymapMsgID || packet.Header.Classification != 0 {
t.Fatalf("restore-default packet=%+v rest=%x", packet.Header, rest)
}
// op454 的正文 = u8 mode + u32 长度前缀的 keymap。
if len(packet.Body) < 5 || packet.Body[0] != currentGameOptionKeymapMode {
t.Fatalf("restore-default body head=%x", packet.Body[:min(8, len(packet.Body))])
}
pushed := packet.Body[1:]
for _, row := range [][]byte{[]byte("I01\t77\n"), []byte("I21\t46\n"), []byte("I22\t47\n"), []byte("I25\t50\n")} {
if !bytes.Contains(pushed, row) {
t.Fatalf("pushed creator table missing %q", row)
}
}
if bytes.Contains(pushed, []byte("I21\t55\n")) {
t.Fatalf("the ordinary default table was pushed back to the client: %q", pushed)
}
// (2) 存的自定义绑定必须被丢掉。
if stored, found, err := loadCurrentGameOptionKeymapFromScope(
ctx, repositories.Settings, currentCharacterGameOptionKeymapScope("13")); err != nil || found {
t.Fatalf("stored binding survived restore-default: found=%t err=%v body=%q", found, err, stored)
}
// (3) 之后登录回到 PVF 默认表。
keymap, source, err := service.currentGameOptionKeymapForSelectedCharacter(
ctx, repositories, session, nil)
if err != nil {
t.Fatal(err)
}
if source != "pvf_creator_ver2_wasd_space_digit_skill_keymap_locked" {
t.Fatalf("post-reset source=%q want the pvf creator profile", source)
}
if bytes.Contains(keymap, []byte("I21\t30\n")) {
t.Fatalf("the dropped rebind was restored: %q", keymap)
}
// (4) 非缔造者不受这条路径影响:账号作用域照旧能存。
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "14",
AccountID: "account-a",
Job: "0",
}); err != nil {
t.Fatal(err)
}
connection2 := &bufferConn{}
ordinarySession := &gameSession{conn: connection2, accountID: "account-a", selectedCharacterID: 14}
if err := service.handleCurrentSaveGameOptionKeymap(
ordinarySession,
buildCurrentGameOptionKeymapSaveBody([]byte(currentCreatorOrdinaryDefaultKeymapFixture)),
); err != nil {
t.Fatal(err)
}
if connection2.write.Len() != 0 {
t.Fatalf("ordinary character received a restore packet: %x", connection2.write.Bytes())
}
accountStored, accountFound, err := loadCurrentGameOptionKeymap(ctx, repositories.Settings, "account-a")
if err != nil || !accountFound || !bytes.Equal(accountStored, []byte(currentCreatorOrdinaryDefaultKeymapFixture)) {
t.Fatalf("ordinary character save was blocked: found=%t err=%v body=%q", accountFound, err, accountStored)
}
}
// 库里已经躺着的那张普通默认表(保存侧拦截上线之前存进去的)必须自愈:
// 它虽然形状完整,但内容是错的,登录时要落到 PVF 表而不是发这张表。
func TestStoredOrdinaryDefaultKeymapDoesNotBecomeCreatorAuthoritative(t *testing.T) {
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("DNFBRIDGE_REAL_PVF_SMOKE is not set")
}
ctx := context.Background()
repositories := testRepositoryGroup()
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "13",
AccountID: "account-a",
Job: "10",
}); err != nil {
t.Fatal(err)
}
if err := saveCurrentGameOptionKeymap(
ctx, repositories.Settings, "account-a", []byte(currentOrdinaryAccountKeymapFixture),
); err != nil {
t.Fatal(err)
}
if err := saveCurrentGameOptionKeymapToScope(
ctx, repositories.Settings, currentCharacterGameOptionKeymapScope("13"),
[]byte(currentCreatorOrdinaryDefaultKeymapFixture),
); err != nil {
t.Fatal(err)
}
service := &Service{options: options{pvfPath: pvfPath, accountID: "account-a"}}
session := &gameSession{accountID: "account-a", selectedCharacterID: 13}
keymap, source, err := service.currentGameOptionKeymapForSelectedCharacter(
ctx, repositories, session, nil)
if err != nil {
t.Fatal(err)
}
if source != "pvf_creator_ver2_wasd_space_digit_skill_keymap_locked" {
t.Fatalf("source=%q want the pvf creator profile for a client-default store", source)
}
if bytes.Contains(keymap, []byte("I21\t55\n")) {
t.Fatalf("the ordinary default keymap was served to a Creator: %q", keymap)
}
for _, row := range [][]byte{[]byte("I21\t46\n"), []byte("I22\t47\n"), []byte("I25\t50\n")} {
if !bytes.Contains(keymap, row) {
t.Fatalf("creator keymap missing %q in %q", row, keymap)
}
}
}
// 副本边界不能冲掉玩家改过的绑定。
//
// 现场 2026-09-15:缔造者改好技能键后**一进副本就变回默认** —— 因为副本每次重建 HUD 都会
// 走 sendCurrentCreatorKeymapInitialization(日志 source=...after_final_scene_skill_manager),
// 而它原来无条件下发 PVF 职业默认表。职业默认本身是对的(新建缔造者按键就靠它),
// 错的是"无论玩家改没改都覆盖"。这里钉住:存过绑定就推绑定,没存过才推职业默认。
func TestCreatorDungeonBoundaryKeepsThePlayersRebind(t *testing.T) {
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("DNFBRIDGE_REAL_PVF_SMOKE is not set")
}
ctx := context.Background()
repositories := testRepositoryGroup()
character := dnfrepo.CharacterRecord{CharacterID: "15", AccountID: "account-a", Job: "10"}
if err := repositories.Character.Save(ctx, character); err != nil {
t.Fatal(err)
}
playerRebind := []byte("I01\t77\nI02\t55\nI03\t73\nI04\t58\nI05\t78\n" +
"I06\t21\nI21\t30\nI22\t25\nI23\t48\nI24\t49\nI25\t50\n")
t.Run("stored rebind wins", func(t *testing.T) {
if err := saveCurrentGameOptionKeymapToScope(
ctx, repositories.Settings, currentCharacterGameOptionKeymapScope("15"), playerRebind,
); err != nil {
t.Fatal(err)
}
connection := &bufferConn{}
service := &Service{
options: options{
pvfPath: pvfPath,
accountID: "account-a",
gameUpperHeader: gameUpperHeaderServer16,
gameUpperBodyCodec: gameUpperBodyCodecPlain,
},
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
session := &gameSession{conn: connection, accountID: "account-a", selectedCharacterID: 15}
if err := service.sendCurrentCreatorKeymapInitialization(session, ctx, character, "unit_test_dungeon"); err != nil {
t.Fatal(err)
}
packet, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
if len(rest) != 0 || packet.Header.MsgID != currentGameOptionKeymapMsgID {
t.Fatalf("dungeon boundary packet=%+v rest=%x", packet.Header, rest)
}
pushed := packet.Body[1:]
for _, row := range [][]byte{[]byte("I21\t30\n"), []byte("I22\t25\n"), []byte("I01\t77\n")} {
if !bytes.Contains(pushed, row) {
t.Fatalf("副本边界没有保留玩家绑定,缺 %q", row)
}
}
if bytes.Contains(pushed, []byte("I21\t46\n")) {
t.Fatalf("副本边界把玩家绑定推回了职业默认: %q", pushed)
}
})
t.Run("no stored rebind falls back to the pvf default", func(t *testing.T) {
if err := clearCurrentSpecialJobKeymapStore(ctx, repositories.Settings, "15"); err != nil {
t.Fatal(err)
}
connection := &bufferConn{}
service := &Service{
options: options{
pvfPath: pvfPath,
accountID: "account-a",
gameUpperHeader: gameUpperHeaderServer16,
gameUpperBodyCodec: gameUpperBodyCodecPlain,
},
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
session := &gameSession{conn: connection, accountID: "account-a", selectedCharacterID: 15}
if err := service.sendCurrentCreatorKeymapInitialization(session, ctx, character, "unit_test_dungeon_fresh"); err != nil {
t.Fatal(err)
}
packet, _ := splitGameServerUpperPacket(t, connection.write.Bytes())
pushed := packet.Body[1:]
for _, row := range [][]byte{[]byte("I21\t46\n"), []byte("I22\t47\n"), []byte("I01\t77\n")} {
if !bytes.Contains(pushed, row) {
t.Fatalf("没存过绑定时应推职业默认,缺 %q", row)
}
}
})
}
// "恢复默认"的判定必须认客户端默认指纹,而不是"只要不像缔造者表就算":
// 玩家手动把所有键清空(整表 134)是合法状态,不能被当成恢复默认而回滚。
func TestCreatorDefaultDetectionIgnoresAnIntentionallyEmptyTable(t *testing.T) {
clientDefault := "I01\t2\nI02\t0\nI03\t3\nI04\t1\nI05\t78\nI06\t57\n" +
"I21\t55\nI22\t73\nI23\t58\nI24\t60\nI25\t61\n"
unbound := "I01\t134\nI02\t134\nI03\t134\nI04\t134\nI05\t134\nI06\t134\n" +
"I21\t134\nI22\t134\nI23\t134\nI24\t134\nI25\t134\n"
playerRebind := "I01\t77\nI02\t55\nI03\t73\nI04\t58\nI05\t78\nI06\t21\n" +
"I21\t30\nI22\t25\nI23\t48\nI24\t49\nI25\t50\n"
cases := []struct {
name string
keymap string
want bool
}{
{"client default table", clientDefault, true},
{"player rebind", playerRebind, false},
{"intentionally unbound everywhere", unbound, false},
{"only movement matches", "I01\t2\nI02\t0\nI03\t3\nI04\t1\nI06\t21\n", false},
{"movement matches but jump differs", "I01\t2\nI02\t0\nI03\t3\nI04\t1\nI06\t21\n", false},
{"empty", "", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := currentCreatorSaveIsClientDefaultTable([]byte(tc.keymap)); got != tc.want {
t.Fatalf("client-default detection = %t want %t", got, tc.want)
}
})
}
}
func TestCurrentGameOptionKeymapForCreatorUsesPVFInsteadOfAccountDefault(t *testing.T) {
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("DNFBRIDGE_REAL_PVF_SMOKE is not set")
}
ctx := context.Background()
repositories := testRepositoryGroup()
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "13",
AccountID: "account-a",
Job: "10",
}); err != nil {
t.Fatal(err)
}
service := &Service{options: options{pvfPath: pvfPath, accountID: "account-a"}}
session := &gameSession{accountID: "account-a", selectedCharacterID: 13}
// A direct-to-tutorial Creator can save the temporary ordinary HUD table
// before its class profile arrives. The stale character-scoped row must not
// override the locked Creator controls on the next restore.
// 缔造者**没有**存过自己的角色作用域表时,必须落到 PVF 表,而不是账号默认值。
// (教学期回显进来的不完整表也算"没有" —— 见上面那个用例的第二个子用例。)
keymap, source, err := service.currentGameOptionKeymapForSelectedCharacter(
ctx,
repositories,
session,
[]byte("I01\t2\nI21\t40\n"),
)
if err != nil {
t.Fatal(err)
}
if source != "pvf_creator_ver2_wasd_space_digit_skill_keymap_locked" {
t.Fatalf("creator keymap source=%q", source)
}
for _, row := range [][]byte{
[]byte("I01\t77\n"), []byte("I02\t55\n"), []byte("I03\t73\n"), []byte("I04\t58\n"), []byte("I05\t78\n"),
[]byte("I21\t46\n"), []byte("I22\t47\n"), []byte("I23\t48\n"), []byte("I24\t49\n"), []byte("I25\t50\n"),
} {
if !bytes.Contains(keymap, row) {
t.Fatalf("creator keymap missing %q in %q", row, keymap)
}
}
if bytes.Contains(keymap, []byte("I01\t2\n")) {
t.Fatalf("creator keymap was overwritten by account default: %q", keymap)
}
}
func TestSendCurrentCreatorKeymapInitializationAfterDungeonHUDReady(t *testing.T) {
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("DNFBRIDGE_REAL_PVF_SMOKE is not set")
}
connection := &bufferConn{}
service := &Service{
options: options{
pvfPath: pvfPath,
accountID: "account-a",
gameUpperHeader: gameUpperHeaderServer16,
gameUpperBodyCodec: gameUpperBodyCodecPlain,
},
}
session := &gameSession{
conn: connection,
accountID: "account-a",
selectedCharacterID: 13,
}
character := dnfrepo.CharacterRecord{CharacterID: "13", AccountID: "account-a", Job: "10"}
if err := service.sendCurrentCreatorKeymapInitialization(session, context.Background(), character, "test_tutorial_dungeon_hud_ready"); err != nil {
t.Fatal(err)
}
packet, rest := splitGameServerUpperPacketWithHeader(t, connection.write.Bytes(), dnfproto.GameServerUpperHeaderSize16)
if len(rest) != 0 || packet.Header.Classification != 0 || packet.Header.MsgID != currentGameOptionKeymapMsgID {
t.Fatalf("creator dungeon keymap packet=%+v rest=%x", packet.Header, rest)
}
profile, err := service.currentCreatorHotkeyProfile(context.Background())
if err != nil {
t.Fatal(err)
}
want, err := buildCurrentGameOptionKeymapTransportBody(profile.keymap)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(packet.Body, want) || !bytes.Equal(session.currentGameOptionNativeApplyKeymapBody, profile.keymap) {
t.Fatalf("creator dungeon keymap body_len=%d want=%d snapshot_len=%d", len(packet.Body), len(want), len(session.currentGameOptionNativeApplyKeymapBody))
}
for _, row := range [][]byte{
[]byte("I01\t77\n"), []byte("I02\t55\n"), []byte("I03\t73\n"), []byte("I04\t58\n"), []byte("I05\t78\n"),
[]byte("I21\t46\n"), []byte("I22\t47\n"), []byte("I23\t48\n"), []byte("I24\t49\n"), []byte("I25\t50\n"),
} {
if !bytes.Contains(packet.Body, row) {
t.Fatalf("creator dungeon keymap missing %q", row)
}
}
}
func TestSendCurrentCreatorKeymapInitializationLeavesOtherJobsUntouched(t *testing.T) {
connection := &bufferConn{}
service := &Service{options: options{
gameUpperHeader: gameUpperHeaderServer16,
gameUpperBodyCodec: gameUpperBodyCodecPlain,
}}
session := &gameSession{conn: connection, selectedCharacterID: 13}
character := dnfrepo.CharacterRecord{CharacterID: "13", Job: "9"}
if err := service.sendCurrentCreatorKeymapInitialization(session, context.Background(), character, "test_non_creator"); err != nil {
t.Fatal(err)
}
if connection.write.Len() != 0 || len(session.currentGameOptionNativeApplyKeymapBody) != 0 {
t.Fatalf("non-Creator received keymap packet=%x snapshot=%q", connection.write.Bytes(), session.currentGameOptionNativeApplyKeymapBody)
}
}