fix(server): advance boss phases from GameData

This commit is contained in:
2026-10-04 13:24:16 +08:00
parent 857381fad6
commit e8bb0cba14
5 changed files with 477 additions and 13 deletions
+84 -13
View File
@@ -24,19 +24,25 @@ type Service struct {
inventory *player.Inventory
currentPack func() (int, error)
loadRewards func(string, string, int, uint64) ([]gamedata.BattleReward, error)
loadPhases func(string, string, int, uint64, uint64) ([]gamedata.BattlePhase, error)
buffs func() ([]gamedata.PictorialBuffStat, error)
onTutorialWin func() error
commitHealth func(map[uint64]uint64) error
}
type battleState struct {
entered bool
index uint64
round uint64
monster uint64
deck uint64
pack int
initialBlue [][]byte
entered bool
index uint64
round uint64
monster uint64
deck uint64
pack int
initialBlue [][]byte
phases []gamedata.BattlePhase
phase int
phaseStarted bool
phaseSeq uint64
phaseReply []byte
}
// BeginSession discards an unfinished battle when LoginUser creates a new
@@ -102,7 +108,7 @@ func checkSeq(request []byte) error {
}
func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) {
if path != "/BattleEnter" && path != "/BattleStart" && path != "/BattleRetry" && path != "/BattleVerifyState" && path != "/BattleEnd" && path != "/BattleExit" {
if path != "/BattleEnter" && path != "/BattleStart" && path != "/BattleRetry" && path != "/BattleVerifyState" && path != "/BattleEnd" && path != "/BattleExit" && path != "/BattlePhaseChange" {
return 0, nil, false, nil
}
if err := checkSeq(request); err != nil {
@@ -112,6 +118,36 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error)
defer s.mu.Unlock()
state := s.stateLocked()
switch path {
case "/BattlePhaseChange":
if !state.entered {
return 0, nil, true, errors.New("battle: phase change before enter")
}
seq, _, _ := wire.Varint(request, 1)
if seq == state.phaseSeq && state.phaseReply != nil {
return 632, append([]byte(nil), state.phaseReply...), true, nil
}
if seq <= state.phaseSeq {
return 0, nil, true, errors.New("battle: stale phase change sequence")
}
if len(state.phases) == 0 || state.phase+1 >= len(state.phases) {
return 0, nil, true, errors.New("battle: no next phase")
}
if !state.phaseStarted {
return 0, nil, true, errors.New("battle: phase change before current phase start")
}
next := state.phases[state.phase+1]
response := wire.AppendVarint(nil, 1, next.GroupID)
response = wire.AppendVarint(response, 2, next.ID)
response = wire.AppendVarint(response, 8, next.DeckID)
// The local engine does not simulate combat. With verification disabled
// and no battle_result the client explicitly preserves its blue team;
// fabricating a verified result would overwrite HP, SP and action state.
// Dynamic official turn/SP values are not available in this request.
state.phase++
state.index, state.deck, state.phaseStarted = next.DeckID, next.DeckID, false
state.phaseSeq, state.phaseReply = seq, append([]byte(nil), response...)
slog.Info("team trace: battle phase changed", "pack", state.pack, "monster", state.monster, "group", next.GroupID, "phase", next.ID, "enemyDeck", next.DeckID)
return 632, response, true, nil
case "/BattleVerifyState":
if !state.entered {
return 0, nil, true, errors.New("battle: verify before enter")
@@ -141,6 +177,20 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error)
return 0, nil, true, errors.New("battle: current pack resolver is unavailable")
}
monster, _, _ := wire.Varint(request, 3)
var phases []gamedata.BattlePhase
if s.loadPhases != nil || (s.gameDataRoot != "" && packID > 0 && monster != 0) {
loader := s.loadPhases
if loader == nil {
loader = gamedata.BattleDeckPhases
}
phases, err = loader(s.gameDataRoot, s.gameDataVersion, packID, monster, deck)
if err != nil {
return 0, nil, true, fmt.Errorf("battle: load phases: %w", err)
}
if len(phases) != 0 && phases[0].DeckID != deck {
return 0, nil, true, errors.New("battle: enter must select first phase deck")
}
}
response := wire.AppendVarint(nil, 2, deck)
if s.buffs != nil {
buffs, err := s.buffs()
@@ -160,6 +210,7 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error)
response = wire.AppendVarint(response, 6, 1)
state.entered, state.index, state.round, state.initialBlue = true, 0, 0, nil
state.monster, state.deck, state.pack = monster, deck, packID
state.phases, state.phase, state.phaseStarted, state.phaseSeq, state.phaseReply = phases, 0, false, 0, nil
slog.Info("team trace: battle entered", "pack", packID, "monster", monster, "enemyDeck", deck, "mode", mode)
return 52, response, true, nil
case "/BattleRetry":
@@ -173,12 +224,24 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error)
if len(state.initialBlue) == 0 {
return 0, nil, true, errors.New("battle: retry before initial battle state")
}
if len(state.phases) != 0 {
// Retry requests carry the current deck; the response must restore
// the first phase's deck, as PhaseBattleManager.ApplyRetryResponse does.
if index != state.index {
return 0, nil, true, errors.New("battle: retry index does not match current phase")
}
index = state.phases[0].DeckID
}
var response []byte
for _, character := range state.initialBlue {
response = wire.AppendBytes(response, 2, character)
}
response = wire.AppendVarint(response, 3, index)
state.index, state.round = index, 0
if len(state.phases) != 0 {
state.deck = state.phases[0].DeckID
}
state.phase, state.phaseStarted, state.phaseSeq, state.phaseReply = 0, false, 0, nil
return 58, response, true, nil
case "/BattleStart":
if !state.entered {
@@ -191,10 +254,11 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error)
if state.index != 0 && index != state.index {
return 0, nil, true, fmt.Errorf("battle: index changed from %d to %d", state.index, index)
}
state.index, state.round = index, state.round+1
if state.round == 1 {
state.initialBlue = nil
if len(state.phases) != 0 && index != state.phases[state.phase].DeckID {
return 0, nil, true, errors.New("battle: start index does not match current phase")
}
nextRound := state.round + 1
var initialBlue [][]byte
var response []byte
err = wire.Walk(request, func(field wire.Field) error {
if field.Type != 2 {
@@ -204,8 +268,8 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error)
response = wire.AppendBytes(response, 1, field.Value)
} else if field.Number == 5 {
response = wire.AppendBytes(response, 2, field.Value)
if state.round == 1 {
state.initialBlue = append(state.initialBlue, append([]byte(nil), field.Value...))
if nextRound == 1 {
initialBlue = append(initialBlue, append([]byte(nil), field.Value...))
}
}
return nil
@@ -213,6 +277,10 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error)
if err != nil {
return 0, nil, true, err
}
state.index, state.round, state.phaseStarted = index, nextRound, true
if nextRound == 1 {
state.initialBlue = initialBlue
}
// Stable per-battle/round seed; reproducible across retries.
seed := index*7919 + state.round*104729
response = wire.AppendVarint(response, 3, seed)
@@ -225,6 +293,9 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error)
if err != nil || !found || result == 0 {
return 0, nil, true, errors.New("battle: invalid result")
}
if result == 1 && len(state.phases) != 0 && (state.phase != len(state.phases)-1 || !state.phaseStarted) {
return 0, nil, true, errors.New("battle: victory before final phase start")
}
response := wire.AppendVarint(nil, 1, result)
var resultCharacters [][]byte
finishedHealth := make(map[uint64]uint64)
+1
View File
@@ -136,6 +136,7 @@ func TestBattleVictoryLocksPackAtEnterForRewardsAndIdentity(t *testing.T) {
})
var loadedPack int
var loadedDeck uint64
s.loadPhases = func(string, string, int, uint64, uint64) ([]gamedata.BattlePhase, error) { return nil, nil }
s.loadRewards = func(_, _ string, packID int, deckID uint64) ([]gamedata.BattleReward, error) {
loadedPack, loadedDeck = packID, deckID
return []gamedata.BattleReward{{Type: 8, ID: 8, Count: 3}}, nil
+208
View File
@@ -0,0 +1,208 @@
package battle
import (
"bytes"
"testing"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/player"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
)
func phaseService(t *testing.T) *Service {
t.Helper()
s := &Service{loadPhases: func(string, string, int, uint64, uint64) ([]gamedata.BattlePhase, error) {
return []gamedata.BattlePhase{{GroupID: 70, ID: 101, DeckID: 11}, {GroupID: 70, ID: 205, DeckID: 12}, {GroupID: 70, ID: 309, DeckID: 13}}, nil
}}
enter := wire.AppendVarint(wire.AppendVarint(request(1), 4, 11), 5, 1)
if _, _, _, err := s.Handle("/BattleEnter", enter); err != nil {
t.Fatal(err)
}
return s
}
func phaseStart(t *testing.T, s *Service, seq, deck uint64, blue []byte) []byte {
t.Helper()
start := wire.AppendVarint(request(seq), 2, deck)
start = wire.AppendBytes(start, 5, blue)
code, reply, _, err := s.Handle("/BattleStart", start)
if err != nil || code != 14 {
t.Fatalf("start %d: code=%d err=%v", deck, code, err)
}
return reply
}
func TestPhaseLifecyclePreservesClientStateAndRejectsSkipping(t *testing.T) {
s := phaseService(t)
blue := wire.AppendVarint(wire.AppendVarint(nil, 2, 400), 4, 1234)
if _, _, _, err := s.Handle("/BattlePhaseChange", request(2)); err == nil {
t.Fatal("phase advanced before start")
}
phaseStart(t, s, 3, 11, blue)
commits := 0
s.AttachCommittedHealth(func(map[uint64]uint64) error { commits++; return nil })
win := wire.AppendVarint(request(4), 2, 1)
if _, _, _, err := s.Handle("/BattleEnd", win); err == nil {
t.Fatal("early victory accepted")
}
if commits != 0 {
t.Fatal("early victory committed health")
}
code, reply, _, err := s.Handle("/BattlePhaseChange", request(5))
if err != nil || code != 632 {
t.Fatalf("phase response=%d %v", code, err)
}
for field, want := range map[int]uint64{1: 70, 2: 205, 8: 12} {
got, found, err := wire.Varint(reply, field)
if err != nil || !found || got != want {
t.Fatalf("field %d = %d/%v: %v", field, got, found, err)
}
}
if result, found, _ := wire.Bytes(reply, 3); found && len(result) != 0 {
t.Fatal("fabricated verified battle result")
}
verify, _, _ := wire.Varint(reply, 7)
if verify != 0 {
t.Fatal("verification enabled without authoritative combat state")
}
_, replay, _, err := s.Handle("/BattlePhaseChange", request(5))
if err != nil || !bytes.Equal(reply, replay) || s.stateLocked().phase != 1 {
t.Fatal("replay advanced phase")
}
if _, _, _, err := s.Handle("/BattlePhaseChange", request(6)); err == nil {
t.Fatal("skipped unstarted phase")
}
if _, _, _, err := s.Handle("/BattleStart", wire.AppendVarint(request(7), 2, 13)); err == nil {
t.Fatal("skipped deck accepted")
}
changedBlue := wire.AppendVarint(wire.AppendVarint(nil, 2, 400), 4, 600)
response := phaseStart(t, s, 8, 12, changedBlue)
got, _, _ := wire.Bytes(response, 2)
if !bytes.Equal(got, changedBlue) || !bytes.Equal(s.stateLocked().initialBlue[0], blue) {
t.Fatal("phase start reset player state or retry baseline")
}
if _, _, _, err := s.Handle("/BattlePhaseChange", request(9)); err != nil {
t.Fatal(err)
}
if _, _, _, err := s.Handle("/BattleEnd", win); err == nil {
t.Fatal("victory before final start")
}
phaseStart(t, s, 10, 13, changedBlue)
if _, _, _, err := s.Handle("/BattlePhaseChange", request(11)); err == nil {
t.Fatal("advanced past final phase")
}
if _, _, _, err := s.Handle("/BattleEnd", win); err != nil {
t.Fatal(err)
}
if _, _, _, err := s.Handle("/BattlePhaseChange", request(12)); err == nil {
t.Fatal("inactive phase request accepted")
}
}
func TestPhaseRetryRestoresFirstDeckAndInitialBlue(t *testing.T) {
s := phaseService(t)
blue := wire.AppendVarint(wire.AppendVarint(nil, 2, 401), 4, 200)
phaseStart(t, s, 2, 11, blue)
if _, _, _, err := s.Handle("/BattlePhaseChange", request(3)); err != nil {
t.Fatal(err)
}
phaseStart(t, s, 4, 12, wire.AppendVarint(nil, 4, 1))
if _, _, _, err := s.Handle("/BattleRetry", wire.AppendVarint(request(5), 2, 11)); err == nil {
t.Fatal("retry accepted wrong current deck")
}
code, response, _, err := s.Handle("/BattleRetry", wire.AppendVarint(request(6), 2, 12))
index, _, _ := wire.Varint(response, 3)
restored, _, _ := wire.Bytes(response, 2)
if err != nil || code != 58 || index != 11 || !bytes.Equal(restored, blue) {
t.Fatalf("retry: code=%d index=%d blue=%x err=%v", code, index, restored, err)
}
state := s.stateLocked()
if state.phase != 0 || state.deck != 11 || state.phaseStarted || state.phaseReply != nil {
t.Fatal("retry did not reset phase state")
}
if _, _, _, err := s.Handle("/BattlePhaseChange", request(7)); err == nil {
t.Fatal("retry advanced before start")
}
}
func TestMalformedPhaseStartDoesNotConsumeRound(t *testing.T) {
s := phaseService(t)
start := append(wire.AppendVarint(request(2), 2, 11), 0x2a, 0x80)
if _, _, _, err := s.Handle("/BattleStart", start); err == nil {
t.Fatal("malformed start accepted")
}
if state := s.stateLocked(); state.round != 0 || state.index != 0 || state.phaseStarted {
t.Fatal("malformed request mutated battle")
}
}
func TestPhaseEntryRejectsLaterDeckWithoutReplacingActiveBattle(t *testing.T) {
s := phaseService(t)
phaseStart(t, s, 2, 11, wire.AppendVarint(nil, 2, 400))
enter := wire.AppendVarint(wire.AppendVarint(request(3), 4, 12), 5, 1)
if _, _, _, err := s.Handle("/BattleEnter", enter); err == nil {
t.Fatal("entered a later phase directly")
}
state := s.stateLocked()
if state.deck != 11 || state.index != 11 || !state.phaseStarted {
t.Fatal("rejected enter replaced active battle")
}
}
func TestOrdinaryBattleRejectsPhaseChange(t *testing.T) {
s := &Service{}
enter := wire.AppendVarint(wire.AppendVarint(request(1), 4, 11), 5, 1)
if _, _, _, err := s.Handle("/BattleEnter", enter); err != nil {
t.Fatal(err)
}
phaseStart(t, s, 2, 11, wire.AppendVarint(nil, 2, 400))
if _, _, _, err := s.Handle("/BattlePhaseChange", request(3)); err == nil {
t.Fatal("ordinary battle accepted phase change")
}
}
func TestPhaseVictoryRewardsOnlyFinalDeck(t *testing.T) {
s := phaseService(t)
var err error
s.inventory, err = player.OpenInventory(stateio.NewMemory(), &player.Starter{Version: "2.35.10"})
if err != nil {
t.Fatal(err)
}
s.gameDataRoot = "test-root"
s.stateLocked().pack, s.stateLocked().monster = 77, 42
loaded := uint64(0)
s.loadRewards = func(_, _ string, pack int, deck uint64) ([]gamedata.BattleReward, error) {
if pack != 77 {
t.Fatalf("reward pack = %d", pack)
}
loaded = deck
return []gamedata.BattleReward{{Type: 8, ID: 8, Count: 1}}, nil
}
blue := wire.AppendVarint(nil, 2, 400)
phaseStart(t, s, 2, 11, blue)
win := wire.AppendVarint(request(3), 2, 1)
if _, _, _, err := s.Handle("/BattleEnd", win); err == nil {
t.Fatal("early win accepted")
}
if loaded != 0 {
t.Fatal("early win loaded rewards")
}
if _, _, _, err := s.Handle("/BattlePhaseChange", request(4)); err != nil {
t.Fatal(err)
}
phaseStart(t, s, 5, 12, blue)
if _, _, _, err := s.Handle("/BattlePhaseChange", request(6)); err != nil {
t.Fatal(err)
}
phaseStart(t, s, 7, 13, blue)
if _, _, _, err := s.Handle("/BattleEnd", win); err != nil {
t.Fatal(err)
}
if loaded != 13 || len(s.inventory.GrantedItems("pack77:monster42:deck13")) != 1 {
t.Fatalf("final reward deck=%d", loaded)
}
if len(s.inventory.GrantedItems("pack77:monster42:deck11")) != 0 {
t.Fatal("granted first phase reward")
}
}
+118
View File
@@ -0,0 +1,118 @@
package gamedata
import (
"database/sql"
"fmt"
"os"
"path/filepath"
"bd2server/internal/server/wire"
)
// BattlePhase identifies a row in the pack's PhaseBattleTable. ID is the
// design row ID sent as current_phase_id, not a zero-based list offset.
type BattlePhase struct{ GroupID, ID, DeckID uint64 }
// BattleDeckPhases resolves a monster's phase group and verifies that the
// selected deck belongs to it. Ordinary monsters have no phase group.
func BattleDeckPhases(root, version string, packID int, monsterID, deckID uint64) ([]BattlePhase, error) {
if packID <= 0 || monsterID == 0 || deckID == 0 || monsterID > 2147483647 || deckID > 2147483647 {
return nil, fmt.Errorf("gamedata: invalid phase battle pack/monster/deck %d/%d/%d", packID, monsterID, deckID)
}
plain, err := ReadDatabase(root, version, fmt.Sprintf("pack%d", packID))
if err != nil {
return nil, err
}
dir, err := os.MkdirTemp("", "bd2-phase-")
if err != nil {
return nil, err
}
defer os.RemoveAll(dir)
path := filepath.Join(dir, "pack.db")
if err := os.WriteFile(path, plain, 0600); err != nil {
return nil, err
}
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
if err != nil {
return nil, err
}
defer db.Close()
return battleDeckPhasesFromDB(db, monsterID, deckID)
}
func battleDeckPhasesFromDB(db *sql.DB, monsterID, deckID uint64) ([]BattlePhase, error) {
var monster []byte
if err := db.QueryRow("SELECT ProtoBuf FROM FieldMonsterTable WHERE id=?", monsterID).Scan(&monster); err != nil {
return nil, fmt.Errorf("gamedata: phase monster %d: %w", monsterID, err)
}
group, err := phaseScalar(monster, 21)
if err != nil {
return nil, err
}
if group == 0 {
return nil, nil
}
if group > 2147483647 {
return nil, fmt.Errorf("gamedata: phase group exceeds int32")
}
rows, err := db.Query("SELECT id, ProtoBuf FROM PhaseBattleTable WHERE groupId=? ORDER BY id", group)
if err != nil {
return nil, err
}
defer rows.Close()
var phases []BattlePhase
selected := false
for rows.Next() {
var data []byte
var sqlID uint64
if err := rows.Scan(&sqlID, &data); err != nil {
return nil, err
}
rowGroup, err := phaseScalar(data, 3)
if err != nil {
return nil, err
}
id, err := phaseScalar(data, 4)
if err != nil {
return nil, err
}
deck, err := phaseScalar(data, 2)
if err != nil {
return nil, err
}
if rowGroup != group || id == 0 || deck == 0 || id > 2147483647 || deck > 2147483647 || id != sqlID || (len(phases) > 0 && phases[len(phases)-1].ID >= id) {
return nil, fmt.Errorf("gamedata: invalid phase group %d row %d deck %d", rowGroup, id, deck)
}
phases = append(phases, BattlePhase{GroupID: group, ID: id, DeckID: deck})
selected = selected || deck == deckID
}
if err := rows.Err(); err != nil {
return nil, err
}
if len(phases) == 0 || !selected {
return nil, fmt.Errorf("gamedata: phase group %d does not contain selected deck %d", group, deckID)
}
return phases, nil
}
func phaseScalar(data []byte, number int) (uint64, error) {
var value uint64
err := wire.Walk(data, func(f wire.Field) error {
if f.Number != number {
return nil
}
if f.Type != 0 {
return fmt.Errorf("gamedata: phase field %d has wire type %d", number, f.Type)
}
values, err := packedInts(data, number)
if err != nil {
return err
}
if len(values) != 1 {
return fmt.Errorf("gamedata: phase field %d is not scalar", number)
}
value = values[0]
return nil
})
return value, err
}
@@ -0,0 +1,66 @@
package gamedata
import (
"database/sql"
"reflect"
"testing"
)
func TestBattleDeckPhasesDesignRows(t *testing.T) {
db, e := sql.Open("sqlite", ":memory:")
if e != nil {
t.Fatal(e)
}
defer db.Close()
db.SetMaxOpenConns(1)
for _, q := range []string{"CREATE TABLE FieldMonsterTable(id INTEGER PRIMARY KEY, ProtoBuf BLOB)", "CREATE TABLE PhaseBattleTable(groupId INTEGER,id INTEGER,ProtoBuf BLOB)"} {
if _, e := db.Exec(q); e != nil {
t.Fatal(e)
}
}
for _, v := range []struct {
id int
b []byte
}{{8, []byte{0xa8, 1, 1}}, {1, nil}, {2, []byte{0xa8, 1, 7}}} {
if _, e := db.Exec("INSERT INTO FieldMonsterTable VALUES (?,?)", v.id, v.b); e != nil {
t.Fatal(e)
}
}
// Rows reproduce Python-confirmed pack22 fields; group7 tests nonadjacent IDs.
for _, v := range []struct{ g, id, d int }{{1, 2, 9}, {1, 1, 8}, {7, 50, 99}, {7, 10, 77}, {7, 30, 88}} {
b := []byte{0x10, byte(v.d), 0x18, byte(v.g), 0x20, byte(v.id)}
if _, e := db.Exec("INSERT INTO PhaseBattleTable VALUES (?,?,?)", v.g, v.id, b); e != nil {
t.Fatal(e)
}
}
for _, c := range []struct {
m, d uint64
w []BattlePhase
}{{8, 8, []BattlePhase{{1, 1, 8}, {1, 2, 9}}}, {8, 9, []BattlePhase{{1, 1, 8}, {1, 2, 9}}}, {1, 1, nil}, {2, 77, []BattlePhase{{7, 10, 77}, {7, 30, 88}, {7, 50, 99}}}} {
g, e := battleDeckPhasesFromDB(db, c.m, c.d)
if e != nil {
t.Fatal(e)
}
if !reflect.DeepEqual(g, c.w) {
t.Fatalf("got %+v want %+v", g, c.w)
}
}
if _, e := battleDeckPhasesFromDB(db, 8, 1); e == nil {
t.Fatal("accepted unrelated deck")
}
for _, b := range [][]byte{{0x10, 8, 0x18, 2, 0x20, 1}, {0x10, 8, 0x18, 1, 0x20, 3}, {0x18, 1, 0x20, 1}} {
if _, e := db.Exec("UPDATE PhaseBattleTable SET ProtoBuf=? WHERE groupId=1 AND id=1", b); e != nil {
t.Fatal(e)
}
if _, e := battleDeckPhasesFromDB(db, 8, 8); e == nil {
t.Fatalf("accepted invalid row %x", b)
}
}
}
func TestPhaseScalarRejectsInvalidDesign(t *testing.T) {
for _, b := range [][]byte{{0x1a, 1, 1}, {0x18, 1, 0x18, 2}} {
if _, e := phaseScalar(b, 3); e == nil {
t.Fatalf("accepted %x", b)
}
}
}