diff options
| author | historia <[not public]> | 2026-09-16 14:41:15 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-09-16 14:41:15 -0400 |
| commit | 9192d94dea6be5498ab080f3e2df5667af871d7a (patch) | |
| tree | e7aef3089d5d1e8da686c6b6ca9d4faeacb8b20b /internal/game | |
| parent | 2f34e75f27e5f99cfc1c5f8eaf6ab4ff2fddb1d9 (diff) | |
| download | thehouseoficarus-9192d94dea6be5498ab080f3e2df5667af871d7a.tar.gz | |
fix: casino room eviction, session close race fix, open-payout recovery evidence
Diffstat (limited to 'internal/game')
| -rw-r--r-- | internal/game/casino.go | 14 | ||||
| -rw-r--r-- | internal/game/cmd_room_remove.go | 4 | ||||
| -rw-r--r-- | internal/game/cmd_undig.go | 3 | ||||
| -rw-r--r-- | internal/game/core_login_account.go | 26 | ||||
| -rw-r--r-- | internal/game/economy.go | 181 | ||||
| -rw-r--r-- | internal/game/economy_open_payout_list_test.go | 120 | ||||
| -rw-r--r-- | internal/game/economy_recovery_regression_test.go | 11 |
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") |
