fix(server): unwrap automatic chest rewards and log actual battle parties

This commit is contained in:
2026-10-06 17:29:25 +08:00
parent c428fc308e
commit 88cb314978
6 changed files with 282 additions and 13 deletions
+13
View File
@@ -424,6 +424,19 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error)
state.index, state.round, state.phaseStarted = index, nextRound, true
if nextRound == 1 {
state.initialBlue = initialBlue
// Log the actual submitted team after pre-battle DeckSave, rather
// than inferring it from the deck that existed at BattleEnter.
// BattleCharDBInfo fields 2/3/7/9 are inventory/character/costume/grid.
blueTeam := make([]string, 0, len(initialBlue))
for _, character := range initialBlue {
inventory, _, _ := wire.Varint(character, 2)
id, _, _ := wire.Varint(character, 3)
costume, _, _ := wire.Varint(character, 7)
grid, _, _ := wire.Varint(character, 9)
blueTeam = append(blueTeam, fmt.Sprintf("inven=%d char=%d costume=%d grid=%d", inventory, id, costume, int32(grid)))
}
seq, _, _ := wire.Varint(request, 1)
slog.Info("team trace: battle started", "seq", seq, "pack", state.pack, "monster", state.monster, "enemyDeck", state.deck, "mode", state.mode, "blueTeam", blueTeam)
}
// Stable per-battle/round seed; reproducible across retries.
seed := index*7919 + state.round*104729
+88 -9
View File
@@ -8,9 +8,10 @@ import (
// FieldObjectDesign retains the client table identities, including unsupported
// reward graphs, so callers can reject them without inventing a reward.
type FieldObjectDesign struct {
Objects map[int]FieldRewardObject
Actions map[int]FieldActionObject
Equipment *EquipmentGachaCatalog
Objects map[int]FieldRewardObject
Actions map[int]FieldActionObject
Equipment *EquipmentGachaCatalog
RewardGraph *RewardGraph
}
type FieldRewardObject struct {
ID, MapID, GroupID, Type, ResetType, QuestID, BuffID, MonsterID int
@@ -34,6 +35,11 @@ func LoadFieldObjects(root, version string, pack int) (FieldObjectDesign, error)
}
func loadFieldObjects(db, common *sql.DB) (FieldObjectDesign, error) {
design := FieldObjectDesign{Objects: map[int]FieldRewardObject{}, Equipment: &EquipmentGachaCatalog{equipment: map[uint64]EquipmentDesign{}}}
graph, err := loadRewardGraph(common)
if err != nil {
return design, err
}
design.RewardGraph = graph
groups := map[int]FieldRewardObject{}
rows, err := db.Query("SELECT id,ProtoBuf FROM FieldRewardObjectGroupTable")
if err != nil {
@@ -95,12 +101,10 @@ func loadFieldObjects(db, common *sql.DB) (FieldObjectDesign, error) {
}
for i := range ids {
obj.Rewards = append(obj.Rewards, BattleReward{ID: ids[i], Type: types[i], Count: counts[i]})
if types[i] == 10 {
if e := design.Equipment.loadEquipmentTree(common, WeightedEquipment{ID: ids[i]}); e != nil {
rows.Close()
return design, e
}
}
}
if e := design.validateLoot(common, obj.Rewards); e != nil {
rows.Close()
return design, e
}
} else {
rows.Close()
@@ -154,6 +158,81 @@ func (o FieldRewardObject) Draw() ([]BattleReward, error) {
return o.draw(cryptoDraw)
}
// Validate every OPEN branch before the catalog is installed, including zero
// weight leaves. DIRECT boxes keep their inventory identity and need no roll.
func (d FieldObjectDesign) validateLoot(db *sql.DB, rewards []BattleReward) error {
visiting := map[uint64]bool{}
budget := 100000
var visit func(BattleReward) error
visit = func(r BattleReward) error {
budget--
if budget < 0 || r.Count == 0 || r.Count > uint64(^uint32(0)>>1) {
return fmt.Errorf("gamedata: invalid field loot quantity/budget")
}
switch r.Type {
case 2, 3, 4, 12, 20:
return nil
case 5, 7, 8, 13, 14, 17, 19, 27, 29:
if r.ID == 0 {
return fmt.Errorf("gamedata: invalid field loot item")
}
return nil
case 10:
if r.ID == 0 || r.Count > 100 {
return fmt.Errorf("gamedata: invalid field loot equipment")
}
return d.Equipment.loadEquipmentTree(db, WeightedEquipment{ID: r.ID})
case 9:
g := d.RewardGraph
gid, exists := g.boxes[r.ID]
if !exists || r.ID == 0 {
return fmt.Errorf("gamedata: missing field loot box %d", r.ID)
}
if g.direct[r.ID] {
return nil
}
if visiting[r.ID] || len(visiting) >= 32 {
return fmt.Errorf("gamedata: cyclic field loot box %d", r.ID)
}
raw, exists := g.groups[gid]
if !exists {
return fmt.Errorf("gamedata: missing field loot group %d", gid)
}
children, e := eventGameRewards(raw, 6, 5, 4)
weights, e2 := packedInts(raw, 8)
drop, e3 := optionalScalar(raw, 2)
count, e4 := optionalScalar(raw, 1)
if e != nil || e2 != nil || e3 != nil || e4 != nil || len(children) == 0 || len(children) != len(weights) || drop > 1 || drop == 0 && (count == 0 || count > 100) {
return fmt.Errorf("gamedata: malformed field loot group %d", gid)
}
var total uint64
visiting[r.ID] = true
defer delete(visiting, r.ID)
for i, child := range children {
if ^uint64(0)-total < weights[i] || drop == 1 && weights[i] > 100 {
return fmt.Errorf("gamedata: invalid field loot ratios %d", gid)
}
total += weights[i]
if e := visit(child); e != nil {
return e
}
}
if drop == 0 && total == 0 {
return fmt.Errorf("gamedata: empty field loot distribution %d", gid)
}
return nil
default:
return fmt.Errorf("gamedata: unsupported field loot type %d", r.Type)
}
}
for _, r := range rewards {
if err := visit(r); err != nil {
return err
}
}
return nil
}
func (o FieldRewardObject) draw(draw func(uint64) (uint64, error)) ([]BattleReward, error) {
if o.DropType > 1 || (o.DropType == 0 && o.DropCount == 0) || o.DropCount > 100 || len(o.Rewards) == 0 || len(o.Rewards) != len(o.Ratios) {
return nil, fmt.Errorf("gamedata: unsupported field reward draw")
@@ -23,17 +23,20 @@ func TestFieldObjectLoadIndependentEquipmentAndRandomBox(t *testing.T) {
t.Fatal(err)
}
}
for _, table := range []string{"RewardGroupTable", "EquipmentTable"} {
for _, table := range []string{"RewardGroupTable", "EquipmentTable", "RandomBoxTable"} {
if _, err := common.Exec("CREATE TABLE " + table + "(id INTEGER, ProtoBuf BLOB)"); err != nil {
t.Fatal(err)
}
}
if _, err := common.Exec("INSERT INTO RandomBoxTable VALUES(?,?)", 88, wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 9, 88)); err != nil {
t.Fatal(err)
}
if _, err := common.Exec("CREATE TABLE EquipmentOptionTable(GroupId INTEGER, id INTEGER, ProtoBuf BLOB)"); err != nil {
t.Fatal(err)
}
group := wire.AppendVarint(nil, 10, 66)
group = wire.AppendVarint(group, 12, 8) // A newly introduced object kind must not need an allowlist.
if _, err := pack.Exec("INSERT INTO FieldRewardObjectGroupTable VALUES(?,?)", 55, group); err != nil {
objectGroup := wire.AppendVarint(nil, 10, 66)
objectGroup = wire.AppendVarint(objectGroup, 12, 8) // A newly introduced object kind must not need an allowlist.
if _, err := pack.Exec("INSERT INTO FieldRewardObjectGroupTable VALUES(?,?)", 55, objectGroup); err != nil {
t.Fatal(err)
}
object := wire.AppendVarint(nil, 3, 55)
@@ -73,6 +76,41 @@ func TestFieldObjectLoadIndependentEquipmentAndRandomBox(t *testing.T) {
if err != nil || len(main) != 1 || main[0].GroupID != 99 || main[0].ID != 7 {
t.Fatalf("options=%+v err=%v", main, err)
}
// A missing dropType uses the client's proto default RBD_OPEN. Keep a
// nested DIRECT box but prepare the wrapped equipment's option catalog.
if _, err := common.Exec("UPDATE RandomBoxTable SET ProtoBuf=? WHERE id=88", wire.AppendVarint(nil, 9, 89)); err != nil {
t.Fatal(err)
}
if _, err := common.Exec("INSERT INTO RandomBoxTable VALUES(?,?)", 90, wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 9, 90)); err != nil {
t.Fatal(err)
}
wrapped := group(0, 1, []BattleReward{{Type: 10, ID: 77, Count: 1}, {Type: 9, ID: 90, Count: 1}}, []uint64{1, 1})
if _, err := common.Exec("INSERT INTO RewardGroupTable VALUES(?,?)", 89, wrapped); err != nil {
t.Fatal(err)
}
d, err = loadFieldObjects(pack, common)
if err != nil {
t.Fatal(err)
}
d.RewardGraph.SetSampler(func(uint64) (uint64, error) { return 0, nil })
resolved, err := d.RewardGraph.ResolveGranted([]BattleReward{{Type: 9, ID: 88, Count: 1}})
if err != nil || len(resolved) != 1 || resolved[0].Type != 10 || resolved[0].ID != 77 {
t.Fatalf("OPEN equipment=%+v err=%v", resolved, err)
}
d.RewardGraph.SetSampler(func(n uint64) (uint64, error) { return n - 1, nil })
resolved, err = d.RewardGraph.ResolveGranted([]BattleReward{{Type: 9, ID: 88, Count: 1}})
if err != nil || len(resolved) != 1 || resolved[0].Type != 9 || resolved[0].ID != 90 {
t.Fatalf("nested DIRECT box=%+v err=%v", resolved, err)
}
// Invalid options in an unselected/zero-weight OPEN branch must reject the
// catalog before a draw or any owned-item receipt can be written.
wrapped = group(0, 1, []BattleReward{{Type: 10, ID: 77, Count: 1}, {Type: 10, ID: 999, Count: 1}}, []uint64{1, 0})
if _, err := common.Exec("UPDATE RewardGroupTable SET ProtoBuf=? WHERE id=89", wrapped); err != nil {
t.Fatal(err)
}
if _, err := loadFieldObjects(pack, common); err == nil {
t.Fatal("invalid unselected OPEN equipment branch accepted")
}
}
func TestFieldResetUsesConfiguredBoundary(t *testing.T) {
@@ -2,6 +2,7 @@ package gamedata
import (
"crypto/rand"
"database/sql"
"fmt"
"math"
"math/big"
@@ -26,6 +27,10 @@ func LoadRewardGraph(root, version string) (*RewardGraph, error) {
return nil, e
}
defer done()
return loadRewardGraph(db)
}
func loadRewardGraph(db *sql.DB) (*RewardGraph, error) {
g := &RewardGraph{boxes: map[uint64]uint64{}, direct: map[uint64]bool{}, special: map[uint64]bool{}, groups: map[uint64][]byte{}}
g.sample = func(n uint64) (uint64, error) {
if n == 0 {
@@ -0,0 +1,128 @@
package world
import (
"bd2server/internal/server/accountstate"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/wire"
"errors"
"os"
"path/filepath"
"testing"
"bd2server/internal/server/stateio"
)
// Player.log's failing map-3 chest returned OPEN box 240001 as ItemDBInfo;
// PlayerController then loaded its intentionally absent ResourceIcon. This
// regression settles that exact selected branch using installed design data.
func TestInstalledFieldChestOpenWrapperProtocolAndSQLiteRollback(t *testing.T) {
root := os.Getenv("BD2_REAL_GAMEDATA")
if root == "" {
t.Skip("set BD2_REAL_GAMEDATA for the chest crash regression")
}
design, err := gamedata.LoadFieldObjects(root, "20260923193640", 1)
if err != nil {
t.Fatal(err)
}
chest := design.Objects[921]
if chest.MapID != 3 || chest.GroupID != 23 {
t.Fatal("crash fixture no longer matches current chest design")
}
var failingBranch gamedata.BattleReward
for _, reward := range chest.Rewards {
if reward.Type == 9 && reward.ID == 240001 {
failingBranch = reward
}
}
if failingBranch.Count != 1 {
t.Fatal("authored OPEN equipment branch missing")
}
// Condition on the real branch selected at 09:15:23; select the first
// authored child deterministically so rollback/restart assertions are exact.
chest.Rewards, chest.Ratios = []gamedata.BattleReward{failingBranch}, []uint64{1}
design.Objects[921] = chest
design.RewardGraph.SetSampler(func(uint64) (uint64, error) { return 0, nil })
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}
service := func() *Service {
s := sqliteFieldService(t, store).WithFieldObjects(map[int]gamedata.FieldObjectDesign{1: design})
s.storyCatalog.Packs[1] = gamedata.StoryPack{ID: 1, Quests: map[int]gamedata.QuestDesign{1: {ID: 1}}, MainQuestIDs: []int{1}}
s.packs = map[int]map[int]gamedata.QuestDesign{1: s.storyCatalog.Packs[1].Quests}
if err := s.state.SetActivePackID(1); err != nil {
t.Fatal(err)
}
return s
}
s := service()
request := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 1), 3, 23), 4, 921)
op, err := repo.BeginOperation()
if err != nil {
t.Fatal(err)
}
store.fail = true
if _, _, _, err := s.handleFieldObjectReward(request); err == nil {
t.Fatal("failed opened marker 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 = service()
opened, err := s.state.FieldRewardOpened(1, 921, "once")
if err != nil || opened || len(s.equipment.All()) != 0 || len(s.inventory.All()) != 0 {
t.Fatal("rolled back chest left equipment, wrapper, or marker")
}
op, err = repo.BeginOperation()
if err != nil {
t.Fatal(err)
}
code, response, _, err := s.handleFieldObjectReward(request)
if err != nil || code != 29 {
t.Fatalf("code=%d error=%v", code, err)
}
if err := op.Commit(); err != nil {
t.Fatal(err)
}
bundle, _, _ := wire.Bytes(response, 1)
if fieldWireCount(t, bundle, 1) != 0 || fieldWireCount(t, bundle, 4) != 1 || len(s.inventory.All()) != 0 {
t.Fatal("OPEN wrapper leaked into ItemDBInfo/inventory instead of EquipDBInfo")
}
equipment, found, err := wire.Bytes(bundle, 4)
base, _, _ := wire.Bytes(equipment, 5)
id, _, _ := wire.Varint(base, 1)
index, _, _ := wire.Varint(equipment, 1)
if err != nil || !found || id != 10010 || index == 0 || len(s.equipment.All()) != 1 || s.equipment.All()[0].InvenIndex != index {
t.Fatalf("wrong authored equipment id=%d index=%d", id, index)
}
for _, field := range []int{3, 4, 5} {
if _, found, err := wire.Bytes(base, field); err != nil || !found {
t.Fatalf("equipment missing client metadata field %d", field)
}
}
if err := repo.Close(); err != nil {
t.Fatal(err)
}
repo, err = accountstate.Open(path)
if err != nil {
t.Fatal(err)
}
store.Repository = repo
s = service()
_, response, _, err = s.handleFieldObjectReward(request)
bundle, _, _ = wire.Bytes(response, 1)
if err != nil || len(bundle) != 0 || len(s.equipment.All()) != 1 || len(s.inventory.All()) != 0 {
t.Fatal("restart/retry repeated equipment or created a wrapper")
}
}
@@ -206,6 +206,12 @@ func (s *Service) openFieldObject(pack, group, id int) ([]byte, error) {
if err != nil {
return nil, err
}
if design.RewardGraph != nil {
selected, err = design.RewardGraph.ResolveGranted(selected)
if err != nil {
return nil, err
}
}
for _, r := range selected {
if r.Count == 0 {
return nil, fmt.Errorf("world: empty field reward")