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