Compare commits

...
4 Commits
33 changed files with 1999 additions and 206 deletions
+9
View File
@@ -0,0 +1,9 @@
root = true
[.build/game-sdk/**/*.cs]
generated_code = true
dotnet_analyzer_diagnostic.severity = none
[data/dev/client-source-readable/**/*.cs]
generated_code = true
dotnet_analyzer_diagnostic.severity = none
+2
View File
@@ -12,6 +12,8 @@
*.cs text eol=lf
*.csproj text eol=lf
*.ps1 text eol=crlf
*.cmd text eol=crlf
bd2w text eol=lf
*.dll binary
*.exe binary
+16
View File
@@ -0,0 +1,16 @@
version: "2"
run:
timeout: 5m
tests: true
linters:
default: none
enable:
- errcheck
- govet
- ineffassign
- staticcheck
- unused
- rowserrcheck
- modernize
+3 -1
View File
@@ -10,4 +10,6 @@ Commit messages must follow this format:
- `client` — for client-side changes
- `plugin` — for plugin-related changes
- `all` — when changes involve multiple parts (`server`, `client`, and/or `plugin`)
- `<description>` must be **concise but complete**, clearly covering **all changes made in the commit**. Do not omit important changes for the sake of brevity.
- `<description>` must be **concise but complete**, clearly covering **all changes made in the commit**. Do not omit important changes for the sake of brevity.
Before pushing, run the code checks for each language you modified.
-16
View File
@@ -7,7 +7,6 @@ import (
"bytes"
"errors"
"fmt"
"log/slog"
"math"
"sync"
@@ -223,7 +222,6 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error)
state.phase++
state.index, state.deck, state.phaseStarted = next.DeckID, next.DeckID, false
state.phaseSeq, state.phaseReply = seq, append([]byte(nil), response...)
slog.Info("team trace: battle phase changed", "pack", state.pack, "monster", state.monster, "group", next.GroupID, "phase", next.ID, "enemyDeck", next.DeckID)
return 632, response, true, nil
case "/BattleVerifyState":
if !state.entered {
@@ -355,7 +353,6 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error)
state.enterReceipt = identity
state.fieldInstance = fieldInstance
state.phases, state.phase, state.phaseStarted, state.phaseSeq, state.phaseReply = phases, 0, false, 0, nil
slog.Info("team trace: battle entered", "pack", packID, "monster", monster, "enemyDeck", deck, "mode", mode)
return 52, response, true, nil
case "/BattleRetry":
if !state.entered {
@@ -424,19 +421,6 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error)
state.index, state.round, state.phaseStarted = index, nextRound, true
if nextRound == 1 {
state.initialBlue = initialBlue
// Log the actual submitted team after pre-battle DeckSave, rather
// than inferring it from the deck that existed at BattleEnter.
// BattleCharDBInfo fields 2/3/7/9 are inventory/character/costume/grid.
blueTeam := make([]string, 0, len(initialBlue))
for _, character := range initialBlue {
inventory, _, _ := wire.Varint(character, 2)
id, _, _ := wire.Varint(character, 3)
costume, _, _ := wire.Varint(character, 7)
grid, _, _ := wire.Varint(character, 9)
blueTeam = append(blueTeam, fmt.Sprintf("inven=%d char=%d costume=%d grid=%d", inventory, id, costume, int32(grid)))
}
seq, _, _ := wire.Varint(request, 1)
slog.Info("team trace: battle started", "seq", seq, "pack", state.pack, "monster", state.monster, "enemyDeck", state.deck, "mode", state.mode, "blueTeam", blueTeam)
}
// Stable per-battle/round seed; reproducible across retries.
seed := index*7919 + state.round*104729
+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()
-7
View File
@@ -6,7 +6,6 @@ import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"os"
"sync"
@@ -401,7 +400,6 @@ func (s *Store) Handle(path string, req []byte) (int, []byte, bool, error) {
}
s.mu.RLock()
defer s.mu.RUnlock()
slog.Info("team trace: deliver saved battle deck", "deck", s.state.Deck)
out := encodeDeck(s.state.Deck)
if s.fieldSettingsDesign != nil {
v, e := s.loadFieldSettings()
@@ -437,14 +435,9 @@ func (s *Store) Handle(path string, req []byte) (int, []byte, bool, error) {
if e = s.validateOwnedDeckLocked(x); e != nil {
return 0, nil, true, e
}
seq, _, _ := wire.Varint(req, 1)
slog.Info("team trace: client requested battle deck replacement", "seq", seq, "before", s.state.Deck, "after", x)
n := clone(s.state)
n.Deck = x
e = s.commit(n)
if e != nil {
slog.Error("team trace: deck replacement failed", "seq", seq, "error", e)
}
return 10, nil, true, e
case "/FieldDeckSave":
if e := checkSeq(req); e != nil {
+36 -20
View File
@@ -7,6 +7,14 @@ import (
)
func (s *Service) BeginSession(id string) { s.SetSession(id) }
func (s *Service) inventorySnapshot() (world.GameplayAchievementSnapshot, error) {
if provider, ok := s.provider.(interface {
InventorySnapshot() (world.GameplayAchievementSnapshot, error)
}); ok {
return provider.InventorySnapshot()
}
return s.provider.Snapshot()
}
func (s *Service) BeforeDispatch(string, []byte) error {
s.mu.Lock()
s.beforeMissions = s.visibleMissionValues()
@@ -15,13 +23,13 @@ func (s *Service) BeforeDispatch(string, []byte) error {
return nil
}
var e error
s.before, e = s.provider.Snapshot()
s.before, e = s.inventorySnapshot()
return e
}
func (s *Service) AttachGameplayProvider(p world.GameplayAchievementProvider) { s.provider = p }
func (s *Service) AfterDispatch(path string, request, response []byte) ([]byte, error) {
if s.provider != nil {
after, e := s.provider.Snapshot()
after, e := s.inventorySnapshot()
if e != nil {
return nil, e
}
@@ -30,6 +38,8 @@ func (s *Service) AfterDispatch(path string, request, response []byte) ([]byte,
_, seen := s.state.Receipts[rk]
s.mu.Unlock()
if !seen {
type delta struct{ condition, sub, count uint64 }
var deltas []delta
for kind, old := range s.before.Items {
current := after.Items[kind]
if current < old {
@@ -37,41 +47,47 @@ func (s *Service) AfterDispatch(path string, request, response []byte) ([]byte,
if kind[1] == 0 {
condition = 11
}
if e = s.RecordEvent(condition, kind[0], old-current, s.unlocked); e != nil {
return nil, e
}
deltas = append(deltas, delta{condition, kind[0], old - current})
}
}
for kind, current := range after.Items {
old := s.before.Items[kind]
if current > old {
if e = s.RecordEvent(32, kind[0], current-old, s.unlocked); e != nil {
return nil, e
}
deltas = append(deltas, delta{32, kind[0], current - old})
}
}
for idx, current := range after.Equipment {
old, ok := s.before.Equipment[idx]
if ok && current.Level > old.Level {
if e = s.RecordEvent(14, 0, current.Level-old.Level, s.unlocked); e != nil {
return nil, e
}
deltas = append(deltas, delta{14, 0, current.Level - old.Level})
}
}
for idx, current := range after.Costumes {
old, ok := s.before.Costumes[idx]
if ok && current.Level > old.Level {
if e = s.RecordEvent(104, current.ID, current.Level-old.Level, s.unlocked); e != nil {
return nil, e
}
deltas = append(deltas, delta{104, current.ID, current.Level - old.Level})
}
}
s.mu.Lock()
s.state.Receipts[rk] = receipt{Digest: "observer"}
e = s.save()
s.mu.Unlock()
if e != nil {
return nil, e
if len(deltas) > 0 {
s.mu.Lock()
before, e := json.Marshal(s.state)
if e != nil {
s.mu.Unlock()
return nil, e
}
for _, d := range deltas {
s.recordEventLocked(d.condition, d.sub, d.count, s.unlocked)
}
s.state.Receipts[rk] = receipt{Digest: "observer"}
e = s.save()
if e != nil {
s.state = snapshot{}
_ = json.Unmarshal(before, &s.state)
}
s.mu.Unlock()
if e != nil {
return nil, e
}
}
}
}
@@ -0,0 +1,142 @@
package eventtasks
import (
"bd2server/internal/server/stateio"
"bd2server/internal/server/world"
"errors"
"testing"
"time"
)
type narrowNoticeProvider struct {
noticeProvider
fullCalls, narrowCalls int
}
func (p *narrowNoticeProvider) Snapshot() (world.GameplayAchievementSnapshot, error) {
p.fullCalls++
return p.noticeProvider.Snapshot()
}
func (p *narrowNoticeProvider) InventorySnapshot() (world.GameplayAchievementSnapshot, error) {
p.narrowCalls++
return p.noticeProvider.Snapshot()
}
type failedObserverStore struct{ stateio.Store }
func (s failedObserverStore) Save(string, []byte) error { return errors.New("observer save failure") }
func TestObserverUsesInventoryProjectionAndRestoresMemoryOnSaveFailure(t *testing.T) {
s, _, store := setup(t)
p := &narrowNoticeProvider{noticeProvider: noticeProvider{count: 1}}
s.AttachGameplayProvider(p)
task := s.design.Missions[10]
task.Type = 32
s.design.Missions[10] = task
if e := s.BeforeDispatch("/grant", req(1)); e != nil {
t.Fatal(e)
}
p.count = 2
s.store = failedObserverStore{store}
if _, e := s.AfterDispatch("/grant", req(1), nil); e == nil {
t.Fatal("observer persistence failure ignored")
}
if len(s.state.Missions) != 0 || len(s.state.Receipts) != 0 {
t.Fatal("failed observer save retained partial tasks or receipt")
}
if p.fullCalls != 0 || p.narrowCalls != 2 {
t.Fatalf("observer requested expensive full snapshot: full=%d narrow=%d", p.fullCalls, p.narrowCalls)
}
s.store = store
p.count = 1
s.BeforeDispatch("/grant", req(1))
p.count = 2
b, e := s.AfterDispatch("/grant", req(1), nil)
if e != nil || len(b) == 0 {
t.Fatal("retry after failed observer persistence lost progress")
}
}
type writeCountingStore struct {
stateio.Store
writes int
bytes int
}
func (s *writeCountingStore) Save(name string, b []byte) error {
s.writes++
s.bytes += len(b)
return s.Store.Save(name, b)
}
func TestReadOnlyBatchHasNoObserverWritesButRealDeltaNotifiesOnce(t *testing.T) {
s, _, store := setup(t)
counter := &writeCountingStore{Store: store}
s.store = counter
p := &noticeProvider{count: 1}
s.AttachGameplayProvider(p)
start := time.Now()
for seq := uint64(1); seq <= 57; seq++ {
if e := s.BeforeDispatch("/read", req(seq)); e != nil {
t.Fatal(e)
}
b, e := s.AfterDispatch("/read", req(seq), nil)
if e != nil || len(b) > 0 {
t.Fatalf("read-only changed missions: %x %v", b, e)
}
}
if counter.writes != 0 || len(s.state.Receipts) != 0 {
t.Fatalf("read-only 57 packets made %d writes/%d receipts", counter.writes, len(s.state.Receipts))
}
t.Logf("57 unchanged observer boundaries: %s, writes=%d", time.Since(start), counter.writes)
task := s.design.Missions[10]
task.Type = 32
s.design.Missions[10] = task
if e := s.BeforeDispatch("/grant", req(58)); e != nil {
t.Fatal(e)
}
p.count = 2
b, e := s.AfterDispatch("/grant", req(58), nil)
if e != nil || len(b) == 0 || counter.writes != 1 {
t.Fatalf("real delta not persisted/notified once: writes=%d body=%x error=%v", counter.writes, b, e)
}
// Even if an upstream replay temporarily exposes the same before/after
// delta, the committed request receipt must not increment tasks twice.
p.count = 1
s.BeforeDispatch("/grant", req(58))
p.count = 2
b, e = s.AfterDispatch("/grant", req(58), nil)
if e != nil || len(b) != 0 || counter.writes != 1 {
t.Fatal("replay repeated mission increment or write")
}
if e = s.RecordEvent(999999, 0, 1, nil); e != nil || counter.writes != 1 {
t.Fatal("irrelevant condition wrote state")
}
if e = s.RecordEvent(32, 0, 100, nil); e != nil {
t.Fatal(e)
}
writes := counter.writes
if e = s.RecordEvent(32, 0, 100, nil); e != nil || counter.writes != writes {
t.Fatal("capped task still wrote whole snapshot")
}
}
func BenchmarkUnchanged57PacketObserverBatch(b *testing.B) {
// Snapshot costs belong to the provider; this benchmark isolates event
// mission observation and persistence decisions without a user's database.
s, _, _ := setup(b)
p := &noticeProvider{count: 1}
s.AttachGameplayProvider(p)
b.ReportAllocs()
b.ResetTimer()
for n := 0; n < b.N; n++ {
for seq := uint64(1); seq <= 57; seq++ {
if e := s.BeforeDispatch("/read", req(seq)); e != nil {
b.Fatal(e)
}
if _, e := s.AfterDispatch("/read", req(seq), nil); e != nil {
b.Fatal(e)
}
}
}
}
+22 -2
View File
@@ -885,9 +885,20 @@ func (s *Service) handle(path string, b []byte, identity string) ([]byte, error)
func (s *Service) RecordEvent(condition, sub, count uint64, unlocked func(uint64, uint64) bool) error {
s.mu.Lock()
defer s.mu.Unlock()
if count == 0 {
if !s.recordEventLocked(condition, sub, count, unlocked) {
return nil
}
return s.save()
}
// recordEventLocked reports every persistent mutation, including initializing
// a matching task or rolling its period. Irrelevant and already capped events
// leave the account snapshot untouched.
func (s *Service) recordEventLocked(condition, sub, count uint64, unlocked func(uint64, uint64) bool) bool {
changed := false
if count == 0 {
return false
}
for _, v := range s.taskSchedules() {
if v.Type != 4 || !s.active(v) {
continue
@@ -908,7 +919,15 @@ func (s *Service) RecordEvent(condition, sub, count uint64, unlocked func(uint64
if !match {
continue
}
previous := s.state.Missions[scheduleKey(v)+"/"+key(t.ID)]
var prior mission
if previous != nil {
prior = *previous
}
m := s.mission(v, t.ID)
if previous == nil || prior != *m {
changed = true
}
if m.Claimed {
continue
}
@@ -920,9 +939,10 @@ func (s *Service) RecordEvent(condition, sub, count uint64, unlocked func(uint64
} else {
m.Value += count
}
changed = true
}
}
return s.save()
return changed
}
func (s *Service) Notify() ([]byte, error) {
s.mu.Lock()
@@ -51,7 +51,7 @@ func (m *attendanceMailStub) IssueAttachmentsOnce(identity, title, body string,
m.identity, m.title, m.body, m.sentAt = identity, title, body, sentAt
return nil
}
func setup(t *testing.T) (*Service, *economyStub, stateio.Store) {
func setup(t testing.TB) (*Service, *economyStub, stateio.Store) {
t.Helper()
now := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC)
registry := events.NewRegistry()
+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")
}
}
+7 -1
View File
@@ -14,8 +14,14 @@ func (s *Service) BeforeDispatch(string, []byte) error {
func (s *Service) AfterDispatch(string, []byte, []byte) ([]byte, error) {
s.mu.Lock()
defer s.mu.Unlock()
// Allocation is monotonic. Without a new ID, no existing row can match
// the notification window, so read-only packets need not scan the inbox.
if s.state.NextDynamicMailID == s.beforeMailID {
return nil, nil
}
now := uint64(s.now().UnixMilli())
for id, entry := range s.dynamic {
if id >= s.beforeMailID && !containsID(s.state.Opened, id) && entry.ExpiresAt > uint64(s.now().UnixMilli()) {
if id >= s.beforeMailID && !containsID(s.state.Opened, id) && entry.ExpiresAt > now {
return wire.AppendVarint(nil, 1, 1), nil // Notify.IsNewMail
}
}
@@ -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()
}
+30
View File
@@ -74,3 +74,33 @@ func (s *Store) OpenedFieldRewards(pack int) ([]int, error) {
sort.Ints(ids)
return ids, nil
}
// OpenedFieldRewardPeriods reads the complete opened-object snapshot once from
// the current request transaction. The returned maps are owned by the caller;
// nothing is cached across requests or transaction rollback.
func (s *Store) OpenedFieldRewardPeriods() (map[int]map[int]string, error) {
s.mu.Lock()
defer s.mu.Unlock()
store, err := s.fieldRewardEntries()
if err != nil {
return nil, err
}
entries, err := store.ListEntries("progress", "field_rewards")
if err != nil {
return nil, err
}
periods := map[int]map[int]string{}
for key, raw := range entries {
packKey, objectKey, found := strings.Cut(key, ":")
pack, packErr := strconv.Atoi(packKey)
id, idErr := strconv.Atoi(objectKey)
if !found || packErr != nil || idErr != nil || pack <= 0 || id <= 0 || key != fieldRewardKey(pack, id) || len(raw) == 0 {
return nil, fmt.Errorf("progress: invalid field reward entry %q", key)
}
if periods[pack] == nil {
periods[pack] = map[int]string{}
}
periods[pack][id] = string(raw)
}
return periods, nil
}
@@ -0,0 +1,53 @@
package progress
import (
"bd2server/internal/server/stateio"
"reflect"
"testing"
)
func TestOpenedFieldRewardPeriodsValidatesAndOwnsSnapshot(t *testing.T) {
storage := stateio.NewMemory()
s, err := OpenStore(storage)
if err != nil {
t.Fatal(err)
}
for _, row := range []struct {
pack, id int
period string
}{{21, 71, "once"}, {22, 71, "2026-10-06"}, {22, 72, "event:777"}} {
if err := s.MarkFieldRewardOpened(row.pack, row.id, row.period); err != nil {
t.Fatal(err)
}
}
want := map[int]map[int]string{21: {71: "once"}, 22: {71: "2026-10-06", 72: "event:777"}}
got, err := s.OpenedFieldRewardPeriods()
if err != nil || !reflect.DeepEqual(got, want) {
t.Fatalf("opened periods=%v err=%v", got, err)
}
got[21][71] = "modified"
fresh, err := s.OpenedFieldRewardPeriods()
if err != nil || !reflect.DeepEqual(fresh, want) {
t.Fatalf("caller modified persisted snapshot: %v err=%v", fresh, err)
}
for _, row := range []struct {
key string
raw []byte
}{
{"0:71", []byte("once")}, {"21:0", []byte("once")}, {"21:71:1", []byte("once")},
{"pack:71", []byte("once")}, {"21:object", []byte("once")}, {"021:71", []byte("once")},
{"21:071", []byte("once")}, {"21:73", nil},
} {
t.Run(row.key, func(t *testing.T) {
if err := storage.PutEntry("progress", "field_rewards", row.key, row.raw); err != nil {
t.Fatal(err)
}
if _, err := s.OpenedFieldRewardPeriods(); err == nil {
t.Fatal("malformed field reward entry accepted")
}
if _, err := storage.DeleteEntry("progress", "field_rewards", row.key); err != nil {
t.Fatal(err)
}
})
}
}
+13 -3
View File
@@ -31,15 +31,25 @@ func Encode(code int, proto, key []byte, now int64) ([]byte, error) {
// Notify is plain base64 protobuf in the JSON envelope. The client parses it
// separately after decrypting the response data (NetworkManager.OnAfterResponse).
func EncodeWithNotify(code int, proto, key []byte, now int64, notify []byte) ([]byte, error) {
data, err := cryptox.EncryptBase64Payload(proto, key)
envelope, err := EnvelopeWithNotify(code, proto, key, now, notify)
if err != nil {
return nil, err
}
return json.Marshal(Envelope{
return json.Marshal(envelope)
}
// EnvelopeWithNotify builds the same wire response without an intermediate
// JSON round trip when the caller is assembling a batch.
func EnvelopeWithNotify(code int, proto, key []byte, now int64, notify []byte) (Envelope, error) {
data, err := cryptox.EncryptBase64Payload(proto, key)
if err != nil {
return Envelope{}, err
}
return Envelope{
PacketCode: code, Length: base64.StdEncoding.EncodedLen(len(proto)),
Data: data, ServerNowTime: now,
Notify: base64.StdEncoding.EncodeToString(notify),
})
}, nil
}
type BatchRequest struct {
+1 -5
View File
@@ -343,14 +343,10 @@ func (s *Server) handleBatch(body, key []byte) (transport.RawReply, error) {
} else if itemElapsed >= 100*time.Millisecond {
slog.Warn("slow batch item", "index", i, "path", request.Path, "duration_ms", float64(itemElapsed.Microseconds())/1000)
}
raw, err := protocol.EncodeWithNotify(code, response, key, time.Now().UnixMilli(), notify)
envelope, err := protocol.EnvelopeWithNotify(code, response, key, time.Now().UnixMilli(), notify)
if err != nil {
return transport.RawReply{}, err
}
var envelope protocol.Envelope
if err := json.Unmarshal(raw, &envelope); err != nil {
return transport.RawReply{}, err
}
items = append(items, protocol.BatchResponse{Path: request.Path, ResponseData: envelope})
}
encoded, err := json.Marshal(items)
@@ -11,6 +11,93 @@ import (
"bd2server/internal/server/wire"
)
type GameplayAchievementRecordedEvent struct {
Identity string
Type, SubType, Count uint64
}
// ApplyGameplayProgress keeps one observer boundary on one validated counter
// snapshot. Conditions are applied first and events retain their original
// order. Event receipts and all counter changes are written atomically; the
// next boundary loads fresh state from its transaction, including after retry.
func (s *AchievementService) ApplyGameplayProgress(conditions []GameplayAchievementCondition, events []GameplayAchievementRecordedEvent) (map[int]uint64, map[int]uint64, error) {
s.mu.Lock()
defer s.mu.Unlock()
state, err := s.load()
if err != nil {
return nil, nil, err
}
values := func() map[int]uint64 {
out := map[int]uint64{}
for key, value := range state.Counts {
group, _ := strconv.Atoi(key) // load validated every key.
out[group] = uint64(value)
}
return out
}
before := values()
groups := map[[2]uint64][]int{}
for group, condition := range s.design.Conditions {
key := [2]uint64{condition.Type, condition.SubType}
groups[key] = append(groups[key], group)
}
for key := range groups {
sort.Ints(groups[key])
}
var changes []stateio.EntryMutation
set := func(group int, value int64) {
key := strconv.Itoa(group)
state.Counts[key] = value
raw, _ := json.Marshal(value)
changes = append(changes, stateio.EntryMutation{Bucket: "achievement_counts", Key: key, Payload: raw})
}
for _, condition := range conditions {
if condition.Value > math.MaxInt64 {
return nil, nil, fmt.Errorf("achievement: invalid absolute value")
}
for _, group := range groups[[2]uint64{condition.Type, condition.SubType}] {
if state.Counts[strconv.Itoa(group)] != int64(condition.Value) {
set(group, int64(condition.Value))
}
}
}
pending := map[string]string{}
for _, event := range events {
if event.Identity == "" || event.Count == 0 || event.Count > math.MaxInt64 {
return nil, nil, fmt.Errorf("achievement: invalid event")
}
digest := fmt.Sprintf("%d/%d/%d", event.Type, event.SubType, event.Count)
raw, found, err := s.store.LoadEntry("missions", "achievement_events", event.Identity)
if err != nil {
return nil, nil, err
}
if prior, exists := pending[event.Identity]; exists {
raw, found = []byte(prior), true
}
if found {
if string(raw) != digest {
return nil, nil, fmt.Errorf("achievement: event replay conflicts")
}
continue
}
for _, group := range groups[[2]uint64{event.Type, event.SubType}] {
old := state.Counts[strconv.Itoa(group)]
if old > math.MaxInt64-int64(event.Count) {
return nil, nil, fmt.Errorf("achievement: event overflow")
}
set(group, old+int64(event.Count))
}
pending[event.Identity] = digest
changes = append(changes, stateio.EntryMutation{Bucket: "achievement_events", Key: event.Identity, Payload: []byte(digest)})
}
if len(changes) != 0 {
if err := s.store.SaveWithEntries("missions", nil, changes); err != nil {
return nil, nil, err
}
}
return before, values(), nil
}
func (s *AchievementService) AchievementValue(groupID uint64) (uint64, error) {
s.mu.Lock()
defer s.mu.Unlock()
@@ -15,6 +15,10 @@ type GameplayAchievementCounter interface {
SetCondition(uint64, uint64, uint64) ([][]byte, error)
CounterValues() (map[int]uint64, error)
}
type gameplayAchievementProgressBatch interface {
ApplyGameplayProgress([]GameplayAchievementCondition, []GameplayAchievementRecordedEvent) (map[int]uint64, map[int]uint64, error)
}
type GameplayAchievementEvent struct {
Type, SubType, Count uint64
Identity string
@@ -57,22 +61,30 @@ func (s *GameplayAchievementObserver) SyncRecordedHistory() error {
if err != nil {
return err
}
for _, condition := range snapshot.Conditions {
if _, err := s.counter.SetCondition(condition.Type, condition.SubType, condition.Value); err != nil {
return err
}
}
var ids []string
for identity := range snapshot.GachaGrants {
ids = append(ids, identity)
}
sort.Strings(ids)
var events []GameplayAchievementRecordedEvent
for _, identity := range ids {
count := snapshot.GachaGrants[identity]
if count == 0 {
continue
}
if _, err := s.counter.RecordEvent("gacha-grant:"+identity, 54, 0, count); err != nil {
events = append(events, GameplayAchievementRecordedEvent{Identity: "gacha-grant:" + identity, Type: 54, Count: count})
}
if batch, ok := s.counter.(gameplayAchievementProgressBatch); ok {
_, _, err := batch.ApplyGameplayProgress(snapshot.Conditions, events)
return err
}
for _, condition := range snapshot.Conditions {
if _, err := s.counter.SetCondition(condition.Type, condition.SubType, condition.Value); err != nil {
return err
}
}
for _, event := range events {
if _, err := s.counter.RecordEvent(event.Identity, event.Type, event.SubType, event.Count); err != nil {
return err
}
}
@@ -80,11 +92,15 @@ func (s *GameplayAchievementObserver) SyncRecordedHistory() error {
}
func (s *GameplayAchievementObserver) BeforeDispatch(_ string, _ []byte) error {
var err error
s.counters, err = s.counter.CounterValues()
s.before, err = s.provider.Snapshot()
if err != nil {
return err
}
s.before, err = s.provider.Snapshot()
if batch, ok := s.counter.(gameplayAchievementProgressBatch); ok {
s.counters, _, err = batch.ApplyGameplayProgress(s.before.Conditions, nil)
return err
}
s.counters, err = s.counter.CounterValues()
if err != nil {
return err
}
@@ -100,15 +116,19 @@ func (s *GameplayAchievementObserver) AfterDispatch(path string, request, respon
if err != nil {
return nil, err
}
for _, condition := range after.Conditions {
if _, err := s.counter.SetCondition(condition.Type, condition.SubType, condition.Value); err != nil {
return nil, err
batch, batches := s.counter.(gameplayAchievementProgressBatch)
if !batches {
for _, condition := range after.Conditions {
if _, err := s.counter.SetCondition(condition.Type, condition.SubType, condition.Value); err != nil {
return nil, err
}
}
}
events, err := s.provider.Events(path, request, response, s.before, after)
if err != nil {
return nil, err
}
var recorded []GameplayAchievementRecordedEvent
if len(events) > 0 {
seq, found, err := wire.Varint(request, 1)
if err != nil || !found || seq == 0 || s.session == "" {
@@ -119,12 +139,20 @@ func (s *GameplayAchievementObserver) AfterDispatch(path string, request, respon
if event.StableIdentity {
identity = event.Identity
}
if _, err := s.counter.RecordEvent(identity, event.Type, event.SubType, event.Count); err != nil {
recorded = append(recorded, GameplayAchievementRecordedEvent{Identity: identity, Type: event.Type, SubType: event.SubType, Count: event.Count})
}
}
var values map[int]uint64
if batches {
_, values, err = batch.ApplyGameplayProgress(after.Conditions, recorded)
} else {
for _, event := range recorded {
if _, err = s.counter.RecordEvent(event.Identity, event.Type, event.SubType, event.Count); err != nil {
return nil, err
}
}
values, err = s.counter.CounterValues()
}
values, err := s.counter.CounterValues()
if err != nil {
return nil, err
}
@@ -167,7 +195,11 @@ type OwnedGameplayAchievementProvider struct {
}
func (p *OwnedGameplayAchievementProvider) Snapshot() (GameplayAchievementSnapshot, error) {
s := GameplayAchievementSnapshot{Characters: map[uint64]player.Character{}, Costumes: map[uint64]player.Costume{}, Equipment: map[uint64]player.Equipment{}, Items: map[[2]uint64]uint64{}}
s, err := p.InventorySnapshot()
if err != nil {
return s, err
}
s.Characters = map[uint64]player.Character{}
if p.Characters != nil {
for _, v := range p.Characters.RawAll() {
if player.IsCharmCharacter(v) || player.IsStoryCharacter(v) {
@@ -176,6 +208,23 @@ func (p *OwnedGameplayAchievementProvider) Snapshot() (GameplayAchievementSnapsh
s.Characters[v.InvenIndex] = v
}
}
if p.Conditions != nil {
s.Conditions, err = p.Conditions()
}
if err == nil && p.FieldObjects != nil {
s.FieldObjects, err = p.FieldObjects()
}
if p.Gacha != nil {
s.GachaGrants = p.Gacha.GachaGrantSummary()
}
return s, err
}
// InventorySnapshot projects the three authoritative domains used by event
// missions. It reads a fresh before/after view, without querying unrelated
// field-object, main-quest, character or gacha-history projections.
func (p *OwnedGameplayAchievementProvider) InventorySnapshot() (GameplayAchievementSnapshot, error) {
s := GameplayAchievementSnapshot{Costumes: map[uint64]player.Costume{}, Equipment: map[uint64]player.Equipment{}, Items: map[[2]uint64]uint64{}}
if p.Costumes != nil {
for _, v := range p.Costumes.Costumes() {
s.Costumes[v.InvenIndex] = v
@@ -191,35 +240,22 @@ func (p *OwnedGameplayAchievementProvider) Snapshot() (GameplayAchievementSnapsh
s.Items[[2]uint64{v.Type, v.ID}] += v.Count
}
}
var err error
if p.Conditions != nil {
s.Conditions, err = p.Conditions()
}
if err == nil && p.FieldObjects != nil {
s.FieldObjects, err = p.FieldObjects()
}
if p.Gacha != nil {
s.GachaGrants = p.Gacha.GachaGrantSummary()
}
return s, err
return s, nil
}
func (p *OwnedGameplayAchievementProvider) Events(path string, _ []byte, _ []byte, before, after GameplayAchievementSnapshot) ([]GameplayAchievementEvent, error) {
var events []GameplayAchievementEvent
conditions := map[[2]uint64]bool{}
if p.Design != nil {
for _, condition := range p.Design.Conditions {
conditions[[2]uint64{condition.Type, condition.SubType}] = true
}
}
emit := func(kind, sub, count uint64, id string) {
if count == 0 {
return
}
if p.Design != nil {
matched := false
for _, c := range p.Design.Conditions {
if c.Type == kind && c.SubType == sub {
matched = true
break
}
}
if !matched {
return
}
if p.Design != nil && !conditions[[2]uint64{kind, sub}] {
return
}
events = append(events, GameplayAchievementEvent{Type: kind, SubType: sub, Count: count, Identity: id})
}
@@ -362,6 +398,10 @@ func (s *Service) GameplayAchievementProvider(design *gamedata.AchievementCounte
}
p.FieldObjects = func() (map[string]gamedata.FieldRewardObject, error) {
objects := map[string]gamedata.FieldRewardObject{}
openedPeriods, err := s.state.OpenedFieldRewardPeriods()
if err != nil {
return nil, err
}
// Opened IDs are persisted independently of quest difficulty. Only loaded
// packs with real opened entries require their reward design to be resolved.
packs := map[int]bool{}
@@ -372,18 +412,15 @@ func (s *Service) GameplayAchievementProvider(design *gamedata.AchievementCounte
packs[id] = true
}
for pack := range packs {
ids, err := s.state.OpenedFieldRewards(pack)
if err != nil {
return nil, err
}
if len(ids) == 0 {
periods := openedPeriods[pack]
if len(periods) == 0 {
continue
}
d, err := s.fieldObjectDesign(pack)
if err != nil {
return nil, err
}
for _, id := range ids {
for id, openedPeriod := range periods {
obj, known := d.Objects[id]
if !known {
return nil, fmt.Errorf("achievement: opened field design absent")
@@ -392,11 +429,7 @@ func (s *Service) GameplayAchievementProvider(design *gamedata.AchievementCounte
if err != nil {
continue
}
opened, err := s.state.FieldRewardOpened(pack, id, period)
if err != nil {
return nil, err
}
if opened {
if openedPeriod == period {
objects[fmt.Sprintf("field:%d:%d:%s", pack, id, period)] = obj
}
}
@@ -1,13 +1,121 @@
package world
import (
"bd2server/internal/server/accountstate"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/player"
"bd2server/internal/server/progress"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
"errors"
"path/filepath"
"reflect"
"testing"
"time"
)
type fieldSnapshotCountStore struct {
*accountstate.Repository
lists, loads int
}
func (s *fieldSnapshotCountStore) ListEntries(domain, bucket string) (map[string][]byte, error) {
if domain == "progress" && bucket == "field_rewards" {
s.lists++
}
return s.Repository.ListEntries(domain, bucket)
}
func (s *fieldSnapshotCountStore) LoadEntry(domain, bucket, key string) ([]byte, bool, error) {
if domain == "progress" && bucket == "field_rewards" {
s.loads++
}
return s.Repository.LoadEntry(domain, bucket, key)
}
func TestGameplayFieldSnapshotReadsOneBucketAndRestoresAfterRollback(t *testing.T) {
path := filepath.Join(t.TempDir(), "state.db")
repo, err := accountstate.Open(path)
if err != nil {
t.Fatal(err)
}
defer func() { _ = repo.Close() }()
storage := &fieldSnapshotCountStore{Repository: repo}
s := testService()
s.state, err = progress.OpenStore(storage)
if err != nil {
t.Fatal(err)
}
s.packs = map[int]map[int]gamedata.QuestDesign{}
for pack := 1; pack <= 40; pack++ {
s.packs[pack] = nil
}
now := time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC)
s.monsterNow = func() time.Time { return now }
s.fieldReset = gamedata.FieldResetSchedule{DailyReset: 9 * time.Hour, WeeklyDay: time.Monday}
s.WithFieldObjects(map[int]gamedata.FieldObjectDesign{
21: {Objects: map[int]gamedata.FieldRewardObject{71: {ID: 71, Type: 2, ResetType: 1}, 72: {ID: 72, Type: 2, ResetType: 0}, 73: {ID: 73, Type: 2, ResetType: 3}}},
22: {Objects: map[int]gamedata.FieldRewardObject{71: {ID: 71, Type: 2, ResetType: 0}}},
})
for _, row := range []struct {
pack, id int
period string
}{{21, 71, "once"}, {21, 72, "2026-10-05"}, {21, 73, "2026-10-05"}, {22, 71, "2026-10-06"}} {
if err := s.state.MarkFieldRewardOpened(row.pack, row.id, row.period); err != nil {
t.Fatal(err)
}
}
p := s.GameplayAchievementProvider(&gamedata.AchievementCounterDesign{}, gamedata.GameplayAchievementGrades{})
assert := func(want map[string]gamedata.FieldRewardObject) {
t.Helper()
storage.lists, storage.loads = 0, 0
got, err := p.Snapshot()
if err != nil || !reflect.DeepEqual(got.FieldObjects, want) {
t.Fatalf("field snapshot=%v err=%v want=%v", got.FieldObjects, err, want)
}
if storage.lists != 1 || storage.loads != 0 {
t.Fatalf("snapshot queried bucket %d times and objects %d times", storage.lists, storage.loads)
}
}
want := map[string]gamedata.FieldRewardObject{"field:21:71:once": s.fieldObjects[21].Objects[71], "field:21:73:2026-10-05": s.fieldObjects[21].Objects[73], "field:22:71:2026-10-06": s.fieldObjects[22].Objects[71]}
assert(want)
op, err := repo.BeginOperation()
if err != nil {
t.Fatal(err)
}
if err := s.state.MarkFieldRewardOpened(21, 72, "2026-10-06"); err != nil {
t.Fatal(err)
}
want["field:21:72:2026-10-06"] = s.fieldObjects[21].Objects[72]
assert(want)
if err := op.Rollback(); !errors.Is(err, stateio.ErrStateRecoveryRequired) {
t.Fatalf("dirty rollback=%v", err)
}
if err := repo.Close(); err != nil {
t.Fatal(err)
}
repo, err = accountstate.Open(path)
if err != nil {
t.Fatal(err)
}
storage.Repository = repo
s.state, err = progress.OpenStore(storage)
if err != nil {
t.Fatal(err)
}
delete(want, "field:21:72:2026-10-06")
assert(want)
now = now.AddDate(0, 0, 7)
delete(want, "field:21:73:2026-10-05")
delete(want, "field:22:71:2026-10-06")
assert(want)
if err := s.state.MarkFieldRewardOpened(21, 999, "once"); err != nil {
t.Fatal(err)
}
if _, err := p.Snapshot(); err == nil {
t.Fatal("unknown opened object skipped validation")
}
}
type gameplayTestSource struct {
characters []player.Character
costumes []player.Costume
@@ -28,6 +136,31 @@ func (s gameplayEquipment) All() []player.Equipment { return s.s.equipment }
type gameplayItems struct{ s *gameplayTestSource }
func (s gameplayItems) All() []player.Item { return s.s.items }
func TestInventorySnapshotProjectsMissionInputsWithoutUnrelatedReads(t *testing.T) {
source := &gameplayTestSource{costumes: []player.Costume{{ID: 1, InvenIndex: 101, Level: 2}}, equipment: []player.Equipment{{ID: 2, InvenIndex: 201, Level: 3}}, items: []player.Item{{Type: 5, ID: 4, Count: 7}, {Type: 5, ID: 4, Count: 2}}}
p := &OwnedGameplayAchievementProvider{Costumes: gameplayCostumes{source}, Equipment: gameplayEquipment{source}, Items: gameplayItems{source}, Conditions: func() ([]GameplayAchievementCondition, error) {
t.Fatal("inventory projection read quest conditions")
return nil, nil
}, FieldObjects: func() (map[string]gamedata.FieldRewardObject, error) {
t.Fatal("inventory projection queried field objects")
return nil, nil
}}
before, err := p.InventorySnapshot()
if err != nil || before.Items[[2]uint64{5, 4}] != 9 || before.Costumes[101].Level != 2 || before.Equipment[201].Level != 3 {
t.Fatalf("inventory projection=%+v err=%v", before, err)
}
source.costumes[0].Level = 4
source.equipment[0].Level = 5
source.items[0].Count = 1
after, err := p.InventorySnapshot()
if err != nil || after.Items[[2]uint64{5, 4}] != 3 || after.Costumes[101].Level != 4 || after.Equipment[201].Level != 5 {
t.Fatalf("changed inventory projection=%+v err=%v", after, err)
}
if before.Items[[2]uint64{5, 4}] != 9 || before.Costumes[101].Level != 2 || before.Equipment[201].Level != 3 {
t.Fatal("new snapshot modified event baseline")
}
}
func TestTemporaryPartyMembersCannotGrantPermanentAcquisitionAchievements(t *testing.T) {
source := &gameplayTestSource{}
p := &OwnedGameplayAchievementProvider{Characters: source}
@@ -0,0 +1,208 @@
package world
import (
"bd2server/internal/server/accountstate"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/stateio"
"errors"
"fmt"
"math"
"path/filepath"
"reflect"
"testing"
)
type achievementQueryStore struct {
*accountstate.Repository
lists, loads, saves int
}
func (s *achievementQueryStore) ListEntries(domain, bucket string) (map[string][]byte, error) {
s.lists++
return s.Repository.ListEntries(domain, bucket)
}
func (s *achievementQueryStore) LoadEntry(domain, bucket, key string) ([]byte, bool, error) {
s.loads++
return s.Repository.LoadEntry(domain, bucket, key)
}
func (s *achievementQueryStore) SaveWithEntries(domain string, core []byte, entries []stateio.EntryMutation) error {
s.saves++
return s.Repository.SaveWithEntries(domain, core, entries)
}
func TestAchievementProgressBoundaryIsAtomicOrderedAndReplayable(t *testing.T) {
path := filepath.Join(t.TempDir(), "state.db")
repo, err := accountstate.Open(path)
if err != nil {
t.Fatal(err)
}
defer func() { _ = repo.Close() }()
store := &achievementQueryStore{Repository: repo}
design := &gamedata.AchievementCounterDesign{Groups: map[int][]int{1: {0}, 2: {0}}, Conditions: map[int]gamedata.AchievementCondition{1: {Type: 14, SubType: 21}, 2: {Type: 7}}}
s, err := NewAchievementService(design, store)
if err != nil {
t.Fatal(err)
}
s.BeginSession("test")
conditions := []GameplayAchievementCondition{{Type: 14, SubType: 21, Value: 1}}
events := []GameplayAchievementRecordedEvent{{Identity: "a", Type: 14, SubType: 21, Count: 3}, {Identity: "b", Type: 14, SubType: 21, Count: 4}, {Identity: "b", Type: 14, SubType: 21, Count: 4}}
before, after, err := s.ApplyGameplayProgress(conditions, events)
if err != nil || len(before) != 0 || !reflect.DeepEqual(after, map[int]uint64{1: 8}) {
t.Fatalf("ordered boundary before=%v after=%v err=%v", before, after, err)
}
if store.lists != 1 || store.saves != 1 {
t.Fatalf("boundary lists=%d saves=%d", store.lists, store.saves)
}
if _, after, err = s.ApplyGameplayProgress(nil, events); err != nil || after[1] != 8 {
t.Fatalf("receipt replay after=%v err=%v", after, err)
}
op, err := repo.BeginOperation()
if err != nil {
t.Fatal(err)
}
// A conflicting event after a new event must install neither event nor
// the preceding condition changes, even before outer rollback executes.
bad := append([]GameplayAchievementRecordedEvent{{Identity: "unwritten", Type: 7, Count: 5}}, GameplayAchievementRecordedEvent{Identity: "a", Type: 14, SubType: 21, Count: 9})
if _, _, err := s.ApplyGameplayProgress([]GameplayAchievementCondition{{Type: 7, Value: 2}}, bad); err == nil {
t.Fatal("receipt conflict accepted")
}
if err := op.Rollback(); err != nil {
t.Fatal(err)
}
if _, found, err := repo.LoadEntry("missions", "achievement_events", "unwritten"); err != nil || found {
t.Fatalf("failed boundary persisted event=%v err=%v", found, err)
}
if got, err := s.CounterValues(); err != nil || !reflect.DeepEqual(got, map[int]uint64{1: 8}) {
t.Fatalf("failed boundary installed partial counts=%v err=%v", got, err)
}
if _, _, err := s.ApplyGameplayProgress([]GameplayAchievementCondition{{Type: 7, Value: math.MaxInt64}}, nil); err != nil {
t.Fatal(err)
}
if _, _, err := s.ApplyGameplayProgress(conditions, []GameplayAchievementRecordedEvent{{Identity: "overflow", Type: 7, Count: 1}}); err == nil {
t.Fatal("overflow accepted")
}
if value, err := s.AchievementValue(1); err != nil || value != 8 {
t.Fatalf("overflow installed preceding condition=%d err=%v", value, err)
}
op, err = repo.BeginOperation()
if err != nil {
t.Fatal(err)
}
if _, _, err := s.ApplyGameplayProgress(nil, []GameplayAchievementRecordedEvent{{Identity: "rolled-back", Type: 14, SubType: 21, Count: 2}}); err != nil {
t.Fatal(err)
}
if err := op.Rollback(); !errors.Is(err, stateio.ErrStateRecoveryRequired) {
t.Fatalf("dirty rollback=%v", err)
}
if err := repo.Close(); err != nil {
t.Fatal(err)
}
repo, err = accountstate.Open(path)
if err != nil {
t.Fatal(err)
}
store.Repository = repo
s, err = NewAchievementService(design, store)
if err != nil {
t.Fatal(err)
}
_, after, err = s.ApplyGameplayProgress(nil, []GameplayAchievementRecordedEvent{{Identity: "rolled-back", Type: 14, SubType: 21, Count: 2}})
if err != nil || after[1] != 10 {
t.Fatalf("rollback poisoned retry counts=%v err=%v", after, err)
}
}
// This comparison uses the prior public counter operations as the baseline,
// and the optimized boundary against the same synthetic SQLite counter set.
// The 57-read case models login: no events, with 20 completed pack conditions.
func BenchmarkAchievementProgressBoundary(b *testing.B) {
for _, eventCount := range []int{0, 20} {
for _, bulk := range []bool{false, true} {
name := fmt.Sprintf("events%d/bulk%v", eventCount, bulk)
b.Run(name, func(b *testing.B) {
repo, err := accountstate.Open(filepath.Join(b.TempDir(), "state.db"))
if err != nil {
b.Fatal(err)
}
defer repo.Close()
store := &achievementQueryStore{Repository: repo}
design := &gamedata.AchievementCounterDesign{Groups: map[int][]int{}, Conditions: map[int]gamedata.AchievementCondition{}}
for id := 1; id <= 500; id++ {
design.Groups[id] = []int{0}
design.Conditions[id] = gamedata.AchievementCondition{Type: 14, SubType: uint64(id)}
}
s, err := NewAchievementService(design, store)
if err != nil {
b.Fatal(err)
}
s.BeginSession("benchmark")
var conditions []GameplayAchievementCondition
for id := 1; id <= 20; id++ {
conditions = append(conditions, GameplayAchievementCondition{Type: 14, SubType: uint64(id), Value: 1})
}
var seed []stateio.EntryMutation
for id := 1; id <= 500; id++ {
seed = append(seed, stateio.EntryMutation{Bucket: "achievement_counts", Key: fmt.Sprint(id), Payload: []byte("1")})
}
if err := repo.SaveWithEntries("missions", nil, seed); err != nil {
b.Fatal(err)
}
store.lists, store.loads, store.saves = 0, 0, 0
b.ReportAllocs()
b.ResetTimer()
for iteration := 0; iteration < b.N; iteration++ {
op, err := repo.BeginOperation()
if err != nil {
b.Fatal(err)
}
for request := 0; request < 57; request++ {
if bulk {
_, _, err = s.ApplyGameplayProgress(conditions, nil)
} else {
_, err = s.CounterValues()
for _, c := range conditions {
if err == nil {
_, err = s.SetCondition(c.Type, c.SubType, c.Value)
}
}
}
if err != nil {
b.Fatal(err)
}
var events []GameplayAchievementRecordedEvent
for id := 0; id < eventCount; id++ {
events = append(events, GameplayAchievementRecordedEvent{Identity: fmt.Sprintf("%d/%d/%d", iteration, request, id), Type: 14, SubType: uint64(id + 1), Count: 1})
}
if bulk {
_, _, err = s.ApplyGameplayProgress(conditions, events)
} else {
for _, c := range conditions {
if err == nil {
_, err = s.SetCondition(c.Type, c.SubType, c.Value)
}
}
for _, e := range events {
if err == nil {
_, err = s.RecordEvent(e.Identity, e.Type, e.SubType, e.Count)
}
}
if err == nil {
_, err = s.CounterValues()
}
}
if err != nil {
b.Fatal(err)
}
}
if err := op.Commit(); err != nil {
b.Fatal(err)
}
}
b.StopTimer()
b.ReportMetric(float64(store.lists)/float64(b.N), "bucket-reads/op")
b.ReportMetric(float64(store.loads)/float64(b.N), "entry-reads/op")
b.ReportMetric(float64(store.saves)/float64(b.N), "entry-writes/op")
})
}
}
}
+3 -2
View File
@@ -130,7 +130,7 @@ func (s *Service) handleQuestSelection(path string, request []byte) (int, []byte
if err := s.state.RemoveQuest(quest, pack, 0); err != nil {
return 0, nil, true, err
}
return 20, wire.AppendVarint(nil, 1, uint64(quest)), true, nil
return 20, s.appendCurrentBattleDeck(wire.AppendVarint(nil, 1, uint64(quest)), 2), true, nil
}
selection, ok := s.state.Selection(pack)
if !ok || selection.QuestID != quest || selection.Difficulty == 0 {
@@ -140,7 +140,7 @@ func (s *Service) handleQuestSelection(path string, request []byte) (int, []byte
if err := s.state.SelectQuest(pack, progress.QuestSelection{}); err != nil {
return 0, nil, true, err
}
return 20, wire.AppendVarint(nil, 1, uint64(quest)), true, nil
return 20, s.appendCurrentBattleDeck(wire.AppendVarint(nil, 1, uint64(quest)), 2), true, nil
}
level, _, err := wire.Varint(request, 4)
if err != nil || level > 4 {
@@ -164,6 +164,7 @@ func (s *Service) handleQuestSelection(path string, request []byte) (int, []byte
return 0, nil, true, err
}
out := wire.AppendBytes(nil, 1, s.questInfoWire(pack, quest))
out = s.appendCurrentBattleDeck(out, 3)
items, err := s.ensureQuestItems(pack, quest)
if err != nil {
return 0, nil, true, err
@@ -1,11 +1,13 @@
package world
import (
"bd2server/internal/server/deck"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/player"
"bd2server/internal/server/progress"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
"reflect"
"testing"
)
@@ -92,18 +94,48 @@ func TestDifficultySelectionPersistsAndRewardsRemainIndependent(t *testing.T) {
}
func TestSideQuestRemainsIndependentFromMainDifficulty(t *testing.T) {
s := testService()
var err error
s.decks, err = deck.NewStore(deck.Seed{Version: "2.35.10", FieldDeck: []deck.FieldEntry{{Slot: 1, CharacterInvenIndex: 905}}})
if err != nil {
t.Fatal(err)
}
saved := []deck.DeckEntry{
{CharacterInvenIndex: 905, CostumeInvenIndex: 0, Slot: 1},
{CharacterInvenIndex: 904, CostumeInvenIndex: 8, Slot: 2},
{CharacterInvenIndex: 903, CostumeInvenIndex: 11, Slot: 3},
{CharacterInvenIndex: 902, CostumeInvenIndex: 3, Slot: 4},
{CharacterInvenIndex: 901, CostumeInvenIndex: ^uint64(0), Slot: 5},
}
saveStoryTestDeck(t, s.decks, saved)
call := func(path string, deckField int) {
t.Helper()
_, response, handled, err := s.Handle(path, selectionRequest(50, 21, 0))
if err != nil || !handled {
t.Fatalf("%s handled=%v err=%v", path, handled, err)
}
var entries [][]byte
if err := wire.Walk(response, func(f wire.Field) error {
if f.Number == deckField {
entries = append(entries, f.Value)
}
return nil
}); err != nil {
t.Fatal(err)
}
if got := decodeStoryTestDeck(t, entries); !reflect.DeepEqual(got, saved) || !reflect.DeepEqual(s.decks.CurrentDeck(), saved) {
t.Fatalf("%s changed saved battle deck: response=%+v saved=%+v", path, got, s.decks.CurrentDeck())
}
}
pack := s.storyCatalog.Packs[21]
pack.Quests[50] = gamedata.QuestDesign{ID: 50, Type: 1}
s.storyCatalog.Packs[21] = pack
if _, _, _, err := s.Handle("/QuestAccept", selectionRequest(50, 21, 0)); err != nil {
t.Fatal(err)
}
call("/QuestAccept", 3)
call("/QuestGiveUp", 2)
call("/QuestAccept", 3)
if first := s.firstUnclearedQuestFor(21); first != 1 {
t.Fatalf("side replaced main%d", first)
}
if _, _, _, err := s.Handle("/QuestClear", selectionRequest(50, 21, 0)); err != nil {
t.Fatal(err)
}
call("/QuestClear", 4)
if !s.state.QuestCleared(50, 21) || s.state.QuestCleared(1, 21) {
t.Fatal("side clear affected main")
}
+1 -9
View File
@@ -2,7 +2,6 @@ package world
import (
"bd2server/internal/server/player"
"bd2server/internal/server/wire"
"fmt"
)
@@ -102,12 +101,5 @@ func (s *Service) resolveActivePartyWires(packID, questID int) ([][]byte, [][]by
for _, c := range party {
characters = append(characters, encodeCharacter(c))
}
var wires [][]byte
if s.decks != nil {
for _, entry := range s.decks.CurrentDeck() {
data := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, entry.CharacterInvenIndex), 2, entry.CostumeInvenIndex), 3, entry.Slot)
wires = append(wires, data)
}
}
return characters, wires, nil
return characters, s.currentBattleDeckWires(), nil
}
+43
View File
@@ -2,6 +2,7 @@ package world
import (
"bd2server/internal/server/todayquest"
"bd2server/internal/server/wire"
"fmt"
)
@@ -13,6 +14,48 @@ func (s *Service) AttachTodayQuests(service *todayquest.Service) error {
return nil
}
// QuestPacket always passes the repeated DeckInfo to CommonPacket.RefreshDeck,
// which replaces the entire battle deck even when that list is empty. Keep
// this live snapshot outside the commission reward receipt: retrying a clear
// after the player changes formation must return the current saved deck.
func (s *Service) commissionResponseDeck(code int, response []byte) []byte {
field := 0
switch code {
case 17: // QuestAcceptResponse.deck_info
field = 3
case 18: // QuestClearResponse.deck_info
field = 4
case 20: // QuestGiveUpResponse.deck_info
field = 2
}
if field != 0 {
return s.appendCurrentBattleDeck(response, field)
}
return response
}
func (s *Service) currentBattleDeckWires() [][]byte {
if s.decks == nil {
return nil
}
var entries [][]byte
for _, current := range s.decks.CurrentDeck() {
entry := wire.AppendVarint(nil, 1, current.CharacterInvenIndex)
// DeckDBInfo field 2 is the battle-grid position, including zero and -1.
entry = wire.AppendVarint(entry, 2, current.CostumeInvenIndex)
entry = wire.AppendVarint(entry, 3, current.Slot)
entries = append(entries, entry)
}
return entries
}
func (s *Service) appendCurrentBattleDeck(response []byte, field int) []byte {
for _, entry := range s.currentBattleDeckWires() {
response = wire.AppendBytes(response, field, entry)
}
return response
}
// NPCController checks whether any owned pack has completed its main story,
// rather than requiring completion of the board's own pack.
func (s *Service) CommissionPackUnlocked(pack int) bool {
+152 -15
View File
@@ -2,6 +2,7 @@ package world
import (
"bd2server/internal/server/accountstate"
"bd2server/internal/server/deck"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/player"
"bd2server/internal/server/progress"
@@ -24,8 +25,9 @@ func (e commissionEconomy) Apply(id string, costs, rewards []gamedata.Reward) ([
}
// Synthetic IDs describe a two-node collection commission and an independent
// pack. The source-backed contract is PackInGameInfo replacing both client
// quest lists (CommonPacket.SendPackInGameInfoRequest), not an official capture.
// pack. CommonPacket parses PackInGameInfo, requests TodayQuestInfo, then calls
// PackManager.Enter from the waypoint callback. Those last two callbacks can
// arrive in either order; only TodayQuestInfo restores commissions.
func TestCommissionPackEntryRestoresSQLiteChainAndRollback(t *testing.T) {
path := filepath.Join(t.TempDir(), "state.db")
design := &gamedata.TodayQuestCatalog{Limit: 3, PostCount: 3, AchievementScore: 7,
@@ -34,6 +36,7 @@ func TestCommissionPackEntryRestoresSQLiteChainAndRollback(t *testing.T) {
101: {ID: 101, PackID: 21, NextID: 102, ConditionType: 19, ConditionCount: 1},
102: {ID: 102, PackID: 21, PriorID: 101, ConditionType: 2, ConditionCount: 2, MagicValues: []uint64{71, 72}, GiveItemIDs: []uint64{100001}, Rewards: []gamedata.Reward{{Type: 4, Count: 10}}},
201: {ID: 201, PackID: 22, ConditionType: 19, ConditionCount: 1},
301: {ID: 301, PackID: 21, ConditionType: 19, ConditionCount: 1},
}}
open := func() (*accountstate.Repository, *Service, *player.Wallet, *player.Inventory) {
t.Helper()
@@ -48,6 +51,10 @@ func TestCommissionPackEntryRestoresSQLiteChainAndRollback(t *testing.T) {
if err != nil {
t.Fatal(err)
}
s.decks, err = deck.OpenStore(repo, deck.Seed{Version: "2.35.10", FieldDeck: []deck.FieldEntry{{Slot: 1, CharacterInvenIndex: 901}}})
if err != nil {
t.Fatal(err)
}
wallet, err := player.OpenWallet(repo, player.Currency{})
if err != nil {
t.Fatal(err)
@@ -81,6 +88,24 @@ func TestCommissionPackEntryRestoresSQLiteChainAndRollback(t *testing.T) {
if !handled || err != nil {
t.Fatalf("%s: handled=%v %v", path, handled, err)
}
// QuestAccept, QuestClear and QuestGiveUp all call client RefreshDeck
// with a non-null repeated list: the response must contain the complete
// current battle deck, even for a replayed commission reward receipt.
deckField := map[string]int{"/QuestAccept": 3, "/QuestClear": 4, "/QuestGiveUp": 2}[path]
if deckField != 0 {
var entries [][]byte
if err := wire.Walk(b, func(f wire.Field) error {
if f.Number == deckField {
entries = append(entries, f.Value)
}
return nil
}); err != nil {
t.Fatal(err)
}
if got := decodeStoryTestDeck(t, entries); !reflect.DeepEqual(got, s.decks.CurrentDeck()) {
t.Fatalf("%s replaced saved formation: got=%+v want=%+v", path, got, s.decks.CurrentDeck())
}
}
return b
}
assertEntry := func(s *Service, pack int, active map[int][]uint64, cleared []int) {
@@ -90,13 +115,52 @@ func TestCommissionPackEntryRestoresSQLiteChainAndRollback(t *testing.T) {
if !handled || err != nil {
t.Fatalf("pack entry: handled=%v %v", handled, err)
}
got := map[int][]uint64{}
var clears []int
var packQuests []int
var packClears []int
// The pack response is parsed before TodayQuestInfo is requested. Its
// regular task list is applied later, when the waypoint request finishes.
if err := wire.Walk(body, func(f wire.Field) error {
if f.Number == 2 {
id, _, _ := wire.Varint(f.Value, 1)
if _, exists := design.Quests[int(id)]; exists {
t.Fatalf("commission %d duplicated into PackInGameInfo", id)
}
packQuests = append(packQuests, int(id))
}
if f.Number == 3 {
for b := f.Value; len(b) > 0; {
id, n := binary.Uvarint(b)
if n <= 0 {
t.Fatal("invalid cleared IDs")
}
if _, exists := design.Quests[int(id)]; exists {
t.Fatalf("commission %d restored through pack completion list", id)
}
packClears = append(packClears, int(id))
b = b[n:]
}
}
return nil
}); err != nil {
t.Fatal(err)
}
if pack == 21 && !reflect.DeepEqual(packQuests, []int{1}) {
t.Fatalf("pack entry lost ordinary main quest: %v", packQuests)
}
today := call(s, "/TodayQuestInfo", 0, 0)
got := map[int][]uint64{}
var clears []int
var currentPackToday []int
if err := wire.Walk(today, func(f wire.Field) error {
if f.Number == 1 {
id, _, _ := wire.Varint(f.Value, 1)
qpack, _, _ := wire.Varint(f.Value, 6)
if int(qpack) == pack {
if _, duplicate := got[int(id)]; duplicate {
t.Fatalf("TodayQuestInfo repeated commission %d", id)
}
got[int(id)] = nil
currentPackToday = append(currentPackToday, int(id))
return wire.Walk(f.Value, func(q wire.Field) error {
if q.Number == 3 {
n, _ := binary.Uvarint(q.Value)
@@ -106,16 +170,10 @@ func TestCommissionPackEntryRestoresSQLiteChainAndRollback(t *testing.T) {
})
}
}
if f.Number == 3 {
for b := f.Value; len(b) > 0; {
id, n := binary.Uvarint(b)
if n <= 0 {
t.Fatal("invalid cleared IDs")
}
if _, exists := design.Quests[int(id)]; exists {
clears = append(clears, int(id))
}
b = b[n:]
if f.Number == 2 {
id, _ := binary.Uvarint(f.Value)
if design.Quests[int(id)].PackID == pack {
clears = append(clears, int(id))
}
}
return nil
@@ -123,12 +181,76 @@ func TestCommissionPackEntryRestoresSQLiteChainAndRollback(t *testing.T) {
t.Fatal(err)
}
if !reflect.DeepEqual(got, active) || !reflect.DeepEqual(clears, cleared) {
t.Fatalf("pack %d active=%v cleared=%v", pack, got, clears)
t.Fatalf("pack %d TodayQuestInfo active=%v cleared=%v", pack, got, clears)
}
for _, todayFirst := range []bool{true, false} {
var clientQuests []int
// The pack completion snapshot was assigned before either of
// these callbacks; TodayQuestInfo subsequently merges its IDs.
clientClears := append([]int(nil), packClears...)
addToday := func() {
var ordinary []int
for _, id := range clientQuests {
if _, today := design.Quests[id]; !today {
ordinary = append(ordinary, id)
}
}
clientQuests = append(ordinary, currentPackToday...)
for _, id := range clears {
present := false
for _, existing := range clientClears {
present = present || existing == id
}
if !present {
clientClears = append(clientClears, id)
}
}
}
if todayFirst {
addToday()
clientQuests = append(clientQuests, packQuests...)
} else {
clientQuests = append(clientQuests, packQuests...)
addToday()
}
seen := map[int]bool{}
for _, id := range clientQuests {
if seen[id] {
t.Fatalf("HUD duplicate quest %d when todayFirst=%v", id, todayFirst)
}
seen[id] = true
}
if len(seen) != len(packQuests)+len(active) {
t.Fatalf("entry callbacks lost tasks when todayFirst=%v: %v", todayFirst, clientQuests)
}
var completedToday []int
for _, id := range clientClears {
if _, today := design.Quests[id]; today {
completedToday = append(completedToday, id)
}
}
if !reflect.DeepEqual(completedToday, cleared) {
t.Fatalf("entry callbacks lost completed commissions when todayFirst=%v: %v", todayFirst, completedToday)
}
}
}
repo, s, wallet, inv := open()
savedDeck := []deck.DeckEntry{
{CharacterInvenIndex: 901, CostumeInvenIndex: 0, Slot: 1},
{CharacterInvenIndex: 905, CostumeInvenIndex: 8, Slot: 2},
{CharacterInvenIndex: 903, CostumeInvenIndex: 11, Slot: 3},
{CharacterInvenIndex: 904, CostumeInvenIndex: 3, Slot: 4},
{CharacterInvenIndex: 902, CostumeInvenIndex: ^uint64(0), Slot: 5},
}
saveStoryTestDeck(t, s.decks, savedDeck)
call(s, "/QuestAccept", 101, 21)
call(s, "/QuestAccept", 201, 22)
call(s, "/QuestAccept", 301, 21)
call(s, "/QuestAccept", 101, 21)
call(s, "/QuestGiveUp", 301, 21)
if !reflect.DeepEqual(s.decks.CurrentDeck(), savedDeck) {
t.Fatal("commission accepts or abandonment changed saved formation")
}
call(s, "/QuestUpdate", 101, 21, 1)
call(s, "/QuestClear", 101, 21)
call(s, "/QuestUpdate", 102, 21, 71)
@@ -139,6 +261,9 @@ func TestCommissionPackEntryRestoresSQLiteChainAndRollback(t *testing.T) {
t.Fatal(err)
}
repo, s, wallet, inv = open()
if !reflect.DeepEqual(s.decks.CurrentDeck(), savedDeck) {
t.Fatal("SQLite reopen lost selected formation")
}
assertEntry(s, 21, map[int][]uint64{102: {71}}, []int{101})
assertEntry(s, 22, map[int][]uint64{201: nil}, nil)
op, err := repo.BeginOperation()
@@ -163,11 +288,23 @@ func TestCommissionPackEntryRestoresSQLiteChainAndRollback(t *testing.T) {
}
call(s, "/QuestUpdate", 102, 21, 72)
call(s, "/QuestClear", 102, 21)
// A retry is an idempotent reward operation, but its deck projection is a
// live snapshot. Persist a different formation and reopen before replay.
savedDeck[0], savedDeck[4] = savedDeck[4], savedDeck[0]
savedDeck[0].Slot, savedDeck[4].Slot = 1, 5
saveStoryTestDeck(t, s.decks, savedDeck)
if err := repo.Close(); err != nil {
t.Fatal(err)
}
repo, s, wallet, inv = open()
call(s, "/QuestClear", 102, 21)
assertEntry(s, 21, map[int][]uint64{}, []int{101, 102})
if wallet.Snapshot().Gold != 10 {
t.Fatal("reward replay changed balance")
}
if !reflect.DeepEqual(s.decks.CurrentDeck(), savedDeck) {
t.Fatal("replayed commission clear restored stale formation")
}
call(s, "/QuestGiveUp", 201, 22)
assertEntry(s, 22, map[int][]uint64{}, nil)
}
+9 -28
View File
@@ -261,6 +261,9 @@ func (s *Service) AttachDecks(decks *deck.Store) error {
func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) {
if s.todayQuests != nil {
if code, body, handled, err := s.todayQuests.Handle(path, request); handled {
if err == nil {
body = s.commissionResponseDeck(code, body)
}
return code, body, handled, err
}
}
@@ -356,7 +359,6 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error)
}
}
}
slog.Info("team trace: deliver owned characters", "characters", characters)
for _, character := range characters {
response = wire.AppendBytes(response, 1, encodeCharacter(character))
}
@@ -375,7 +377,6 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error)
if s.collection != nil {
costumes = s.collection.Costumes()
}
slog.Info("team trace: deliver owned costumes", "costumes", costumes, "quest26", s.seed.RewardCostume)
var selections map[uint64]uint64
if s.prestigeSelections != nil {
var err error
@@ -422,7 +423,6 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error)
if !s.packUnlocked(pack) {
return 0, nil, true, fmt.Errorf("%w: unsupported pack %d", ErrInvalidRequest, pack)
}
slog.Info("team trace: deliver pack progress", "pack", pack, "clearedQuests", s.state.ClearedQuests(pack), "storyCharacters", s.storyCharacters(pack))
if active := s.firstUnclearedQuestFor(pack); active != 0 {
if _, err := s.ensureQuestItems(pack, active); err != nil {
return 0, nil, true, err
@@ -502,7 +502,6 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error)
}
}
}
slog.Info("team trace: quest cleared", "pack", pack, "quest", quest, "changesBattleDeck", false)
return 18, s.clearResponse(pack, quest, design.Rewards[s.questDifficultyFor(pack, quest)], items, questEquipment, nextItems, nextChars), true, nil
default:
return 0, nil, false, nil
@@ -740,19 +739,10 @@ func (s *Service) basePackInfoFor(packID int) ([]byte, error) {
out = wire.AppendBytes(out, 2, quest)
}
cleared := s.state.ClearedQuests(packID, s.questDifficulty(packID))
// Entering a pack replaces the client's entire task and completion lists.
// Include commissions here as well as in QuestInfo so cross-pack travel and
// login restore the same active chain and already completed nodes.
if s.todayQuests != nil {
quests, ids, err := s.todayQuests.Info(packID)
if err != nil {
return nil, err
}
for _, quest := range quests {
out = wire.AppendBytes(out, 2, quest)
}
cleared = append(cleared, ids...)
}
// CommonPacket requests TodayQuestInfo after parsing this response, before
// the waypoint callback calls PackManager.Enter. That separate response owns
// commission restoration; including commissions here lets Enter append them
// a second time when TodayQuestInfo arrives first, crashing the quest HUD.
if len(cleared) != 0 {
var packed []byte
for _, id := range cleared {
@@ -922,17 +912,8 @@ func (s *Service) clearResponse(packID, quest int, designRewards []gamedata.Rewa
out = wire.AppendBytes(out, 11, info)
}
}
if s.decks != nil {
// Echo the player's saved formation, including quests that unlock a
// character. Receiving a character is not a request to change the deck.
for _, current := range s.decks.CurrentDeck() {
entry := wire.AppendVarint(nil, 1, current.CharacterInvenIndex)
// Preserve the saved battle-grid position, including cell zero.
entry = wire.AppendVarint(entry, 2, current.CostumeInvenIndex)
entry = wire.AppendVarint(entry, 3, current.Slot)
out = wire.AppendBytes(out, 4, entry)
}
}
// Receiving a character is not a request to change the saved formation.
out = s.appendCurrentBattleDeck(out, 4)
for _, char := range nextChars {
out = wire.AppendBytes(out, 5, char)
}
+32
View File
@@ -0,0 +1,32 @@
root = true
[*.cs]
generated_code = false
dotnet_diagnostic.CA2000.severity = warning
dotnet_code_quality.CA2000.interprocedural_analysis_kind = none
dotnet_diagnostic.CA2016.severity = warning
dotnet_diagnostic.CA2200.severity = warning
dotnet_diagnostic.IDE0005.severity = suggestion
dotnet_diagnostic.IDE0059.severity = warning
dotnet_diagnostic.IDE0055.severity = suggestion
csharp_style_var_for_built_in_types = false:suggestion
csharp_style_var_when_type_is_apparent = true:suggestion
csharp_style_var_elsewhere = false:suggestion
csharp_style_namespace_declarations = file_scoped:suggestion
dotnet_style_qualification_for_field = false:suggestion
dotnet_style_qualification_for_property = false:suggestion
dotnet_style_qualification_for_method = false:suggestion
dotnet_style_qualification_for_event = false:suggestion
[**/obj/**/*.cs]
generated_code = true
[*.g.cs]
generated_code = true
[*.generated.cs]
generated_code = true
+3
View File
@@ -3,6 +3,9 @@
<PropertyGroup>
<BD2PluginsDir>$(MSBuildThisFileDirectory)</BD2PluginsDir>
<BD2RepositoryDir>$([System.IO.Path]::GetFullPath('$(BD2PluginsDir)..'))/</BD2RepositoryDir>
<EnableNETAnalyzers>true</EnableNETAnalyzers>
<AnalysisLevel>8.0-recommended</AnalysisLevel>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
</PropertyGroup>
<Import Project="$(BD2PluginsDir)Directory.Build.local.props" Condition="Exists('$(BD2PluginsDir)Directory.Build.local.props')" />
<PropertyGroup>