fix(server): correct NPC sale rates and clear-package claim parsing and mail delivery

This commit is contained in:
2026-10-06 20:47:14 +08:00
parent 88cb314978
commit 7363f7aadd
6 changed files with 875 additions and 43 deletions
+102 -30
View File
@@ -5,8 +5,10 @@ import (
"bd2server/internal/server/player"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
"encoding/binary"
"encoding/json"
"fmt"
"math"
"sort"
"sync"
"time"
@@ -86,39 +88,98 @@ func (s *ClearPackages) entitled(ticket uint64) bool {
func (s *ClearPackages) Handle(path string, request []byte) (int, []byte, bool, error) {
return s.HandleSession(path, request, "")
}
// Both clear-info fields are ordinary proto3 messages, not a oneof. The native
// client creates an empty message for the inactive kind (CommonPacket), and
// omits scalar zero values such as ClearPackagePack and the normal pack level.
func parseClearClaim(request []byte) (clearClaim, error) {
var scalar [2]uint64
var rows [2][]byte
var seen [4]bool
err := wire.Walk(request, func(f wire.Field) error {
if f.Number < 1 || f.Number > 4 {
return nil
}
if seen[f.Number-1] {
return fmt.Errorf("commerce: duplicate clear claim field %d", f.Number)
}
seen[f.Number-1] = true
if f.Number <= 2 {
if f.Type != 0 {
return fmt.Errorf("commerce: invalid clear claim scalar %d", f.Number)
}
scalar[f.Number-1], _ = binary.Uvarint(f.Value)
if scalar[f.Number-1] > math.MaxInt32 {
return fmt.Errorf("commerce: clear claim scalar %d exceeds int32", f.Number)
}
} else {
if f.Type != 2 {
return fmt.Errorf("commerce: invalid clear claim row %d", f.Number)
}
rows[f.Number-3] = f.Value
}
return nil
})
if err != nil {
return clearClaim{}, err
}
if scalar[0] == 0 {
return clearClaim{}, fmt.Errorf("commerce: invalid clear claim sequence")
}
kind := scalar[1]
if kind > 1 {
return clearClaim{}, fmt.Errorf("commerce: invalid clear claim type")
}
if !seen[kind+2] {
return clearClaim{}, fmt.Errorf("commerce: missing clear claim row")
}
var active [4]uint64
for i, row := range rows {
var values [4]uint64
var fields [4]bool
if err := wire.Walk(row, func(f wire.Field) error {
if f.Number < 1 || f.Number > 4 {
// Unknown active fields retain normal protobuf compatibility. An
// inactive row must contain only the schema's default scalars.
if uint64(i) != kind {
return fmt.Errorf("commerce: nonempty inactive clear claim row")
}
return nil
}
if fields[f.Number-1] || f.Type != 0 {
return fmt.Errorf("commerce: invalid clear claim row scalar %d", f.Number)
}
fields[f.Number-1] = true
v, _ := binary.Uvarint(f.Value)
if v > math.MaxInt32 {
return fmt.Errorf("commerce: clear claim row scalar %d exceeds int32", f.Number)
}
if uint64(i) != kind && v != 0 {
return fmt.Errorf("commerce: conflicting inactive clear claim row")
}
values[f.Number-1] = v
return nil
}); err != nil {
return clearClaim{}, err
}
if uint64(i) == kind {
active = values
}
}
if active[0] == 0 || active[1] == 0 {
return clearClaim{}, fmt.Errorf("commerce: invalid clear claim identity")
}
return clearClaim{kind, active[0], active[1], active[2], active[3]}, nil
}
func (s *ClearPackages) HandleSession(path string, request []byte, session string) (int, []byte, bool, error) {
if path != "/ClearPackageReward" {
return 0, nil, false, nil
}
seq, ok, err := wire.Varint(request, 1)
if err != nil || !ok || seq == 0 {
return 286, nil, true, fmt.Errorf("commerce: invalid clear claim sequence")
claim, err := parseClearClaim(request)
if err != nil {
return 286, nil, true, err
}
kind, _, err := wire.Varint(request, 2)
if err != nil || kind > 1 {
return 286, nil, true, fmt.Errorf("commerce: invalid clear claim type")
}
var nested []byte
err = wire.Walk(request, func(f wire.Field) error {
if f.Number == 3 || f.Number == 4 {
if int(f.Number) != int(kind)+3 || f.Type != 2 || nested != nil {
return fmt.Errorf("commerce: invalid clear claim row")
}
nested = append([]byte(nil), f.Value...)
}
return nil
})
if err != nil || nested == nil {
return 286, nil, true, fmt.Errorf("commerce: missing clear claim row")
}
var values [4]uint64
for i := range values {
values[i], _, err = wire.Varint(nested, i+1)
if err != nil {
return 286, nil, true, err
}
}
claim := clearClaim{kind, values[0], values[1], values[2], values[3]}
d, ok := s.design[claim]
if !ok {
return 286, nil, true, fmt.Errorf("commerce: unknown clear reward")
@@ -137,13 +198,24 @@ func (s *ClearPackages) HandleSession(path string, request []byte, session strin
return 286, nil, true, fmt.Errorf("commerce: clear reward premium ticket required")
}
proof := s.packCleared
if kind == 1 {
if claim.Kind == 1 {
proof = s.towerCleared
}
if proof == nil || !proof(d.TargetID, d.Level) {
return 286, nil, true, fmt.Errorf("commerce: clear reward progression incomplete")
}
bundle, err := s.economy.Apply(identity, nil, []gamedata.Reward{{Type: 9, ID: d.RandomBoxID, Count: 1}})
rewards := []gamedata.Reward{{Type: 9, ID: d.RandomBoxID, Count: 1}}
var bundle []byte
if delivery, ok := s.economy.(interface {
ApplyPurchase(string, []gamedata.Reward, []gamedata.Reward) ([]byte, error)
}); ok {
// Clear reward groups carry MailId just like cash products. The
// delivery resolver selects the versioned mail template and keeps
// mailed contents out of the direct inventory response.
bundle, err = delivery.ApplyPurchase(identity, nil, rewards)
} else {
bundle, err = s.economy.Apply(identity, nil, rewards)
}
if err != nil {
return 286, nil, true, err
}
@@ -1,11 +1,22 @@
package commerce
import (
"bd2server/internal/server/accountstate"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/mail"
"bd2server/internal/server/player"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
"bytes"
"encoding/binary"
"errors"
"fmt"
"math"
"os"
"path/filepath"
"reflect"
"testing"
"time"
)
type clearInventory struct{ items []player.Item }
@@ -13,12 +24,418 @@ type clearInventory struct{ items []player.Item }
func (i *clearInventory) All() []player.Item { return i.items }
func clearRequest(kind, ticket uint64) []byte {
b := wire.AppendVarint(nil, 1, 1)
b = wire.AppendVarint(b, 2, kind)
if kind != 0 {
b = wire.AppendVarint(b, 2, kind)
}
row := wire.AppendVarint(nil, 1, 10)
row = wire.AppendVarint(row, 2, ticket)
row = wire.AppendVarint(row, 3, 2)
row = wire.AppendVarint(row, 4, 0)
return wire.AppendBytes(b, int(kind)+3, row)
b = wire.AppendBytes(b, 3+int(kind), row)
return wire.AppendBytes(b, 4-int(kind), nil)
}
func TestClearPackageNativeProtoDefaultsAndInactiveRows(t *testing.T) {
for _, kind := range []uint64{0, 1} {
for _, placeholder := range []bool{false, true} {
b := wire.AppendVarint(nil, 1, 1)
if kind != 0 {
b = wire.AppendVarint(b, 2, kind)
}
row := wire.AppendVarint(nil, 1, 10)
row = wire.AppendVarint(row, 2, 12)
row = wire.AppendVarint(row, 3, 2)
if placeholder {
// The native tower request serializes its empty pack placeholder
// before the active tower row; pack serializes it afterwards.
if kind == 1 {
b = wire.AppendBytes(b, 3, nil)
}
}
b = wire.AppendBytes(b, int(kind)+3, row)
if placeholder && kind == 0 {
b = wire.AppendBytes(b, 4, nil)
}
got, err := parseClearClaim(b)
want := clearClaim{kind, 10, 12, 2, 0}
if err != nil || got != want {
t.Fatalf("kind %d placeholder %t: %+v %v", kind, placeholder, got, err)
}
}
}
// Explicit defaults are also valid protobuf; they must not look like a
// second active claim, nor be confused with absent active message presence.
row := wire.AppendVarint(nil, 1, 10)
row = wire.AppendVarint(row, 2, 12)
row = wire.AppendVarint(row, 3, 2)
b := wire.AppendVarint(nil, 1, 1)
b = wire.AppendVarint(b, 2, 0)
b = wire.AppendBytes(b, 3, wire.AppendVarint(row, 4, 0))
var defaults []byte
for f := 1; f <= 4; f++ {
defaults = wire.AppendVarint(defaults, f, 0)
}
if _, err := parseClearClaim(wire.AppendBytes(b, 4, defaults)); err != nil {
t.Fatal(err)
}
}
func TestClearPackageRejectsConflictingMalformedAndSpoofedRows(t *testing.T) {
row := wire.AppendVarint(nil, 1, 10)
row = wire.AppendVarint(row, 2, 12)
row = wire.AppendVarint(row, 3, 2)
prefix := wire.AppendVarint(nil, 1, 1)
row = row[:len(row):len(row)]
prefix = prefix[:len(prefix):len(prefix)]
active := wire.AppendBytes(append([]byte(nil), prefix...), 3, row)
active = active[:len(active):len(active)]
requests := map[string][]byte{
"missing row": prefix,
"empty active": wire.AppendBytes(prefix, 3, nil),
"only inactive": wire.AppendBytes(prefix, 4, nil),
"conflicting inactive": wire.AppendBytes(active, 4, row),
"duplicate active": wire.AppendBytes(active, 3, row),
"duplicate inactive": wire.AppendBytes(wire.AppendBytes(active, 4, nil), 4, nil),
"duplicate kind": wire.AppendVarint(wire.AppendVarint(active, 2, 0), 2, 1),
"duplicate seq": wire.AppendVarint(active, 1, 1),
"kind wire type": wire.AppendBytes(active, 2, nil),
"active wire type": wire.AppendVarint(prefix, 3, 1),
"inactive wire type": wire.AppendVarint(active, 4, 0),
"row scalar wire type": wire.AppendBytes(prefix, 3, wire.AppendBytes(row, 4, nil)),
"duplicate row scalar": wire.AppendBytes(prefix, 3, wire.AppendVarint(row, 3, 2)),
"overflow seq": wire.AppendBytes(wire.AppendVarint(nil, 1, uint64(math.MaxInt32)+1), 3, row),
"negative kind": wire.AppendVarint(active, 2, math.MaxUint64),
"negative level": wire.AppendBytes(prefix, 3, wire.AppendVarint(row, 4, math.MaxUint64)),
"truncated inactive": append(append([]byte(nil), active...), 34, 2, 8),
"unknown inactive data": wire.AppendBytes(active, 4, wire.AppendVarint(nil, 5, 1)),
"unknown static row": wire.AppendBytes(prefix, 3, wire.AppendVarint(row, 4, 1)),
}
for name, request := range requests {
t.Run(name, func(t *testing.T) {
eco := &purchaseEconomy{}
design := &gamedata.ClearPackageCatalog{Rewards: []gamedata.ClearPackageRewardDesign{{GroupID: 10, TicketID: 12, TargetID: 2, RandomBoxID: 100}}}
s, err := NewClearPackages(stateio.NewMemory(), design, eco, &clearInventory{})
if err != nil {
t.Fatal(err)
}
s.AttachProgress(func(uint64, uint64) bool { return true }, nil)
if _, _, _, err := s.Handle("/ClearPackageReward", request); err == nil || eco.calls != 0 {
t.Fatal("invalid request granted a reward", err, eco.calls)
}
})
}
}
func TestClearPackagePremiumTicketExpiryAndTowerProof(t *testing.T) {
items := &clearInventory{items: []player.Item{{Type: 19, ID: 77, Count: 1, ExpiryTime: 1000}}}
eco := &purchaseEconomy{}
design := &gamedata.ClearPackageCatalog{Rewards: []gamedata.ClearPackageRewardDesign{{Kind: 0, GroupID: 10, TicketID: 77, TargetID: 2, RandomBoxID: 100, Type: 1}, {Kind: 1, GroupID: 10, TicketID: 12, TargetID: 2, RandomBoxID: 101}}}
s, err := NewClearPackages(stateio.NewMemory(), design, eco, items)
if err != nil {
t.Fatal(err)
}
s.now = func() time.Time { return time.UnixMilli(1000) }
s.AttachProgress(func(uint64, uint64) bool { return true }, nil)
if _, _, _, err := s.Handle("/ClearPackageReward", clearRequest(0, 77)); err == nil || eco.calls != 0 {
t.Fatal("expired premium accepted", err)
}
if _, _, _, err := s.Handle("/ClearPackageReward", clearRequest(1, 12)); err == nil || eco.calls != 0 {
t.Fatal("pack proof authorized tower", err)
}
s.AttachProgress(nil, func(tower, floor uint64) bool { return tower == 2 && floor == 0 })
if _, _, _, err := s.Handle("/ClearPackageReward", clearRequest(1, 12)); err != nil {
t.Fatal(err)
}
p, e, err := s.RewardDBInfos()
if err != nil || len(p) != 0 || len(e) != 1 {
t.Fatal("tower receipt not separated", p, e, err)
}
if got, _, _ := wire.Varint(e[0], 3); got != 2 {
t.Fatal("tower_type field wrong", got)
}
}
type clearReceiptFailure struct {
stateio.Store
fail bool
}
func (s *clearReceiptFailure) Save(name string, payload []byte) error {
if s.fail && name == "commerce_clear_claims" {
return fmt.Errorf("injected clear receipt failure")
}
return s.Store.Save(name, payload)
}
func TestClearPackageMailSQLiteAtomicRetryAndReconnect(t *testing.T) {
path := filepath.Join(t.TempDir(), "state.db")
design := &gamedata.ClearPackageCatalog{Rewards: []gamedata.ClearPackageRewardDesign{{GroupID: 10, TicketID: 12, TargetID: 2, RandomBoxID: 100}}}
graph := &deliveryGraph{}
open := func(fail bool) (*accountstate.Repository, *ClearPackages, *mail.Service, *player.Wallet, *player.Inventory) {
t.Helper()
repo, err := accountstate.Open(path)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = repo.Close() })
items, err := player.OpenInventory(repo, &player.Starter{Version: "2.35.10"})
if err != nil {
t.Fatal(err)
}
wallet, err := player.OpenWallet(repo, player.Currency{})
if err != nil {
t.Fatal(err)
}
for _, persist := range []func() error{items.EnsurePersisted, wallet.EnsurePersisted} {
if err := persist(); err != nil {
t.Fatal(err)
}
}
economy, err := NewEntitlementEconomy(repo, deliveryBase{wallet, items}, graph, items, &gamedata.CashEntitlementDesign{})
if err != nil {
t.Fatal(err)
}
mailbox, err := mail.OpenService(repo, &mail.Starter{Version: "2.35.10", MailCount: 1}, items, wallet)
if err != nil {
t.Fatal(err)
}
if err := mailbox.AttachCashRewards(economy, map[uint64]bool{40: true}); err != nil {
t.Fatal(err)
}
if err := economy.AttachCashMail(mailbox); err != nil {
t.Fatal(err)
}
s, err := NewClearPackages(&clearReceiptFailure{Store: repo, fail: fail}, design, economy, items)
if err != nil {
t.Fatal(err)
}
s.AttachProgress(func(pack, level uint64) bool { return pack == 2 && level == 0 }, nil)
return repo, s, mailbox, wallet, items
}
repo, s, _, _, _ := open(true)
op, err := repo.BeginOperation()
if err != nil {
t.Fatal(err)
}
if _, _, _, err := s.Handle("/ClearPackageReward", clearRequest(0, 12)); err == nil {
t.Fatal("receipt failure lost")
}
if err := op.Rollback(); err != nil && !errors.Is(err, stateio.ErrStateRecoveryRequired) {
t.Fatal(err)
}
if err := repo.Close(); err != nil {
t.Fatal(err)
}
repo, s, mailbox, wallet, items := open(false)
if ids, _ := cashList(t, mailbox, 0, 100); len(ids) != 0 {
t.Fatal("mail survived failed transaction", ids)
}
for _, name := range []string{"commerce_clear_claims", "commerce_entitlements"} {
if raw, err := repo.Load(name); err != nil || raw != nil {
t.Fatal("receipt survived rollback", name, err)
}
}
op, err = repo.BeginOperation()
if err != nil {
t.Fatal(err)
}
_, response, _, err := s.Handle("/ClearPackageReward", clearRequest(0, 12))
if err != nil {
_ = op.Rollback()
t.Fatal(err)
}
if err := op.Commit(); err != nil {
t.Fatal(err)
}
if wallet.Snapshot().Gold != 0 || len(items.All()) != 0 {
t.Fatal("mail contents directly granted")
}
bundle, ok, err := wire.Bytes(response, 1)
if err != nil || !ok {
t.Fatal("response bundle missing", err)
}
if _, direct, _ := wire.Bytes(bundle, 1); direct {
t.Fatal("mailed items returned as inventory")
}
// CommonPacket updates the claimed row from the request and shows the
// localized "sent to mail" notice. An empty bundle is valid for mail-only
// delivery; it must not claim that those attachments entered inventory.
if len(bundle) != 0 {
t.Fatal("mail-only clear returned direct rewards", bundle)
}
ids, _ := cashList(t, mailbox, 0, 100)
if len(ids) != 1 {
t.Fatal("one clear reward should issue one mail", ids)
}
if err := repo.Close(); err != nil {
t.Fatal(err)
}
repo, s, mailbox, _, _ = open(false)
// A replay must restore the exact response without requiring progress again
// or issuing a second mail, including after sequence/session changes.
s.AttachProgress(nil, nil)
request, _, err := wire.ReplaceVarint(clearRequest(0, 12), 1, 9)
if err != nil {
t.Fatal(err)
}
_, replay, _, err := s.HandleSession("/ClearPackageReward", request, "reconnected")
if err != nil || !bytes.Equal(replay, response) {
t.Fatal("reconnect response differs", err)
}
if got, _ := cashList(t, mailbox, 0, 100); !reflect.DeepEqual(got, ids) {
t.Fatal("retry duplicated mail", got, ids)
}
p, e, err := s.RewardDBInfos()
if err != nil || len(p) != 1 || len(e) != 0 {
t.Fatal("claimed row not restored", p, e, err)
}
for f, want := range []uint64{10, 12, 2, 0} {
if got, _, err := wire.Varint(p[0], f+1); err != nil || got != want {
t.Fatal("claim protocol field", f+1, got, want, err)
}
}
}
func TestInstalledClearPackageDeliveryAndNativeClaims23510(t *testing.T) {
root := os.Getenv("BD2_REAL_GAMEDATA")
if root == "" {
t.Skip("BD2_REAL_GAMEDATA not configured")
}
const version = "20260923193640"
design, err := gamedata.LoadClearPackageCatalog(root, version)
if err != nil {
t.Fatal(err)
}
resolver, err := gamedata.LoadCashRewardResolver(root, version)
if err != nil {
t.Fatal(err)
}
templates, err := gamedata.LoadCashMailTemplates(root, version)
if err != nil {
t.Fatal(err)
}
store := stateio.NewMemory()
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)
}
eco, err := NewEntitlementEconomy(store, deliveryBase{wallet, items}, resolver, items, &gamedata.CashEntitlementDesign{})
if err != nil {
t.Fatal(err)
}
mailbox, err := mail.OpenService(store, &mail.Starter{Version: "2.35.10", MailCount: 1}, items, wallet)
if err != nil {
t.Fatal(err)
}
if err := mailbox.AttachCashRewards(eco, templates); err != nil {
t.Fatal(err)
}
if err := eco.AttachCashMail(mailbox); err != nil {
t.Fatal(err)
}
s, err := NewClearPackages(store, design, eco, items)
if err != nil {
t.Fatal(err)
}
// Current design has normal and premium rows for both pack and tower.
// Every row must derive its mail from GameData and retain its claim identity.
counts := [2][2]int{}
for index, row := range design.Rewards {
counts[row.Kind][row.Type]++
plan, err := resolver.ResolveDelivery([]gamedata.BattleReward{{Type: 9, ID: row.RandomBoxID, Count: 1}})
if err != nil || len(plan.Direct) != 0 || len(plan.Mail) != 1 || plan.Mail[0].TemplateID == 0 || len(plan.Mail[0].Rewards) == 0 {
t.Fatalf("clear row %+v delivery %+v: %v", row, plan, err)
}
if row.Type == 1 {
if _, err := items.GrantOnce(fmt.Sprintf("installed-clear-ticket:%d", index), []gamedata.BattleReward{{Type: 19, ID: row.TicketID, Count: 1}}); err != nil {
t.Fatal(err)
}
}
proof := func(target, level uint64) bool { return target == row.TargetID && level == row.Level }
if row.Kind == 0 {
s.AttachProgress(proof, nil)
} else {
s.AttachProgress(nil, proof)
}
request := wire.AppendVarint(nil, 1, 1)
if row.Kind != 0 {
request = wire.AppendVarint(request, 2, row.Kind)
}
var nested []byte
for f, value := range []uint64{row.GroupID, row.TicketID, row.TargetID, row.Level} {
if value != 0 {
nested = wire.AppendVarint(nested, f+1, value)
}
}
request = wire.AppendBytes(request, 3+int(row.Kind), nested)
request = wire.AppendBytes(request, 4-int(row.Kind), nil)
_, response, _, err := s.Handle("/ClearPackageReward", request)
if err != nil {
t.Fatalf("native clear row %+v: %v", row, err)
}
bundle, present, err := wire.Bytes(response, 1)
if err != nil || !present || len(bundle) != 0 {
t.Fatal("mail-only native response", row, response, err)
}
_, replay, _, err := s.Handle("/ClearPackageReward", request)
if err != nil || !bytes.Equal(response, replay) {
t.Fatal("native clear retry", row, err)
}
listRequest := wire.AppendVarint(nil, 1, 1)
listRequest = wire.AppendVarint(listRequest, 3, 1)
_, list, _, err := mailbox.Handle("/CashMailInfo", listRequest)
if err != nil {
t.Fatal(err)
}
if total, _, err := wire.Varint(list, 2); err != nil || total != uint64(index+1) {
t.Fatal("mail duplicated or missing", row, total, err)
}
entry, present, err := wire.Bytes(list, 1)
if err != nil || !present {
t.Fatal("newest clear mail missing", row, err)
}
if template, _, err := wire.Varint(entry, 3); err != nil || template != plan.Mail[0].TemplateID {
t.Fatal("GameData clear mail template mismatch", row, template, plan.Mail[0].TemplateID, err)
}
// MailDBInfo represents attachment vectors as packed repeated int32.
for field, expected := range map[int]func(gamedata.BattleReward) uint64{
8: func(r gamedata.BattleReward) uint64 { return r.Type },
9: func(r gamedata.BattleReward) uint64 { return r.ID },
10: func(r gamedata.BattleReward) uint64 { return r.Count },
} {
packed, present, err := wire.Bytes(entry, field)
if err != nil || !present {
t.Fatal("clear mail attachments missing", row, field, err)
}
for _, reward := range plan.Mail[0].Rewards {
value, n := binary.Uvarint(packed)
if n <= 0 || value != expected(reward) {
t.Fatal("clear mail attachment mismatch", row, field, value, reward)
}
packed = packed[n:]
}
if len(packed) != 0 {
t.Fatal("extra clear mail attachment", row, field)
}
}
}
if wallet.Snapshot() != (player.Currency{}) {
t.Fatal("mail contents directly credited", wallet.Snapshot())
}
p, e, err := s.RewardDBInfos()
if err != nil || len(p) != counts[0][0]+counts[0][1] || len(e) != counts[1][0]+counts[1][1] {
t.Fatal("native claims not restored by kind", len(p), len(e), counts, err)
}
for kind, byType := range counts {
for typ, n := range byType {
if n == 0 {
t.Fatalf("missing kind %d type %d design rows", kind, typ)
}
}
}
}
func TestClearPackageRequiresServerProgressAndPremiumEntitlement(t *testing.T) {
store := stateio.NewMemory()
+14 -1
View File
@@ -20,6 +20,13 @@ type NPCProduct struct {
PremiumPriceType, HighPremium, HighDay, HighShop uint64
}
// ShopInfo.GetShopSellProducts reads only Food and Resource inventory rows.
// SellItemTable also has equipment prices; those definitions do not create
// an ordinary ShopSell equipment entry point in the current client.
func (p NPCProduct) InventorySellable() bool {
return p.Reward.Type == 5 || p.Reward.Type == 8
}
func LoadNPCShopDesign(root, version string) (NPCShopDesign, error) {
db, closeDB, err := openStatDatabase(root, version)
if err != nil {
@@ -139,6 +146,7 @@ func loadNPCShopDesign(db *sql.DB) (NPCShopDesign, error) {
if err != nil {
return d, err
}
sellKeys := map[[2]uint64]bool{}
err = friendshipRows(db, "SELECT id,ProtoBuf FROM SellItemTable ORDER BY id", func(id uint64, p []byte) error {
r := NPCProduct{}
for i, dst := range []*uint64{&r.ID, &r.Discount, &r.Premium, &r.Reward.Type, &r.Reward.ID, &r.Reward.Count, &r.Price.Type, &r.Price.ID, &r.Price.Count, &r.PremiumPriceType, &r.HighPremium, &r.HighDay, &r.HighShop} {
@@ -148,9 +156,14 @@ func loadNPCShopDesign(db *sql.DB) (NPCShopDesign, error) {
}
*dst = v
}
if r.ID != id || r.Reward.Count == 0 || r.Price.Count == 0 || r.Discount > 100 {
if id == 0 || r.ID != id || r.Reward.ID == 0 || r.Reward.Count != 1 || r.Price.Type == 0 || r.Price.Count == 0 || r.Discount > 100 {
return fmt.Errorf("gamedata: invalid sell product %d", id)
}
k := [2]uint64{r.Reward.Type, r.Reward.ID}
if sellKeys[k] {
return fmt.Errorf("gamedata: ambiguous sell definition %d:%d", k[0], k[1])
}
sellKeys[k] = true
d.Sell[id] = r
return nil
})
@@ -41,3 +41,37 @@ func TestNPCShopCatalogKeepsCompositeProductKeysAndRejectsOrphans(t *testing.T)
t.Fatal("accepted product whose shop is missing")
}
}
func TestSellCatalogUsesTypesAndRejectsAmbiguousDefinitions(t *testing.T) {
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatal(err)
}
defer db.Close()
for _, q := range []string{"CREATE TABLE ShopTable(id INTEGER,ProtoBuf BLOB)", "CREATE TABLE ProductTable(id INTEGER,groupId INTEGER,ProtoBuf BLOB)", "CREATE TABLE SellItemTable(id INTEGER,ProtoBuf BLOB)"} {
if _, err = db.Exec(q); err != nil {
t.Fatal(err)
}
}
insert := func(id, typ, item uint64) {
t.Helper()
var p []byte
for _, f := range [][2]uint64{{8, id}, {4, typ}, {3, item}, {2, 1}, {13, 4}, {11, 7}} {
p = wire.AppendVarint(p, int(f[0]), f[1])
}
if _, err = db.Exec("INSERT INTO SellItemTable VALUES(?,?)", id, p); err != nil {
t.Fatal(err)
}
}
for i, typ := range []uint64{5, 8, 10} {
insert(uint64(i+1), typ, 400)
}
d, err := loadNPCShopDesign(db)
if err != nil || len(d.Sell) != 3 || !d.Sell[1].InventorySellable() || !d.Sell[2].InventorySellable() || d.Sell[3].InventorySellable() {
t.Fatal("sell definition type boundaries", d, err)
}
insert(4, 8, 400)
if _, err = loadNPCShopDesign(db); err == nil {
t.Fatal("accepted ambiguous type/item price definition")
}
}
@@ -0,0 +1,263 @@
package npcshop
import (
"bytes"
"encoding/json"
"errors"
"path/filepath"
"strconv"
"testing"
"time"
"bd2server/internal/server/accountstate"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/player"
"bd2server/internal/server/stateio"
"bd2server/internal/server/versionconfig"
"bd2server/internal/server/wire"
)
func saleLine(index, product, n, rate uint64) []byte {
b := wire.AppendVarint(nil, 1, index)
b = wire.AppendVarint(b, 2, product)
b = wire.AppendVarint(b, 3, n)
return wire.AppendVarint(b, 4, rate)
}
func saleRequest(seq uint64, rows ...[]byte) []byte {
b := wire.AppendVarint(wire.AppendVarint(nil, 1, seq), 2, 71)
for _, row := range rows {
b = wire.AppendBytes(b, 3, row)
}
return b
}
func TestSaleAllClientTypesAndWholeBatchValidation(t *testing.T) {
s, e, store := fixture(t)
s.design.Sell[23] = gamedata.NPCProduct{ID: 23, Reward: gamedata.Reward{Type: 8, ID: 401, Count: 1}, Price: gamedata.Reward{Type: 4, Count: 9}}
items, err := s.items.GrantOnce("sale-types", []gamedata.BattleReward{{Type: 5, ID: 400, Count: 8}, {Type: 8, ID: 401, Count: 10}})
if err != nil {
t.Fatal(err)
}
food, resource := items[0], items[1]
good := saleLine(resource.InvenIndex, 23, 4, 100)
for _, bad := range [][]byte{
saleLine(resource.InvenIndex, 19, 1, 100),
saleLine(resource.InvenIndex, 23, 0, 100),
saleLine(resource.InvenIndex, 23, 11, 100),
saleLine(resource.InvenIndex, 23, 1<<31, 100),
saleLine(resource.InvenIndex, 23, 1, 0),
wire.AppendVarint(good, 3, 1),
wire.AppendBytes(good, 4, nil),
} {
if _, _, _, err = s.Handle("/ShopSell", saleRequest(1, saleLine(food.InvenIndex, 19, 3, 100), bad)); err == nil || e.calls != 0 {
t.Fatalf("invalid batch mutated economy: %x %v", bad, err)
}
}
// KeepFlag is the actual ItemDBInfo protection flag; these two types have
// no binding/equipment LockFlag or UseChar fields in the current protocol.
resource.KeepFlag = 1
payload, _ := json.Marshal(resource)
if err = store.SaveWithEntries("items", nil, []stateio.EntryMutation{{Bucket: "items", Key: strconv.FormatUint(resource.InvenIndex, 10), Payload: payload}}); err != nil {
t.Fatal(err)
}
s.items, err = player.OpenInventory(store, &player.Starter{Version: versionconfig.State()})
if err != nil {
t.Fatal(err)
}
if _, _, _, err = s.Handle("/ShopSell", saleRequest(2, good)); err == nil || e.calls != 0 {
t.Fatal("protected resource sold", err)
}
resource.KeepFlag = 0
payload, _ = json.Marshal(resource)
if err = store.SaveWithEntries("items", nil, []stateio.EntryMutation{{Bucket: "items", Key: strconv.FormatUint(resource.InvenIndex, 10), Payload: payload}}); err != nil {
t.Fatal(err)
}
s.items, err = player.OpenInventory(store, &player.Starter{Version: versionconfig.State()})
if err != nil {
t.Fatal(err)
}
if _, _, _, err = s.Handle("/ShopSell", saleRequest(3, saleLine(food.InvenIndex, 19, 3, 100), good)); err != nil {
t.Fatal(err)
}
if e.calls != 1 || len(e.rewards) != 2 || e.rewards[0].Count != 21 || e.rewards[1].Count != 36 {
t.Fatal("wrong mixed sale", e)
}
remaining := s.items.All()
if len(remaining) != 2 || remaining[0].Count != 5 || remaining[1].Count != 6 {
t.Fatal("wrong mixed inventory debit", remaining)
}
}
func TestSaleQuoteSeedRemainsUntilShopInfoRefresh(t *testing.T) {
s, _, _ := fixture(t)
p := gamedata.NPCProduct{Reward: gamedata.Reward{Type: 8, ID: 401, Count: 1}, Discount: 20, Premium: 19}
// Independent vectors from the client's ulong WELL512 operations.
for _, row := range []struct{ seed, rate uint64 }{{0, 117}, {20731, 80}, {20732, 101}} {
s.quotedSeed = &row.seed
if got := s.rate(p, 71, 2); got != row.rate {
t.Fatal("client WELL512 mismatch", row, got)
}
}
s.quotedSeed = nil
s.now = func() time.Time { return time.Unix(20731*86400, 0) }
_, info, _, err := s.Handle("/ShopInfo", wire.AppendVarint(nil, 1, 1))
seed, _, _ := wire.Varint(info, 2)
if err != nil || seed != 20731 {
t.Fatal(seed, err)
}
s.now = func() time.Time { return time.Unix(20732*86400, 0) }
s.BeginSession("login") // The router activates the same session on each request.
if s.rate(p, 71, 2) != 80 {
t.Fatal("clock change invalidated displayed sale price")
}
if _, _, _, err = s.Handle("/ShopInfo", wire.AppendVarint(nil, 1, 2)); err != nil || s.rate(p, 71, 2) != 101 {
t.Fatal("new quote failed to refresh seed", err)
}
}
func TestShopPriceUsesUnsignedWELLMaskForFoodResourceAndBuy(t *testing.T) {
s, _, _ := fixture(t)
seed := uint64(20732)
s.quotedSeed = &seed
for _, row := range []struct {
typ, item, tab, expected uint64
}{
// Current SellItemTable[2], also observed in the client's request:
// ShopInfo seed=20732; shop=1; Food=5; item=2002; Sell=2; Rate=98.
{5, 2002, 2, 98},
{8, 101, 2, 93},
{5, 2002, 1, 110},
} {
p := gamedata.NPCProduct{Reward: gamedata.Reward{Type: row.typ, ID: row.item, Count: 1}, Discount: 20, Premium: 19}
if got := s.rate(p, 1, row.tab); got != row.expected {
t.Fatalf("unsigned WELL mask type=%d item=%d tab=%d got=%d want=%d", row.typ, row.item, row.tab, got, row.expected)
}
}
}
func TestSaleClientObservedFoodRateCreditsDerivedGold(t *testing.T) {
s, economy, _ := fixture(t)
s.design.Shops[1] = gamedata.NPCShop{ID: 1, PackID: 91}
s.design.Sell[2] = gamedata.NPCProduct{ID: 2, Reward: gamedata.Reward{Type: 5, ID: 2002, Count: 1}, Price: gamedata.Reward{Type: 4, Count: 28}, Discount: 20, Premium: 19, PremiumPriceType: 1, HighDay: 1, HighShop: 14, HighPremium: 20}
seed := uint64(20732)
s.quotedSeed = &seed
items, err := s.items.GrantOnce("observed-food", []gamedata.BattleReward{{Type: 5, ID: 2002, Count: 2}})
if err != nil {
t.Fatal(err)
}
request := func(rate uint64) []byte {
b := wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 1)
return wire.AppendBytes(b, 3, saleLine(items[0].InvenIndex, 2, 1, rate))
}
if _, _, _, err = s.Handle("/ShopSell", request(93)); err == nil || economy.calls != 0 {
t.Fatal("incorrect sign-extended rate accepted", err)
}
if _, _, _, err = s.Handle("/ShopSell", request(98)); err != nil {
t.Fatal("actual client rate rejected", err)
}
if economy.calls != 1 || len(economy.rewards) != 1 || economy.rewards[0].Type != 4 || economy.rewards[0].Count != 27 || s.items.All()[0].Count != 1 {
t.Fatal("wrong derived sale credit/debit", economy.rewards, s.items.All())
}
}
type saleWalletEconomy struct{ wallet *player.Wallet }
func (e saleWalletEconomy) Apply(identity string, costs, rewards []gamedata.Reward) ([]byte, error) {
if err := e.wallet.ExchangeOnce(identity, costs, rewards); err != nil {
return nil, err
}
return wire.AppendVarint(nil, 1, e.wallet.Snapshot().Gold), nil
}
func TestSQLiteMixedSaleWalletInventoryReceiptRollbackAndRestartRetry(t *testing.T) {
base, _, _ := fixture(t)
base.design.Sell[23] = gamedata.NPCProduct{ID: 23, Reward: gamedata.Reward{Type: 8, ID: 401, Count: 1}, Price: gamedata.Reward{Type: 4, Count: 9}}
path := filepath.Join(t.TempDir(), "state.db")
starter := &player.Starter{Version: versionconfig.State()}
repo, err := accountstate.Open(path)
if err != nil {
t.Fatal(err)
}
defer func() { _ = repo.Close() }()
items, err := player.OpenInventory(repo, starter)
if err != nil {
t.Fatal(err)
}
granted, err := items.GrantOnce("initial", []gamedata.BattleReward{{Type: 5, ID: 400, Count: 8}, {Type: 8, ID: 401, Count: 10}})
if err != nil {
t.Fatal(err)
}
req := saleRequest(1, saleLine(granted[0].InvenIndex, 19, 3, 100), saleLine(granted[1].InvenIndex, 23, 4, 100))
var wallet *player.Wallet
var response []byte
for phase := 0; phase < 3; phase++ {
items, err = player.OpenInventory(repo, starter)
if err != nil {
t.Fatal(err)
}
wallet, err = player.OpenWallet(repo, player.Currency{Gold: 100})
if err != nil {
t.Fatal(err)
}
var store stateio.Store = repo
if phase == 0 {
store = failedReceiptStore{repo}
}
s, err := New(base.design, store, saleWalletEconomy{wallet}, items, base.available)
if err != nil {
t.Fatal(err)
}
s.now, s.session = base.now, base.session
op, err := repo.BeginOperation()
if err != nil {
t.Fatal(err)
}
_, body, _, handleErr := s.Handle("/ShopSell", req)
if phase == 0 {
if handleErr == nil {
t.Fatal("injected receipt failure accepted")
}
if err = op.Rollback(); err != nil && !errors.Is(err, stateio.ErrStateRecoveryRequired) {
t.Fatal(err)
}
} else {
if handleErr != nil {
t.Fatal(handleErr)
}
if phase == 1 {
response = body
} else if !bytes.Equal(response, body) {
t.Fatal("restart replay changed response")
}
if err = op.Commit(); err != nil {
t.Fatal(err)
}
}
if err = repo.Close(); err != nil {
t.Fatal(err)
}
repo, err = accountstate.Open(path)
if err != nil {
t.Fatal(err)
}
checkItems, err := player.OpenInventory(repo, starter)
if err != nil {
t.Fatal(err)
}
checkWallet, err := player.OpenWallet(repo, player.Currency{Gold: 100})
if err != nil {
t.Fatal(err)
}
wantGold, wantFood, wantResource := uint64(157), uint64(5), uint64(6)
if phase == 0 {
wantGold, wantFood, wantResource = 100, 8, 10
if raw, err := repo.Load("npc_shop"); err != nil || raw != nil {
t.Fatal("sale receipt survived rollback", err)
}
}
rows := checkItems.All()
if checkWallet.Snapshot().Gold != wantGold || len(rows) != 2 || rows[0].Count != wantFood || rows[1].Count != wantResource {
t.Fatal("SQLite atomic/replay state mismatch", phase, checkWallet.Snapshot(), rows)
}
}
}
+42 -9
View File
@@ -4,6 +4,7 @@ package npcshop
import (
"bytes"
"crypto/sha256"
"encoding/binary"
"encoding/json"
"fmt"
"math"
@@ -44,6 +45,7 @@ type Service struct {
session func() string
reputation func(uint64) (uint64, uint64, error)
talentDiscount func(uint64, uint64) (uint64, error)
quotedSeed *uint64
}
func New(d gamedata.NPCShopDesign, store stateio.Store, e Economy, items *player.Inventory, available func(uint64) bool) (*Service, error) {
@@ -58,7 +60,11 @@ func (s *Service) SetSessionSource(source func() string) { s.session = source }
func (s *Service) BeginSession(id string) {
s.mu.Lock()
defer s.mu.Unlock()
if s.session != nil && s.session() == id {
return
}
s.session = func() string { return id }
s.quotedSeed = nil
}
// SetReputationSource supplies the world's persisted pack reputation and
@@ -209,7 +215,9 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error)
}
if path == "/ShopInfo" {
b := s.allShops(v, 1)
return code, wire.AppendVarint(b, 2, s.seed()), true, nil
seed := s.seed()
s.quotedSeed = &seed
return code, wire.AppendVarint(b, 2, seed), true, nil
}
if path == "/ShopOpen" {
var b []byte
@@ -405,19 +413,32 @@ func (s *Service) sell(request []byte) ([]player.Item, []gamedata.Reward, error)
var rewards []gamedata.Reward
seen := map[uint64]bool{}
for _, b := range rows {
product, _ := scalar(b, 2)
n, _ := scalar(b, 3)
inven, _ := scalar(b, 1)
rate, _ := scalar(b, 4)
values := map[int]uint64{}
if err := wire.Walk(b, func(f wire.Field) error {
if f.Number < 1 || f.Number > 4 {
return nil
}
if _, duplicate := values[f.Number]; duplicate || f.Type != 0 {
return fmt.Errorf("npcshop: malformed sale item field %d", f.Number)
}
values[f.Number], _ = binary.Uvarint(f.Value)
return nil
}); err != nil {
return nil, nil, err
}
product, n, inven, rate := values[2], values[3], values[1], values[4]
p, ok := s.design.Sell[product]
item, owned := inventory[inven]
if !ok || !owned || seen[inven] || n == 0 || n > item.Count || p.Reward.Type != item.Type || p.Reward.ID != item.ID || item.KeepFlag != 0 {
if !ok || !p.InventorySellable() || !owned || seen[inven] || inven == 0 || inven > math.MaxInt64 || product > math.MaxInt32 || n == 0 || n > math.MaxInt32 || n > item.Count || p.Reward.Type != item.Type || p.Reward.ID != item.ID || item.KeepFlag != 0 {
return nil, nil, fmt.Errorf("npcshop: invalid sale item")
}
seen[inven] = true
expected := s.rate(p, id, 2)
if rate != expected {
return nil, nil, fmt.Errorf("npcshop: stale/invalid sell rate")
return nil, nil, fmt.Errorf("npcshop: stale/invalid sell rate: shop=%d product=%d rate=%d expected=%d seed=%d", id, product, rate, expected, s.marketSeed())
}
if expected > 0 && p.Price.Count > math.MaxInt32/expected {
return nil, nil, fmt.Errorf("npcshop: sale unit price overflow")
}
price := uint64(float32(p.Price.Count*expected) / 100)
if price > math.MaxInt32/n {
@@ -485,14 +506,17 @@ func (s *Service) rate(p gamedata.NPCProduct, shop, tab uint64) uint64 {
if p.PremiumPriceType == 1 && p.HighShop == shop && p.HighDay == uint64(s.now().UTC().Day()) {
return 100 + p.HighPremium
}
seed := s.seed() + shop + tab + p.Reward.Type + p.Reward.ID
seed := s.marketSeed() + shop + tab + p.Reward.Type + p.Reward.ID
a := seed
b := seed + 13*90
c := a ^ b ^ (a << 16) ^ (b << 15)
b = seed + 9*90
b ^= b >> 11
a = c ^ b
d := a ^ ((a << 5) & uint64(0xffffffffda442d24))
// WELL512's mask is the unsigned 32-bit pattern 0xDA442D24. The
// decompiler displays its signed int32 spelling, -633066204, inside
// an ulong cast; sign-extending that spelling changes the RNG result.
d := a ^ ((a << 5) & uint64(0xda442d24))
a = seed + 15*90
value := a ^ c ^ d ^ (a << 2) ^ (c << 18) ^ (b << 28)
lo := 100 - p.Discount
@@ -502,3 +526,12 @@ func (s *Service) rate(p gamedata.NPCProduct, shop, tab uint64) uint64 {
}
return lo + value%span
}
// The client keeps ShopRandSeed until the next ShopInfo response. Advancing
// the server clock alone must not change a price already displayed in its UI.
func (s *Service) marketSeed() uint64 {
if s.quotedSeed != nil {
return *s.quotedSeed
}
return s.seed()
}