Files
bd2/go/internal/server/world/field_object_routes_test.go
T

379 lines
11 KiB
Go

package world
import (
"bd2server/internal/server/accountstate"
"bd2server/internal/server/deck"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/player"
"bd2server/internal/server/progress"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
"errors"
"os"
"path/filepath"
"testing"
"time"
)
func sqliteFieldService(t *testing.T, store *fieldRewardFailStore) *Service {
t.Helper()
s := testService()
var err error
s.state, err = progress.OpenStore(store)
if err != nil {
t.Fatal(err)
}
if err = s.state.SetActivePackID(21); err != nil {
t.Fatal(err)
}
s.wallet, err = player.OpenWallet(store, player.Currency{})
if err != nil {
t.Fatal(err)
}
s.inventory, err = player.OpenInventory(store, s.starter)
if err != nil {
t.Fatal(err)
}
s.equipment, err = player.OpenEquipmentInventory(store)
if err != nil {
t.Fatal(err)
}
s.monsterStore = store
return s
}
func fieldBatchRequest(ids ...int) []byte {
request := wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 21)
for _, id := range ids {
obj := wire.AppendVarint(wire.AppendVarint(nil, 3, 101), 4, uint64(id))
request = wire.AppendBytes(request, 3, obj)
}
return request
}
func TestFieldBatchSQLiteAtomicResetAndRestart(t *testing.T) {
path := filepath.Join(t.TempDir(), "state.db")
repo, err := accountstate.Open(path)
if err != nil {
t.Fatal(err)
}
defer func() { repo.Close() }()
store := &fieldRewardFailStore{Repository: repo}
s := sqliteFieldService(t, store)
now := time.Date(2026, 10, 6, 0, 0, 0, 0, time.UTC)
s.monsterNow = func() time.Time { return now }
objects := map[int]gamedata.FieldRewardObject{}
for _, id := range []int{1001, 1002} {
objects[id] = gamedata.FieldRewardObject{ID: id, GroupID: 101, MapID: 211, Type: 6, ResetType: 0, DropCount: 1, Ratios: []uint64{1}, Rewards: []gamedata.BattleReward{{Type: 3, Count: 50}}}
}
designs := map[int]gamedata.FieldObjectDesign{21: {Objects: objects}}
s.WithFieldObjects(designs)
if _, _, _, err := s.handleFieldObjectRewardList(fieldBatchRequest(1001, 9999)); err == nil {
t.Fatal("invalid later batch object accepted")
}
if s.wallet.Snapshot().FreeJewelry != 0 {
t.Fatal("invalid batch settled earlier object")
}
op, err := repo.BeginOperation()
if err != nil {
t.Fatal(err)
}
store.fail = true
if _, _, _, err := s.handleFieldObjectRewardList(fieldBatchRequest(1001, 1002)); err == nil {
t.Fatal("marker failure accepted")
}
if err := op.Rollback(); err != nil && !errors.Is(err, stateio.ErrStateRecoveryRequired) {
t.Fatal(err)
}
if err := repo.Close(); err != nil {
t.Fatal(err)
}
repo, err = accountstate.Open(path)
if err != nil {
t.Fatal(err)
}
store.Repository, store.fail = repo, false
s = sqliteFieldService(t, store).WithFieldObjects(designs)
s.monsterNow = func() time.Time { return now }
if s.wallet.Snapshot().FreeJewelry != 0 {
t.Fatal("rollback persisted partial currency")
}
op, err = repo.BeginOperation()
if err != nil {
t.Fatal(err)
}
code, reply, _, err := s.handleFieldObjectRewardList(fieldBatchRequest(1001, 1002, 1001))
if err != nil || code != 260 {
t.Fatalf("batch code=%d err=%v", code, err)
}
if err := op.Commit(); err != nil {
t.Fatal(err)
}
bundle, _, _ := wire.Bytes(reply, 1)
var total uint64
if err := wire.Walk(bundle, func(f wire.Field) error {
if f.Number == 1 {
count, _, e := wire.Varint(f.Value, 4)
total += count
return e
}
return nil
}); err != nil {
t.Fatal(err)
}
if total != 100 || s.wallet.Snapshot().FreeJewelry != 100 {
t.Fatalf("batch delta=%d wallet=%+v", total, s.wallet.Snapshot())
}
if err := repo.Close(); err != nil {
t.Fatal(err)
}
repo, err = accountstate.Open(path)
if err != nil {
t.Fatal(err)
}
store.Repository = repo
s = sqliteFieldService(t, store).WithFieldObjects(designs)
s.monsterNow = func() time.Time { return now }
_, reply, _, err = s.handleFieldObjectRewardList(fieldBatchRequest(1001, 1002))
if err != nil {
t.Fatal(err)
}
bundle, _, _ = wire.Bytes(reply, 1)
if len(bundle) != 0 || s.wallet.Snapshot().FreeJewelry != 100 {
t.Fatal("restart duplicate paid again")
}
respawnRequest := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 2), 2, 21), 3, 101)
_, before, _, err := s.handleFieldObjectRespawn(respawnRequest)
if err != nil {
t.Fatal(err)
}
if fieldWireCount(t, before, 1) != 2 {
t.Fatal("premature respawn cleared opened state")
}
now = now.Add(24 * time.Hour)
_, after, _, err := s.handleFieldObjectPreview(wire.AppendVarint(wire.AppendVarint(nil, 1, 3), 2, 21))
if err != nil || fieldWireCount(t, after, 1) != 0 {
t.Fatalf("reset preview=%x err=%v", after, err)
}
op, err = repo.BeginOperation()
if err != nil {
t.Fatal(err)
}
if _, _, _, err := s.handleFieldObjectRewardList(fieldBatchRequest(1001, 1002)); err != nil {
t.Fatal(err)
}
if err := op.Commit(); err != nil {
t.Fatal(err)
}
if s.wallet.Snapshot().FreeJewelry != 200 {
t.Fatal("new reset period did not award")
}
}
func fieldWireCount(t *testing.T, raw []byte, number int) int {
t.Helper()
count := 0
if err := wire.Walk(raw, func(f wire.Field) error {
if f.Number == number {
count++
}
return nil
}); err != nil {
t.Fatal(err)
}
return count
}
func TestFieldEffectsSQLitePersistenceRepeatAndBattleConsumption(t *testing.T) {
path := filepath.Join(t.TempDir(), "state.db")
repo, err := accountstate.Open(path)
if err != nil {
t.Fatal(err)
}
defer func() { repo.Close() }()
store := &fieldRewardFailStore{Repository: repo}
s := sqliteFieldService(t, store)
designs := map[int]gamedata.FieldObjectDesign{21: {Objects: map[int]gamedata.FieldRewardObject{
1001: {ID: 1001, GroupID: 101, MapID: 211, ResetType: 1, BuffID: 1},
1002: {ID: 1002, GroupID: 101, MapID: 211, ResetType: 1, BuffID: 2},
1003: {ID: 1003, GroupID: 101, MapID: 211, ResetType: 1, MonsterID: 9},
}}}
buffs := map[uint64]gamedata.FieldBuffDesign{1: {ID: 1, Type: 0, Time: 2}, 2: {ID: 2, Type: 0, Time: 3}}
attach := func(s *Service) {
s.WithFieldObjects(designs)
s.fieldBuffs = buffs
s.monsterLoader = func(int) ([]gamedata.FieldMonsterDesign, error) {
return []gamedata.FieldMonsterDesign{{ID: 9, GroupID: 7, Type: 3, BattleDeck: 99}}, nil
}
}
attach(s)
for _, id := range []int{1001, 1002, 1003} {
op, err := repo.BeginOperation()
if err != nil {
t.Fatal(err)
}
response, err := s.openFieldObjectResponse(21, 101, id)
if err != nil {
t.Fatal(err)
}
if id == 1003 {
monster, _, _ := wire.Bytes(response, 4)
mid, _, _ := wire.Varint(monster, 1)
if mid != 9 {
t.Fatal("dynamic monster response missing")
}
} else if fieldWireCount(t, response, 2) != 1 {
t.Fatal("buff response missing")
}
if err := op.Commit(); err != nil {
t.Fatal(err)
}
}
if err := repo.Close(); err != nil {
t.Fatal(err)
}
repo, err = accountstate.Open(path)
if err != nil {
t.Fatal(err)
}
store.Repository = repo
s = sqliteFieldService(t, store)
attach(s)
rows, err := s.state.FieldBuffs()
if err != nil || len(rows) != 1 {
t.Fatalf("buff rows=%v err=%v", rows, err)
}
id, _, _ := wire.Varint(rows[0], 1)
count, _, _ := wire.Varint(rows[0], 2)
if id != 2 || count != 3 {
t.Fatal("same-type replacement or persisted duration lost")
}
response, err := s.openFieldObjectResponse(21, 101, 1002)
if err != nil || fieldWireCount(t, response, 2) != 0 {
t.Fatal("repeat reapplied buff")
}
for _, identity := range []string{"battle-a", "battle-a", "battle-b", "battle-c"} {
op, err := repo.BeginOperation()
if err != nil {
t.Fatal(err)
}
if err := s.ConsumeFieldBattleBuff(identity); err != nil {
t.Fatal(err)
}
if err := op.Commit(); err != nil {
t.Fatal(err)
}
}
rows, err = s.state.FieldBuffs()
if err != nil || len(rows) != 0 {
t.Fatal("battle retries decremented incorrectly or buff never expired")
}
snapshot, err := s.loadMonsterState()
if err != nil || len(snapshot.Monsters) != 1 {
t.Fatal("dynamic monster spawn lost on restart")
}
}
func TestInstalledFieldChestAndHealingObjectSQLiteSettlement(t *testing.T) {
root := os.Getenv("BD2_REAL_GAMEDATA")
if root == "" {
t.Skip("set BD2_REAL_GAMEDATA for installed field settlement regression")
}
version := "20260923193640"
chests, err := gamedata.LoadFieldObjects(root, version, 21)
if err != nil {
t.Fatal(err)
}
traps, err := gamedata.LoadFieldObjects(root, version, 1)
if err != nil {
t.Fatal(err)
}
buffs, err := gamedata.LoadFieldBuffDesign(root, version)
if err != nil {
t.Fatal(err)
}
var chest, healer gamedata.FieldRewardObject
for _, obj := range chests.Objects {
if len(obj.Rewards) > 0 && (chest.ID == 0 || obj.ID < chest.ID) {
chest = obj
}
}
for _, obj := range traps.Objects {
if buff, ok := buffs[uint64(obj.BuffID)]; ok && buff.Type == 3 && (healer.ID == 0 || obj.ID < healer.ID) {
healer = obj
}
}
if chest.ID == 0 || healer.ID == 0 {
t.Fatal("current authored chest/healer graphs missing")
}
repo, err := accountstate.Open(filepath.Join(t.TempDir(), "state.db"))
if err != nil {
t.Fatal(err)
}
defer repo.Close()
store := &fieldRewardFailStore{Repository: repo}
s := sqliteFieldService(t, store).WithFieldObjects(map[int]gamedata.FieldObjectDesign{21: chests, 1: traps})
s.fieldBuffs = buffs
op, err := repo.BeginOperation()
if err != nil {
t.Fatal(err)
}
response, err := s.openFieldObjectResponse(21, chest.GroupID, chest.ID)
if err != nil {
t.Fatal(err)
}
bundle, _, _ := wire.Bytes(response, 1)
if len(bundle) == 0 {
t.Fatal("installed chest gave no authored rewards")
}
if err := op.Commit(); err != nil {
t.Fatal(err)
}
s.storyCatalog.Packs[1] = gamedata.StoryPack{ID: 1, Quests: map[int]gamedata.QuestDesign{1: {ID: 1}}, MainQuestIDs: []int{1}}
if s.packs == nil {
s.packs = map[int]map[int]gamedata.QuestDesign{}
}
s.packs[1] = s.storyCatalog.Packs[1].Quests
if err := s.state.SetActivePackID(1); err != nil {
t.Fatal(err)
}
s.setCurrentPack(1)
s.characters, err = player.OpenCharacterStore(store, []player.Character{{ID: 350, InvenIndex: 77, Level: 1, HP: 50}}, s.inventory, "", "")
if err != nil {
t.Fatal(err)
}
s.characters.AttachMaxHealth(func(player.Character) (uint64, error) { return 100, nil })
if err := s.characters.EnsurePersisted(); err != nil {
t.Fatal(err)
}
if err := s.characters.SetCurrentHealth(77, 50); err != nil {
t.Fatal(err)
}
s.decks, err = deck.NewStore(deck.Seed{Version: s.seed.Version, FieldCharControlDeckType: 1, FieldDeck: []deck.FieldEntry{{Slot: 1, CharacterInvenIndex: 77}}})
if err != nil {
t.Fatal(err)
}
op, err = repo.BeginOperation()
if err != nil {
t.Fatal(err)
}
response, err = s.openFieldObjectResponse(1, healer.GroupID, healer.ID)
if err != nil {
t.Fatal(err)
}
if err := op.Commit(); err != nil {
t.Fatal(err)
}
hp, err := s.characters.CurrentHealth(77)
if err != nil || hp != 80 || fieldWireCount(t, response, 2) != 1 || fieldWireCount(t, response, 3) != 1 {
t.Fatalf("installed 30%% healer hp=%d response=%x err=%v", hp, response, err)
}
if _, err := s.openFieldObjectResponse(1, healer.GroupID, healer.ID); err != nil {
t.Fatal(err)
}
hp, _ = s.characters.CurrentHealth(77)
if hp != 80 {
t.Fatal("healing retry applied twice")
}
}