feat(server): persist rapport prologue unlocks with ContentOpen replay safety

This commit is contained in:
2026-10-05 18:29:20 +08:00
parent deea9360ee
commit 8a28dd40fb
5 changed files with 674 additions and 0 deletions
+15
View File
@@ -670,6 +670,20 @@ func serve(args []string) (serveErr error) {
return fmt.Errorf("read initial hunting AP: %w", err)
}
gameplayStore := stateio.EntrySnapshotStore{Entries: stateRepository, Domain: "missions", Bucket: "gameplay"}
contentOpeningDesign, err := gamedata.LoadContentOpeningDesign(gameData, *gameDataVersion)
if err != nil {
return fmt.Errorf("load content opening GameData: %w", err)
}
contentOpenService, err := player.NewContentOpenService(contentOpeningDesign, ownedItems, gameplayStore, func() (uint64, error) {
experience, err := missionService.AchievementExperience()
if err != nil {
return 0, err
}
return levelDesign.Level(experience), nil
})
if err != nil {
return fmt.Errorf("load content opening state: %w", err)
}
huntingService, err := hunting.Open(gameplayStore, gameData, *gameDataVersion, ownedItems, wallet,
worldService.CurrentPackID, freeHuntingAP, bonusHuntingAP)
if err != nil {
@@ -1115,6 +1129,7 @@ func serve(args []string) (serveErr error) {
costumePotentialService,
costumeBurstService,
friendshipService,
contentOpenService,
masterTitleService,
recruitService,
foodService,
+106
View File
@@ -0,0 +1,106 @@
package gamedata
import (
"database/sql"
"fmt"
)
// ContentOpeningDesign describes the rapport prologue's prerequisite and
// completion marker. The client maps ContentOpenRequest type 1 to group 23,
// detail 2; ticket identities and level requirements remain version data.
type ContentOpeningDesign struct {
Prerequisite, Completion ContentOpenRule
}
func LoadContentOpeningDesign(root, version string) (*ContentOpeningDesign, error) {
db, done, err := openStatDatabase(root, version)
if err != nil {
return nil, err
}
defer done()
return loadContentOpeningDesign(db)
}
func loadContentOpeningDesign(db *sql.DB) (*ContentOpeningDesign, error) {
if db == nil {
return nil, fmt.Errorf("gamedata: nil content opening database")
}
tickets := map[uint64]uint64{}
rows, err := db.Query("SELECT id,ProtoBuf FROM ContentTicketTable ORDER BY id")
if err != nil {
return nil, err
}
for rows.Next() {
var id uint64
var raw []byte
if err = rows.Scan(&id, &raw); err != nil {
break
}
var protoID, typ uint64
protoID, err = friendshipScalar(raw, 4)
if err == nil {
typ, err = friendshipScalar(raw, 8)
}
if err != nil || id == 0 || protoID != id || typ < 1 || typ > 6 {
err = fmt.Errorf("gamedata: invalid content ticket %d", id)
break
}
if _, exists := tickets[id]; exists {
err = fmt.Errorf("gamedata: duplicate content ticket %d", id)
break
}
tickets[id] = typ
}
if err == nil {
err = rows.Err()
}
rows.Close()
if err != nil {
return nil, err
}
rows, err = db.Query("SELECT groupId,id,ticketId,ProtoBuf FROM ContentOpenTable ORDER BY groupId,id")
if err != nil {
return nil, err
}
defer rows.Close()
d := &ContentOpeningDesign{}
seen := map[[2]uint64]bool{}
for rows.Next() {
var group, id, ticket uint64
var raw []byte
if err := rows.Scan(&group, &id, &ticket, &raw); err != nil {
return nil, err
}
values := make([]uint64, 6)
for i := range values {
values[i], err = friendshipScalar(raw, i+1)
if err != nil {
return nil, fmt.Errorf("gamedata: content opening %d/%d: %w", group, id, err)
}
}
key := [2]uint64{group, id}
if group == 0 || id == 0 || values[0] != group || values[1] != id || ticket == 0 || values[5] != ticket || tickets[ticket] == 0 || seen[key] {
return nil, fmt.Errorf("gamedata: invalid content opening %d/%d identity or ticket", group, id)
}
seen[key] = true
if group != 23 || (id != 1 && id != 2) {
continue
}
if tickets[ticket] != 1 {
return nil, fmt.Errorf("gamedata: rapport opening %d requires permanent content ticket", id)
}
rule := ContentOpenRule{TutorialID: values[3], SquadLevel: values[4], TicketID: ticket}
if id == 1 {
d.Prerequisite = rule
} else {
d.Completion = rule
}
}
if err := rows.Err(); err != nil {
return nil, err
}
if !seen[[2]uint64{23, 1}] || !seen[[2]uint64{23, 2}] || d.Prerequisite.TicketID == d.Completion.TicketID {
return nil, fmt.Errorf("gamedata: missing or indistinguishable rapport opening rules")
}
return d, nil
}
@@ -0,0 +1,66 @@
package gamedata
import (
"database/sql"
"testing"
)
func contentOpeningTestDB(t *testing.T) *sql.DB {
t.Helper()
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatal(err)
}
db.SetMaxOpenConns(1)
t.Cleanup(func() { db.Close() })
for _, q := range []string{"CREATE TABLE ContentOpenTable(groupId INTEGER,id INTEGER,ticketId INTEGER,ProtoBuf BLOB)", "CREATE TABLE ContentTicketTable(id INTEGER,ProtoBuf BLOB)"} {
if _, err := db.Exec(q); err != nil {
t.Fatal(err)
}
}
for _, id := range []uint64{901, 902} {
if _, err := db.Exec("INSERT INTO ContentTicketTable VALUES(?,?)", id, friendshipTestProto(map[int][]uint64{4: {id}, 8: {1}})); err != nil {
t.Fatal(err)
}
}
for _, id := range []uint64{1, 2} {
if _, err := db.Exec("INSERT INTO ContentOpenTable VALUES(23,?,?,?)", id, 900+id, friendshipTestProto(map[int][]uint64{1: {23}, 2: {id}, 4: {100 + id}, 5: {10 + id}, 6: {900 + id}})); err != nil {
t.Fatal(err)
}
}
return db
}
func TestContentOpeningUsesVersionTicketsAndRequirements(t *testing.T) {
d, err := loadContentOpeningDesign(contentOpeningTestDB(t))
if err != nil {
t.Fatal(err)
}
if d.Prerequisite != (ContentOpenRule{TicketID: 901, SquadLevel: 11, TutorialID: 101}) || d.Completion != (ContentOpenRule{TicketID: 902, SquadLevel: 12, TutorialID: 102}) {
t.Fatalf("design=%+v", d)
}
}
func TestContentOpeningRejectsIncompleteAndInvalidCatalog(t *testing.T) {
for _, tc := range []struct {
name, query string
args []any
}{
{"missing completion", "DELETE FROM ContentOpenTable WHERE id=2", nil},
{"missing ticket", "DELETE FROM ContentTicketTable WHERE id=902", nil},
{"temporary ticket", "UPDATE ContentTicketTable SET ProtoBuf=? WHERE id=902", []any{friendshipTestProto(map[int][]uint64{4: {902}, 8: {2}})}},
{"wrong ticket identity", "UPDATE ContentTicketTable SET ProtoBuf=? WHERE id=901", []any{friendshipTestProto(map[int][]uint64{4: {999}, 8: {1}})}},
{"wrong opening identity", "UPDATE ContentOpenTable SET ProtoBuf=? WHERE id=2", []any{friendshipTestProto(map[int][]uint64{1: {23}, 2: {1}, 6: {902}})}},
{"invalid unrelated row", "INSERT INTO ContentOpenTable VALUES(1,1,999,?)", []any{friendshipTestProto(map[int][]uint64{1: {1}, 2: {1}, 6: {999}})}},
} {
t.Run(tc.name, func(t *testing.T) {
db := contentOpeningTestDB(t)
if _, err := db.Exec(tc.query, tc.args...); err != nil {
t.Fatal(err)
}
if _, err := loadContentOpeningDesign(db); err == nil {
t.Fatal("invalid catalog accepted")
}
})
}
}
+230
View File
@@ -0,0 +1,230 @@
package player
import (
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"math"
"strconv"
"strings"
"sync"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
)
const contentOpenPacketCode = 622
type contentOpenReceipt struct {
Digest string `json:"digest"`
Body []byte `json:"body"`
}
type contentOpenState struct {
Receipts map[string]contentOpenReceipt `json:"receipts"`
}
// ContentOpenService grants the completion ticket for the client's sole
// implemented ContentOpen type (1). The prerequisite ticket is never spent.
// Handle must run in the account request transaction: inventory and receipt
// writes commit together, and account recovery reconstructs Inventory after
// a dirty rollback. Receipts are reloaded for every request.
type ContentOpenService struct {
mu sync.Mutex
design *gamedata.ContentOpeningDesign
inventory *Inventory
store stateio.Store
squadLevel func() (uint64, error)
session string
}
func NewContentOpenService(design *gamedata.ContentOpeningDesign, inventory *Inventory, store stateio.Store, squadLevel func() (uint64, error)) (*ContentOpenService, error) {
if design == nil || inventory == nil || store == nil || squadLevel == nil || design.Prerequisite.TicketID == 0 || design.Completion.TicketID == 0 || design.Completion.TicketID == design.Prerequisite.TicketID {
return nil, errors.New("player: incomplete ContentOpen service")
}
s := &ContentOpenService{design: design, inventory: inventory, store: store, squadLevel: squadLevel}
if _, err := s.loadState(); err != nil {
return nil, err
}
return s, nil
}
func (s *ContentOpenService) BeginSession(id string) {
s.mu.Lock()
defer s.mu.Unlock()
s.session = id
}
func (s *ContentOpenService) Handle(path string, request []byte) (int, []byte, bool, error) {
if path != "/ContentOpen" {
return 0, nil, false, nil
}
var seq, kind uint64
var haveSeq, haveKind bool
err := wire.Walk(request, func(field wire.Field) error {
switch field.Number {
case 1, 2:
if field.Type != 0 {
return errors.New("player: ContentOpen field must be varint")
}
value, _ := binary.Uvarint(field.Value)
if field.Number == 1 {
if haveSeq {
return errors.New("player: duplicate ContentOpen sequence")
}
seq, haveSeq = value, true
} else {
if haveKind {
return errors.New("player: duplicate ContentOpen type")
}
kind, haveKind = value, true
}
}
return nil
})
if err != nil || !haveSeq || seq == 0 || seq > math.MaxInt32 || !haveKind || kind != 1 {
if err == nil {
err = errors.New("player: invalid ContentOpen sequence or unsupported type")
}
return 0, nil, true, err
}
s.mu.Lock()
defer s.mu.Unlock()
if s.session == "" {
return 0, nil, true, errors.New("player: ContentOpen requires authenticated session")
}
sessionHash := sha256.Sum256([]byte(s.session))
key := hex.EncodeToString(sessionHash[:]) + ":" + strconv.FormatUint(seq, 10)
requestHash := sha256.Sum256(request)
digest := hex.EncodeToString(requestHash[:])
state, err := s.loadState()
if err != nil {
return 0, nil, true, err
}
if receipt, ok := state.Receipts[key]; ok {
if receipt.Digest != digest {
return 0, nil, true, errors.New("player: ContentOpen sequence reused with different request")
}
return contentOpenPacketCode, append([]byte(nil), receipt.Body...), true, nil
}
var prerequisite, complete bool
var bundle []byte
for _, item := range s.inventory.All() {
if item.Type != 19 || item.Count == 0 {
continue
}
prerequisite = prerequisite || item.ID == s.design.Prerequisite.TicketID
if item.ID == s.design.Completion.TicketID && !complete {
if item.Count != 1 || item.InvenIndex == 0 || item.InvenIndex > math.MaxInt64 {
return 0, nil, true, errors.New("player: invalid owned ContentOpen completion ticket")
}
complete = true
bundle = wire.AppendBytes(bundle, 1, ItemWire(item))
}
}
if !complete {
if !prerequisite {
return 0, nil, true, errors.New("player: ContentOpen prerequisite ticket is not owned")
}
level, err := s.squadLevel()
if err != nil {
return 0, nil, true, fmt.Errorf("player: ContentOpen squad level: %w", err)
}
if level < max(s.design.Prerequisite.SquadLevel, s.design.Completion.SquadLevel) {
return 0, nil, true, errors.New("player: ContentOpen squad level is too low")
}
items, err := s.inventory.GrantOnce("content-open:"+strconv.FormatUint(s.design.Completion.TicketID, 10), []gamedata.BattleReward{{Type: 19, ID: s.design.Completion.TicketID, Count: 1}})
if err != nil {
return 0, nil, true, fmt.Errorf("player: grant ContentOpen ticket: %w", err)
}
if len(items) != 1 {
return 0, nil, true, errors.New("player: ContentOpen grant marker has no completion ticket")
}
for _, item := range items {
bundle = wire.AppendBytes(bundle, 1, ItemWire(item))
}
}
body := wire.AppendBytes(nil, 1, bundle)
state.Receipts[key] = contentOpenReceipt{Digest: digest, Body: body}
data, err := json.Marshal(state)
if err != nil {
return 0, nil, true, err
}
if err := s.store.Save("content_open", data); err != nil {
return 0, nil, true, fmt.Errorf("player: persist ContentOpen receipt: %w", err)
}
return contentOpenPacketCode, append([]byte(nil), body...), true, nil
}
func (s *ContentOpenService) loadState() (contentOpenState, error) {
state := contentOpenState{Receipts: make(map[string]contentOpenReceipt)}
data, err := s.store.Load("content_open")
if err != nil || data == nil {
return state, err
}
if err := stateio.RequireExactJSONObject(data, "receipts"); err != nil {
return state, fmt.Errorf("player: invalid ContentOpen state: %w", err)
}
if err := json.Unmarshal(data, &state); err != nil || state.Receipts == nil {
return state, errors.New("player: invalid ContentOpen receipts")
}
validHash := func(value string) bool {
decoded, err := hex.DecodeString(value)
return err == nil && len(decoded) == sha256.Size && hex.EncodeToString(decoded) == value
}
for key, receipt := range state.Receipts {
hash, sequence, found := strings.Cut(key, ":")
seq, err := strconv.ParseUint(sequence, 10, 32)
if !found || !validHash(hash) || err != nil || seq == 0 || seq > math.MaxInt32 || strconv.FormatUint(seq, 10) != sequence || !validHash(receipt.Digest) {
return state, errors.New("player: invalid ContentOpen receipt identity")
}
if err := validateContentOpenBody(receipt.Body, s.design.Completion.TicketID); err != nil {
return state, err
}
}
return state, nil
}
func validateContentOpenBody(body []byte, ticketID uint64) error {
unwrap := func(data []byte) ([]byte, error) {
var value []byte
var found bool
err := wire.Walk(data, func(field wire.Field) error {
if field.Number != 1 || field.Type != 2 || found {
return errors.New("player: invalid ContentOpen receipt bundle")
}
value, found = field.Value, true
return nil
})
if err != nil {
return nil, err
}
if !found {
return nil, errors.New("player: missing ContentOpen receipt bundle")
}
return value, nil
}
bundle, err := unwrap(body)
if err != nil {
return err
}
item, err := unwrap(bundle)
if err != nil {
return err
}
for field, want := range map[int]uint64{2: ticketID, 3: 19, 4: 1} {
value, present, err := wire.Varint(item, field)
if err != nil || !present || value != want {
return errors.New("player: invalid ContentOpen receipt ticket")
}
}
index, present, err := wire.Varint(item, 1)
if err != nil || !present || index == 0 || index > math.MaxInt64 {
return errors.New("player: invalid ContentOpen receipt ticket index")
}
return nil
}
@@ -0,0 +1,257 @@
package player
import (
"bytes"
"errors"
"path/filepath"
"testing"
"bd2server/internal/server/accountstate"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/stateio"
"bd2server/internal/server/versionconfig"
"bd2server/internal/server/wire"
)
func contentOpenHarness(t *testing.T, store stateio.Store, prerequisite bool, level uint64) (*ContentOpenService, *Inventory) {
t.Helper()
starter := &Starter{Version: versionconfig.State()}
if prerequisite {
starter.Items = []Item{{InvenIndex: 100, Type: 19, ID: 41, Count: 1}}
}
inventory, err := OpenInventory(store, starter)
if err != nil {
t.Fatal(err)
}
design := &gamedata.ContentOpeningDesign{Prerequisite: gamedata.ContentOpenRule{TicketID: 41, SquadLevel: 3}, Completion: gamedata.ContentOpenRule{TicketID: 42, SquadLevel: 2}}
service, err := NewContentOpenService(design, inventory, store, func() (uint64, error) { return level, nil })
if err != nil {
t.Fatal(err)
}
service.BeginSession("authenticated-session")
return service, inventory
}
func contentOpenRequest(seq, kind uint64) []byte {
return wire.AppendVarint(wire.AppendVarint(nil, 1, seq), 2, kind)
}
func contentOpenTicketCount(inventory *Inventory, id uint64) uint64 {
var count uint64
for _, item := range inventory.All() {
if item.Type == 19 && item.ID == id {
count += item.Count
}
}
return count
}
func assertContentOpenResponse(t *testing.T, body []byte) {
t.Helper()
bundle, present, err := wire.Bytes(body, 1)
if err != nil || !present {
t.Fatalf("missing RewardDBInfoBundle: %x %v", body, err)
}
item, present, err := wire.Bytes(bundle, 1)
if err != nil || !present {
t.Fatalf("missing completion ItemDBInfo: %x %v", bundle, err)
}
for field, want := range map[int]uint64{2: 42, 3: 19, 4: 1} {
got, present, err := wire.Varint(item, field)
if err != nil || !present || got != want {
t.Fatalf("ItemDBInfo field %d=%d, want %d", field, got, want)
}
}
}
func TestContentOpenTicketOnceAcrossSequencesAndSessions(t *testing.T) {
store := stateio.NewMemory()
service, inventory := contentOpenHarness(t, store, true, 3)
request := contentOpenRequest(1, 1)
code, body, handled, err := service.Handle("/ContentOpen", request)
if err != nil || !handled || code != 622 {
t.Fatalf("open: %d %v %v", code, handled, err)
}
assertContentOpenResponse(t, body)
for _, tc := range []struct {
session string
seq uint64
}{{"authenticated-session", 1}, {"authenticated-session", 2}, {"another-session", 1}} {
service.BeginSession(tc.session)
_, next, _, err := service.Handle("/ContentOpen", contentOpenRequest(tc.seq, 1))
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(next, body) {
t.Fatal("ticket repair/retry returned a different item")
}
}
if contentOpenTicketCount(inventory, 41) != 1 || contentOpenTicketCount(inventory, 42) != 1 {
t.Fatal("prerequisite consumed or completion duplicated")
}
data, err := store.Load("content_open")
if err != nil || bytes.Contains(data, []byte("authenticated-session")) || bytes.Contains(data, []byte("another-session")) {
t.Fatal("receipt contains session credentials")
}
service.BeginSession("authenticated-session")
changed := wire.AppendVarint(append([]byte(nil), request...), 3, 7)
if _, _, _, err := service.Handle("/ContentOpen", changed); err == nil {
t.Fatal("same sequence accepted different request")
}
}
func TestContentOpenRequiresSessionAndRepairsOwnedTicket(t *testing.T) {
store := stateio.NewMemory()
service, inventory := contentOpenHarness(t, store, false, 0)
service.BeginSession("")
if _, _, _, err := service.Handle("/ContentOpen", contentOpenRequest(1, 1)); err == nil {
t.Fatal("missing authenticated session accepted")
}
if _, err := inventory.GrantOnce("existing-completion", []gamedata.BattleReward{{ID: 42, Type: 19, Count: 1}}); err != nil {
t.Fatal(err)
}
service.BeginSession("repair-session")
_, body, _, err := service.Handle("/ContentOpen", contentOpenRequest(1, 1))
if err != nil {
t.Fatal(err)
}
assertContentOpenResponse(t, body)
if contentOpenTicketCount(inventory, 42) != 1 {
t.Fatal("repair duplicated owned completion")
}
}
func TestContentOpenRejectsCorruptReceiptAtOpenAndAfterConstruction(t *testing.T) {
store := stateio.NewMemory()
service, inventory := contentOpenHarness(t, store, true, 3)
if err := store.Save("content_open", []byte(`{"receipts":{"credentials:1":{"digest":"bad","body":"CgA="}}}`)); err != nil {
t.Fatal(err)
}
if _, _, _, err := service.Handle("/ContentOpen", contentOpenRequest(1, 1)); err == nil {
t.Fatal("corrupt reloaded receipt accepted")
}
if contentOpenTicketCount(inventory, 42) != 0 {
t.Fatal("corrupt receipts granted completion")
}
if _, err := NewContentOpenService(service.design, inventory, store, service.squadLevel); err == nil {
t.Fatal("corrupt receipt accepted during open")
}
}
func TestContentOpenRejectsUnqualifiedAndMalformedRequests(t *testing.T) {
for _, tc := range []struct {
name string
prerequisite bool
level uint64
request []byte
}{
{"missing-prerequisite", false, 3, contentOpenRequest(1, 1)},
{"level", true, 2, contentOpenRequest(1, 1)},
{"unknown", true, 3, contentOpenRequest(1, 2)},
{"zero-type", true, 3, contentOpenRequest(1, 0)},
{"group-is-not-type", true, 3, contentOpenRequest(1, 23)},
{"zero-sequence", true, 3, contentOpenRequest(0, 1)},
{"wrong-wire", true, 3, wire.AppendBytes(wire.AppendVarint(nil, 1, 1), 2, []byte{1})},
{"truncated-trailing-wire", true, 3, append(contentOpenRequest(1, 1), 0x1a, 0x02, 0x01)},
{"duplicate-type", true, 3, wire.AppendVarint(contentOpenRequest(1, 1), 2, 1)},
} {
t.Run(tc.name, func(t *testing.T) {
store := stateio.NewMemory()
service, inventory := contentOpenHarness(t, store, tc.prerequisite, tc.level)
_, _, handled, err := service.Handle("/ContentOpen", tc.request)
if err == nil || !handled {
t.Fatal("invalid request accepted")
}
if contentOpenTicketCount(inventory, 42) != 0 {
t.Fatal("rejected request granted ticket")
}
state, err := store.Load("content_open")
if err != nil || state != nil {
t.Fatal("rejected request persisted receipt")
}
})
}
}
type contentOpenFailStore struct {
stateio.AtomicEntryStore
fail bool
}
func (s *contentOpenFailStore) Save(name string, payload []byte) error {
if name == "content_open" && s.fail {
return errors.New("injected receipt write failure")
}
return s.AtomicEntryStore.Save(name, payload)
}
func TestContentOpenSQLiteReceiptRestartAndRollback(t *testing.T) {
for _, fail := range []bool{false, true} {
t.Run(map[bool]string{false: "durable-exact-retry", true: "failed-receipt-rolls-back-ticket"}[fail], func(t *testing.T) {
path := filepath.Join(t.TempDir(), "state.db")
repo, err := accountstate.Open(path)
if err != nil {
t.Fatal(err)
}
store := &contentOpenFailStore{AtomicEntryStore: repo, fail: fail}
service, _ := contentOpenHarness(t, store, true, 3)
op, err := repo.BeginOperation()
if err != nil {
t.Fatal(err)
}
request := contentOpenRequest(1, 1)
_, body, _, err := service.Handle("/ContentOpen", request)
if fail {
if err == nil {
t.Fatal("receipt failure ignored")
}
if err := op.Rollback(); err == nil {
t.Fatal("dirty rollback did not request account recovery")
}
} else {
if err != nil {
t.Fatal(err)
}
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)
}
defer repo.Close()
service, inventory := contentOpenHarness(t, repo, true, 3)
if fail {
if contentOpenTicketCount(inventory, 42) != 0 {
t.Fatal("uncommitted completion survived restart")
}
data, err := repo.Load("content_open")
if err != nil || data != nil {
t.Fatal("failed receipt survived restart")
}
}
op, err = repo.BeginOperation()
if err != nil {
t.Fatal(err)
}
_, replay, _, err := service.Handle("/ContentOpen", request)
if err != nil {
t.Fatal(err)
}
if err := op.Commit(); err != nil {
t.Fatal(err)
}
if !fail && !bytes.Equal(body, replay) {
t.Fatal("exact retry changed after restart")
}
assertContentOpenResponse(t, replay)
if contentOpenTicketCount(inventory, 42) != 1 || contentOpenTicketCount(inventory, 41) != 1 {
t.Fatal("ticket ownership not preserved exactly once")
}
})
}
}