fix(server): correct NPC sale rates and clear-package claim parsing and mail delivery
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user