Compare commits
4
Commits
88cb314978
...
e8e9d77974
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8e9d77974 | ||
|
|
b9f1ac4aa6 | ||
|
|
c13ff41878 | ||
|
|
7363f7aadd |
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
version: "2"
|
||||
|
||||
run:
|
||||
timeout: 5m
|
||||
tests: true
|
||||
|
||||
linters:
|
||||
default: none
|
||||
enable:
|
||||
- errcheck
|
||||
- govet
|
||||
- ineffassign
|
||||
- staticcheck
|
||||
- unused
|
||||
- rowserrcheck
|
||||
- modernize
|
||||
+3
-1
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 := ¬iceProvider{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 := ¬iceProvider{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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
Reference in New Issue
Block a user