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

1223 lines
50 KiB
Go

package dnfbridge
import (
"bytes"
"context"
"encoding/binary"
"errors"
"testing"
"longheng.io/server/internal/modules/dnf/channelcatalog"
"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"
)
func buildCurrentGameOptionSaveBody() []byte {
body := make([]byte, currentGameOptionSaveBodySize)
binary.LittleEndian.PutUint32(body[:currentGameOptionSaveHeaderLen], currentGameOptionMainBodySize)
for index := currentGameOptionSaveHeaderLen; index < len(body); index++ {
body[index] = byte(index)
}
return body
}
func buildCurrentGameOptionKeymapBody() []byte {
keymap := make([]byte, 649)
const row = "I01\t2\n"
for index := range keymap {
keymap[index] = row[index%len(row)]
}
return keymap
}
func buildCurrentGameOptionKeymapSaveBody(keymap []byte) []byte {
body := make([]byte, currentGameOptionSaveHeaderLen+len(keymap))
binary.LittleEndian.PutUint32(body[:currentGameOptionSaveHeaderLen], uint32(len(keymap)))
copy(body[currentGameOptionSaveHeaderLen:], keymap)
return body
}
func setCurrentGameOptionJobAwakenEffect(body []byte, enabled bool) {
value := uint16(0)
if enabled {
value = 1
}
binary.LittleEndian.PutUint16(
body[currentGameOptionSaveHeaderLen+currentGameOptionJobAwakenEffectOffset:],
value,
)
}
func setCurrentGameOptionShowAllAvatar(body []byte, enabled bool) {
value := uint16(0)
if enabled {
value = 1
}
binary.LittleEndian.PutUint16(
body[currentGameOptionSaveHeaderLen+currentGameOptionShowAllAvatarOffset:],
value,
)
}
func setCurrentGameOptionRapidFire(body []byte, enabled bool) {
value := uint16(0)
if enabled {
value = 1
}
binary.LittleEndian.PutUint16(
body[currentGameOptionSaveHeaderLen+currentGameOptionRapidFireOffset:],
value,
)
}
func TestCurrentGameOptionJobAwakenEffectUsesOption1(t *testing.T) {
main := make([]byte, currentGameOptionMainBodySize)
binary.LittleEndian.PutUint16(main[74*2:], 1)
if enabled, ok := currentGameOptionJobAwakenEffectEnabled(main); !ok || enabled {
t.Fatalf("option74 selected option1 clear: enabled=%t ok=%t", enabled, ok)
}
binary.LittleEndian.PutUint16(main[74*2:], 0)
binary.LittleEndian.PutUint16(main[1*2:], 1)
if enabled, ok := currentGameOptionJobAwakenEffectEnabled(main); !ok || !enabled {
t.Fatalf("option1 selected: enabled=%t ok=%t", enabled, ok)
}
}
func TestCurrentGameOptionJobAwakenEffectPreservesOtherUserStateBits(t *testing.T) {
for _, test := range []struct {
name string
current byte
enabled bool
want byte
}{
{name: "clear bit1", current: 0xa3, enabled: false, want: 0xa1},
{name: "set bit1", current: 0xa1, enabled: true, want: 0xa3},
{name: "default clear", current: currentActorDefaultUserStateBits, enabled: false, want: 0x01},
{name: "preserve bit3", current: 0x09, enabled: true, want: 0x0b},
} {
t.Run(test.name, func(t *testing.T) {
if got := currentGameOptionJobAwakenEffectUserState(test.current, test.enabled); got != test.want {
t.Fatalf("user state=%#x want=%#x", got, test.want)
}
})
}
}
func TestCurrentGameOptionVisibilityMapsShowAllAvatarAndPreservesOtherBits(t *testing.T) {
main := make([]byte, currentGameOptionMainBodySize)
binary.LittleEndian.PutUint16(main[currentGameOptionShowAllAvatarOffset:], 1)
if enabled, ok := currentGameOptionShowAllAvatarEnabled(main); !ok || !enabled {
t.Fatalf("option55 selected: enabled=%t ok=%t", enabled, ok)
}
if got := currentGameOptionVisibilityUserState(0xab, false, true); got != 0xa1 {
t.Fatalf("show all visibility=%#x want=0xa1", got)
}
if got := currentGameOptionVisibilityUserState(0xa1, true, false); got != 0xab {
t.Fatalf("hide all with advanced visibility=%#x want=0xab", got)
}
}
func TestCurrentGameOptionRapidFireUsesLiveType197Option50(t *testing.T) {
main := make([]byte, currentGameOptionMainBodySize)
if enabled, ok := currentGameOptionRapidFireEnabled(main); !ok || enabled {
t.Fatalf("default rapid fire enabled=%t ok=%t", enabled, ok)
}
binary.LittleEndian.PutUint16(main[currentGameOptionRapidFireOffset:], 1)
if enabled, ok := currentGameOptionRapidFireEnabled(main); !ok || !enabled {
t.Fatalf("option50 rapid fire enabled=%t ok=%t", enabled, ok)
}
binary.LittleEndian.PutUint16(main[(currentGameOptionRapidFireIndex-1)*2:], 1)
binary.LittleEndian.PutUint16(main[currentGameOptionRapidFireOffset:], 0)
if enabled, ok := currentGameOptionRapidFireEnabled(main); !ok || enabled {
t.Fatalf("neighbor option49 incorrectly enabled rapid fire=%t ok=%t", enabled, ok)
}
}
func TestCurrentGameOptionJobAwakenEffectChangePersistsAndRefreshesReadyTownActorOnce(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
character := dnfrepo.CharacterRecord{
CharacterID: "19",
AccountID: "account-a",
Name: "Actor19",
Job: "1",
Level: 90,
Stats: map[string]int64{"grow_type": 3, currentGameOptionUserStateStat: 0xa3},
}
if err := repositories.Character.Save(ctx, character); err != nil {
t.Fatal(err)
}
connection := &bufferConn{}
service := &Service{
options: options{
accountID: "account-a",
gameUpperHeader: gameUpperHeaderServer16,
gameUpperBodyCodec: gameUpperBodyCodecPlain,
},
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
session := &gameSession{
conn: connection,
connID: "game-option-user-state-refresh",
accountID: "account-a",
channel: channelcatalog.Channel{ID: 19},
selectedCharacterID: 19,
townSceneReadyCharacterID: 19,
connectionTownActorOwnerChannel: 19,
townActorOwnerChannel: 19,
}
body := buildCurrentGameOptionSaveBody()
setCurrentGameOptionJobAwakenEffect(body, true)
if err := service.handleCurrentSaveGameOption(session, body); err != nil {
t.Fatal(err)
}
if connection.write.Len() != 0 {
t.Fatalf("first save using default-enabled state refreshed actor: %x", connection.write.Bytes())
}
setCurrentGameOptionJobAwakenEffect(body, false)
if err := service.handleCurrentSaveGameOption(session, body); err != nil {
t.Fatal(err)
}
packet, trailing := splitLongHengGameServerUpperPacket(t, connection.write.Bytes())
if packet.Header.Classification != 0 || packet.Header.MsgID != currentActorUserStateMsgID || len(trailing) != 0 {
t.Fatalf("user-state refresh header=%+v trailing=%x", packet.Header, trailing)
}
if !bytes.Equal(packet.Body, []byte{19, 0, 0xa1}) {
t.Fatalf("user-state refresh body=%x want=1300a1", packet.Body)
}
stored, found, err := repositories.Character.Load(ctx, "19")
if err != nil || !found || stored.Stats[currentGameOptionUserStateStat] != 0xa1 {
t.Fatalf("stored user state found=%t err=%v stats=%v", found, err, stored.Stats)
}
legacy := fakeLegacyUserInfoRepo{rows: map[string][]dnfrepo.LegacyUserInfoRow{
"legacy_character_subtype0_fields": {{"user_state_bits": "3"}},
}}
serviceForReselect := Service{repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true }}
userinfo := serviceForReselect.buildCSharpSelectedUserInfoBody(
ctx,
nil,
legacy,
0,
stored,
true,
19,
"Actor19",
)
const subtype0UserStateOffset = 5 + 4 + len("Actor19") + 7 + 65
if len(userinfo) <= subtype0UserStateOffset || userinfo[subtype0UserStateOffset] != 0xa1 {
t.Fatalf("reselect USERINFO0 user-state byte=%x want=a1", userinfo)
}
main, found, err := loadCurrentGameOption(ctx, repositories.Settings, "account-a")
if err != nil || !found {
t.Fatalf("stored game option found=%t err=%v", found, err)
}
if enabled, ok := currentGameOptionJobAwakenEffectEnabled(main); !ok || enabled {
t.Fatalf("stored option1 enabled=%t ok=%t want=false", enabled, ok)
}
before := connection.write.Len()
if err := service.handleCurrentSaveGameOption(session, body); err != nil {
t.Fatal(err)
}
if connection.write.Len() != before {
t.Fatalf("unchanged save sent another op357: before=%d after=%d", before, connection.write.Len())
}
}
func TestCurrentGameOptionJobAwakenEffectChangePersistsWithoutRefreshingUnreadyScene(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
character := dnfrepo.CharacterRecord{
CharacterID: "19",
AccountID: "account-a",
Name: "Actor19",
Level: 90,
Stats: map[string]int64{currentGameOptionUserStateStat: int64(currentActorDefaultUserStateBits)},
}
if err := repositories.Character.Save(ctx, character); err != nil {
t.Fatal(err)
}
connection := &bufferConn{}
service := &Service{
options: options{accountID: "account-a", gameUpperHeader: gameUpperHeaderServer16, gameUpperBodyCodec: gameUpperBodyCodecPlain},
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
session := &gameSession{conn: connection, accountID: "account-a", selectedCharacterID: 19}
body := buildCurrentGameOptionSaveBody()
setCurrentGameOptionJobAwakenEffect(body, false)
if err := service.handleCurrentSaveGameOption(session, body); err != nil {
t.Fatal(err)
}
if connection.write.Len() != 0 {
t.Fatalf("unready scene received op357 refresh: %x", connection.write.Bytes())
}
stored, found, err := repositories.Character.Load(ctx, "19")
if err != nil || !found || stored.Stats[currentGameOptionUserStateStat] != 1 {
t.Fatalf("unready scene did not persist user state found=%t err=%v stats=%v", found, err, stored.Stats)
}
}
func TestCurrentGameOptionJobAwakenEffectChangePersistsWithoutTownRefreshInDungeon(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
character := dnfrepo.CharacterRecord{
CharacterID: "19",
AccountID: "account-a",
Name: "Actor19",
Level: 90,
Stats: map[string]int64{currentGameOptionUserStateStat: int64(currentActorDefaultUserStateBits)},
}
if err := repositories.Character.Save(ctx, character); err != nil {
t.Fatal(err)
}
connection := &bufferConn{}
service := &Service{
options: options{accountID: "account-a", gameUpperHeader: gameUpperHeaderServer16, gameUpperBodyCodec: gameUpperBodyCodecPlain},
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
session := &gameSession{
conn: connection,
accountID: "account-a",
selectedCharacterID: 19,
townSceneReadyCharacterID: 19,
connectionTownActorOwnerChannel: 19,
townActorOwnerChannel: 19,
dungeon: dungeonSessionState{runtime: &runtimeDungeonState{Character: character}},
}
body := buildCurrentGameOptionSaveBody()
setCurrentGameOptionJobAwakenEffect(body, false)
if err := service.handleCurrentSaveGameOption(session, body); err != nil {
t.Fatal(err)
}
if connection.write.Len() != 0 {
t.Fatalf("active dungeon received town op357 refresh: %x", connection.write.Bytes())
}
main, found, err := loadCurrentGameOption(ctx, repositories.Settings, "account-a")
if err != nil || !found {
t.Fatalf("load active-dungeon save found=%t err=%v", found, err)
}
if enabled, ok := currentGameOptionJobAwakenEffectEnabled(main); !ok || enabled {
t.Fatalf("active-dungeon saved option enabled=%t ok=%t want=false", enabled, ok)
}
stored, found, err := repositories.Character.Load(ctx, "19")
if err != nil || !found || stored.Stats[currentGameOptionUserStateStat] != 1 {
t.Fatalf("active-dungeon stored user state found=%t err=%v stats=%v", found, err, stored.Stats)
}
}
func TestCurrentGameOptionVisibilityReconcilesNewlySelectedCharacterFromAccountState(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
character := dnfrepo.CharacterRecord{
CharacterID: "13",
AccountID: "account-a",
Stats: map[string]int64{currentGameOptionUserStateStat: 0x03},
}
if err := repositories.Character.Save(ctx, character); err != nil {
t.Fatal(err)
}
option := buildCurrentGameOptionSaveBody()
setCurrentGameOptionJobAwakenEffect(option, false)
setCurrentGameOptionShowAllAvatar(option, true)
if err := saveCurrentGameOption(
ctx,
repositories.Settings,
"account-a",
option[currentGameOptionSaveHeaderLen:],
); err != nil {
t.Fatal(err)
}
service := &Service{
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
session := &gameSession{accountID: "account-a"}
reconciled, changed, err := service.reconcileCurrentGameOptionVisibilityForSelectedCharacter(
ctx,
session,
character,
)
if err != nil {
t.Fatal(err)
}
if !changed || reconciled.Stats[currentGameOptionUserStateStat] != 0x01 {
t.Fatalf("reconciled changed=%t stats=%v", changed, reconciled.Stats)
}
stored, found, err := repositories.Character.Load(ctx, "13")
if err != nil || !found || stored.Stats[currentGameOptionUserStateStat] != 0x01 {
t.Fatalf("stored selected character visibility found=%t err=%v stats=%v", found, err, stored.Stats)
}
}
func TestCurrentGameOptionPersistsExactMainBodyPerAccount(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
connection := &bufferConn{}
service := &Service{
options: options{gameUpperHeader: gameUpperHeaderServer16, gameUpperBodyCodec: gameUpperBodyCodecPlain},
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
body := buildCurrentGameOptionSaveBody()
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "3",
AccountID: "account-a",
Stats: map[string]int64{currentGameOptionUserStateStat: int64(currentActorDefaultUserStateBits)},
}); err != nil {
t.Fatal(err)
}
session := &gameSession{conn: connection, accountID: "account-a", selectedCharacterID: 3}
if err := service.handleCurrentSaveGameOption(session, buildCurrentGameOptionSaveBody()); err != nil {
t.Fatalf("save game option: %v", err)
}
main, found, err := loadCurrentGameOption(ctx, repositories.Settings, "account-a")
if err != nil || !found || !bytes.Equal(main, body[currentGameOptionSaveHeaderLen:]) {
t.Fatalf("load account-a found=%t err=%v body=%x", found, err, main)
}
if _, found, err := loadCurrentGameOption(ctx, repositories.Settings, "account-b"); err != nil || found {
t.Fatalf("other account found=%t err=%v", found, err)
}
if connection.write.Len() != 0 {
t.Fatalf("saving game options injected a native apply packet: %x", connection.write.Bytes())
}
}
func TestCurrentGameOptionWithoutSelectedCharacterSavesAccountOnly(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
service := &Service{repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true }}
persisted := buildCurrentGameOptionSaveBody()
setCurrentGameOptionJobAwakenEffect(persisted, true)
setCurrentGameOptionShowAllAvatar(persisted, true)
if err := saveCurrentGameOption(ctx, repositories.Settings, "account-a", persisted[currentGameOptionSaveHeaderLen:]); err != nil {
t.Fatal(err)
}
body := buildCurrentGameOptionSaveBody()
setCurrentGameOptionJobAwakenEffect(body, false)
setCurrentGameOptionShowAllAvatar(body, false)
if err := service.handleCurrentSaveGameOption(&gameSession{accountID: "account-a"}, body); err != nil {
t.Fatal(err)
}
main, found, err := loadCurrentGameOption(ctx, repositories.Settings, "account-a")
if err != nil || !found || !bytes.Equal(main, body[currentGameOptionSaveHeaderLen:]) {
t.Fatalf("account-only save found=%t err=%v body=%x", found, err, main)
}
characters, err := repositories.Character.ListByAccount(ctx, "account-a", 0)
if err != nil || len(characters) != 0 {
t.Fatalf("account-only save created character rows=%v err=%v", characters, err)
}
}
func TestCurrentGameOptionSavePersistsDisableDuringSceneRebuild(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "20",
AccountID: "account-a",
Stats: map[string]int64{currentGameOptionUserStateStat: 0x03},
}); err != nil {
t.Fatal(err)
}
persisted := buildCurrentGameOptionSaveBody()
setCurrentGameOptionJobAwakenEffect(persisted, true)
setCurrentGameOptionShowAllAvatar(persisted, true)
if err := saveCurrentGameOption(ctx, repositories.Settings, "account-a", persisted[currentGameOptionSaveHeaderLen:]); err != nil {
t.Fatal(err)
}
service := &Service{repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true }}
incoming := buildCurrentGameOptionSaveBody()
setCurrentGameOptionJobAwakenEffect(incoming, false)
setCurrentGameOptionShowAllAvatar(incoming, true)
if err := service.handleCurrentSaveGameOption(&gameSession{accountID: "account-a", selectedCharacterID: 20}, incoming); err != nil {
t.Fatal(err)
}
main, found, err := loadCurrentGameOption(ctx, repositories.Settings, "account-a")
if err != nil || !found {
t.Fatalf("load saved game option found=%t err=%v", found, err)
}
if enabled, ok := currentGameOptionJobAwakenEffectEnabled(main); !ok || enabled {
t.Fatalf("scene rebuild save did not persist opt[1]=0: enabled=%t ok=%t", enabled, ok)
}
if enabled, ok := currentGameOptionShowAllAvatarEnabled(main); !ok || !enabled {
t.Fatalf("scene rebuild save did not persist opt[55]=1: enabled=%t ok=%t", enabled, ok)
}
character, found, err := repositories.Character.Load(ctx, "20")
if err != nil || !found || character.Stats[currentGameOptionUserStateStat] != 0x01 {
t.Fatalf("scene rebuild save user state found=%t err=%v stats=%v", found, err, character.Stats)
}
}
func TestCurrentGameOptionRejectsMalformedSaveWithoutReplacingState(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
service := &Service{repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true }}
body := buildCurrentGameOptionSaveBody()
session := &gameSession{accountID: "account-a"}
if err := service.handleCurrentSaveGameOption(session, body); err != nil {
t.Fatal(err)
}
malformed := append([]byte(nil), body...)
binary.LittleEndian.PutUint32(malformed[:currentGameOptionSaveHeaderLen], currentGameOptionMainBodySize-1)
if err := service.handleCurrentSaveGameOption(session, malformed); err != nil {
t.Fatalf("malformed save returned error: %v", err)
}
main, found, err := loadCurrentGameOption(ctx, repositories.Settings, "account-a")
if err != nil || !found || !bytes.Equal(main, body[currentGameOptionSaveHeaderLen:]) {
t.Fatalf("malformed save replaced state found=%t err=%v body=%x", found, err, main)
}
}
func TestCurrentGameOptionKeymapSavePersistsExactBodyWithoutReplacingMainOrOtherValues(t *testing.T) {
ctx := context.Background()
repositories := testRepositoryGroup()
main := buildCurrentGameOptionSaveBody()[currentGameOptionSaveHeaderLen:]
if err := repositories.Settings.Save(ctx, dnfrepo.SettingsRecord{
Scope: currentGameOptionScope("account-a"),
Values: map[string]string{"unrelated": "preserve-me"},
}); err != nil {
t.Fatal(err)
}
if err := saveCurrentGameOption(ctx, repositories.Settings, "account-a", main); err != nil {
t.Fatal(err)
}
service := &Service{repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true }}
session := &gameSession{accountID: "account-a", selectedCharacterID: 19}
keymap := buildCurrentGameOptionKeymapBody()
if err := service.handleCurrentSaveGameOptionKeymap(session, buildCurrentGameOptionKeymapSaveBody(keymap)); err != nil {
t.Fatal(err)
}
state, found, err := loadCurrentGameOptionState(ctx, repositories.Settings, "account-a")
if err != nil || !found || !bytes.Equal(state.main, main) || !bytes.Equal(state.keymap, keymap) {
t.Fatalf("saved state found=%t err=%v main_len=%d keymap_len=%d", found, err, len(state.main), len(state.keymap))
}
updatedMain := append([]byte(nil), main...)
updatedMain[0] ^= 0xff
if err := saveCurrentGameOption(ctx, repositories.Settings, "account-a", updatedMain); err != nil {
t.Fatal(err)
}
state, found, err = loadCurrentGameOptionState(ctx, repositories.Settings, "account-a")
if err != nil || !found || !bytes.Equal(state.main, updatedMain) || !bytes.Equal(state.keymap, keymap) {
t.Fatalf("updated state found=%t err=%v main_len=%d keymap_len=%d", found, err, len(state.main), len(state.keymap))
}
record, found, err := repositories.Settings.Load(ctx, currentGameOptionScope("account-a"))
if err != nil || !found || record.Values["unrelated"] != "preserve-me" {
t.Fatalf("unrelated setting found=%t err=%v values=%v", found, err, record.Values)
}
}
func TestCurrentGameOptionKeymapSaveUsesCharacterScopeForCreator(t *testing.T) {
for _, testCase := range []struct {
name string
job string
}{
{name: "creator", job: "10"},
} {
t.Run(testCase.name, func(t *testing.T) {
ctx := context.Background()
repositories := testRepositoryGroup()
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "19",
AccountID: "account-a",
Job: testCase.job,
}); err != nil {
t.Fatal(err)
}
accountKeymap := []byte("I01\t2\n")
if err := saveCurrentGameOptionKeymap(ctx, repositories.Settings, "account-a", accountKeymap); err != nil {
t.Fatal(err)
}
service := &Service{repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true }}
session := &gameSession{accountID: "account-a", selectedCharacterID: 19}
specialKeymap := []byte("I01\t77\nI21\t46\n")
if err := service.handleCurrentSaveGameOptionKeymap(session, buildCurrentGameOptionKeymapSaveBody(specialKeymap)); err != nil {
t.Fatal(err)
}
storedAccount, found, err := loadCurrentGameOptionKeymap(ctx, repositories.Settings, "account-a")
if err != nil || !found || !bytes.Equal(storedAccount, accountKeymap) {
t.Fatalf("account keymap changed found=%t err=%v body=%q", found, err, storedAccount)
}
storedCharacter, found, err := loadCurrentGameOptionKeymapFromScope(
ctx,
repositories.Settings,
currentCharacterGameOptionKeymapScope("19"),
)
if err != nil || !found || !bytes.Equal(storedCharacter, specialKeymap) {
t.Fatalf("special keymap found=%t err=%v body=%q", found, err, storedCharacter)
}
})
}
}
func TestCurrentGameOptionKeymapSaveUsesAccountScopeForDarkKnight(t *testing.T) {
ctx := context.Background()
repositories := testRepositoryGroup()
character := dnfrepo.CharacterRecord{CharacterID: "19", AccountID: "account-a", Job: "9"}
if err := repositories.Character.Save(ctx, character); err != nil {
t.Fatal(err)
}
service := &Service{repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true }}
session := &gameSession{accountID: "account-a", selectedCharacterID: 19}
scope, source, err := service.currentGameOptionKeymapSaveScope(ctx, repositories, session, character.AccountID)
if err != nil {
t.Fatal(err)
}
if want := currentGameOptionScope(character.AccountID); scope != want || source != "account_settings" {
t.Fatalf("dark knight keymap scope=%q source=%q, want scope=%q source=account_settings", scope, source, want)
}
}
func TestCurrentGameOptionKeymapRejectsMalformedLengthWithoutReplacingState(t *testing.T) {
ctx := context.Background()
repositories := testRepositoryGroup()
service := &Service{repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true }}
session := &gameSession{accountID: "account-a"}
keymap := buildCurrentGameOptionKeymapBody()
if err := service.handleCurrentSaveGameOptionKeymap(session, buildCurrentGameOptionKeymapSaveBody(keymap)); err != nil {
t.Fatal(err)
}
malformed := buildCurrentGameOptionKeymapSaveBody(append([]byte(nil), keymap...))
binary.LittleEndian.PutUint32(malformed[:currentGameOptionSaveHeaderLen], uint32(len(keymap)-1))
malformed[currentGameOptionSaveHeaderLen] ^= 0xff
if err := service.handleCurrentSaveGameOptionKeymap(session, malformed); err != nil {
t.Fatalf("malformed keymap save returned error: %v", err)
}
stored, found, err := loadCurrentGameOptionKeymap(ctx, repositories.Settings, "account-a")
if err != nil || !found || !bytes.Equal(stored, keymap) {
t.Fatalf("malformed keymap replaced state found=%t err=%v keymap_len=%d", found, err, len(stored))
}
}
func TestCurrentGameOptionRepeatedSaveDoesNotInjectNativeApply(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
connection := &bufferConn{}
service := &Service{
options: options{gameUpperHeader: gameUpperHeaderServer16, gameUpperBodyCodec: gameUpperBodyCodecPlain},
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
body := buildCurrentGameOptionSaveBody()
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "3",
AccountID: "account-a",
Stats: map[string]int64{currentGameOptionUserStateStat: int64(currentActorDefaultUserStateBits)},
}); err != nil {
t.Fatal(err)
}
session := &gameSession{conn: connection, accountID: "account-a", selectedCharacterID: 3}
if err := service.handleCurrentSaveGameOption(session, body); err != nil {
t.Fatalf("initial save: %v", err)
}
if err := service.handleCurrentSaveGameOption(session, body); err != nil {
t.Fatalf("repeat save: %v", err)
}
if connection.write.Len() != 0 {
t.Fatalf("repeat save injected a native apply packet: %x", connection.write.Bytes())
}
main, found, err := loadCurrentGameOption(ctx, repositories.Settings, "account-a")
if err != nil || !found || !bytes.Equal(main, body[currentGameOptionSaveHeaderLen:]) {
t.Fatalf("repeat save changed saved main found=%t err=%v body=%x", found, err, main)
}
}
func TestCurrentGameOptionTransportUsesExactMainAndQuickChatVectors(t *testing.T) {
main := buildCurrentGameOptionSaveBody()[currentGameOptionSaveHeaderLen:]
quickChat0 := bytes.Repeat([]byte{0x11}, currentGameOptionQuickChatBodySize)
quickChat1 := bytes.Repeat([]byte{0x22}, currentGameOptionQuickChatBodySize)
transport, err := buildCurrentGameOptionTransportBody(main, quickChat0, quickChat1)
if err != nil {
t.Fatal(err)
}
quickChat0LengthOffset := currentGameOptionSaveHeaderLen + currentGameOptionMainBodySize
quickChat0Offset := quickChat0LengthOffset + currentGameOptionSaveHeaderLen
quickChat1LengthOffset := quickChat0Offset + currentGameOptionQuickChatBodySize
quickChat1Offset := quickChat1LengthOffset + currentGameOptionSaveHeaderLen
if len(transport) != quickChat1Offset+currentGameOptionQuickChatBodySize ||
binary.LittleEndian.Uint32(transport[:4]) != currentGameOptionMainBodySize ||
!bytes.Equal(transport[4:4+currentGameOptionMainBodySize], main) ||
binary.LittleEndian.Uint32(transport[quickChat0LengthOffset:quickChat0Offset]) != currentGameOptionQuickChatBodySize ||
!bytes.Equal(transport[quickChat0Offset:quickChat1LengthOffset], quickChat0) ||
binary.LittleEndian.Uint32(transport[quickChat1LengthOffset:quickChat1Offset]) != currentGameOptionQuickChatBodySize ||
!bytes.Equal(transport[quickChat1Offset:], quickChat1) {
t.Fatalf("game option transport=%x", transport)
}
}
func TestCurrentGameOptionTransportFillsMissingQuickChatWithFixedEmptySlots(t *testing.T) {
main := buildCurrentGameOptionSaveBody()[currentGameOptionSaveHeaderLen:]
transport, err := buildCurrentGameOptionTransportBody(main, nil, nil)
if err != nil {
t.Fatal(err)
}
if len(transport) != 3*currentGameOptionSaveHeaderLen+currentGameOptionMainBodySize+2*currentGameOptionQuickChatBodySize {
t.Fatalf("game option transport len=%d", len(transport))
}
firstQuickChatLength := currentGameOptionSaveHeaderLen + currentGameOptionMainBodySize
firstQuickChatBody := firstQuickChatLength + currentGameOptionSaveHeaderLen
secondQuickChatLength := firstQuickChatBody + currentGameOptionQuickChatBodySize
secondQuickChatBody := secondQuickChatLength + currentGameOptionSaveHeaderLen
if binary.LittleEndian.Uint32(transport[firstQuickChatLength:firstQuickChatBody]) != currentGameOptionQuickChatBodySize ||
binary.LittleEndian.Uint32(transport[secondQuickChatLength:secondQuickChatBody]) != currentGameOptionQuickChatBodySize ||
!bytes.Equal(transport[firstQuickChatBody:secondQuickChatLength], make([]byte, currentGameOptionQuickChatBodySize)) ||
!bytes.Equal(transport[secondQuickChatBody:], make([]byte, currentGameOptionQuickChatBodySize)) {
t.Fatalf("game option empty quick chats=%x", transport)
}
}
func TestCurrentGameOptionKeymapTransportUsesClass0Op454Layout(t *testing.T) {
keymap := buildCurrentGameOptionKeymapBody()
transport, err := buildCurrentGameOptionKeymapTransportBody(keymap)
if err != nil {
t.Fatal(err)
}
if len(transport) != 1+currentGameOptionSaveHeaderLen+len(keymap) ||
transport[0] != currentGameOptionKeymapMode ||
binary.LittleEndian.Uint32(transport[1:5]) != uint32(len(keymap)) ||
!bytes.Equal(transport[5:], keymap) {
t.Fatalf("game option keymap transport=%x", transport)
}
}
func TestCurrentGameOptionLoginRapidFireKeymapExtractsOnlyI01(t *testing.T) {
keymap := []byte("I00\t7\r\nI01\t2\r\nI02\t65\n")
got, found := currentGameOptionLoginRapidFireKeymap(keymap)
if !found || !bytes.Equal(got, []byte("I01\t2\n")) {
t.Fatalf("rapid-fire keymap found=%t body=%q", found, got)
}
for _, malformed := range [][]byte{
[]byte("I00\t7\nI02\t65\n"),
[]byte("I01\tnot-a-number\n"),
[]byte("I01\t2\textra\n"),
} {
if got, found := currentGameOptionLoginRapidFireKeymap(malformed); found || len(got) != 0 {
t.Fatalf("malformed rapid-fire keymap found=%t body=%q input=%q", found, got, malformed)
}
}
}
func TestSavedRapidFireRelogRestoresSwitchBeforeI01AfterSuccessWithoutOp455(t *testing.T) {
ctx := context.Background()
repositories := testRepositoryGroup()
keymap := []byte("I00\t7\nI01\t2\nI02\t65\n")
if err := saveCurrentGameOptionKeymap(ctx, repositories.Settings, "account-a", keymap); err != nil {
t.Fatal(err)
}
service := &Service{
options: options{
serverIP: "192.168.1.10",
gameUpperHeader: gameUpperHeaderServer16,
gameUpperBodyCodec: gameUpperBodyCodecPlain,
},
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
savedMain := buildCurrentGameOptionSaveBody()
setCurrentGameOptionRapidFire(savedMain, true)
if err := service.handleCurrentSaveGameOption(
&gameSession{accountID: "account-a"},
savedMain,
); err != nil {
t.Fatalf("save rapid-fire option50: %v", err)
}
persistedMain, found, err := loadCurrentGameOption(ctx, repositories.Settings, "account-a")
if err != nil || !found {
t.Fatalf("load persisted rapid-fire main found=%t err=%v", found, err)
}
if enabled, valid := currentGameOptionRapidFireEnabled(persistedMain); !valid || !enabled {
t.Fatalf("persisted rapid-fire option50 enabled=%t valid=%t", enabled, valid)
}
connection := &bufferConn{}
session := &gameSession{
conn: connection,
connID: "login-rapid-fire",
accountID: "account-a",
channel: channelcatalog.Channel{ID: 19, Type: 22, Name: "ch.19", Port: 10019},
}
if _, _, sent, err := service.sendGameInitial(session); err != nil || !sent {
t.Fatalf("send initial sent=%t err=%v", sent, err)
}
loginPacket, rest := splitGameServerUpperPacketWithHeader(
t,
connection.write.Bytes(),
dnfproto.GameServerUpperHeaderSize16,
)
rapidFireSwitchPacket, rest := splitGameServerUpperPacketWithHeader(
t,
rest,
dnfproto.GameServerUpperHeaderSize16,
)
rapidFirePacket, rest := splitGameServerUpperPacketWithHeader(
t,
rest,
dnfproto.GameServerUpperHeaderSize16,
)
if len(rest) != 0 {
t.Fatalf("login rapid-fire trailing bytes=%x", rest)
}
if loginPacket.Header.Classification != dnfproto.DefaultChannelClassification ||
loginPacket.Header.MsgID != uint16(dnfenum.UpperMsgGameEndpoint) {
t.Fatalf("login packet header=%+v", loginPacket.Header)
}
wantMainBody, err := buildCurrentGameOptionTransportBody(persistedMain, nil, nil)
if err != nil {
t.Fatal(err)
}
if rapidFireSwitchPacket.Header.Classification != 0 ||
rapidFireSwitchPacket.Header.MsgID != currentGameOptionMsgID ||
!bytes.Equal(rapidFireSwitchPacket.Body, wantMainBody) {
t.Fatalf("rapid-fire switch packet header=%+v body=%x want=%x", rapidFireSwitchPacket.Header, rapidFireSwitchPacket.Body, wantMainBody)
}
if enabled, valid := currentGameOptionRapidFireEnabled(
rapidFireSwitchPacket.Body[currentGameOptionSaveHeaderLen : currentGameOptionSaveHeaderLen+currentGameOptionMainBodySize],
); !valid || !enabled {
t.Fatalf("wire rapid-fire option50 enabled=%t valid=%t", enabled, valid)
}
wantBody, err := buildCurrentGameOptionKeymapTransportBody([]byte("I01\t2\n"))
if err != nil {
t.Fatal(err)
}
if rapidFirePacket.Header.Classification != 0 ||
rapidFirePacket.Header.MsgID != currentGameOptionKeymapMsgID ||
!bytes.Equal(rapidFirePacket.Body, wantBody) {
t.Fatalf("rapid-fire packet header=%+v body=%x want=%x", rapidFirePacket.Header, rapidFirePacket.Body, wantBody)
}
if rapidFirePacket.Header.MsgID == 455 || bytes.Contains(rapidFirePacket.Body, []byte("I02")) {
t.Fatalf("login projection leaked op455/full keymap: header=%+v body=%q", rapidFirePacket.Header, rapidFirePacket.Body)
}
}
func TestSavedDisabledRapidFireRelogRestoresMainWithoutProjectingI01(t *testing.T) {
ctx := context.Background()
repositories := testRepositoryGroup()
main := buildCurrentGameOptionSaveBody()
setCurrentGameOptionRapidFire(main, false)
if err := saveCurrentGameOption(ctx, repositories.Settings, "account-a", main[currentGameOptionSaveHeaderLen:]); err != nil {
t.Fatal(err)
}
if err := saveCurrentGameOptionKeymap(
ctx,
repositories.Settings,
"account-a",
[]byte("I00\t7\nI01\t2\nI02\t65\n"),
); err != nil {
t.Fatal(err)
}
connection := &bufferConn{}
service := &Service{
options: options{
serverIP: "192.168.1.10",
gameUpperHeader: gameUpperHeaderServer16,
gameUpperBodyCodec: gameUpperBodyCodecPlain,
},
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
session := &gameSession{
conn: connection,
accountID: "account-a",
channel: channelcatalog.Channel{ID: 19, Type: 22, Name: "ch.19", Port: 10019},
}
if _, _, sent, err := service.sendGameInitial(session); err != nil || !sent {
t.Fatalf("send initial sent=%t err=%v", sent, err)
}
loginPacket, rest := splitGameServerUpperPacketWithHeader(
t,
connection.write.Bytes(),
dnfproto.GameServerUpperHeaderSize16,
)
mainPacket, rest := splitGameServerUpperPacketWithHeader(
t,
rest,
dnfproto.GameServerUpperHeaderSize16,
)
if loginPacket.Header.MsgID != uint16(dnfenum.UpperMsgGameEndpoint) ||
mainPacket.Header.Classification != 0 ||
mainPacket.Header.MsgID != currentGameOptionMsgID ||
len(rest) != 0 {
t.Fatalf("disabled rapid fire login=%+v main=%+v trailing=%x", loginPacket.Header, mainPacket.Header, rest)
}
wantMain, err := buildCurrentGameOptionTransportBody(
main[currentGameOptionSaveHeaderLen:],
nil,
nil,
)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(mainPacket.Body, wantMain) {
t.Fatalf("disabled rapid fire main=%x want=%x", mainPacket.Body, wantMain)
}
}
func TestSavedOpaqueMainOptionsRelogRestoresEveryByteBeforeCharacterSelection(t *testing.T) {
ctx := context.Background()
repositories := testRepositoryGroup()
main := buildCurrentGameOptionSaveBody()[currentGameOptionSaveHeaderLen:]
// Lock/display/input switches share the fixed type197 table. Use several
// otherwise-uninterpreted words to prove the login replay is byte-exact and
// is not gated on the separately understood rapid-fire option 50.
binary.LittleEndian.PutUint16(main[9*2:], 1)
binary.LittleEndian.PutUint16(main[37*2:], 0x1234)
binary.LittleEndian.PutUint16(main[81*2:], 1)
binary.LittleEndian.PutUint16(main[currentGameOptionRapidFireOffset:], 0)
if err := saveCurrentGameOption(ctx, repositories.Settings, "account-a", main); err != nil {
t.Fatal(err)
}
connection := &bufferConn{}
service := &Service{
options: options{
serverIP: "192.168.1.10",
gameUpperHeader: gameUpperHeaderServer16,
gameUpperBodyCodec: gameUpperBodyCodecPlain,
},
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
session := &gameSession{
conn: connection,
accountID: "account-a",
channel: channelcatalog.Channel{ID: 19, Type: 22, Name: "ch.19", Port: 10019},
}
if _, _, sent, err := service.sendGameInitial(session); err != nil || !sent {
t.Fatalf("send initial sent=%t err=%v", sent, err)
}
loginPacket, rest := splitGameServerUpperPacketWithHeader(
t,
connection.write.Bytes(),
dnfproto.GameServerUpperHeaderSize16,
)
mainPacket, rest := splitGameServerUpperPacketWithHeader(
t,
rest,
dnfproto.GameServerUpperHeaderSize16,
)
if loginPacket.Header.MsgID != uint16(dnfenum.UpperMsgGameEndpoint) ||
mainPacket.Header.Classification != 0 ||
mainPacket.Header.MsgID != currentGameOptionMsgID ||
len(rest) != 0 {
t.Fatalf("login=%+v main=%+v trailing=%x", loginPacket.Header, mainPacket.Header, rest)
}
want, err := buildCurrentGameOptionTransportBody(main, nil, nil)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(mainPacket.Body, want) {
t.Fatalf("login main=%x want=%x", mainPacket.Body, want)
}
}
func TestCurrentGameOptionRestoreSendsMainBeforeSceneLayoutAndSeparateKeymapAfterHUDReadyOnce(t *testing.T) {
ctx := context.Background()
repositories := testRepositoryGroup()
main := buildCurrentGameOptionSaveBody()[currentGameOptionSaveHeaderLen:]
binary.LittleEndian.PutUint16(main[87*2:], 75)
keymap := buildCurrentGameOptionKeymapBody()
if err := saveCurrentGameOption(ctx, repositories.Settings, "account-a", main); err != nil {
t.Fatal(err)
}
if err := saveCurrentGameOptionKeymap(ctx, repositories.Settings, "account-a", keymap); err != nil {
t.Fatal(err)
}
mainTransport, err := buildCurrentGameOptionTransportBody(main, nil, nil)
if err != nil {
t.Fatal(err)
}
keymapTransport, err := buildCurrentGameOptionKeymapTransportBody(keymap)
if err != nil {
t.Fatal(err)
}
connection := &bufferConn{}
service := &Service{
options: options{gameUpperHeader: gameUpperHeaderServer16, gameUpperBodyCodec: gameUpperBodyCodecPlain},
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
session := &gameSession{
conn: connection,
accountID: "account-a",
selectedCharacterID: 3,
initialTownRouteCharacterID: 3,
initialTownRouteStage: currentInitialTownRoutePlayerStateSent,
initialTownLegacySceneReadyAccepted: true,
sceneBootstrapTailSent: true,
upperSeq: 23,
}
if err := service.sendCurrentSceneLayoutCommit(session, "test_game_option_before_scene_layout"); err != nil {
t.Fatal(err)
}
earlyWire := append([]byte(nil), connection.write.Bytes()...)
mainPacket, rest := splitGameServerUpperPacketWithHeader(t, earlyWire, dnfproto.GameServerUpperHeaderSize16)
layoutPacket, rest := splitGameServerUpperPacketWithHeader(t, rest, dnfproto.GameServerUpperHeaderSize16)
if len(rest) != 0 {
t.Fatalf("pre-layout game option trailing bytes=%x", rest)
}
if mainPacket.Header.Classification != 0 || mainPacket.Header.MsgID != currentGameOptionMsgID ||
!bytes.Equal(mainPacket.Body, mainTransport) {
t.Fatalf("pre-layout game option header=%+v body=%x", mainPacket.Header, mainPacket.Body)
}
if layoutPacket.Header.Classification != 0 ||
layoutPacket.Header.MsgID != uint16(dnfenum.CmdPacketReportClientSpec) ||
len(layoutPacket.Body) != 0 {
t.Fatalf("scene layout packet header=%+v body=%x", layoutPacket.Header, layoutPacket.Body)
}
if session.currentGameOptionMainRestoreCharacterID != 3 {
t.Fatalf("pre-layout main restore cursor=%d want=3", session.currentGameOptionMainRestoreCharacterID)
}
if err := service.sendCurrentGameOptionAfterLegacySceneReadyOnce(session, "test_game_option_restore"); err != nil {
t.Fatal(err)
}
wire := append([]byte(nil), connection.write.Bytes()...)
keymapPacket, rest := splitGameServerUpperPacketWithHeader(
t,
wire[len(earlyWire):],
dnfproto.GameServerUpperHeaderSize16,
)
if len(rest) != 0 {
t.Fatalf("game option restore trailing bytes=%x", rest)
}
if keymapPacket.Header.Classification != 0 || keymapPacket.Header.MsgID != currentGameOptionKeymapMsgID ||
!bytes.Equal(keymapPacket.Body, keymapTransport) {
t.Fatalf("game option keymap restore header=%+v body=%x", keymapPacket.Header, keymapPacket.Body)
}
if bytes.Contains(mainPacket.Body, keymap) {
t.Fatalf("variable keymap leaked into fixed-size op173 body")
}
if !bytes.Equal(session.currentGameOptionNativeApplyBody, main) ||
!bytes.Equal(session.currentGameOptionNativeApplyKeymapBody, keymap) {
t.Fatalf("native apply snapshots main_len=%d keymap_len=%d", len(session.currentGameOptionNativeApplyBody), len(session.currentGameOptionNativeApplyKeymapBody))
}
if err := service.sendCurrentGameOptionAfterLegacySceneReadyOnce(session, "test_game_option_restore_repeat"); err != nil {
t.Fatal(err)
}
if connection.write.Len() != len(wire) {
t.Fatalf("repeat restore grew wire from %d to %d", len(wire), connection.write.Len())
}
nativeMainEcho := make([]byte, currentGameOptionSaveHeaderLen+len(main))
binary.LittleEndian.PutUint32(nativeMainEcho[:currentGameOptionSaveHeaderLen], uint32(len(main)))
copy(nativeMainEcho[currentGameOptionSaveHeaderLen:], main)
if err := service.handleCurrentSaveGameOption(session, nativeMainEcho); err != nil {
t.Fatalf("restore native apply echo: %v", err)
}
if connection.write.Len() != len(wire) {
t.Fatalf("restore native apply echo appended packet: before=%d after=%d", len(wire), connection.write.Len())
}
if err := service.handleCurrentSaveGameOptionKeymap(session, buildCurrentGameOptionKeymapSaveBody(keymap)); err != nil {
t.Fatalf("restore keymap native apply echo: %v", err)
}
}
func TestCurrentGameOptionSceneLayoutRetryDoesNotReplayCommittedMain(t *testing.T) {
ctx := context.Background()
repositories := testRepositoryGroup()
main := buildCurrentGameOptionSaveBody()[currentGameOptionSaveHeaderLen:]
binary.LittleEndian.PutUint16(main[87*2:], 50)
if err := saveCurrentGameOption(ctx, repositories.Settings, "account-a", main); err != nil {
t.Fatal(err)
}
wantLayoutErr := errors.New("test op124 write failure")
failing := &failNthDungeonWriteConn{failAt: 2, err: wantLayoutErr}
service := &Service{
options: options{gameUpperHeader: gameUpperHeaderServer16, gameUpperBodyCodec: gameUpperBodyCodecPlain},
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
session := &gameSession{
conn: failing,
accountID: "account-a",
selectedCharacterID: 3,
}
if err := service.sendCurrentSceneLayoutCommit(session, "test_layout_first_attempt"); !errors.Is(err, wantLayoutErr) {
t.Fatalf("first scene layout err=%v want=%v", err, wantLayoutErr)
}
mainPacket, rest := splitGameServerUpperPacketWithHeader(
t,
failing.write.Bytes(),
dnfproto.GameServerUpperHeaderSize16,
)
if len(rest) != 0 || mainPacket.Header.MsgID != currentGameOptionMsgID {
t.Fatalf("failed layout committed stream header=%+v rest=%x", mainPacket.Header, rest)
}
if session.currentGameOptionMainRestoreCharacterID != 3 {
t.Fatalf("successful main cursor=%d want=3", session.currentGameOptionMainRestoreCharacterID)
}
retry := &bufferConn{}
session.conn = retry
if err := service.sendCurrentSceneLayoutCommit(session, "test_layout_retry"); err != nil {
t.Fatal(err)
}
layoutPacket, rest := splitGameServerUpperPacketWithHeader(
t,
retry.write.Bytes(),
dnfproto.GameServerUpperHeaderSize16,
)
if len(rest) != 0 ||
layoutPacket.Header.MsgID != uint16(dnfenum.CmdPacketReportClientSpec) ||
len(layoutPacket.Body) != 0 {
t.Fatalf("layout retry packet header=%+v body=%x rest=%x", layoutPacket.Header, layoutPacket.Body, rest)
}
}
func TestCurrentGameOptionSceneLayoutRetriesMainWhenItsWriteDidNotCommit(t *testing.T) {
ctx := context.Background()
repositories := testRepositoryGroup()
main := buildCurrentGameOptionSaveBody()[currentGameOptionSaveHeaderLen:]
binary.LittleEndian.PutUint16(main[87*2:], 25)
if err := saveCurrentGameOption(ctx, repositories.Settings, "account-a", main); err != nil {
t.Fatal(err)
}
wantMainErr := errors.New("test op173 write failure")
failing := &failNthDungeonWriteConn{failAt: 1, err: wantMainErr}
service := &Service{
options: options{gameUpperHeader: gameUpperHeaderServer16, gameUpperBodyCodec: gameUpperBodyCodecPlain},
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
session := &gameSession{
conn: failing,
accountID: "account-a",
selectedCharacterID: 3,
}
if err := service.sendCurrentSceneLayoutCommit(session, "test_main_first_attempt"); !errors.Is(err, wantMainErr) {
t.Fatalf("first main restore err=%v want=%v", err, wantMainErr)
}
if failing.write.Len() != 0 || session.currentGameOptionMainRestoreCharacterID != 0 {
t.Fatalf("failed main committed bytes=%x cursor=%d", failing.write.Bytes(), session.currentGameOptionMainRestoreCharacterID)
}
if len(session.currentGameOptionNativeApplyBody) != 0 || !session.currentGameOptionNativeApplyMainAt.IsZero() {
t.Fatalf("failed main retained native echo snapshot len=%d at=%v", len(session.currentGameOptionNativeApplyBody), session.currentGameOptionNativeApplyMainAt)
}
retry := &bufferConn{}
session.conn = retry
if err := service.sendCurrentSceneLayoutCommit(session, "test_main_retry"); err != nil {
t.Fatal(err)
}
mainPacket, rest := splitGameServerUpperPacketWithHeader(
t,
retry.write.Bytes(),
dnfproto.GameServerUpperHeaderSize16,
)
layoutPacket, rest := splitGameServerUpperPacketWithHeader(
t,
rest,
dnfproto.GameServerUpperHeaderSize16,
)
if len(rest) != 0 ||
mainPacket.Header.MsgID != currentGameOptionMsgID ||
layoutPacket.Header.MsgID != uint16(dnfenum.CmdPacketReportClientSpec) {
t.Fatalf("main retry headers main=%+v layout=%+v rest=%x", mainPacket.Header, layoutPacket.Header, rest)
}
}
func TestCurrentGameOptionQuickChatSavePersistsBothExactSlots(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
service := &Service{repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true }}
session := &gameSession{accountID: "account-a", selectedCharacterID: 19}
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "19", AccountID: "account-a", Stats: map[string]int64{}}); err != nil {
t.Fatal(err)
}
for slot := byte(0); slot < 2; slot++ {
body := make([]byte, currentGameOptionQuickChatSaveBodySize)
body[0] = slot
binary.LittleEndian.PutUint32(body[1:1+currentGameOptionSaveHeaderLen], currentGameOptionQuickChatBodySize)
copy(body[1+currentGameOptionSaveHeaderLen:], bytes.Repeat([]byte{slot + 1}, currentGameOptionQuickChatBodySize))
if err := service.handleCurrentSaveGameOptionQuickChat(session, body); err != nil {
t.Fatal(err)
}
}
// The current EXE saves op198, both op368 slots, and only then op197.
// The later main-option transaction must preserve both shortcut values.
if err := service.handleCurrentSaveGameOption(session, buildCurrentGameOptionSaveBody()); err != nil {
t.Fatal(err)
}
state, found, err := loadCurrentGameOptionState(ctx, repositories.Settings, "account-a")
if err != nil || !found {
t.Fatalf("quick chat state found=%t err=%v", found, err)
}
for slot := 0; slot < 2; slot++ {
want := bytes.Repeat([]byte{byte(slot + 1)}, currentGameOptionQuickChatBodySize)
if !bytes.Equal(state.quickChat[slot], want) {
t.Fatalf("quick chat slot%d len=%d", slot, len(state.quickChat[slot]))
}
}
}
func TestCurrentGameOptionQuickChatRejectsMalformedBodyWithoutReplacingState(t *testing.T) {
ctx := context.Background()
repositories := testRepositoryGroup()
service := &Service{repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true }}
session := &gameSession{accountID: "account-a"}
want := bytes.Repeat([]byte{0x5a}, currentGameOptionQuickChatBodySize)
if err := saveCurrentGameOptionQuickChat(ctx, repositories.Settings, "account-a", 0, want); err != nil {
t.Fatal(err)
}
for _, malformed := range [][]byte{
{0},
func() []byte {
body := make([]byte, currentGameOptionQuickChatSaveBodySize)
body[0] = 2
binary.LittleEndian.PutUint32(body[1:1+currentGameOptionSaveHeaderLen], currentGameOptionQuickChatBodySize)
return body
}(),
func() []byte {
body := make([]byte, currentGameOptionQuickChatSaveBodySize-1)
body[0] = 0
binary.LittleEndian.PutUint32(body[1:1+currentGameOptionSaveHeaderLen], currentGameOptionQuickChatBodySize)
return body
}(),
func() []byte {
body := make([]byte, currentGameOptionQuickChatSaveBodySize)
body[0] = 0
binary.LittleEndian.PutUint32(body[1:1+currentGameOptionSaveHeaderLen], currentGameOptionQuickChatBodySize-1)
return body
}(),
} {
if err := service.handleCurrentSaveGameOptionQuickChat(session, malformed); err != nil {
t.Fatalf("malformed quick chat returned error: %v", err)
}
}
state, found, err := loadCurrentGameOptionState(ctx, repositories.Settings, "account-a")
if err != nil || !found || !bytes.Equal(state.quickChat[0], want) {
t.Fatalf("malformed quick chat replaced state found=%t err=%v len=%d", found, err, len(state.quickChat[0]))
}
}
func TestCurrentGameOptionRestoreSkipsAccountsWithoutSavedState(t *testing.T) {
repositories := testRepositoryGroup()
connection := &bufferConn{}
service := &Service{
options: options{gameUpperHeader: gameUpperHeaderServer16, gameUpperBodyCodec: gameUpperBodyCodecPlain},
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
session := &gameSession{
conn: connection,
accountID: "account-without-game-option",
selectedCharacterID: 3,
initialTownRouteCharacterID: 3,
initialTownRouteStage: currentInitialTownRoutePlayerStateSent,
initialTownLegacySceneReadyAccepted: true,
sceneBootstrapTailSent: true,
}
if err := service.sendCurrentGameOptionAfterLegacySceneReadyOnce(session, "test_missing_game_option"); err != nil {
t.Fatal(err)
}
if connection.write.Len() != 0 {
t.Fatalf("missing game option sent %x", connection.write.Bytes())
}
if session.initialTownGameOptionRestoreCharacterID != 3 {
t.Fatalf("missing game option restore cursor=%d, want 3", session.initialTownGameOptionRestoreCharacterID)
}
}
func TestGameOptionGameplayModuleOwnsGameOptionSaveOpcodes(t *testing.T) {
for _, opcode := range []uint16{
uint16(dnfenum.CmdPacketSaveGameOption1),
uint16(dnfenum.CmdPacketSaveGameOption2),
uint16(dnfenum.CmdPacketSaveGameOptionQuickchatting),
} {
route, ok := currentGameplayModules.legacyHandlers[opcode]
if !ok || route.module != "game-option" {
t.Fatalf("legacy op%d owner=%q found=%t", opcode, route.module, ok)
}
}
}