服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
1298 lines
44 KiB
Go
1298 lines
44 KiB
Go
package mysql
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"errors"
|
||
"fmt"
|
||
"longheng.io/server/internal/modules/dnf/dungeonpermission"
|
||
"longheng.io/server/internal/modules/dnf/repository"
|
||
"slices"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
type fakeSQLCall struct {
|
||
Query string
|
||
Args []any
|
||
}
|
||
|
||
func TestMySQLGroupExposesAtomicCharacterSettlement(t *testing.T) {
|
||
database, err := sql.Open("mysql", "root:password@tcp(127.0.0.1:1)/dnf")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer database.Close()
|
||
group, err := NewMySQLGroupFromDB(database, MySQLGroupOptions{DatabasePlan: repository.DatabasePlan{
|
||
Feature: repository.FeatureRepository, ShardID: "9999", GroupID: "test",
|
||
ReadDatabases: []string{"dnf"}, WriteDatabases: []string{"dnf"},
|
||
}})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if group.CharacterSettlement == nil {
|
||
t.Fatal("MySQL repository group does not expose the atomic quest/inventory settlement")
|
||
}
|
||
}
|
||
|
||
func TestMySQLDungeonPermissionLoadsAccountOwnerKeyWithoutCharacterCoercion(t *testing.T) {
|
||
accountKey, err := dungeonpermission.AccountOwnerKey("account-shared")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
sqlDB := &fakeSQLDB{
|
||
rowsets: []fakeSQLRows{{
|
||
rows: []fakeSQLRow{{
|
||
values: []any{
|
||
uint64(700), uint8(4), 0,
|
||
time.Date(2026, 8, 21, 23, 0, 0, 0, time.UTC),
|
||
},
|
||
}},
|
||
}},
|
||
}
|
||
repositories := newTestMySQLGroup(t, sqlDB)
|
||
record, found, err := repositories.DungeonPermission.Load(context.Background(), accountKey)
|
||
if err != nil || !found || record.CharacterID != accountKey || len(record.Entries) != 1 ||
|
||
record.Entries[0].DungeonID != 700 || record.Entries[0].ClearState != 4 {
|
||
t.Fatalf("account permission=%+v found=%t err=%v", record, found, err)
|
||
}
|
||
call := requireExecContaining(t, sqlDB, "dnf_dungeon_permissions")
|
||
if len(call.Args) != 1 || call.Args[0] != accountKey {
|
||
t.Fatalf("account owner query args=%#v want=%q", call.Args, accountKey)
|
||
}
|
||
}
|
||
|
||
type fakeSQLDB struct {
|
||
execs []fakeSQLCall
|
||
rows []fakeSQLRow
|
||
rowsets []fakeSQLRows
|
||
pings int
|
||
execErr error
|
||
pingErr error
|
||
}
|
||
|
||
func (f *fakeSQLDB) ExecContext(_ context.Context, query string, args ...any) (sql.Result, error) {
|
||
f.execs = append(f.execs, fakeSQLCall{
|
||
Query: query,
|
||
Args: append([]any(nil), args...),
|
||
})
|
||
if f.execErr != nil {
|
||
return nil, f.execErr
|
||
}
|
||
return nil, nil
|
||
}
|
||
|
||
func (f *fakeSQLDB) QueryContext(_ context.Context, query string, args ...any) (SQLRows, error) {
|
||
f.execs = append(f.execs, fakeSQLCall{
|
||
Query: query,
|
||
Args: append([]any(nil), args...),
|
||
})
|
||
if len(f.rowsets) == 0 {
|
||
return &fakeSQLRows{}, nil
|
||
}
|
||
rows := f.rowsets[0]
|
||
f.rowsets = f.rowsets[1:]
|
||
return &rows, nil
|
||
}
|
||
|
||
func (f *fakeSQLDB) QueryRowContext(_ context.Context, query string, args ...any) SQLRow {
|
||
f.execs = append(f.execs, fakeSQLCall{
|
||
Query: query,
|
||
Args: append([]any(nil), args...),
|
||
})
|
||
if len(f.rows) == 0 {
|
||
return fakeSQLRow{err: sql.ErrNoRows}
|
||
}
|
||
row := f.rows[0]
|
||
f.rows = f.rows[1:]
|
||
return row
|
||
}
|
||
|
||
func (f *fakeSQLDB) PingContext(context.Context) error {
|
||
f.pings++
|
||
if f.pingErr != nil {
|
||
return f.pingErr
|
||
}
|
||
return nil
|
||
}
|
||
|
||
type fakeSQLRow struct {
|
||
values []any
|
||
err error
|
||
}
|
||
|
||
type fakeSQLRows struct {
|
||
rows []fakeSQLRow
|
||
idx int
|
||
err error
|
||
}
|
||
|
||
func (r *fakeSQLRows) Close() error {
|
||
return nil
|
||
}
|
||
|
||
func (r *fakeSQLRows) Next() bool {
|
||
if r == nil || r.idx >= len(r.rows) {
|
||
return false
|
||
}
|
||
r.idx++
|
||
return true
|
||
}
|
||
|
||
func (r *fakeSQLRows) Scan(dest ...any) error {
|
||
if r == nil || r.idx == 0 || r.idx > len(r.rows) {
|
||
return errors.New("fake sql rows scan before next")
|
||
}
|
||
return r.rows[r.idx-1].Scan(dest...)
|
||
}
|
||
|
||
func (r *fakeSQLRows) Err() error {
|
||
if r == nil {
|
||
return nil
|
||
}
|
||
return r.err
|
||
}
|
||
|
||
func (r fakeSQLRow) Scan(dest ...any) error {
|
||
if r.err != nil {
|
||
return r.err
|
||
}
|
||
if len(dest) != len(r.values) {
|
||
return errors.New("fake sql row destination count mismatch")
|
||
}
|
||
for idx, value := range r.values {
|
||
if err := assignFakeSQLValue(dest[idx], value); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func assignFakeSQLValue(dest any, value any) error {
|
||
switch out := dest.(type) {
|
||
case *string:
|
||
if value == nil {
|
||
*out = ""
|
||
return nil
|
||
}
|
||
*out = value.(string)
|
||
case *int:
|
||
if value == nil {
|
||
*out = 0
|
||
return nil
|
||
}
|
||
*out = value.(int)
|
||
case *int16:
|
||
if value == nil {
|
||
*out = 0
|
||
return nil
|
||
}
|
||
switch v := value.(type) {
|
||
case int16:
|
||
*out = v
|
||
case int:
|
||
*out = int16(v)
|
||
case int64:
|
||
*out = int16(v)
|
||
default:
|
||
return errors.New("unsupported fake sql int16 value")
|
||
}
|
||
case *int64:
|
||
if value == nil {
|
||
*out = 0
|
||
return nil
|
||
}
|
||
switch v := value.(type) {
|
||
case int:
|
||
*out = int64(v)
|
||
case int64:
|
||
*out = v
|
||
default:
|
||
return errors.New("unsupported fake sql int64 value")
|
||
}
|
||
case *bool:
|
||
if value == nil {
|
||
*out = false
|
||
return nil
|
||
}
|
||
*out = value.(bool)
|
||
case *byte:
|
||
if value == nil {
|
||
*out = 0
|
||
return nil
|
||
}
|
||
switch v := value.(type) {
|
||
case byte:
|
||
*out = v
|
||
case int:
|
||
*out = byte(v)
|
||
default:
|
||
return errors.New("unsupported fake sql byte value")
|
||
}
|
||
case *uint16:
|
||
if value == nil {
|
||
*out = 0
|
||
return nil
|
||
}
|
||
switch v := value.(type) {
|
||
case uint16:
|
||
*out = v
|
||
case int:
|
||
*out = uint16(v)
|
||
default:
|
||
return errors.New("unsupported fake sql uint16 value")
|
||
}
|
||
case *uint32:
|
||
if value == nil {
|
||
*out = 0
|
||
return nil
|
||
}
|
||
switch v := value.(type) {
|
||
case uint32:
|
||
*out = v
|
||
case int:
|
||
*out = uint32(v)
|
||
default:
|
||
return errors.New("unsupported fake sql uint32 value")
|
||
}
|
||
case *uint64:
|
||
if value == nil {
|
||
*out = 0
|
||
return nil
|
||
}
|
||
switch v := value.(type) {
|
||
case uint64:
|
||
*out = v
|
||
case int64:
|
||
*out = uint64(v)
|
||
case int:
|
||
*out = uint64(v)
|
||
default:
|
||
return errors.New("unsupported fake sql uint64 value")
|
||
}
|
||
case *[]byte:
|
||
if value == nil {
|
||
*out = nil
|
||
return nil
|
||
}
|
||
*out = append([]byte(nil), value.([]byte)...)
|
||
case *sql.NullString:
|
||
if value == nil {
|
||
*out = sql.NullString{}
|
||
return nil
|
||
}
|
||
switch v := value.(type) {
|
||
case string:
|
||
*out = sql.NullString{String: v, Valid: true}
|
||
case []byte:
|
||
*out = sql.NullString{String: string(v), Valid: true}
|
||
default:
|
||
return errors.New("unsupported fake sql null string value")
|
||
}
|
||
case *sql.NullTime:
|
||
if value == nil {
|
||
*out = sql.NullTime{}
|
||
return nil
|
||
}
|
||
*out = sql.NullTime{Time: value.(time.Time), Valid: true}
|
||
case *sql.NullInt64:
|
||
if value == nil {
|
||
*out = sql.NullInt64{}
|
||
return nil
|
||
}
|
||
switch v := value.(type) {
|
||
case int:
|
||
*out = sql.NullInt64{Int64: int64(v), Valid: true}
|
||
case int64:
|
||
*out = sql.NullInt64{Int64: v, Valid: true}
|
||
default:
|
||
return errors.New("unsupported fake sql null int64 value")
|
||
}
|
||
case *time.Time:
|
||
if value == nil {
|
||
*out = time.Time{}
|
||
return nil
|
||
}
|
||
*out = value.(time.Time)
|
||
default:
|
||
return errors.New("unsupported fake sql destination")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func TestNewMySQLGroupRejectsMissingDB(t *testing.T) {
|
||
_, err := NewMySQLGroup(nil, MySQLGroupOptions{
|
||
DatabasePlan: repository.DatabasePlan{WriteDatabases: []string{"dnf_s1_w1"}},
|
||
})
|
||
if !errors.Is(err, ErrMySQLDBRequired) {
|
||
t.Fatalf("NewMySQLGroup() error = %v, want ErrMySQLDBRequired", err)
|
||
}
|
||
}
|
||
|
||
func TestMySQLGroupCheckPingsStores(t *testing.T) {
|
||
sqlDB := &fakeSQLDB{}
|
||
repos := newTestMySQLGroup(t, sqlDB)
|
||
if err := repos.Check(context.Background()); err != nil {
|
||
t.Fatalf("Check() error = %v", err)
|
||
}
|
||
if sqlDB.pings != 15 {
|
||
t.Fatalf("pings = %d, want 15", sqlDB.pings)
|
||
}
|
||
}
|
||
|
||
func TestMySQLCharacterCreateWritesSQLColumnsOnly(t *testing.T) {
|
||
sqlDB := &fakeSQLDB{}
|
||
repos := newTestMySQLGroup(t, sqlDB)
|
||
record := repository.CharacterRecord{
|
||
CharacterID: "char-1",
|
||
AccountID: "acc-1",
|
||
Slot: 2,
|
||
Name: "fighter",
|
||
Job: "15",
|
||
Level: 1,
|
||
}
|
||
if err := repository.CreateCharacter(context.Background(), repos.Character, record); err != nil {
|
||
t.Fatalf("CreateCharacter() error = %v", err)
|
||
}
|
||
call := requireExecContaining(t, sqlDB, "`dnf_s1_w1`.`dnf_characters`")
|
||
assertContains(t, call.Query, "`dnf_s1_w1`.`dnf_characters`")
|
||
assertContains(t, call.Query, "`character_id`")
|
||
assertContains(t, call.Query, "`grow_type`")
|
||
assertContains(t, call.Query, "`user_state`")
|
||
assertContains(t, call.Query, "`tutorial_completed`")
|
||
assertContains(t, call.Query, "`tutorial_reward_progress_38`")
|
||
assertContains(t, call.Query, "`delete_flag`")
|
||
assertContains(t, call.Query, "`pc_room_id`")
|
||
assertContains(t, call.Query, "`stat_block_marker`")
|
||
assertContains(t, call.Query, "`roster_card_flag`")
|
||
assertContains(t, call.Query, "`create_option_byte_63`")
|
||
assertNotContains(t, call.Query, "`stats_json`")
|
||
assertNotContains(t, call.Query, "`location_json`")
|
||
assertNotContains(t, call.Query, "`roster_json`")
|
||
statIndex := requireMySQLCharacterStatIndex(t, "tutorial_completed")
|
||
if got := call.Args[8+statIndex]; got != int64(0) {
|
||
t.Fatalf("created tutorial_completed arg = %#v, want 0", got)
|
||
}
|
||
rewardStatIndex := requireMySQLCharacterStatIndex(t, "tutorial_reward_progress_38")
|
||
if got := call.Args[8+rewardStatIndex]; got != int64(0) {
|
||
t.Fatalf("created tutorial_reward_progress_38 arg = %#v, want 0", got)
|
||
}
|
||
}
|
||
|
||
func TestMySQLCharacterFindIDByNameIgnoresSoftDeletedRows(t *testing.T) {
|
||
sqlDB := &fakeSQLDB{}
|
||
repos := newTestMySQLGroup(t, sqlDB)
|
||
|
||
_, ok, err := repos.Character.FindIDByName(context.Background(), "gone")
|
||
if err != nil {
|
||
t.Fatalf("FindIDByName() error = %v", err)
|
||
}
|
||
if ok {
|
||
t.Fatalf("FindIDByName() found a row from empty fake DB")
|
||
}
|
||
call := requireExecContaining(t, sqlDB, "`dnf_s1_r1`.`dnf_characters`")
|
||
assertContains(t, call.Query, "WHERE name = ? AND delete_flag = 0")
|
||
}
|
||
|
||
func TestMySQLCharacterSaveFieldsOnlyUpdatesDirtyColumns(t *testing.T) {
|
||
sqlDB := &fakeSQLDB{}
|
||
repos := newTestMySQLGroup(t, sqlDB)
|
||
record := repository.CharacterRecord{
|
||
CharacterID: "char-1",
|
||
AccountID: "acc-1",
|
||
Name: "fighter",
|
||
Stats: map[string]int64{"str": 99},
|
||
}
|
||
if err := repository.SaveCharacterFields(context.Background(), repos.Character, record, repository.CharacterFieldStats); err != nil {
|
||
t.Fatalf("SaveCharacterFields() error = %v", err)
|
||
}
|
||
call := requireExecContaining(t, sqlDB, "`dnf_s1_w1`.`dnf_characters`")
|
||
assertContains(t, call.Query, "`dnf_s1_w1`.`dnf_characters`")
|
||
assertContains(t, call.Query, "`grow_type` = VALUES(`grow_type`)")
|
||
assertContains(t, call.Query, "`roster_display_flags` = VALUES(`roster_display_flags`)")
|
||
assertNotContains(t, call.Query, "`location_json` = VALUES(`location_json`)")
|
||
assertNotContains(t, call.Query, "`roster_json` = VALUES(`roster_json`)")
|
||
assertNotContains(t, call.Query, "`stats_json`")
|
||
assertNotContains(t, call.Query, "`name` = VALUES(`name`)")
|
||
}
|
||
|
||
func TestMySQLCharacterTutorialFlagsSaveAndLoadThroughMirrorColumns(t *testing.T) {
|
||
ctx := context.Background()
|
||
statIndex := requireMySQLCharacterStatIndex(t, "tutorial_completed")
|
||
rewardStatIndex := requireMySQLCharacterStatIndex(t, "tutorial_reward_progress_38")
|
||
|
||
saveDB := &fakeSQLDB{}
|
||
saveRepos := newTestMySQLGroup(t, saveDB)
|
||
record := repository.CharacterRecord{
|
||
CharacterID: "tutorial-char-1",
|
||
AccountID: "acc-1",
|
||
Name: "gunner",
|
||
Job: "2",
|
||
Level: 1,
|
||
Stats: map[string]int64{
|
||
"tutorial_completed": 1,
|
||
"tutorial_reward_progress_38": 1,
|
||
},
|
||
}
|
||
if err := repository.SaveCharacterFields(ctx, saveRepos.Character, record, repository.CharacterFieldStats); err != nil {
|
||
t.Fatalf("SaveCharacterFields() error = %v", err)
|
||
}
|
||
saveCall := requireExecContaining(t, saveDB, "`dnf_s1_w1`.`dnf_characters`")
|
||
assertContains(t, saveCall.Query, "`tutorial_completed` = VALUES(`tutorial_completed`)")
|
||
assertContains(t, saveCall.Query, "`tutorial_reward_progress_38` = VALUES(`tutorial_reward_progress_38`)")
|
||
if got := saveCall.Args[8+statIndex]; got != int64(1) {
|
||
t.Fatalf("saved tutorial_completed arg = %#v, want 1", got)
|
||
}
|
||
if got := saveCall.Args[8+rewardStatIndex]; got != int64(1) {
|
||
t.Fatalf("saved tutorial_reward_progress_38 arg = %#v, want 1", got)
|
||
}
|
||
|
||
rowValues := []any{"tutorial-char-1", "acc-1", 0, "gunner", "2", 1}
|
||
for idx := range mysqlCharacterStatColumns {
|
||
value := int64(0)
|
||
if idx == statIndex || idx == rewardStatIndex {
|
||
value = 1
|
||
}
|
||
rowValues = append(rowValues, value)
|
||
}
|
||
rowValues = append(rowValues, nil, nil)
|
||
loadDB := &fakeSQLDB{rows: []fakeSQLRow{{values: rowValues}}}
|
||
loadRepos := newTestMySQLGroup(t, loadDB)
|
||
loaded, ok, err := loadRepos.Character.Load(ctx, "tutorial-char-1")
|
||
if err != nil || !ok {
|
||
t.Fatalf("Load() ok=%v error=%v", ok, err)
|
||
}
|
||
if got := loaded.Stats["tutorial_completed"]; got != 1 {
|
||
t.Fatalf("loaded tutorial_completed = %d, want 1", got)
|
||
}
|
||
if got := loaded.Stats["tutorial_reward_progress_38"]; got != 1 {
|
||
t.Fatalf("loaded tutorial_reward_progress_38 = %d, want 1", got)
|
||
}
|
||
loadCall := requireExecContaining(t, loadDB, "`dnf_s1_r1`.`dnf_characters`")
|
||
assertContains(t, loadCall.Query, "`tutorial_completed`")
|
||
assertContains(t, loadCall.Query, "`tutorial_reward_progress_38`")
|
||
}
|
||
|
||
func TestMySQLCharacterCrystalSelectionSaveAndLoadThroughSignedMirrorColumn(t *testing.T) {
|
||
ctx := context.Background()
|
||
statIndex := requireMySQLCharacterStatIndex(t, "premium_crystal_selection")
|
||
|
||
for _, test := range []struct {
|
||
name string
|
||
selection int64
|
||
}{
|
||
{name: "none", selection: -1},
|
||
{name: "black", selection: 0},
|
||
} {
|
||
selection := test.selection
|
||
t.Run(test.name, func(t *testing.T) {
|
||
saveDB := &fakeSQLDB{}
|
||
saveRepos := newTestMySQLGroup(t, saveDB)
|
||
record := repository.CharacterRecord{
|
||
CharacterID: "crystal-char-1",
|
||
AccountID: "acc-1",
|
||
Name: "slayer",
|
||
Job: "0",
|
||
Level: 1,
|
||
Stats: map[string]int64{
|
||
"premium_crystal_selection": selection,
|
||
},
|
||
}
|
||
if err := repository.SaveCharacterFields(ctx, saveRepos.Character, record, repository.CharacterFieldStats); err != nil {
|
||
t.Fatalf("SaveCharacterFields() error = %v", err)
|
||
}
|
||
saveCall := requireExecContaining(t, saveDB, "`dnf_s1_w1`.`dnf_characters`")
|
||
assertContains(t, saveCall.Query, "`premium_crystal_selection` = VALUES(`premium_crystal_selection`)")
|
||
if got := saveCall.Args[8+statIndex]; got != selection {
|
||
t.Fatalf("saved premium_crystal_selection arg = %#v, want %d", got, selection)
|
||
}
|
||
|
||
rowValues := []any{"crystal-char-1", "acc-1", 0, "slayer", "0", 1}
|
||
for idx, column := range mysqlCharacterStatColumns {
|
||
value := column.fallback
|
||
if idx == statIndex {
|
||
value = selection
|
||
}
|
||
rowValues = append(rowValues, value)
|
||
}
|
||
rowValues = append(rowValues, nil, nil)
|
||
loadDB := &fakeSQLDB{rows: []fakeSQLRow{{values: rowValues}}}
|
||
loadRepos := newTestMySQLGroup(t, loadDB)
|
||
loaded, ok, err := loadRepos.Character.Load(ctx, "crystal-char-1")
|
||
if err != nil || !ok {
|
||
t.Fatalf("Load() ok=%v error=%v", ok, err)
|
||
}
|
||
if got := loaded.Stats["premium_crystal_selection"]; got != selection {
|
||
t.Fatalf("loaded premium_crystal_selection = %d, want %d", got, selection)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestMySQLCharacterSaveFieldsUsesRelationalRosterTables(t *testing.T) {
|
||
sqlDB := &fakeSQLDB{}
|
||
repos := newTestMySQLGroup(t, sqlDB)
|
||
record := repository.CharacterRecord{
|
||
CharacterID: "char-1",
|
||
AccountID: "acc-1",
|
||
Name: "fighter",
|
||
Roster: repository.CharacterRoster{
|
||
Entry: repository.CharacterRosterEntry{
|
||
PackedJobGrow: 0x24,
|
||
ByteC: 56,
|
||
ObjectID: 17,
|
||
Flags: []int64{1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 3, 0, 0, 4},
|
||
},
|
||
},
|
||
}
|
||
if err := repository.SaveCharacterFields(context.Background(), repos.Character, record, repository.CharacterFieldRoster); err != nil {
|
||
t.Fatalf("SaveCharacterFields() error = %v", err)
|
||
}
|
||
call := requireExecContaining(t, sqlDB, "`dnf_s1_w1`.`dnf_characters`")
|
||
assertNotContains(t, call.Query, "`roster_json`")
|
||
assertNotContains(t, call.Query, "`stats_json` = VALUES(`stats_json`)")
|
||
assertNotContains(t, call.Query, "`location_json` = VALUES(`location_json`)")
|
||
assertNotContains(t, call.Query, "`name` = VALUES(`name`)")
|
||
requireExecContaining(t, sqlDB, "`dnf_s1_w1`.`dnf_character_rosters`")
|
||
requireExecContaining(t, sqlDB, "`dnf_s1_w1`.`dnf_character_roster_lists`")
|
||
}
|
||
|
||
func TestMergeCharacterMirrorStatsUsesOnlyMySQLColumns(t *testing.T) {
|
||
record := repository.CharacterRecord{
|
||
Stats: map[string]int64{
|
||
"grow_type": 7,
|
||
"user_state": 9,
|
||
"pvp_grade": 8,
|
||
"custom_only": 123,
|
||
},
|
||
}
|
||
values := make([]sql.NullInt64, len(mysqlCharacterStatColumns))
|
||
for idx, column := range mysqlCharacterStatColumns {
|
||
switch column.key {
|
||
case "grow_type":
|
||
values[idx] = sql.NullInt64{Int64: 0, Valid: true}
|
||
case "user_state":
|
||
values[idx] = sql.NullInt64{Int64: 0, Valid: true}
|
||
case "pvp_grade":
|
||
values[idx] = sql.NullInt64{Int64: 0, Valid: true}
|
||
}
|
||
}
|
||
|
||
mergeCharacterMirrorStats(&record, values)
|
||
|
||
if record.Stats["grow_type"] != 0 || record.Stats["user_state"] != 0 || record.Stats["pvp_grade"] != 0 {
|
||
t.Fatalf("MySQL mirror columns did not override stale stats_json: %+v", record.Stats)
|
||
}
|
||
if _, ok := record.Stats["custom_only"]; ok {
|
||
t.Fatalf("custom stats_json field leaked into MySQL-only stats: %+v", record.Stats)
|
||
}
|
||
}
|
||
|
||
func TestMySQLInventorySaveFieldsOnlyWritesWarehouse(t *testing.T) {
|
||
sqlDB := &fakeSQLDB{}
|
||
repos := newTestMySQLGroup(t, sqlDB)
|
||
record := repository.InventoryRecord{
|
||
CharacterID: "char-1",
|
||
Warehouse: map[string]repository.ItemStack{"safe-1": {ItemID: 1001, Count: 2}},
|
||
}
|
||
if err := repository.SaveInventoryFields(context.Background(), repos.Inventory, record, repository.InventoryFieldWarehouse); err != nil {
|
||
t.Fatalf("SaveInventoryFields() error = %v", err)
|
||
}
|
||
requireExecContaining(t, sqlDB, "`dnf_s1_w1`.`dnf_inventories`")
|
||
insert := requireExecContaining(t, sqlDB, "`dnf_s1_w1`.`dnf_inventory_items`")
|
||
assertContains(t, insert.Query, "collection_name")
|
||
assertNotContains(t, strings.Join(execQueries(sqlDB), "\n"), "slots_json")
|
||
}
|
||
|
||
// 一个普通背包保存必须把全部槽位行合并成单条多行 VALUES INSERT;逐行
|
||
// ExecContext 是 op26 分解 ~350ms 延迟的主要放大器(93 行循环 vs 单语句)。
|
||
func TestMySQLInventorySaveSlotsUsesSingleMultiRowInsert(t *testing.T) {
|
||
ctx := context.Background()
|
||
sqlDB := &fakeSQLDB{}
|
||
repos := newTestMySQLGroup(t, sqlDB)
|
||
slots := make(map[string]repository.ItemStack, 93)
|
||
for index := 0; index < 93; index++ {
|
||
slots[fmt.Sprintf("slot-%02d", index)] = repository.ItemStack{
|
||
ItemID: int64(1000 + index),
|
||
Count: 1,
|
||
RawEntry: make([]byte, 0x77),
|
||
Extra: map[string]string{"source": "test"},
|
||
}
|
||
}
|
||
record := repository.InventoryRecord{CharacterID: "char-bulk-slots", Slots: slots}
|
||
if err := repository.SaveInventoryFields(ctx, repos.Inventory, record, repository.InventoryFieldSlots); err != nil {
|
||
t.Fatalf("SaveInventoryFields() error = %v", err)
|
||
}
|
||
itemInserts := 0
|
||
extraInserts := 0
|
||
for _, call := range sqlDB.execs {
|
||
switch {
|
||
case strings.Contains(call.Query, "INSERT INTO `dnf_s1_w1`.`dnf_inventory_items`"):
|
||
itemInserts++
|
||
assertContains(t, call.Query, "), (")
|
||
rows := len(call.Args) / 8
|
||
if rows == 0 || len(call.Args)%8 != 0 || rows > mysqlReplaceBatchSize {
|
||
t.Fatalf("item insert args=%d rows=%d query=%s", len(call.Args), rows, call.Query)
|
||
}
|
||
case strings.Contains(call.Query, "INSERT INTO `dnf_s1_w1`.`dnf_inventory_item_extra`"):
|
||
extraInserts++
|
||
assertContains(t, call.Query, "), (")
|
||
rows := len(call.Args) / 5
|
||
if rows == 0 || len(call.Args)%5 != 0 || rows > mysqlReplaceBatchSize {
|
||
t.Fatalf("extra insert args=%d rows=%d query=%s", len(call.Args), rows, call.Query)
|
||
}
|
||
}
|
||
}
|
||
if itemInserts != 1 || extraInserts != 1 {
|
||
t.Fatalf("slot writes must be one multi-row INSERT per table; items=%d extra=%d queries=%v",
|
||
itemInserts, extraInserts, execQueries(sqlDB))
|
||
}
|
||
}
|
||
|
||
// 超过批大小(128 行)时按块拆分,且保持 entry_key 排序顺序。
|
||
func TestMySQLInventorySaveSlotsChunksBeyondBatchSize(t *testing.T) {
|
||
ctx := context.Background()
|
||
sqlDB := &fakeSQLDB{}
|
||
repos := newTestMySQLGroup(t, sqlDB)
|
||
const total = mysqlReplaceBatchSize + 10
|
||
slots := make(map[string]repository.ItemStack, total)
|
||
for index := 0; index < total; index++ {
|
||
slots[fmt.Sprintf("slot-%04d", index)] = repository.ItemStack{ItemID: int64(2000 + index), Count: 1}
|
||
}
|
||
record := repository.InventoryRecord{CharacterID: "char-chunked-slots", Slots: slots}
|
||
if err := repository.SaveInventoryFields(ctx, repos.Inventory, record, repository.InventoryFieldSlots); err != nil {
|
||
t.Fatalf("SaveInventoryFields() error = %v", err)
|
||
}
|
||
var chunkRows []int
|
||
for _, call := range sqlDB.execs {
|
||
if !strings.Contains(call.Query, "INSERT INTO `dnf_s1_w1`.`dnf_inventory_items`") {
|
||
continue
|
||
}
|
||
chunkRows = append(chunkRows, len(call.Args)/8)
|
||
}
|
||
if len(chunkRows) != 2 || chunkRows[0] != mysqlReplaceBatchSize || chunkRows[1] != 10 {
|
||
t.Fatalf("expected chunks [%d 10], got %v", mysqlReplaceBatchSize, chunkRows)
|
||
}
|
||
}
|
||
|
||
func TestMySQLEquipmentSaveFieldsWritesEntries(t *testing.T) {
|
||
sqlDB := &fakeSQLDB{}
|
||
repos := newTestMySQLGroup(t, sqlDB)
|
||
record := repository.EquipmentRecord{
|
||
CharacterID: "char-1",
|
||
Entries: map[string]repository.EquipmentEntry{
|
||
"11": {SlotIndex: 11, ItemID: 1001, RawEntry: []byte{1, 2, 3}},
|
||
},
|
||
}
|
||
if err := repository.SaveEquipmentFields(context.Background(), repos.Equipment, record, repository.EquipmentFieldEntries); err != nil {
|
||
t.Fatalf("SaveEquipmentFields() error = %v", err)
|
||
}
|
||
requireExecContaining(t, sqlDB, "`dnf_s1_w1`.`dnf_equipments`")
|
||
requireExecContaining(t, sqlDB, "`dnf_s1_w1`.`dnf_equipment_entries`")
|
||
assertNotContains(t, strings.Join(execQueries(sqlDB), "\n"), "entries_json")
|
||
}
|
||
|
||
func TestMySQLSkillSaveFieldsWritesLevelsAndPointsTogether(t *testing.T) {
|
||
sqlDB := &fakeSQLDB{}
|
||
repos := newTestMySQLGroup(t, sqlDB)
|
||
record := repository.SkillRecord{
|
||
CharacterID: "char-1",
|
||
Skills: map[int64]repository.SkillState{46: {Level: 2, Enabled: true}},
|
||
Points: repository.SkillPointState{TotalSP: 100, RemainingSP: 60},
|
||
Layouts: map[int]repository.SkillLayout{0: {0: 46}},
|
||
}
|
||
if err := repository.SaveSkillFields(context.Background(), repos.Skill, record, repository.SkillFieldSkills, repository.SkillFieldPoints, repository.SkillFieldLayouts); err != nil {
|
||
t.Fatalf("SaveSkillFields() error = %v", err)
|
||
}
|
||
root := requireExecContaining(t, sqlDB, "`dnf_s1_w1`.`dnf_skills`")
|
||
assertContains(t, root.Query, "`total_sp` = VALUES(`total_sp`)")
|
||
requireExecContaining(t, sqlDB, "`dnf_s1_w1`.`dnf_skill_states`")
|
||
requireExecContaining(t, sqlDB, "`dnf_s1_w1`.`dnf_skill_layouts`")
|
||
assertNotContains(t, strings.Join(execQueries(sqlDB), "\n"), "_json")
|
||
}
|
||
|
||
func TestMySQLSkillSaveFieldsWritesIndependentAlternateTree(t *testing.T) {
|
||
sqlDB := &fakeSQLDB{}
|
||
repos := newTestMySQLGroup(t, sqlDB)
|
||
record := repository.SkillRecord{
|
||
CharacterID: "char-1",
|
||
Trees: map[int]repository.SkillTreeState{
|
||
1: {
|
||
Skills: map[int64]repository.SkillState{47: {Level: 3, Enabled: true}},
|
||
Points: repository.SkillPointState{TotalSP: 200, RemainingSP: 80, TotalTP: 20, RemainingTP: 7},
|
||
},
|
||
},
|
||
}
|
||
if err := repository.SaveSkillFields(context.Background(), repos.Skill, record, repository.SkillFieldTrees); err != nil {
|
||
t.Fatalf("SaveSkillFields() error = %v", err)
|
||
}
|
||
// 列序仍被断言:下面的 Args 断言按下标取值,列序是它们的前提。
|
||
// 形状改为批量插入后与 TestMySQLInventorySaveSlotsUsesSingleMultiRowInsert
|
||
// 固化的规范一致:标识符由 buildMySQLBulkInsert 加反引号。
|
||
points := requireExecContaining(t, sqlDB, "`dnf_s1_w1`.`dnf_skill_tree_points` (`character_id`, `tree_id`")
|
||
if len(points.Args) < 7 || points.Args[1] != 1 || points.Args[2] != 200 || points.Args[3] != 80 || points.Args[4] != 20 || points.Args[5] != 7 {
|
||
t.Fatalf("alternate-tree point args = %#v", points.Args)
|
||
}
|
||
states := requireExecContaining(t, sqlDB, "`dnf_s1_w1`.`dnf_skill_tree_states` (`character_id`, `tree_id`")
|
||
if len(states.Args) < 5 || states.Args[1] != 1 || states.Args[2] != int64(47) || states.Args[3] != 3 || states.Args[4] != true {
|
||
t.Fatalf("alternate-tree skill args = %#v", states.Args)
|
||
}
|
||
}
|
||
|
||
func TestMySQLSkillSaveFieldsWritesCommandBytes(t *testing.T) {
|
||
sqlDB := &fakeSQLDB{}
|
||
repos := newTestMySQLGroup(t, sqlDB)
|
||
record := repository.SkillRecord{
|
||
CharacterID: "char-1",
|
||
Commands: map[int64][]byte{46: {0, 4, 0xff}},
|
||
}
|
||
if err := repository.SaveSkillFields(context.Background(), repos.Skill, record, repository.SkillFieldCommands); err != nil {
|
||
t.Fatalf("SaveSkillFields() error = %v", err)
|
||
}
|
||
queries := strings.Join(execQueries(sqlDB), "\n")
|
||
if !strings.Contains(queries, "INSERT INTO `dnf_s1_w1`.`dnf_skill_commands` (`character_id`, `skill_id`, `command_bytes`)") {
|
||
t.Fatalf("command write queries=%q", queries)
|
||
}
|
||
}
|
||
|
||
func TestMySQLPetSaveFieldsWritesEntriesAndDisplay(t *testing.T) {
|
||
sqlDB := &fakeSQLDB{}
|
||
repos := newTestMySQLGroup(t, sqlDB)
|
||
record := repository.PetRecord{
|
||
CharacterID: "char-1",
|
||
Entries: map[string]repository.PetEntry{
|
||
"pet-1": {PetKey: "pet-1", ItemID: 1001},
|
||
},
|
||
EquippedKey: "pet-1",
|
||
TownDisplay: true,
|
||
}
|
||
if err := repository.SavePetFields(context.Background(), repos.Pet, record, repository.PetFieldEntries, repository.PetFieldEquipped, repository.PetFieldDisplay); err != nil {
|
||
t.Fatalf("SavePetFields() error = %v", err)
|
||
}
|
||
call := requireExecContaining(t, sqlDB, "`dnf_s1_w1`.`dnf_pets`")
|
||
assertContains(t, call.Query, "`equipped_key` = VALUES(`equipped_key`)")
|
||
assertContains(t, call.Query, "`town_display` = VALUES(`town_display`)")
|
||
requireExecContaining(t, sqlDB, "`dnf_s1_w1`.`dnf_pet_entries`")
|
||
assertNotContains(t, strings.Join(execQueries(sqlDB), "\n"), "entries_json")
|
||
}
|
||
|
||
func TestMySQLMailboxSaveFieldsWritesMails(t *testing.T) {
|
||
sqlDB := &fakeSQLDB{}
|
||
repos := newTestMySQLGroup(t, sqlDB)
|
||
record := repository.MailboxRecord{
|
||
CharacterID: "char-1",
|
||
Mails: map[string]repository.MailRecord{
|
||
"mail-1": {MailID: "mail-1", Title: "hello", Gold: 100},
|
||
},
|
||
}
|
||
if err := repository.SaveMailboxFields(context.Background(), repos.Mailbox, record, repository.MailboxFieldMails); err != nil {
|
||
t.Fatalf("SaveMailboxFields() error = %v", err)
|
||
}
|
||
requireExecContaining(t, sqlDB, "`dnf_s1_w1`.`dnf_mailboxes`")
|
||
requireExecContaining(t, sqlDB, "`dnf_s1_w1`.`dnf_mails`")
|
||
assertNotContains(t, strings.Join(execQueries(sqlDB), "\n"), "mails_json")
|
||
}
|
||
|
||
func TestMySQLMailboxLoadReadsMails(t *testing.T) {
|
||
sqlDB := &fakeSQLDB{
|
||
rows: []fakeSQLRow{{
|
||
values: []any{
|
||
"char-1",
|
||
time.Date(2026, 7, 20, 2, 0, 0, 0, time.UTC),
|
||
},
|
||
}},
|
||
rowsets: []fakeSQLRows{{rows: []fakeSQLRow{{values: []any{
|
||
"mail-1", "", "", "", "", "hello", "", int64(100), false, false, false, nil, nil,
|
||
}}}}},
|
||
}
|
||
repos := newTestMySQLGroup(t, sqlDB)
|
||
record, ok, err := repos.Mailbox.Load(context.Background(), "char-1")
|
||
if err != nil || !ok {
|
||
t.Fatalf("Load() ok=%v error=%v", ok, err)
|
||
}
|
||
if record.Mails["mail-1"].Gold != 100 || record.Mails["mail-1"].Title != "hello" {
|
||
t.Fatalf("mailbox = %+v", record)
|
||
}
|
||
call := requireExecContaining(t, sqlDB, "`dnf_s1_r1`.`dnf_mailboxes`")
|
||
assertContains(t, call.Query, "`dnf_s1_r1`.`dnf_mailboxes`")
|
||
}
|
||
|
||
func TestMySQLAccountLoadUsesReadDatabase(t *testing.T) {
|
||
sqlDB := &fakeSQLDB{
|
||
rows: []fakeSQLRow{{
|
||
values: []any{
|
||
"acc-1",
|
||
"open",
|
||
uint64(123456789),
|
||
"adventure-one",
|
||
time.Date(2026, 6, 29, 1, 0, 0, 0, time.UTC),
|
||
time.Date(2026, 6, 29, 2, 0, 0, 0, time.UTC),
|
||
},
|
||
}},
|
||
rowsets: []fakeSQLRows{{rows: []fakeSQLRow{{values: []any{"source", "unit"}}}}},
|
||
}
|
||
repos := newTestMySQLGroup(t, sqlDB)
|
||
record, ok, err := repos.Account.Load(context.Background(), "acc-1")
|
||
if err != nil || !ok {
|
||
t.Fatalf("Load() ok=%v err=%v", ok, err)
|
||
}
|
||
if record.Metadata["source"] != "unit" || record.State != "open" || record.HonorExp != 123456789 || record.RepresentAccountName != "adventure-one" {
|
||
t.Fatalf("unexpected account: %+v", record)
|
||
}
|
||
call := requireExecContaining(t, sqlDB, "`dnf_s1_r1`.`dnf_accounts`")
|
||
assertContains(t, call.Query, "`dnf_s1_r1`.`dnf_accounts`")
|
||
assertNotContains(t, call.Query, "FOR UPDATE")
|
||
}
|
||
|
||
func TestReplaceQuestStatesUsesBoundedMultiRowInsertBatches(t *testing.T) {
|
||
sqlDB := &fakeSQLDB{}
|
||
states := make(map[int64]repository.QuestState, 129)
|
||
for questID := int64(1); questID <= 129; questID++ {
|
||
states[questID] = repository.QuestState{
|
||
Status: "active",
|
||
TriggerType: 1,
|
||
ProgressValue: questID,
|
||
Extra: map[string]string{"completion_key": "batch-test"},
|
||
}
|
||
}
|
||
if err := replaceQuestStates(context.Background(), sqlDB, "quest_states", "quest_extra", "char-1", "states", states); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(sqlDB.execs) != 6 {
|
||
t.Fatalf("exec count=%d want 6 (2 deletes + 2 state batches + 2 extra batches)", len(sqlDB.execs))
|
||
}
|
||
stateInserts, extraInserts := 0, 0
|
||
for _, call := range sqlDB.execs {
|
||
switch {
|
||
case strings.Contains(call.Query, "INSERT INTO quest_states"):
|
||
stateInserts++
|
||
if len(call.Args) > 128*9 {
|
||
t.Fatalf("state batch args=%d exceeds bounded batch", len(call.Args))
|
||
}
|
||
case strings.Contains(call.Query, "INSERT INTO quest_extra"):
|
||
extraInserts++
|
||
if len(call.Args) > 128*5 {
|
||
t.Fatalf("extra batch args=%d exceeds bounded batch", len(call.Args))
|
||
}
|
||
}
|
||
}
|
||
if stateInserts != 2 || extraInserts != 2 {
|
||
t.Fatalf("state inserts=%d extra inserts=%d", stateInserts, extraInserts)
|
||
}
|
||
}
|
||
|
||
// replaceStringMap 的差异写:**值没变的行一个字节都不写**。
|
||
//
|
||
// 这条是数据库那批移植里风险最高的分支:默认的假 driver 读回来是"没有行",等于只覆盖了
|
||
// "全是新增";只有喂进一份与输入相同的现有行集,才能证明"相等 → 不删也不插"。
|
||
// (上游对这条的证明放在 DNF_MYSQL_LIVE_DSN 的真库往返用例里,见 live_bulk_roundtrip_test.go;
|
||
// 本树运行时 MySQL 当时没起,所以补一条不依赖真库的等价断言。)
|
||
func TestReplaceStringMapWritesOnlyChangedKeys(t *testing.T) {
|
||
ctx := context.Background()
|
||
database := &fakeSQLDB{rowsets: []fakeSQLRows{{rows: []fakeSQLRow{
|
||
{values: []any{"keep", "same"}},
|
||
{values: []any{"edit", "old"}},
|
||
{values: []any{"drop", "gone"}},
|
||
}}}}
|
||
err := replaceStringMap(ctx, database, "account_metadata", "character_id", "77", map[string]string{
|
||
"keep": "same", // 未变:一个语句里都不该出现
|
||
"edit": "new", // 改值:删掉再插回
|
||
"fresh": "added", // 新增:只插
|
||
// "drop" 从输入里消失:只删
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("replaceStringMap() error = %v", err)
|
||
}
|
||
deletes, inserts := 0, 0
|
||
insertedRows := make([]string, 0, 2)
|
||
for _, call := range database.execs {
|
||
switch {
|
||
case strings.HasPrefix(call.Query, "DELETE FROM account_metadata"):
|
||
deletes++
|
||
assertContains(t, call.Query, "IN (")
|
||
// 只应删「要重写的」与「已消失的」:edit / fresh / drop —— 不含 keep。
|
||
if containsArg(call.Args, "keep") {
|
||
t.Fatalf("未变化的键被删除: args=%v", call.Args)
|
||
}
|
||
for _, key := range []string{"edit", "fresh", "drop"} {
|
||
if !containsArg(call.Args, key) {
|
||
t.Fatalf("键 %q 应被删除: args=%v", key, call.Args)
|
||
}
|
||
}
|
||
case strings.HasPrefix(call.Query, "INSERT INTO account_metadata"):
|
||
inserts++
|
||
assertContains(t, call.Query, "), (")
|
||
if len(call.Args)%3 != 0 {
|
||
t.Fatalf("insert args=%v", call.Args)
|
||
}
|
||
for index := 0; index < len(call.Args); index += 3 {
|
||
insertedRows = append(insertedRows, call.Args[index+1].(string))
|
||
if containsArg([]any{call.Args[index+1]}, "keep") {
|
||
t.Fatalf("未变化的键被重写: args=%v", call.Args)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if deletes != 1 || inserts != 1 {
|
||
t.Fatalf("差异写应恰好一次 DELETE + 一次 INSERT,实际 deletes=%d inserts=%d queries=%v",
|
||
deletes, inserts, execQueries(database))
|
||
}
|
||
if !slices.Equal(insertedRows, []string{"edit", "fresh"}) {
|
||
t.Fatalf("只应重写变化/新增的键,实际插入 %v", insertedRows)
|
||
}
|
||
}
|
||
|
||
// 宠物保存的差异写:**没变的主行、附加键、清除令牌一行都不写**。
|
||
//
|
||
// 这是 0897707 要证明的核心 —— 上游抓包显示"每次宠物保存都会重写全部宠物连同它们
|
||
// 119 个清除令牌,只为改一个饱食度"。这里喂进一只完全没变的宠物(带 3 个清除令牌)、
|
||
// 一只只改了饱食度的宠物、一只新增的、一只被删掉的,逐一断言各部分的写法。
|
||
func TestReplacePetEntriesWritesOnlyChangedPetsAndParts(t *testing.T) {
|
||
ctx := context.Background()
|
||
entryRow := func(key string, satiety byte, extra ...any) fakeSQLRow {
|
||
values := []any{
|
||
key, uint32(5001), int64(900001), byte(0), int16(9),
|
||
"", []byte(nil), satiety, int64(0), byte(0), byte(0), byte(0),
|
||
int64(1), int64(0), byte(0), []byte(nil),
|
||
}
|
||
return fakeSQLRow{values: append(values, extra...)}
|
||
}
|
||
database := &fakeSQLDB{rowsets: []fakeSQLRows{
|
||
// 现有 pet_entries:unchanged(饱食度 50)与 shrunk(饱食度 50,输入改成 40)
|
||
{rows: []fakeSQLRow{entryRow("unchanged", 50), entryRow("shrunk", 50), entryRow("dropped", 7)}},
|
||
// 现有 pet_extra:unchanged 有一条
|
||
{rows: []fakeSQLRow{{values: []any{"unchanged", "k", "v"}}}},
|
||
// 现有 pet_clear_tokens:unchanged 三个令牌(顺序 t1,t2,t3)
|
||
{rows: []fakeSQLRow{
|
||
{values: []any{"unchanged", "t1", true}},
|
||
{values: []any{"unchanged", "t2", false}},
|
||
{values: []any{"unchanged", "t3", true}},
|
||
}},
|
||
}}
|
||
unchanged := repository.PetEntry{
|
||
PetKey: "unchanged", CreatureKey: 5001, ItemID: 900001, SourceSlotIndex: 9,
|
||
Satiety: 50, Level: 1,
|
||
Extra: map[string]string{"k": "v"},
|
||
AppliedClearTokens: map[string]bool{"t1": true, "t2": false, "t3": true},
|
||
AppliedClearTokenOrder: []string{"t1", "t2", "t3"},
|
||
}
|
||
// shrunk 的持久状态只有主行(无附加键、无令牌),输入只改了饱食度 50→40。
|
||
shrunk := repository.PetEntry{
|
||
PetKey: "shrunk", CreatureKey: 5001, ItemID: 900001, SourceSlotIndex: 9,
|
||
Satiety: 40, Level: 1,
|
||
}
|
||
added := unchanged
|
||
added.PetKey = "added"
|
||
|
||
err := replacePetEntries(ctx, database, "pet_entries", "pet_extra", "pet_clear_tokens", "77",
|
||
map[string]repository.PetEntry{"unchanged": unchanged, "shrunk": shrunk, "added": added})
|
||
if err != nil {
|
||
t.Fatalf("replacePetEntries() error = %v", err)
|
||
}
|
||
|
||
for _, call := range database.execs {
|
||
isWrite := strings.HasPrefix(call.Query, "DELETE FROM") || strings.HasPrefix(call.Query, "INSERT INTO")
|
||
if !isWrite {
|
||
continue // 三次 FOR UPDATE 读
|
||
}
|
||
if containsArg(call.Args, "unchanged") {
|
||
t.Fatalf("完全没变的宠物被写了: query=%s args=%v", call.Query, call.Args)
|
||
}
|
||
}
|
||
// shrunk 只改了饱食度 ⇒ 只有主行重写,令牌一行不动。
|
||
// added 是新键 ⇒ 三部分都写。dropped 从输入里消失 ⇒ 三部分都删。
|
||
var entryDeletes, tokenDeletes, entryInserts, tokenInserts int
|
||
for _, call := range database.execs {
|
||
switch {
|
||
case strings.HasPrefix(call.Query, "DELETE FROM pet_entries"):
|
||
entryDeletes++
|
||
for _, key := range []string{"shrunk", "added", "dropped"} {
|
||
if !containsArg(call.Args, key) {
|
||
t.Fatalf("主行应删除 %q: args=%v", key, call.Args)
|
||
}
|
||
}
|
||
case strings.HasPrefix(call.Query, "DELETE FROM pet_clear_tokens"):
|
||
tokenDeletes++
|
||
if containsArg(call.Args, "shrunk") {
|
||
t.Fatalf("只改了饱食度的宠物不应重写清除令牌: args=%v", call.Args)
|
||
}
|
||
if !containsArg(call.Args, "dropped") || !containsArg(call.Args, "added") {
|
||
t.Fatalf("被删与新增的宠物都该重写清除令牌: args=%v", call.Args)
|
||
}
|
||
case strings.HasPrefix(call.Query, "INSERT INTO pet_entries"):
|
||
entryInserts++
|
||
for index := 1; index < len(call.Args); index += 17 {
|
||
key, _ := call.Args[index].(string)
|
||
if key == "unchanged" || key == "dropped" {
|
||
t.Fatalf("只应重插变化/新增的主行,出现 %q", key)
|
||
}
|
||
}
|
||
case strings.HasPrefix(call.Query, "INSERT INTO pet_clear_tokens"):
|
||
tokenInserts++
|
||
if containsArg(call.Args, "unchanged") || containsArg(call.Args, "shrunk") {
|
||
t.Fatalf("未变化的令牌不应重插: args=%v", call.Args)
|
||
}
|
||
}
|
||
}
|
||
if entryDeletes != 1 || entryInserts != 1 || tokenDeletes != 1 || tokenInserts != 1 {
|
||
t.Fatalf("语句条数 entryDel=%d entryIns=%d tokenDel=%d tokenIns=%d queries=%v",
|
||
entryDeletes, entryInserts, tokenDeletes, tokenInserts, execQueries(database))
|
||
}
|
||
}
|
||
|
||
// 背包与装备的差异写:**没变的行一条语句都不发**(c0ebdce)。
|
||
//
|
||
// 上游的证明在 DNF_MYSQL_LIVE_DSN 真库用例里;这里用能返回行集的假 driver 做等价断言 ——
|
||
// 默认的假 driver 读回来是"没有行",只覆盖"全是新增",恰好漏掉最该防的那条分支。
|
||
func TestDiffCollectionWritersTouchOnlyChangedRows(t *testing.T) {
|
||
ctx := context.Background()
|
||
stack := func(itemID, count int64) repository.ItemStack {
|
||
return repository.ItemStack{ItemID: itemID, Count: count}
|
||
}
|
||
database := &fakeSQLDB{rowsets: []fakeSQLRows{
|
||
// 现有 inventory_items:keep(会被原样写回)与 edit(数量变了)、drop(输入里没了)
|
||
{rows: []fakeSQLRow{
|
||
{values: []any{"keep", int64(100), int64(1), false, nil, []byte(nil)}},
|
||
{values: []any{"edit", int64(200), int64(1), false, nil, []byte(nil)}},
|
||
{values: []any{"drop", int64(300), int64(1), false, nil, []byte(nil)}},
|
||
}},
|
||
// 现有 inventory_item_extra:空
|
||
{rows: nil},
|
||
}}
|
||
err := replaceItemStackCollection(ctx, database, "inventory_items", "inventory_item_extra",
|
||
"character_id", "77", "", false, map[string]repository.ItemStack{
|
||
"keep": stack(100, 1), // 未变
|
||
"edit": stack(200, 5), // 数量变了
|
||
"fresh": stack(400, 1), // 新增
|
||
// "drop" 消失
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("replaceItemStackCollection() error = %v", err)
|
||
}
|
||
for _, call := range database.execs {
|
||
if !strings.HasPrefix(call.Query, "DELETE FROM") && !strings.HasPrefix(call.Query, "INSERT INTO") {
|
||
continue
|
||
}
|
||
if containsArg(call.Args, "keep") {
|
||
t.Fatalf("没变的背包行被写了: query=%s args=%v", call.Query, call.Args)
|
||
}
|
||
}
|
||
|
||
// 装备同理。
|
||
equipmentDB := &fakeSQLDB{rowsets: []fakeSQLRows{
|
||
{rows: []fakeSQLRow{
|
||
{values: []any{"7", int16(7), int64(900), false, nil, []byte(nil)}},
|
||
{values: []any{"13", int16(13), int64(901), false, nil, []byte(nil)}},
|
||
}},
|
||
{rows: nil},
|
||
}}
|
||
unchangedEntry := repository.EquipmentEntry{SlotIndex: 7, ItemID: 900}
|
||
changedEntry := repository.EquipmentEntry{SlotIndex: 13, ItemID: 999}
|
||
if err := replaceEquipmentEntries(ctx, equipmentDB, "equipment_entries", "equipment_extra", "77",
|
||
map[string]repository.EquipmentEntry{"7": unchangedEntry, "13": changedEntry}); err != nil {
|
||
t.Fatalf("replaceEquipmentEntries() error = %v", err)
|
||
}
|
||
sawWrite := false
|
||
for _, call := range equipmentDB.execs {
|
||
if strings.HasPrefix(call.Query, "DELETE FROM") || strings.HasPrefix(call.Query, "INSERT INTO") {
|
||
sawWrite = true
|
||
if containsArg(call.Args, "7") {
|
||
t.Fatalf("没变的装备条目被写了: query=%s args=%v", call.Query, call.Args)
|
||
}
|
||
if !containsArg(call.Args, "13") {
|
||
t.Fatalf("变化的装备条目应被写: query=%s args=%v", call.Query, call.Args)
|
||
}
|
||
}
|
||
}
|
||
if !sawWrite {
|
||
t.Fatal("变化的装备条目一条语句都没发")
|
||
}
|
||
}
|
||
|
||
func containsArg(args []any, want string) bool {
|
||
for _, arg := range args {
|
||
if value, ok := arg.(string); ok && value == want {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func TestMySQLAccountLoadLocksCurrentRowInsideTransaction(t *testing.T) {
|
||
sqlDB := &fakeSQLDB{rows: []fakeSQLRow{{values: []any{
|
||
"acc-1",
|
||
"open",
|
||
uint64(123),
|
||
nil,
|
||
nil,
|
||
nil,
|
||
}}}}
|
||
store := &mysqlAccountStore{mysqlStoreBase: mysqlStoreBase{router: mysqlRouter{
|
||
db: sqlDB,
|
||
readDBs: []string{"dnf_s1_w1"},
|
||
writeDBs: []string{"dnf_s1_w1"},
|
||
tablePrefix: "dnf",
|
||
lockReads: true,
|
||
}}}
|
||
if _, ok, err := store.Load(context.Background(), "acc-1"); err != nil || !ok {
|
||
t.Fatalf("Load() ok=%v err=%v", ok, err)
|
||
}
|
||
call := requireExecContaining(t, sqlDB, "`dnf_s1_w1`.`dnf_accounts`")
|
||
assertContains(t, call.Query, "FOR UPDATE")
|
||
}
|
||
|
||
func TestMySQLAccountSaveWritesHonorExp(t *testing.T) {
|
||
sqlDB := &fakeSQLDB{}
|
||
repos := newTestMySQLGroup(t, sqlDB)
|
||
if err := repos.Account.Save(context.Background(), repository.AccountRecord{
|
||
AccountID: "acc-1",
|
||
State: "open",
|
||
HonorExp: 17699999999,
|
||
RepresentAccountName: "adventure-one",
|
||
}); err != nil {
|
||
t.Fatalf("Save() error = %v", err)
|
||
}
|
||
call := requireExecContaining(t, sqlDB, "`dnf_s1_w1`.`dnf_accounts`")
|
||
assertContains(t, call.Query, "`honor_exp`")
|
||
assertContains(t, call.Query, "`represent_account_name`")
|
||
if len(call.Args) < 3 || call.Args[2] != uint64(17699999999) {
|
||
t.Fatalf("honor_exp args = %#v", call.Args)
|
||
}
|
||
if len(call.Args) < 4 || call.Args[3] != "adventure-one" {
|
||
t.Fatalf("represent_account_name args = %#v", call.Args)
|
||
}
|
||
}
|
||
|
||
func TestMySQLAccountFindIDByRepresentNameUsesReadDatabase(t *testing.T) {
|
||
sqlDB := &fakeSQLDB{rows: []fakeSQLRow{{values: []any{"acc-1"}}}}
|
||
repos := newTestMySQLGroup(t, sqlDB)
|
||
finder := repos.Account.(repository.RepresentAccountNameFinder)
|
||
accountID, found, err := finder.FindAccountIDByRepresentName(context.Background(), "adventure-one")
|
||
if err != nil || !found || accountID != "acc-1" {
|
||
t.Fatalf("FindAccountIDByRepresentName() account=%q found=%v err=%v", accountID, found, err)
|
||
}
|
||
call := requireOneExec(t, sqlDB)
|
||
assertContains(t, call.Query, "`dnf_s1_r1`.`dnf_accounts`")
|
||
assertContains(t, call.Query, "represent_account_name = ?")
|
||
}
|
||
|
||
func TestMySQLAccountFindIDByLoginUsernameUsesLoginMapping(t *testing.T) {
|
||
database := &fakeSQLDB{rows: []fakeSQLRow{{values: []any{"dnf:1"}}}}
|
||
router, err := newMySQLRouter(database, MySQLGroupOptions{
|
||
TablePrefix: "dnf",
|
||
DatabasePlan: repository.DatabasePlan{
|
||
ReadDatabases: []string{"dnf_local"},
|
||
WriteDatabases: []string{"dnf_local"},
|
||
},
|
||
})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
accountID, found, err := (&mysqlAccountStore{mysqlStoreBase{router: router}}).FindAccountIDByLoginUsername(context.Background(), " ACCOUNT-ONE ")
|
||
if err != nil || !found || accountID != "dnf:1" {
|
||
t.Fatalf("FindAccountIDByLoginUsername() account=%q found=%t error=%v", accountID, found, err)
|
||
}
|
||
if len(database.execs) != 1 || !strings.Contains(database.execs[0].Query, "dnf_login_accounts") ||
|
||
len(database.execs[0].Args) != 1 || database.execs[0].Args[0] != "account-one" {
|
||
t.Fatalf("login lookup query = %+v", database.execs)
|
||
}
|
||
}
|
||
|
||
func TestMySQLCharacterLoadNoRows(t *testing.T) {
|
||
sqlDB := &fakeSQLDB{}
|
||
repos := newTestMySQLGroup(t, sqlDB)
|
||
_, ok, err := repos.Character.Load(context.Background(), "missing-char")
|
||
if err != nil || ok {
|
||
t.Fatalf("Load() ok=%v err=%v, want no row", ok, err)
|
||
}
|
||
}
|
||
|
||
func TestMySQLPacketSaveClonesBodyArg(t *testing.T) {
|
||
sqlDB := &fakeSQLDB{}
|
||
repos := newTestMySQLGroup(t, sqlDB)
|
||
body := []byte{1, 2, 3}
|
||
if err := repos.PacketTemplate.Save(context.Background(), repository.PacketTemplateRecord{
|
||
TemplateID: "tpl-1",
|
||
Name: "login",
|
||
Body: body,
|
||
}); err != nil {
|
||
t.Fatalf("Save() error = %v", err)
|
||
}
|
||
body[0] = 9
|
||
call := requireExecContaining(t, sqlDB, "`dnf_s1_w1`.`dnf_packet_templates`")
|
||
got := call.Args[2].([]byte)
|
||
if got[0] != 1 {
|
||
t.Fatalf("body arg should be cloned, got %v", got)
|
||
}
|
||
}
|
||
|
||
func newTestMySQLGroup(t *testing.T, sqlDB *fakeSQLDB) repository.Group {
|
||
t.Helper()
|
||
repos, err := NewMySQLGroup(sqlDB, MySQLGroupOptions{
|
||
DatabasePlan: repository.DatabasePlan{
|
||
WriteDatabases: []string{"dnf_s1_w1"},
|
||
ReadDatabases: []string{"dnf_s1_r1"},
|
||
},
|
||
Now: func() time.Time {
|
||
return time.Date(2026, 6, 29, 3, 0, 0, 0, time.UTC)
|
||
},
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("NewMySQLGroup() error = %v", err)
|
||
}
|
||
return repos
|
||
}
|
||
|
||
func requireOneExec(t *testing.T, sqlDB *fakeSQLDB) fakeSQLCall {
|
||
t.Helper()
|
||
if len(sqlDB.execs) != 1 {
|
||
t.Fatalf("exec count = %d, want 1", len(sqlDB.execs))
|
||
}
|
||
return sqlDB.execs[0]
|
||
}
|
||
|
||
func requireExecContaining(t *testing.T, sqlDB *fakeSQLDB, needle string) fakeSQLCall {
|
||
t.Helper()
|
||
for _, call := range sqlDB.execs {
|
||
if strings.Contains(call.Query, needle) {
|
||
return call
|
||
}
|
||
}
|
||
t.Fatalf("no SQL call contains %q; calls=%v", needle, execQueries(sqlDB))
|
||
return fakeSQLCall{}
|
||
}
|
||
|
||
func execQueries(sqlDB *fakeSQLDB) []string {
|
||
queries := make([]string, 0, len(sqlDB.execs))
|
||
for _, call := range sqlDB.execs {
|
||
queries = append(queries, call.Query)
|
||
}
|
||
return queries
|
||
}
|
||
|
||
func assertContains(t *testing.T, value, want string) {
|
||
t.Helper()
|
||
if !strings.Contains(value, want) {
|
||
t.Fatalf("value missing %q:\n%s", want, value)
|
||
}
|
||
}
|
||
|
||
func assertNotContains(t *testing.T, value, want string) {
|
||
t.Helper()
|
||
if strings.Contains(value, want) {
|
||
t.Fatalf("value unexpectedly contains %q:\n%s", want, value)
|
||
}
|
||
}
|
||
|
||
func requireMySQLCharacterStatIndex(t *testing.T, key string) int {
|
||
t.Helper()
|
||
for idx, column := range mysqlCharacterStatColumns {
|
||
if column.key == key {
|
||
return idx
|
||
}
|
||
}
|
||
t.Fatalf("mysql character stat column %q not found", key)
|
||
return -1
|
||
}
|