fix(server): expand reward wrappers and preserve inventory boxes in bulk grants

This commit is contained in:
2026-10-05 13:56:23 +08:00
parent 90cbd6871b
commit 9ece9d9afa
5 changed files with 234 additions and 31 deletions
@@ -0,0 +1,129 @@
package eventtasks
import (
"bytes"
"testing"
"bd2server/internal/server/events"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/player"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
)
type passRewardDesign struct{}
func (passRewardDesign) Character(uint64) (gamedata.CharacterDesign, bool) {
return gamedata.CharacterDesign{}, false
}
func (passRewardDesign) RollOptions(uint64) ([]gamedata.EquipmentOptionChoice, []gamedata.EquipmentOptionChoice, *gamedata.EquipmentOptionChoice, error) {
return nil, nil, nil, nil
}
func (passRewardDesign) Resolve(r []gamedata.BattleReward) ([]gamedata.BattleReward, error) {
return r, nil
}
func realPassEconomy(t *testing.T, store stateio.Store) (*events.Economy, *player.Inventory, *player.Wallet) {
t.Helper()
items, err := player.OpenInventory(store, &player.Starter{Version: "2.35.10"})
if err != nil {
t.Fatal(err)
}
wallet, err := player.OpenWallet(store, player.Currency{})
if err != nil {
t.Fatal(err)
}
collection, err := player.OpenCollectionStore(store, nil)
if err != nil {
t.Fatal(err)
}
equipment, err := player.OpenEquipmentInventory(store)
if err != nil {
t.Fatal(err)
}
for _, persist := range []func() error{items.EnsurePersisted, wallet.EnsurePersisted, collection.EnsurePersisted, equipment.EnsurePersisted} {
if err := persist(); err != nil {
t.Fatal(err)
}
}
design := passRewardDesign{}
economy, err := events.NewEconomy(store, items, wallet, collection, equipment, design, design, design, nil)
if err != nil {
t.Fatal(err)
}
return economy, items, wallet
}
// A spy alone cannot detect a claim recorded without a durable inventory grant.
// Exercise the native BASIC -> PREMIUM one-click flow through the real economy.
func TestPassRewardAllPersistsEveryCurrencyAndItemAcrossRestart(t *testing.T) {
s, _, store := setup(t)
economy, items, wallet := realPassEconomy(t, store)
s.economy = economy
s.design.PassLevels[8] = []gamedata.EventPassLevel{
{ID: 1, NeedExp: 20, Basic: gamedata.Reward{Type: 3, Count: 5}, Premium: gamedata.Reward{Type: 8, ID: 1000, Count: 2}},
{ID: 2, NeedExp: 30, Basic: gamedata.Reward{Type: 3, Count: 7}, Premium: gamedata.Reward{Type: 8, ID: 1000, Count: 3}},
{ID: 3, NeedExp: 40, Basic: gamedata.Reward{Type: 8, ID: 1000, Count: 11}, Premium: gamedata.Reward{Type: 4, Count: 100}},
{ID: 4, Basic: gamedata.Reward{Type: 3, Count: 999}, Premium: gamedata.Reward{Type: 4, Count: 999}},
}
p := s.pass(s.registry.List()[2])
p.Exp = 50
p.Premium = true
basic := passClaimRequest(1, 0, true, 0)
premium := passClaimRequest(2, 1, true, 0)
basicReply := claimPass(t, s, basic)
premiumReply := claimPass(t, s, premium)
assertBalances := func(items *player.Inventory, wallet *player.Wallet) {
t.Helper()
c := wallet.Snapshot()
if c.FreeJewelry != 12 || c.Gold != 100 {
t.Fatalf("durable currencies: %+v", c)
}
var total uint64
for _, item := range items.All() {
if item.Type == 8 && item.ID == 1000 {
total += item.Count
}
}
if total != 16 {
t.Fatalf("durable ticket quantity=%d want=16", total)
}
}
assertBalances(items, wallet)
for _, response := range [][]byte{basicReply, premiumReply} {
bundle, _, err := wire.Bytes(response, 1)
if err != nil {
t.Fatal(err)
}
count := 0
if err := wire.Walk(bundle, func(f wire.Field) error {
if f.Number == 1 {
count++
}
return nil
}); err != nil {
t.Fatal(err)
}
if count != 3 {
t.Fatalf("bundle omitted rewards: entries=%d want=3", count)
}
}
economy, items, wallet = realPassEconomy(t, store)
reopened, err := Open(store, s.design, s.registry, economy)
if err != nil {
t.Fatal(err)
}
reopened.now = s.now
reopened.SetSession("test")
if !bytes.Equal(claimPass(t, reopened, basic), basicReply) || !bytes.Equal(claimPass(t, reopened, premium), premiumReply) {
t.Fatal("replay response changed")
}
assertBalances(items, wallet)
for typ := uint64(0); typ <= 1; typ++ {
claimPass(t, reopened, passClaimRequest(3+typ, typ, true, 0))
}
assertBalances(items, wallet)
if reopened.pass(reopened.registry.List()[2]).Claimed[key(4, 0)] || reopened.pass(reopened.registry.List()[2]).Claimed[key(4, 1)] {
t.Fatal("locked level was claimed")
}
}
+1 -1
View File
@@ -40,7 +40,7 @@ func (c *CashRewardResolver) ResolveGranted(rewards []BattleReward) ([]BattleRew
return fmt.Errorf("gamedata: invalid cash reward/budget")
}
budget--
if r.Type != 9 || !force && !c.direct[r.ID] {
if r.Type != 9 || !force && c.direct[r.ID] {
out = append(out, r)
return nil
}
@@ -33,7 +33,7 @@ func TestLoginPassTopLevelWrapperExpandsAndNestedManualGiftStaysOwned(t *testing
}{{6, 3}, {6, 9}, {5, 0}, {5, 200}, {4, 125}, {4, 1}, {8, 1}, {8, 100}} {
raw = wire.AppendVarint(raw, f.n, f.v)
}
c := &CashRewardResolver{boxes: map[uint64]uint64{100: 1000, 200: 2000}, direct: map[uint64]bool{}, groups: map[uint64][]byte{1000: raw}}
c := &CashRewardResolver{boxes: map[uint64]uint64{100: 1000, 200: 2000}, direct: map[uint64]bool{100: false, 200: true}, groups: map[uint64][]byte{1000: raw}}
got, err := c.ResolveGranted([]BattleReward{{Type: 9, ID: 100, Count: 1}})
if err != nil || len(got) != 2 || got[0].Type != 3 || got[0].Count != 125 || got[1].Type != 9 || got[1].ID != 200 {
t.Fatalf("login pass wrapper=%+v err=%v", got, err)
+40 -27
View File
@@ -12,11 +12,12 @@ import (
// RewardGraph resolves random-box nodes; costume/equipment leaves retain
// their design IDs for the owning grant domain.
type RewardGraph struct {
mu sync.Mutex
boxes map[uint64]uint64
direct map[uint64]bool
groups map[uint64][]byte
sample func(uint64) (uint64, error)
mu sync.Mutex
boxes map[uint64]uint64
direct map[uint64]bool
special map[uint64]bool
groups map[uint64][]byte
sample func(uint64) (uint64, error)
}
func LoadRewardGraph(root, version string) (*RewardGraph, error) {
@@ -25,7 +26,7 @@ func LoadRewardGraph(root, version string) (*RewardGraph, error) {
return nil, e
}
defer done()
g := &RewardGraph{boxes: map[uint64]uint64{}, direct: map[uint64]bool{}, groups: map[uint64][]byte{}}
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 {
return 0, fmt.Errorf("gamedata: empty reward pool")
@@ -57,7 +58,15 @@ func LoadRewardGraph(root, version string) (*RewardGraph, error) {
rows.Close()
return nil, err
}
g.direct[id] = drop == 1
// RBD_DIRECT owns the box itself; RBD_OPEN grants its selected contents.
// Design also contains special mode 10 boxes; only the verified OPEN
// mode is automatically expanded, other modes retain their entity.
if drop != 0 && drop != 1 && drop != 10 {
rows.Close()
return nil, fmt.Errorf("gamedata: unsupported random box drop type %d", drop)
}
g.direct[id] = drop != 0
g.special[id] = drop == 10
g.boxes[id] = gid
}
e = rows.Err()
@@ -88,13 +97,17 @@ func (g *RewardGraph) SetSampler(f func(uint64) (uint64, error)) {
g.sample = f
}
func (g *RewardGraph) Resolve(rewards []BattleReward) ([]BattleReward, error) {
return g.resolve(rewards, true)
}
func (g *RewardGraph) resolve(rewards []BattleReward, openRoots bool) ([]BattleReward, error) {
g.mu.Lock()
defer g.mu.Unlock()
budget := uint64(100000)
totals := map[[2]uint64]uint64{}
seen := map[uint64]bool{}
var emit func(BattleReward) error
emit = func(r BattleReward) error {
var emit func(BattleReward, bool) error
emit = func(r BattleReward, forceOpen bool) error {
if r.Count == 0 || r.Count > math.MaxInt32 || r.Type == 0 {
return fmt.Errorf("gamedata: invalid reward graph quantity/type")
}
@@ -102,7 +115,18 @@ func (g *RewardGraph) Resolve(rewards []BattleReward) ([]BattleReward, error) {
return fmt.Errorf("gamedata: reward graph operation limit")
}
budget--
if r.Type != 9 {
if r.Type == 9 {
if forceOpen && g.special[r.ID] {
return fmt.Errorf("gamedata: special random box %d cannot use generic opening", r.ID)
}
if _, ok := g.boxes[r.ID]; !ok {
return fmt.Errorf("gamedata: unknown random box %d", r.ID)
}
if _, ok := g.direct[r.ID]; !ok {
return fmt.Errorf("gamedata: missing random box drop type %d", r.ID)
}
}
if r.Type != 9 || !forceOpen && g.direct[r.ID] {
k := [2]uint64{r.Type, r.ID}
if totals[k] > math.MaxInt32-r.Count {
return fmt.Errorf("gamedata: reward graph overflow")
@@ -163,7 +187,7 @@ func (g *RewardGraph) Resolve(rewards []BattleReward) ([]BattleReward, error) {
if x >= weights[i] {
continue
}
if e = emit(child); e != nil {
if e = emit(child, false); e != nil {
return e
}
}
@@ -192,7 +216,7 @@ func (g *RewardGraph) Resolve(rewards []BattleReward) ([]BattleReward, error) {
}
for j, w := range weights {
if x < w {
if e = emit(children[j]); e != nil {
if e = emit(children[j], false); e != nil {
return e
}
break
@@ -204,7 +228,7 @@ func (g *RewardGraph) Resolve(rewards []BattleReward) ([]BattleReward, error) {
return nil
}
for _, r := range rewards {
if e := emit(r); e != nil {
if e := emit(r, openRoots); e != nil {
return nil, e
}
}
@@ -221,19 +245,8 @@ func (g *RewardGraph) Resolve(rewards []BattleReward) ([]BattleReward, error) {
return out, nil
}
// ResolveGranted preserves manually opened boxes; only RbdDirect boxes expand.
// ResolveGranted opens RBD_OPEN wrappers at every level, preserving RBD_DIRECT
// boxes as inventory items. Resolve force-opens only explicitly requested roots.
func (g *RewardGraph) ResolveGranted(rewards []BattleReward) ([]BattleReward, error) {
var out []BattleReward
for _, r := range rewards {
if r.Type == 9 && !g.direct[r.ID] {
out = append(out, r)
continue
}
expanded, e := g.Resolve([]BattleReward{r})
if e != nil {
return nil, e
}
out = append(out, expanded...)
}
return out, nil
return g.resolve(rewards, false)
}
@@ -2,10 +2,54 @@ package gamedata
import (
"bd2server/internal/server/wire"
"errors"
"os"
"reflect"
"testing"
)
func TestGrantedOpenWrappersAndDirectInventoryBoxes(t *testing.T) {
g := &RewardGraph{
boxes: map[uint64]uint64{1: 1, 2: 2, 3: 3},
direct: map[uint64]bool{1: false, 2: true, 3: false},
groups: map[uint64][]byte{
1: group(1, 0, []BattleReward{{9, 2, 2}, {9, 3, 1}}, []uint64{100, 100}),
2: group(1, 0, []BattleReward{{8, 1000, 5}}, []uint64{100}),
3: group(1, 0, []BattleReward{{3, 0, 10}}, []uint64{100}),
},
sample: func(uint64) (uint64, error) { return 0, nil },
}
got, err := g.ResolveGranted([]BattleReward{{9, 1, 2}, {9, 2, 1}})
want := []BattleReward{{3, 0, 20}, {9, 2, 5}}
if err != nil || !reflect.DeepEqual(got, want) {
t.Fatalf("granted=%+v want=%+v err=%v", got, want, err)
}
// Explicitly opening the root does not consume a nested inventory box.
got, err = g.Resolve([]BattleReward{{9, 1, 1}})
if err != nil || !reflect.DeepEqual(got, []BattleReward{{3, 0, 10}, {9, 2, 2}}) {
t.Fatalf("open nested=%+v err=%v", got, err)
}
got, err = g.Resolve([]BattleReward{{9, 2, 1}})
if err != nil || !reflect.DeepEqual(got, []BattleReward{{8, 1000, 5}}) {
t.Fatalf("manual direct root=%+v err=%v", got, err)
}
if _, err := g.ResolveGranted([]BattleReward{{9, 999, 1}}); err == nil {
t.Fatal("unknown box accepted")
}
g.boxes[10], g.direct[10] = 10, true
g.special = map[uint64]bool{10: true}
if got, err := g.ResolveGranted([]BattleReward{{9, 10, 1}}); err != nil || !reflect.DeepEqual(got, []BattleReward{{9, 10, 1}}) {
t.Fatalf("special box grant=%+v err=%v", got, err)
}
if _, err := g.Resolve([]BattleReward{{9, 10, 1}}); err == nil {
t.Fatal("special box used generic random opening")
}
g.sample = func(uint64) (uint64, error) { return 0, errors.New("sample failed") }
if out, err := g.ResolveGranted([]BattleReward{{9, 1, 1}}); err == nil || out != nil {
t.Fatal("failed opening returned partial rewards")
}
}
func group(drop, count uint64, rs []BattleReward, ws []uint64) []byte {
b := wire.AppendVarint(nil, 2, drop)
b = wire.AppendVarint(b, 1, count)
@@ -20,7 +64,7 @@ func group(drop, count uint64, rs []BattleReward, ws []uint64) []byte {
return b
}
func TestRewardGraphWeightedNestedAndEquipmentLeaves(t *testing.T) {
g := &RewardGraph{boxes: map[uint64]uint64{1: 1, 2: 2}, groups: map[uint64][]byte{1: group(0, 1, []BattleReward{{9, 2, 1}, {8, 8, 2}}, []uint64{1, 3}), 2: group(1, 0, []BattleReward{{10, 10, 1}, {11, 11, 1}}, []uint64{100, 100})}, sample: func(uint64) (uint64, error) { return 0, nil }}
g := &RewardGraph{boxes: map[uint64]uint64{1: 1, 2: 2}, direct: map[uint64]bool{1: false, 2: false}, groups: map[uint64][]byte{1: group(0, 1, []BattleReward{{9, 2, 1}, {8, 8, 2}}, []uint64{1, 3}), 2: group(1, 0, []BattleReward{{10, 10, 1}, {11, 11, 1}}, []uint64{100, 100})}, sample: func(uint64) (uint64, error) { return 0, nil }}
out, e := g.Resolve([]BattleReward{{9, 1, 2}})
if e != nil || len(out) != 2 || out[0].Type != 10 || out[0].Count != 2 || out[1].Type != 11 {
t.Fatalf("out=%v e=%v", out, e)
@@ -32,7 +76,7 @@ func TestRewardGraphWeightedNestedAndEquipmentLeaves(t *testing.T) {
}
}
func TestRewardGraphCycleBadRandomAndOverflow(t *testing.T) {
g := &RewardGraph{boxes: map[uint64]uint64{1: 1}, groups: map[uint64][]byte{1: group(1, 0, []BattleReward{{9, 1, 1}}, nil)}}
g := &RewardGraph{boxes: map[uint64]uint64{1: 1}, direct: map[uint64]bool{1: false}, groups: map[uint64][]byte{1: group(1, 0, []BattleReward{{9, 1, 1}}, nil)}}
if _, e := g.Resolve([]BattleReward{{9, 1, 1}}); e == nil {
t.Fatal("cycle accepted")
}
@@ -57,4 +101,21 @@ func TestRewardGraphInstalledLoad(t *testing.T) {
if len(g.boxes) == 0 || len(g.groups) == 0 {
t.Fatal("empty graph")
}
// Real 2.35.10 pass levels 5..20 BASIC/PREMIUM: OPEN wrappers must yield
// actual wallet/material leaves, while nested DIRECT boxes remain owned.
var rewards []BattleReward
for id := uint64(532657); id <= 532672; id++ {
rewards = append(rewards, BattleReward{9, id, 1})
}
for id := uint64(532677); id <= 532692; id++ {
rewards = append(rewards, BattleReward{9, id, 1})
}
out, err := g.ResolveGranted(rewards)
if err != nil {
t.Fatal(err)
}
want := []BattleReward{{3, 0, 600}, {4, 0, 70000}, {8, 7, 40}, {8, 8, 35}, {8, 11, 1}, {8, 12, 2}, {8, 13, 8}, {8, 14, 1}, {8, 1000, 16}, {8, 1002, 10}, {8, 1003, 1}, {9, 504143, 13}, {9, 504145, 23}, {12, 0, 1500}}
if !reflect.DeepEqual(out, want) {
t.Fatalf("real pass material grant=%+v want=%+v", out, want)
}
}