fix(server): unwrap automatic chest rewards and log actual battle parties
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user