aboutsummaryrefslogtreecommitdiff
path: root/internal/game
diff options
context:
space:
mode:
Diffstat (limited to 'internal/game')
-rw-r--r--internal/game/casino.go14
-rw-r--r--internal/game/cmd_room_remove.go4
-rw-r--r--internal/game/cmd_undig.go3
-rw-r--r--internal/game/core_login_account.go26
-rw-r--r--internal/game/economy.go181
-rw-r--r--internal/game/economy_open_payout_list_test.go120
-rw-r--r--internal/game/economy_recovery_regression_test.go11
7 files changed, 339 insertions, 20 deletions
diff --git a/internal/game/casino.go b/internal/game/casino.go
index 0441903..5268d34 100644
--- a/internal/game/casino.go
+++ b/internal/game/casino.go
@@ -105,13 +105,25 @@ func (w casinoWallet) payCreditsFlagged(amount int, refund bool) error {
return capErr
}
w.p.Credits = creditsAfter
+ // Record the payout in the character's open-payout evidence list: the
+ // save below is the commit point, and the file itself then proves the
+ // amount is included in the balance. Recovery reads this list instead
+ // of guessing from balance checkpoints, which unrelated credit changes
+ // (a shop sale, a bank deposit) can replicate by coincidence.
+ w.p.OpenPayouts = append(w.p.OpenPayouts, rec.ID)
if err := w.g.AccountStore.SaveCharacter(w.p); err != nil {
w.p.Credits = creditsBefore
+ removeOpenPayout(w.p, rec.ID)
// Keep the record open: recovery re-pays on next startup, since a
// reported win must be durable.
return casino.ErrWalletPersistence
}
- w.g.Economy.settle(rec)
+ // The evidence entry may only be dropped once the settled marker is
+ // durable; while the marker is still open, the file's membership is
+ // what proves durability to recovery.
+ if w.g.Economy.settleDurable(rec) {
+ removeOpenPayout(w.p, rec.ID)
+ }
return nil
}
diff --git a/internal/game/cmd_room_remove.go b/internal/game/cmd_room_remove.go
index 187ec34..af745fd 100644
--- a/internal/game/cmd_room_remove.go
+++ b/internal/game/cmd_room_remove.go
@@ -216,6 +216,10 @@ func (g *Game) roomRemove(sess *net.Session, args []string) {
g.World.RebuildRoomIndex(g.DataDir)
g.MobStore.RemoveMobsInRoom(bID)
g.World.ClearRoomState(bID)
+ // Drop any cached in-room games and refund their outstanding wagers
+ // before the room stops existing (same eviction semantics as the
+ // admin editor's room deletion).
+ g.EvictCasinoRoom(bID)
// Relocate any other players standing in B to A (the current player is in
// A and unaffected). Mirrors performUndig's relocate tail.
diff --git a/internal/game/cmd_undig.go b/internal/game/cmd_undig.go
index 2215c51..a41cb08 100644
--- a/internal/game/cmd_undig.go
+++ b/internal/game/cmd_undig.go
@@ -241,6 +241,9 @@ func (g *Game) performUndig(sess *net.Session, p *player.Player, targetID int) {
g.MobStore.RemoveMobsInRoom(targetID)
g.World.ClearRoomState(targetID)
+ // Drop any cached in-room games and refund their outstanding wagers
+ // (same eviction semantics as the other room-removal paths).
+ g.EvictCasinoRoom(targetID)
sess.WriteLine(fmt.Sprintf("Deleted room %s and %d exit(s) pointing to it.", roomName, cleaned))
}
diff --git a/internal/game/core_login_account.go b/internal/game/core_login_account.go
index c5f1064..1acd1a6 100644
--- a/internal/game/core_login_account.go
+++ b/internal/game/core_login_account.go
@@ -20,7 +20,7 @@ func (g *Game) handleAccountName(sess *net.Session, input string) {
if err == nil {
sess.Account = &player.Account{Name: actualName}
sess.State = net.StatePassword
- sess.Conn.SetEcho(false)
+ sess.SetEcho(false)
sess.Write("Password: ")
} else {
if verr := player.ValidName(name); verr != nil {
@@ -30,7 +30,7 @@ func (g *Game) handleAccountName(sess *net.Session, input string) {
}
sess.Account = &player.Account{Name: name}
sess.State = net.StateNewAccountPass
- sess.Conn.SetEcho(false)
+ sess.SetEcho(false)
sess.WriteLine("\r\nOh, a new visitor to this rock!")
sess.Write("Choose password: ")
}
@@ -42,7 +42,7 @@ func (g *Game) handlePassword(sess *net.Session, input string) {
return
}
if len(input) > 128 {
- sess.Conn.SetEcho(true)
+ sess.SetEcho(true)
sess.WriteLine("Wrong password.")
sess.State = net.StateAccountName
sess.Write("Account name: ")
@@ -50,14 +50,14 @@ func (g *Game) handlePassword(sess *net.Session, input string) {
}
acc, err := g.AccountStore.LoadAccount(sess.Account.Name)
if err != nil {
- sess.Conn.SetEcho(true)
+ sess.SetEcho(true)
sess.WriteLine("Error loading account.")
sess.State = net.StateAccountName
sess.Write("Account name: ")
return
}
if !player.CheckPassword(input, acc.PasswordHash) {
- sess.Conn.SetEcho(true)
+ sess.SetEcho(true)
sess.WriteLine("Wrong password.")
sess.State = net.StateAccountName
sess.Write("Account name: ")
@@ -76,7 +76,7 @@ func (g *Game) handlePassword(sess *net.Session, input string) {
log.Printf("login: password rehash for %s: %v", acc.Name, hashErr)
}
}
- sess.Conn.SetEcho(true)
+ sess.SetEcho(true)
sess.Account = &player.Account{
Name: acc.Name,
PasswordHash: acc.PasswordHash,
@@ -101,7 +101,7 @@ func (g *Game) handlePassword(sess *net.Session, input string) {
func (g *Game) handleNewPass(sess *net.Session, input string) {
input = strings.TrimSpace(input)
if input == "" {
- sess.Conn.SetEcho(true)
+ sess.SetEcho(true)
sess.Account = nil
sess.State = net.StateAccountName
sess.Write("Account name: ")
@@ -120,14 +120,14 @@ func (g *Game) handleNewPass(sess *net.Session, input string) {
func (g *Game) handleNewAccount(sess *net.Session, input string) {
input = strings.TrimSpace(input)
if input == "" {
- sess.Conn.SetEcho(true)
+ sess.SetEcho(true)
sess.Account = nil
sess.State = net.StateAccountName
sess.Write("Account name: ")
return
}
if input != sess.PendingPass {
- sess.Conn.SetEcho(true)
+ sess.SetEcho(true)
sess.WriteLine("Passwords do not match.")
sess.Account = nil
sess.State = net.StateAccountName
@@ -137,7 +137,7 @@ func (g *Game) handleNewAccount(sess *net.Session, input string) {
hash, err := player.HashPassword(input)
if err != nil {
- sess.Conn.SetEcho(true)
+ sess.SetEcho(true)
sess.WriteLine(fmt.Sprintf("Error creating account: %v", err))
sess.Account = nil
sess.State = net.StateAccountName
@@ -150,7 +150,7 @@ func (g *Game) handleNewAccount(sess *net.Session, input string) {
// the admin API) may have created it in the meantime; saving without
// this check would overwrite that account's password and characters.
if _, findErr := g.AccountStore.FindAccount(sess.Account.Name); findErr == nil {
- sess.Conn.SetEcho(true)
+ sess.SetEcho(true)
sess.Account = nil
sess.State = net.StateAccountName
sess.WriteLine("That account name was just taken; please sign in instead.")
@@ -163,7 +163,7 @@ func (g *Game) handleNewAccount(sess *net.Session, input string) {
PasswordHash: hash,
}
if err := g.AccountStore.SaveAccount(acc); err != nil {
- sess.Conn.SetEcho(true)
+ sess.SetEcho(true)
sess.WriteLine(fmt.Sprintf("Error creating account: %v", err))
sess.Account = nil
sess.State = net.StateAccountName
@@ -171,7 +171,7 @@ func (g *Game) handleNewAccount(sess *net.Session, input string) {
return
}
- sess.Conn.SetEcho(true)
+ sess.SetEcho(true)
sess.Account = &player.Account{
Name: acc.Name,
PasswordHash: acc.PasswordHash,
diff --git a/internal/game/economy.go b/internal/game/economy.go
index 3d48605..0d5f08f 100644
--- a/internal/game/economy.go
+++ b/internal/game/economy.go
@@ -13,7 +13,14 @@ package game
// state was lost in the crash, so the stake returns to the player.
// - "payout": money movement (winnings, pushes, refunds). The record
// closes when the balance mutation is durable; an open payout at
-// startup is owed money and is re-applied.
+// startup is owed money and is re-applied. Durability is proven by
+// the character file itself, not by balance checkpoints: the same
+// save that includes the payout in the balance records the record's
+// ID in the character's open_payouts evidence list, and the entry is
+// dropped once the settled marker is durable. Checkpoints alone are
+// ambiguous — an unrelated credit change (shop sale, bank deposit)
+// can coincidentally land on the record's CreditsAfter balance, and
+// settling on that coincidence silently erases an owed payout.
//
// A crash at any point therefore leaves either:
// - an open record with the character file at its pre-transaction state
@@ -70,6 +77,13 @@ type economyRecord struct {
// the round resolved, closing the stake without any chip return.
Refund bool `json:"refund,omitempty"`
+ // Tracked marks a payout written under the character-file evidence
+ // scheme: the record's ID is recorded in the character's open_payouts
+ // list by the same save that includes its amount in the balance, and
+ // recovery uses that membership — not balance checkpoints — to decide
+ // whether the payout became durable.
+ Tracked bool `json:"tracked,omitempty"`
+
// Balance checkpoints used to decide recovery compensation.
CreditsBefore int `json:"credits_before"`
CreditsAfter int `json:"credits_after"`
@@ -84,12 +98,17 @@ type economyJournal struct {
// All journal mutations happen on the game owner (or during startup
// recovery before any session connects), so no lock is needed.
stakes map[string][]*economyRecord
+ // openPayouts indexes payout records that are still open (journaled but
+ // not provably durable). It drives evidence pruning: a character-file
+ // ID absent from this set is settled or void and must not be trusted as
+ // durability evidence.
+ openPayouts map[string]bool
}
func newEconomyJournal(dataDir string) *economyJournal {
dir := filepath.Join(dataDir, "economy")
_ = os.MkdirAll(dir, 0700)
- return &economyJournal{path: filepath.Join(dir, "journal.jsonl"), stakes: make(map[string][]*economyRecord)}
+ return &economyJournal{path: filepath.Join(dir, "journal.jsonl"), stakes: make(map[string][]*economyRecord), openPayouts: make(map[string]bool)}
}
func newEconomyRecordID() string {
@@ -153,6 +172,7 @@ func (j *economyJournal) startKind(kind string, refund bool, p *player.Player, c
PlayerID: p.ID,
Kind: kind,
Refund: refund,
+ Tracked: kind == "payout",
Chips: chips,
Credit: credits,
Status: "open",
@@ -164,13 +184,33 @@ func (j *economyJournal) startKind(kind string, refund bool, p *player.Player, c
if err := j.append(*rec); err != nil {
return nil, err
}
+ if rec.Tracked {
+ j.openPayouts[rec.ID] = true
+ }
return rec, nil
}
// settle marks a record settled (the balance mutation is durable).
+// Fire-and-forget: callers whose recovery semantics depend on the marker
+// being durable use settleDurable instead.
func (j *economyJournal) settle(rec *economyRecord) {
rec.Status = "settled"
_ = j.append(*rec)
+ delete(j.openPayouts, rec.ID)
+}
+
+// settleDurable marks a record settled and persists the marker with retries,
+// reporting whether the marker became durable. A false return leaves the
+// record open in the journal: callers keeping file-side evidence for this
+// record must NOT drop that evidence until this returns true.
+func (j *economyJournal) settleDurable(rec *economyRecord) bool {
+ rec.Status = "settled"
+ if err := j.appendRetry(*rec, 3); err != nil {
+ rec.Status = "open"
+ return false
+ }
+ delete(j.openPayouts, rec.ID)
+ return true
}
// void marks a record void (the mutation was reverted in memory and never
@@ -178,6 +218,7 @@ func (j *economyJournal) settle(rec *economyRecord) {
func (j *economyJournal) void(rec *economyRecord) {
rec.Status = "void"
_ = j.append(*rec)
+ delete(j.openPayouts, rec.ID)
}
// registerStake tracks an outstanding wager record for its player. Wager
@@ -257,11 +298,15 @@ func payoutIdentifies(a, b economyRecord) bool {
// recovery compaction.
func (j *economyJournal) reloadStakes() {
j.stakes = make(map[string][]*economyRecord)
+ j.openPayouts = make(map[string]bool)
for _, fr := range j.loadFolded() {
if fr.rec.Kind == "wager" && fr.rec.Status == "open" {
rec := fr.rec
j.registerStake(&rec)
}
+ if fr.rec.Kind == "payout" && fr.rec.Status == "open" {
+ j.openPayouts[fr.rec.ID] = true
+ }
}
}
@@ -399,6 +444,30 @@ func (g *Game) payCreditsDurable(p *player.Player, amount int) error {
return nil
}
+// applyOpenPayout re-applies an open TRACKED payout record during recovery:
+// checked credit increase plus a character save that records the record ID
+// in the file's open_payouts evidence list. A failed save reverts the
+// in-memory mutation (credits and evidence) so the record stays open and a
+// later recovery attempt retries cleanly.
+func (g *Game) applyOpenPayout(p *player.Player, rec *economyRecord) error {
+ if rec.Credit < 0 {
+ return fmt.Errorf("negative payout %d", rec.Credit)
+ }
+ sum, err := addCreditsChecked(p.Credits, rec.Credit)
+ if err != nil {
+ return err
+ }
+ before := p.Credits
+ p.Credits = sum
+ p.OpenPayouts = append(p.OpenPayouts, rec.ID)
+ if err := g.AccountStore.SaveCharacter(p); err != nil {
+ p.Credits = before
+ removeOpenPayout(p, rec.ID)
+ return err
+ }
+ return nil
+}
+
// addCreditsChecked is the game-side checked addition against the economy
// cap (mirrors casino.MaxCredits; kept local to avoid a dependency cycle).
func addCreditsChecked(a, b int) (int, error) {
@@ -412,6 +481,54 @@ func addCreditsChecked(a, b int) (int, error) {
return a + b, nil
}
+// openPayoutRecorded reports whether the character's durable evidence list
+// claims rec's amount is included in the balance.
+func openPayoutRecorded(p *player.Player, id string) bool {
+ for _, existing := range p.OpenPayouts {
+ if existing == id {
+ return true
+ }
+ }
+ return false
+}
+
+// removeOpenPayout drops id from the in-memory evidence list; the on-disk
+// copy is corrected by the next character save.
+func removeOpenPayout(p *player.Player, id string) {
+ kept := p.OpenPayouts[:0]
+ for _, existing := range p.OpenPayouts {
+ if existing != id {
+ kept = append(kept, existing)
+ }
+ }
+ p.OpenPayouts = kept
+}
+
+// pruneOpenPayouts drops evidence IDs that no longer correspond to an open
+// payout record (settled or void). It reports whether anything changed, so
+// callers only pay for a save when the file would actually differ.
+func pruneOpenPayouts(j *economyJournal, p *player.Player) bool {
+ if j == nil || len(p.OpenPayouts) == 0 {
+ return false
+ }
+ if j.openPayouts == nil {
+ j.openPayouts = make(map[string]bool)
+ }
+ kept := p.OpenPayouts[:0]
+ changed := false
+ for _, id := range p.OpenPayouts {
+ if j.openPayouts[id] {
+ kept = append(kept, id)
+ } else {
+ changed = true
+ }
+ }
+ if changed {
+ p.OpenPayouts = kept
+ }
+ return changed
+}
+
// recoverEconomy compensates open records from the last crash. It is called
// once from New, before any session connects.
//
@@ -439,6 +556,19 @@ func (g *Game) recoverEconomy() {
return
}
folded := g.Economy.loadFolded()
+ if g.Economy.openPayouts == nil {
+ g.Economy.openPayouts = make(map[string]bool)
+ } else {
+ for id := range g.Economy.openPayouts {
+ delete(g.Economy.openPayouts, id)
+ }
+ }
+ for i := range folded {
+ rec := &folded[i].rec
+ if rec.Kind == "payout" && rec.Status == "open" {
+ g.Economy.openPayouts[rec.ID] = true
+ }
+ }
// Player records resolve by the STABLE character ID (a rename must
// never strand an owed payout); the name lookup remains for legacy
// records written before IDs existed. Every character file is loaded
@@ -455,6 +585,18 @@ func (g *Game) recoverEconomy() {
}
}
}
+ // finalizeLoaded prunes stale open-payout evidence the first time a
+ // character file is touched: IDs the journal shows as settled or void
+ // cannot prove anything and are dropped (with a durable save) so the
+ // list stays small and meaningful.
+ finalizeLoaded := func(p *player.Player) *player.Player {
+ if pruneOpenPayouts(g.Economy, p) {
+ if err := g.AccountStore.SaveCharacter(p); err != nil {
+ fmt.Fprintf(os.Stderr, "economy recovery: evidence prune save %s: %v\n", p.Name, err)
+ }
+ }
+ return p
+ }
loadPlayer := func(rec *economyRecord) *player.Player {
if rec.PlayerID != "" {
// A record carrying a stable ID resolves to THAT identity only.
@@ -463,7 +605,7 @@ func (g *Game) recoverEconomy() {
// character's payout and the original liability would be
// settled as paid. See TestRecoveryDoesNotPayReusedCharacterName.
if p, ok := playersByID[rec.PlayerID]; ok {
- return p
+ return finalizeLoaded(p)
}
// A character file added since the index was built: load every
// file once and match by stable ID.
@@ -472,7 +614,7 @@ func (g *Game) recoverEconomy() {
if p, err := g.AccountStore.LoadCharacter(name); err == nil && p.ID == rec.PlayerID {
playersByID[p.ID] = p
playersByName[strings.ToLower(p.Name)] = p
- return p
+ return finalizeLoaded(p)
}
}
}
@@ -486,7 +628,7 @@ func (g *Game) recoverEconomy() {
// name is the only identity available.
if rec.Player != "" {
if p, ok := playersByName[strings.ToLower(rec.Player)]; ok {
- return p
+ return finalizeLoaded(p)
}
}
fmt.Fprintf(os.Stderr, "economy recovery: load %s: character not found\n", rec.Player)
@@ -623,6 +765,35 @@ func (g *Game) recoverEconomy() {
if p == nil {
continue
}
+ if rec.Tracked {
+ // Tracked records carry durable evidence in the character file
+ // itself: the same save that includes the amount in the balance
+ // records the record ID in open_payouts. Membership decides.
+ // Balance checkpoints are NEVER consulted for tracked records:
+ // unrelated credit changes (shop sales, bank deposits, other
+ // games) can coincidentally land on CreditsAfter, and settling
+ // on that coincidence silently erases an owed payout.
+ if openPayoutRecorded(p, rec.ID) {
+ // The save landed; the amount is already in the balance.
+ g.Economy.settle(rec)
+ settled[rec.ID] = true
+ continue
+ }
+ if err := g.applyOpenPayout(p, rec); err != nil {
+ // Cap-blocked or transiently failed: the record stays open
+ // (no evidence entry was added) and a later recovery
+ // retries. A liability is never silently dropped.
+ fmt.Fprintf(os.Stderr, "economy recovery: tracked payout %s for %s not re-applied (%v) — kept open for operator inspection\n",
+ rec.ID, rec.Player, err)
+ } else {
+ if g.Economy.settleDurable(rec) {
+ removeOpenPayout(p, rec.ID)
+ }
+ compensated++
+ }
+ settled[rec.ID] = true
+ continue
+ }
sig := payoutSig{rec.key(), rec.CreditsBefore, rec.CreditsAfter, rec.Credit}
group := siblings[sig]
switch {
diff --git a/internal/game/economy_open_payout_list_test.go b/internal/game/economy_open_payout_list_test.go
new file mode 100644
index 0000000..893022f
--- /dev/null
+++ b/internal/game/economy_open_payout_list_test.go
@@ -0,0 +1,120 @@
+package game
+
+// Regressions for the character-file payout-evidence scheme: an owed payout
+// must never be erased because an unrelated credit change coincidentally
+// lands on the record's post-payout balance, and a payout whose save became
+// durable must never be re-applied (double-paid) just because its settled
+// journal marker was lost.
+
+import (
+ "os"
+ "testing"
+
+ "thehouseoficarus/internal/casino"
+ "thehouseoficarus/internal/player"
+)
+
+// newEvidenceHarness builds a minimal Game with just the account store and
+// economy journal — enough for casinoWallet and recoverEconomy.
+func newEvidenceHarness(t *testing.T) (*Game, *player.Player) {
+ t.Helper()
+ dir := t.TempDir()
+ if err := os.MkdirAll(dir+"/players/characters", 0o700); err != nil {
+ t.Fatal(err)
+ }
+ g := &Game{
+ Deps: Deps{AccountStore: player.NewAccountStore(dir)},
+ Economy: newEconomyJournal(dir),
+ }
+ p := player.New("alice")
+ p.ID = "stable-identity"
+ p.Credits = 90
+ if err := g.AccountStore.SaveCharacter(p); err != nil {
+ t.Fatal(err)
+ }
+ return g, p
+}
+
+// TestRecoveryReappliesPayoutWhenUnrelatedCreditsMaskTheBalance: a payout
+// whose save failed leaves an OPEN record at balance 90 (want 110). A later,
+// unrelated 20-credit gain brings the durable balance to 110 — the record's
+// CreditsAfter. Recovery must read the character-file evidence (absent: the
+// save never landed) and re-apply the payout, not settle it as "already
+// durable" and silently erase the 20 owed credits.
+func TestRecoveryReappliesPayoutWhenUnrelatedCreditsMaskTheBalance(t *testing.T) {
+ g, p := newEvidenceHarness(t)
+
+ // Force the payout save to fail without touching its journal.
+ path := g.AccountStore.CharPath(p.Name)
+ backup := path + ".backup"
+ if err := os.Rename(path, backup); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Mkdir(path, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ w := casinoWallet{g: g, p: p}
+ if err := w.PayCredits(20); err != casino.ErrWalletPersistence {
+ t.Fatalf("PayCredits error = %v, want ErrWalletPersistence", err)
+ }
+ if err := os.Remove(path); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Rename(backup, path); err != nil {
+ t.Fatal(err)
+ }
+ if p.Credits != 90 {
+ t.Fatalf("failed payout was paid: credits=%d, want 90", p.Credits)
+ }
+
+ // The unrelated credit change: a shop sale, a bank withdrawal, anything.
+ p.Credits += 20
+ if err := g.AccountStore.SaveCharacter(p); err != nil {
+ t.Fatal(err)
+ }
+
+ g.recoverEconomy()
+
+ loaded, err := g.AccountStore.LoadCharacter(p.Name)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if loaded.Credits != 130 {
+ t.Fatalf("balance after recovery = %d, want 130 (owed payout re-applied once)", loaded.Credits)
+ }
+ if open := g.Economy.loadOpen(); len(open) != 0 {
+ t.Fatalf("records left open after recovery: %+v", open)
+ }
+}
+
+// TestRecoverySettlesDurablePayoutWithoutReapplying: the payout save landed
+// (the character file records the record ID in open_payouts) but the settled
+// journal marker was lost in the crash. Recovery must settle from the file
+// evidence — re-applying would double-pay the win.
+func TestRecoverySettlesDurablePayoutWithoutReapplying(t *testing.T) {
+ g, p := newEvidenceHarness(t)
+
+ rec, err := g.Economy.start("payout", p, 0, 20, 90, 110, 0, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ // The save landed: balance and evidence both durable.
+ p.Credits = 110
+ p.OpenPayouts = append(p.OpenPayouts, rec.ID)
+ if err := g.AccountStore.SaveCharacter(p); err != nil {
+ t.Fatal(err)
+ }
+
+ g.recoverEconomy()
+
+ loaded, err := g.AccountStore.LoadCharacter(p.Name)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if loaded.Credits != 110 {
+ t.Fatalf("balance after recovery = %d, want 110 (durable payout not re-applied)", loaded.Credits)
+ }
+ if open := g.Economy.loadOpen(); len(open) != 0 {
+ t.Fatalf("records left open after recovery: %+v", open)
+ }
+}
diff --git a/internal/game/economy_recovery_regression_test.go b/internal/game/economy_recovery_regression_test.go
index 241b235..f818037 100644
--- a/internal/game/economy_recovery_regression_test.go
+++ b/internal/game/economy_recovery_regression_test.go
@@ -266,9 +266,18 @@ func TestRecoveryPreservesAmbiguousPayout(t *testing.T) {
if err := h.g.AccountStore.SaveCharacter(p); err != nil {
t.Error(err)
}
- if _, err := h.g.Economy.start("payout", p, 0, 20, 100, 120, 0, 0); err != nil {
+ rec, err := h.g.Economy.start("payout", p, 0, 20, 100, 120, 0, 0)
+ if err != nil {
t.Error(err)
+ return
}
+ // A LEGACY record (no character-file evidence, as written before the
+ // open_payouts scheme existed): checkpoint matching is the only
+ // evidence, balance 95 matches neither checkpoint, and the record
+ // stays open for operator inspection. Tracked records decide by
+ // file evidence instead — see economy_open_payout_list_test.go.
+ rec.Tracked = false
+ h.g.Economy.append(*rec)
h.g.recoverEconomy()
})
loaded, err := h.g.AccountStore.LoadCharacter("alice")