fix(server): expand reward wrappers and preserve inventory boxes in bulk grants
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user