486 lines
16 KiB
Go
486 lines
16 KiB
Go
package player
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"bd2server/internal/server/accountstate"
|
|
"bd2server/internal/server/gamedata"
|
|
"bd2server/internal/server/stateio"
|
|
"bd2server/internal/server/wire"
|
|
)
|
|
|
|
func foodTestService(t *testing.T, store stateio.Store) (*FoodService, *Inventory, *CharacterStore) {
|
|
t.Helper()
|
|
starter := &Starter{Version: "2.35.10"}
|
|
inventory, err := OpenInventory(store, starter)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
characters, err := OpenCharacterStore(store, []Character{{InvenIndex: 77, ID: 350, Level: 1, HP: 7}, {InvenIndex: 78, ID: 360, Level: 1, HP: 7}}, inventory, "", "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err = characters.AttachMaxHealth(func(Character) (uint64, error) { return 100, nil }); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err = characters.EnsurePersisted(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
design := &gamedata.FoodDesign{Foods: map[uint64]gamedata.Food{101: {ID: 101, Point: 15}, 102: {ID: 102, Type: 1, Point: 10, FavoritePoint: 25, FavoriteUniqueCharIDs: []uint64{35}}, 103: {ID: 103, Type: 2}, 104: {ID: 104, Point: 100, FoodBuffID: 101}, 105: {ID: 105, Point: 50, RecoveryType: 1}}}
|
|
s, err := OpenFoodService(store, design, inventory, characters)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s.BeginSession("food-session")
|
|
if err = s.AttachContext(func() (int, error) { return 21, nil }, func() bool { return false }); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return s, inventory, characters
|
|
}
|
|
|
|
func autoFoodRequest(seq uint64, indices []uint64, items []Item) []byte {
|
|
out := wire.AppendVarint(nil, 1, seq)
|
|
for i, index := range indices {
|
|
target := wire.AppendVarint(nil, 1, index)
|
|
target = wire.AppendBytes(target, 2, ItemWire(items[i]))
|
|
out = wire.AppendBytes(out, 2, target)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func TestEatFoodAutoSharesStacksValidatesWholeRequestAndReplaysSQLite(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "state.db")
|
|
repo, err := accountstate.Open(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s, inventory, characters := foodTestService(t, repo)
|
|
stacks, err := inventory.GrantOnce("auto-food", []gamedata.BattleReward{{Type: 5, ID: 102, Count: 3}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, index := range []uint64{77, 78} {
|
|
if err = characters.SetCurrentHealth(index, 0); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
stack := stacks[0]
|
|
stack.Count = 2
|
|
overspend := autoFoodRequest(1, []uint64{77, 78}, []Item{stack, stack})
|
|
if _, _, _, err = s.Handle("/EatFoodAuto", overspend); err == nil {
|
|
t.Fatal("accepted aggregate stack overspend")
|
|
}
|
|
if hp, _ := characters.CurrentHealth(77); hp != 0 {
|
|
t.Fatal("partially healed rejected request")
|
|
}
|
|
if inventory.All()[0].Count != 3 {
|
|
t.Fatal("partially consumed rejected request")
|
|
}
|
|
stack.Count = 1
|
|
request := autoFoodRequest(2, []uint64{77, 78}, []Item{stack, stack})
|
|
op, err := repo.BeginOperation()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
code, body, _, err := s.Handle("/EatFoodAuto", request)
|
|
if err != nil || code != 27 {
|
|
t.Fatalf("auto code=%d err=%v", code, err)
|
|
}
|
|
if err = op.Commit(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if hp, _ := characters.CurrentHealth(77); hp != 25 {
|
|
t.Fatalf("favorite hp=%d", hp)
|
|
}
|
|
if hp, _ := characters.CurrentHealth(78); hp != 10 {
|
|
t.Fatalf("ordinary hp=%d", hp)
|
|
}
|
|
var resultIndices []uint64
|
|
if err = wire.Walk(body, func(field wire.Field) error {
|
|
index, _, err := wire.Varint(field.Value, 1)
|
|
resultIndices = append(resultIndices, index)
|
|
return err
|
|
}); err != nil || len(resultIndices) != 2 || resultIndices[0] != 77 || resultIndices[1] != 78 {
|
|
t.Fatalf("auto response=%v err=%v", resultIndices, err)
|
|
}
|
|
repo.Close()
|
|
repo, err = accountstate.Open(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer repo.Close()
|
|
s, inventory, characters = foodTestService(t, repo)
|
|
code, replay, _, err := s.Handle("/EatFoodAuto", request)
|
|
if err != nil || code != 27 || !bytes.Equal(replay, body) || inventory.All()[0].Count != 1 {
|
|
t.Fatalf("auto replay code=%d err=%v", code, err)
|
|
}
|
|
// Equal sequences on the two endpoints refer to separate requests.
|
|
if _, _, _, err = s.Handle("/EatFood", foodRequest(2, 77, 0, stack)); err != nil {
|
|
t.Fatalf("normal/auto sequence collision: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestEatFoodAutoRejectsEmptyTargetWithoutConsumingValidTarget(t *testing.T) {
|
|
s, inventory, characters := foodTestService(t, stateio.NewMemory())
|
|
stacks, err := inventory.GrantOnce("auto-empty", []gamedata.BattleReward{{Type: 5, ID: 101, Count: 1}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err = characters.SetCurrentHealth(77, 0); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
request := autoFoodRequest(1, []uint64{77}, stacks)
|
|
request = wire.AppendBytes(request, 2, wire.AppendVarint(nil, 1, 78))
|
|
if _, _, _, err = s.Handle("/EatFoodAuto", request); err == nil {
|
|
t.Fatal("accepted empty recovery target")
|
|
}
|
|
if inventory.All()[0].Count != 1 {
|
|
t.Fatal("empty target consumed valid target food")
|
|
}
|
|
if hp, _ := characters.CurrentHealth(77); hp != 0 {
|
|
t.Fatal("empty target partially healed valid target")
|
|
}
|
|
}
|
|
|
|
func foodRequest(seq, index, pack uint64, items ...Item) []byte {
|
|
out := wire.AppendVarint(nil, 1, seq)
|
|
if pack != 0 {
|
|
out = wire.AppendVarint(out, 2, pack)
|
|
}
|
|
out = wire.AppendVarint(out, 3, index)
|
|
for _, item := range items {
|
|
out = wire.AppendBytes(out, 4, ItemWire(item))
|
|
}
|
|
return out
|
|
}
|
|
|
|
func TestEatFoodSQLitePersistsRecoveryAndSequenceReplay(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "state.db")
|
|
repo, err := accountstate.Open(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s, inventory, characters := foodTestService(t, repo)
|
|
stacks, err := inventory.GrantOnce("food", []gamedata.BattleReward{{Type: 5, ID: 101, Count: 4}, {Type: 5, ID: 102, Count: 2}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err = characters.SetCurrentHealth(77, 20); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
stacks[0].Count = 2
|
|
stacks[1].Count = 1
|
|
request := foodRequest(10, 77, 0, stacks...)
|
|
op, err := repo.BeginOperation()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
code, body, handled, err := s.Handle("/EatFood", request)
|
|
if err != nil || code != 22 || !handled {
|
|
t.Fatalf("code=%d handled=%v err=%v", code, handled, err)
|
|
}
|
|
if err = op.Commit(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if hp, err := characters.CurrentHealth(77); err != nil || hp != 75 {
|
|
t.Fatalf("hp=%d err=%v", hp, err)
|
|
}
|
|
if c, _ := characters.Find(77); c.HP != 75 || characters.All()[0].HP != 75 {
|
|
t.Fatal("current HP overwritten by maximum")
|
|
}
|
|
if err = repo.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
repo, err = accountstate.Open(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer repo.Close()
|
|
s, inventory, characters = foodTestService(t, repo)
|
|
code, replayed, _, err := s.Handle("/EatFood", request)
|
|
if err != nil || code != 22 || !bytes.Equal(body, replayed) {
|
|
t.Fatalf("replay err=%v", err)
|
|
}
|
|
if items := inventory.All(); len(items) != 2 || items[0].Count != 2 || items[1].Count != 1 {
|
|
t.Fatalf("retry consumed items %+v", items)
|
|
}
|
|
if hp, _ := characters.CurrentHealth(77); hp != 75 {
|
|
t.Fatalf("reopened hp=%d", hp)
|
|
}
|
|
request = foodRequest(10, 77, 0, stacks[0])
|
|
if _, _, _, err = s.Handle("/EatFood", request); err == nil {
|
|
t.Fatal("accepted changed request with reused sequence")
|
|
}
|
|
}
|
|
|
|
func TestEatFoodRejectsInvalidStacksAndContextWithoutMutation(t *testing.T) {
|
|
s, inventory, characters := foodTestService(t, stateio.NewMemory())
|
|
stacks, err := inventory.GrantOnce("food", []gamedata.BattleReward{{Type: 5, ID: 101, Count: 2}, {Type: 5, ID: 103, Count: 2}, {Type: 5, ID: 104, Count: 2}, {Type: 5, ID: 999, Count: 2}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err = characters.SetCurrentHealth(77, 0); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
wrongID := stacks[0]
|
|
wrongID.ID = 102
|
|
wrongType := stacks[0]
|
|
wrongType.Type = 8
|
|
over := stacks[0]
|
|
over.Count = 3
|
|
missing := stacks[0]
|
|
missing.InvenIndex++
|
|
requests := [][]byte{foodRequest(1, 999, 0, stacks[0]), foodRequest(2, 77, 22, stacks[0]), foodRequest(3, 77, 0, wrongID), foodRequest(4, 77, 0, wrongType), foodRequest(5, 77, 0, over), foodRequest(6, 77, 0, missing), foodRequest(7, 77, 0, stacks[1]), foodRequest(8, 77, 0, stacks[2]), foodRequest(9, 77, 0, stacks[3]), foodRequest(10, 77, 0, stacks[0], stacks[0]), foodRequest(11, 77, 0)}
|
|
for i, request := range requests {
|
|
if _, _, _, err := s.Handle("/EatFood", request); err == nil {
|
|
t.Fatalf("invalid request %d accepted", i)
|
|
}
|
|
}
|
|
s.AttachContext(func() (int, error) { return 21, nil }, func() bool { return true })
|
|
if _, _, _, err = s.Handle("/EatFood", foodRequest(12, 77, 0, stacks[0])); err == nil {
|
|
t.Fatal("accepted food during battle")
|
|
}
|
|
if hp, _ := characters.CurrentHealth(77); hp != 0 {
|
|
t.Fatalf("rejected request changed HP=%d", hp)
|
|
}
|
|
if got := inventory.All(); len(got) != 4 || got[0].Count != 2 {
|
|
t.Fatalf("rejected request consumed inventory %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestEatFoodPercentageRecoversDeadCharacterAndClampsToMaximum(t *testing.T) {
|
|
s, inventory, characters := foodTestService(t, stateio.NewMemory())
|
|
stacks, err := inventory.GrantOnce("food", []gamedata.BattleReward{{Type: 5, ID: 105, Count: 3}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err = characters.SetCurrentHealth(77, 0); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
stack := stacks[0]
|
|
stack.Count = 1
|
|
if _, _, _, err = s.Handle("/EatFood", foodRequest(1, 77, 21, stack)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if hp, _ := characters.CurrentHealth(77); hp != 50 {
|
|
t.Fatalf("percentage hp=%d", hp)
|
|
}
|
|
stack.Count = 2
|
|
if _, _, _, err = s.Handle("/EatFood", foodRequest(2, 77, 21, stack)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if hp, _ := characters.CurrentHealth(77); hp != 100 {
|
|
t.Fatalf("clamped hp=%d", hp)
|
|
}
|
|
if len(inventory.All()) != 0 {
|
|
t.Fatal("empty food stack remains")
|
|
}
|
|
if err = characters.SetCurrentHealth(77, 101); err == nil {
|
|
t.Fatal("accepted health above maximum")
|
|
}
|
|
}
|
|
|
|
func TestCurrentHealthGrowthAndImmortalClearPersistedInjury(t *testing.T) {
|
|
_, inventory, characters := foodTestService(t, stateio.NewMemory())
|
|
stacks, err := inventory.GrantOnce("growth-food-health", []gamedata.BattleReward{{Type: 8, ID: 1, Count: 1}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
characters.grow = func(Character, []gamedata.GrowthMaterial) (uint64, uint64, []gamedata.GrowthMaterial, error) {
|
|
return 2, 0, nil, nil
|
|
}
|
|
if err = characters.SetCurrentHealth(77, 3); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
request := wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 77)
|
|
request = wire.AppendBytes(request, 3, ItemWire(stacks[0]))
|
|
if _, _, _, err = characters.Handle("/CharGrowth", request); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if hp, _ := characters.CurrentHealth(77); hp != 100 {
|
|
t.Fatalf("grown current HP=%d", hp)
|
|
}
|
|
if err = characters.SetCurrentHealth(77, 0); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
request = wire.AppendVarint(wire.AppendVarint(nil, 1, 2), 2, 77)
|
|
if _, _, _, err = characters.Handle("/CharImmortal", request); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if hp, _ := characters.CurrentHealth(77); hp != 100 {
|
|
t.Fatalf("revived current HP=%d", hp)
|
|
}
|
|
}
|
|
|
|
func TestCurrentHealthRetainsSavedCharacterHPWithoutSeparateEntryAcrossReopen(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "state.db")
|
|
repo, err := accountstate.Open(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
seed := []Character{{InvenIndex: 77, ID: 350, Level: 1, HP: 17}, {InvenIndex: 78, ID: 360, Level: 1, HP: 0}}
|
|
open := func(store stateio.Store) *CharacterStore {
|
|
inventory, err := OpenInventory(store, &Starter{Version: "2.35.10"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
characters, err := OpenCharacterStore(store, seed, inventory, "", "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err = characters.AttachMaxHealth(func(Character) (uint64, error) { return 500, nil }); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err = characters.EnsurePersisted(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return characters
|
|
}
|
|
check := func(characters *CharacterStore) {
|
|
for _, saved := range seed {
|
|
hp, err := characters.CurrentHealth(saved.InvenIndex)
|
|
if err != nil || hp != saved.HP {
|
|
t.Fatalf("current %d=%d want=%d err=%v", saved.InvenIndex, hp, saved.HP, err)
|
|
}
|
|
character, found := characters.Find(saved.InvenIndex)
|
|
if !found || character.HP != saved.HP {
|
|
t.Fatalf("Find=%+v found=%v", character, found)
|
|
}
|
|
}
|
|
all := characters.All()
|
|
if len(all) != 2 || all[0].HP != 17 || all[1].HP != 0 {
|
|
t.Fatalf("All=%+v", all)
|
|
}
|
|
maximum, err := characters.MaxHealth(77)
|
|
if err != nil || maximum != 500 {
|
|
t.Fatalf("maximum=%d err=%v", maximum, err)
|
|
}
|
|
}
|
|
characters := open(repo)
|
|
check(characters)
|
|
rows, err := repo.ListEntries("characters", "current_hp")
|
|
if err != nil || len(rows) != 0 {
|
|
t.Fatalf("reads created entries=%v err=%v", rows, err)
|
|
}
|
|
if err = repo.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
repo, err = accountstate.Open(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer repo.Close()
|
|
characters = open(repo)
|
|
check(characters)
|
|
if err = characters.SetCurrentHealth(77, 300); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err = characters.SetCurrentHealth(78, 0); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if hp, _ := characters.CurrentHealth(77); hp != 300 {
|
|
t.Fatalf("explicit health entry lost=%d", hp)
|
|
}
|
|
// Revival must write the restored value, since deleting the entry alone
|
|
// would expose the zero HP in the owned character record again.
|
|
request := wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 78)
|
|
if _, _, _, err = characters.Handle("/CharImmortal", request); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if hp, _ := characters.CurrentHealth(78); hp != 500 {
|
|
t.Fatalf("revived health=%d", hp)
|
|
}
|
|
}
|
|
|
|
func TestEatFoodRecoversSavedHPWithoutSeparateCurrentHealthEntry(t *testing.T) {
|
|
food, inventory, characters := foodTestService(t, stateio.NewMemory())
|
|
// The seed has current HP 7 and maximum 100, with no current_hp entry.
|
|
stacks, err := inventory.GrantOnce("saved-health-food", []gamedata.BattleReward{{Type: 5, ID: 101, Count: 1}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, _, _, err = food.Handle("/EatFood", foodRequest(1, 77, 21, stacks[0])); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if hp, _ := characters.CurrentHealth(77); hp != 22 {
|
|
t.Fatalf("recovered saved health=%d want22", hp)
|
|
}
|
|
if len(inventory.All()) != 0 {
|
|
t.Fatal("food was not consumed")
|
|
}
|
|
}
|
|
|
|
type failFoodStore struct {
|
|
stateio.AtomicEntryStore
|
|
fail bool
|
|
}
|
|
|
|
func (s *failFoodStore) SaveWithEntries(domain string, core []byte, changes []stateio.EntryMutation) error {
|
|
if s.fail && domain == "characters" {
|
|
return errors.New("injected food save failure")
|
|
}
|
|
return s.AtomicEntryStore.SaveWithEntries(domain, core, changes)
|
|
}
|
|
|
|
func TestEatFoodSQLiteRollbackRestoresInventoryHealthAndReplayLedger(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "state.db")
|
|
repo, err := accountstate.Open(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
store := &failFoodStore{AtomicEntryStore: repo}
|
|
s, inventory, characters := foodTestService(t, store)
|
|
stacks, err := inventory.GrantOnce("food", []gamedata.BattleReward{{Type: 5, ID: 101, Count: 1}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err = characters.SetCurrentHealth(77, 1); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
store.fail = true
|
|
op, err := repo.BeginOperation()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
request := foodRequest(1, 77, 21, stacks[0])
|
|
if _, _, _, err = s.Handle("/EatFood", request); err == nil {
|
|
t.Fatal("injected failure was ignored")
|
|
}
|
|
if err = op.Rollback(); !errors.Is(err, stateio.ErrStateRecoveryRequired) {
|
|
t.Fatalf("expected recovery fencing, got %v", err)
|
|
}
|
|
repo.Close()
|
|
repo, err = accountstate.Open(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer repo.Close()
|
|
s, inventory, characters = foodTestService(t, repo)
|
|
if inventory.All()[0].Count != 1 {
|
|
t.Fatal("rollback lost inventory")
|
|
}
|
|
if hp, _ := characters.CurrentHealth(77); hp != 1 {
|
|
t.Fatalf("rollback HP=%d", hp)
|
|
}
|
|
ledger, err := repo.ListEntries("characters", "food_requests")
|
|
if err != nil || len(ledger) != 0 {
|
|
t.Fatalf("rollback ledger=%v err=%v", ledger, err)
|
|
}
|
|
op, err = repo.BeginOperation()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, _, _, err = s.Handle("/EatFood", request); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err = op.Commit(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if hp, _ := characters.CurrentHealth(77); hp != 16 {
|
|
t.Fatalf("recovery after rollback HP=%d", hp)
|
|
}
|
|
}
|