diff options
23 files changed, 917 insertions, 91 deletions
diff --git a/building_guide/concurrency.md b/building_guide/concurrency.md index 984a61d..2fa4558 100644 --- a/building_guide/concurrency.md +++ b/building_guide/concurrency.md @@ -37,6 +37,17 @@ Rules: per-message write deadlines. A full queue disconnects only that client. - Saves run on the owner and serialize a value snapshot; concurrent read-modify-write cycles of the same files cannot happen. +- Password work is the one deliberate exception: Argon2id verification and + hashing (~64 MiB, tens of milliseconds) run on the CALLER's connection + goroutine in `HandleSessionOnOwner` (`StatePassword` and + `StateNewAccount` are intercepted before any owner task is submitted). + A burst of logins therefore costs memory and CPU on connection + goroutines, never a stall of the owner's command/tick queue. The owner + task re-checks the stored hash against the one that was verified so a + password change mid-login cannot authorize a stale attempt, and + `player.hashLimiter` caps concurrent Argon2id computations at 4. + All resulting state transitions (echo, session state, account saves) + still happen on the owner. Unit tests construct Games without an owner; such Games run submitted tasks inline on the caller's goroutine (legacy behavior), which is fine for @@ -126,6 +137,26 @@ an insufficient-funds message). A failed payout keeps its record open (the reported win is never announced as paid) and startup recovery re-pays the owed settlement. +If the JOURNAL ITSELF cannot be appended (persistent write failure, e.g. a +deleted or unwritable `data/economy`), appends are retried and then the +wallet degrades safely: + +- A **wager** is never charged without a journal record — the bet is + rejected with the persistence message (unchanged). +- A **payout** falls back to paying through the character file alone (the + save is the commit point), so the settlement is real, announced, and + durable even with no journal record. `ResolveStakes` then records the + unresolved stake IDs in the character file (`player.Player + .ResolvedStakes`) as durable resolution evidence, and recovery settles + those stakes without refunding them (no double pay, and a settlement's + chip stake is not returned in kind on top of credits that already + returned it). If the character save fails too, no evidence exists and + the open wager record drives the ordinary refund-on-lost-state recovery. +- A journal that cannot be fully READ (oversized or unreadable line) + aborts recovery and compaction entirely: records are preserved verbatim + for operator inspection instead of compensating over a partial fold or + truncating the file. + At startup (`game.New`), open records are compensated in two phases: 1. Open **payout** records (append order, evolving balance): file at the @@ -145,7 +176,10 @@ At startup (`game.New`), open records are compensated in two phases: Currency arithmetic is checked (`internal/casino/credits.go`): explicit `MaxCredits` cap, bounded `DefaultMaxBet` default (the explicit unlimited sentinel is still honored), floor conversion for payout multipliers, and -overflow rejected instead of wrapping. +overflow rejected instead of wrapping. The cap is shared by the wallet: +bank withdrawals are rejected when they would push the on-hand balance +past it, and gameplay credit grants (shop sales, pickups, activity +payouts) clamp at the cap and log rather than wrap. ## Account lifecycle transactions diff --git a/internal/game/act_effects.go b/internal/game/act_effects.go index 4438eb6..cae54a5 100644 --- a/internal/game/act_effects.go +++ b/internal/game/act_effects.go @@ -169,7 +169,7 @@ func (g *Game) applyStep(sess *net.Session, p *player.Player, step *behavior.Ste sess.WriteLine(fmt.Sprintf("You don't have enough credits. (Need %d, have %d)", -step.Credits, p.Credits)) } } else { - p.Credits += step.Credits + g.grantCredits(p, step.Credits) g.saveCharacter(p) } } @@ -226,7 +226,7 @@ func (g *Game) giveTriggerDrop(sess *net.Session, p *player.Player, drop *behavi } if drop.ItemID == "credits" { - p.Credits += qty + g.grantCredits(p, qty) if sess != nil { sess.WriteLine(fmt.Sprintf("You receive %d credits.", qty)) } diff --git a/internal/game/act_steal.go b/internal/game/act_steal.go index f9ca095..32889ce 100644 --- a/internal/game/act_steal.go +++ b/internal/game/act_steal.go @@ -418,7 +418,7 @@ func (g *Game) giveStealLoot(sess *net.Session, p *player.Player, drop *behavior } if drop.ItemID == "credits" { - p.Credits += qty + g.grantCredits(p, qty) sess.WriteLine(g.colorize(sess, "currency_pickup", fmt.Sprintf("You steal %d credits.", qty))) return } diff --git a/internal/game/casino.go b/internal/game/casino.go index 9af9156..f9eb771 100644 --- a/internal/game/casino.go +++ b/internal/game/casino.go @@ -99,7 +99,28 @@ func (w casinoWallet) payCreditsFlagged(amount int, refund bool) error { rec, err := w.g.Economy.startKind("payout", refund, w.p, 0, amount, creditsBefore, creditsAfter, chipsBefore, chipsBefore) if err != nil { - return casino.ErrWalletPersistence + // The journal is unavailable (persistent append failure). Last + // resort: pay through the character file only. The save below is + // the commit point — the money is durable even without a journal + // record, so the settlement may be announced as paid. The stake + // resolution evidence (ResolveStakes) keeps recovery from treating + // the round as unresolved; without a payout record, an open wager + // can no longer be distinguished from a lost one, so the balance + // itself is the only record of what happened. + if capErr != nil { + return capErr + } + w.p.Credits = creditsAfter + if serr := w.g.AccountStore.SaveCharacter(w.p); serr != nil { + w.p.Credits = creditsBefore + return casino.ErrWalletPersistence + } + kind := "settlement" + if refund { + kind = "refund" + } + fmt.Fprintf(os.Stderr, "economy: %s payout of %d credits for %s could not be journaled; the character file is the only durable record\n", kind, amount, w.p.Name) + return nil } if capErr != nil { return capErr @@ -129,9 +150,28 @@ func (w casinoWallet) payCreditsFlagged(amount int, refund bool) error { // ResolveStakes marks every outstanding stake for the player settled: the // round resolved (won, lost, pushed, or forfeited), so startup recovery -// must no longer refund it. +// must no longer refund it. When the journal cannot persist the settled +// markers, the record IDs are recorded in the character file as durable +// resolution evidence (player.Player.ResolvedStakes) so recovery still +// honors the resolution instead of refunding an already-resolved round. func (w casinoWallet) ResolveStakes() { - w.g.Economy.ResolveStakes(economyKey(w.p)) + unresolved := w.g.Economy.ResolveStakes(economyKey(w.p)) + if len(unresolved) == 0 { + return + } + changed := false + for _, rec := range unresolved { + if !resolvedStakeRecorded(w.p, rec.ID) { + w.p.ResolvedStakes = append(w.p.ResolvedStakes, rec.ID) + changed = true + } + } + if !changed { + return + } + if err := w.g.AccountStore.SaveCharacter(w.p); err != nil { + fmt.Fprintf(os.Stderr, "casino resolve stakes: evidence save for %s: %v\n", w.p.Name, err) + } } // RefundOutstanding returns every outstanding stake to the player: chips diff --git a/internal/game/cmd_bank.go b/internal/game/cmd_bank.go index 8da6e89..264bab2 100644 --- a/internal/game/cmd_bank.go +++ b/internal/game/cmd_bank.go @@ -390,6 +390,12 @@ func (g *Game) doBankWithdrawCredits(sess *net.Session, parts []string) { sess.WriteLine("You don't have that many banked credits.") return } + // The withdrawal must respect the shared economy cap: banked credits + // are not part of the on-hand balance, so the sum can exceed it. + if _, capErr := addCreditsChecked(p.Credits, amount); capErr != nil { + sess.WriteLine("Your wallet cannot hold that many credits.") + return + } p.BankedCredits -= amount p.Credits += amount g.saveCharacter(p) diff --git a/internal/game/cmd_get.go b/internal/game/cmd_get.go index f2776a3..bc48394 100644 --- a/internal/game/cmd_get.go +++ b/internal/game/cmd_get.go @@ -276,7 +276,7 @@ func (g *Game) doGetAll(sess *net.Session) { if taken <= 0 { continue } - p.Credits += taken + g.grantCredits(p, taken) if taken == 1 { picked = append(picked, "1 credit") } else { @@ -378,7 +378,7 @@ func (g *Game) pickupCredits(sess *net.Session, p *player.Player, itemID string) if qty <= 0 { return } - p.Credits += qty + g.grantCredits(p, qty) g.saveCharacter(p) if qty == 1 { sess.WriteLine(g.colorize(sess, "currency_pickup", "You pick up 1 credit.")) diff --git a/internal/game/cmd_shop.go b/internal/game/cmd_shop.go index ca1bf53..7299ad3 100644 --- a/internal/game/cmd_shop.go +++ b/internal/game/cmd_shop.go @@ -351,7 +351,7 @@ func (g *Game) doShopSell(sess *net.Session, mob *world.MobInstance, input strin } p.RemoveItem(match.ID, actualQty) - p.Credits += total + g.grantCredits(p, total) g.MobStore.ShopAdd(mob.InstanceID, match.ID, actualQty) g.saveCharacter(p) diff --git a/internal/game/cmd_trigger_utility.go b/internal/game/cmd_trigger_utility.go index b48742a..ca65445 100644 --- a/internal/game/cmd_trigger_utility.go +++ b/internal/game/cmd_trigger_utility.go @@ -65,7 +65,7 @@ func (g *Game) triggerProcessing(sess *net.Session, p *player.Player, mod *ModDe p.SetInvSlot(slot, nil) } - p.Credits += creditValue + g.grantCredits(p, creditValue) g.triggerModReward(sess, p, mod, 1.0) diff --git a/internal/game/core_login_account.go b/internal/game/core_login_account.go index 47421e9..371c287 100644 --- a/internal/game/core_login_account.go +++ b/internal/game/core_login_account.go @@ -63,20 +63,127 @@ func (g *Game) handlePassword(sess *net.Session, input string) { sess.Write("Account name: ") return } + auth := passwordAuth{ok: true, stored: acc.PasswordHash} // Transparent upgrade: legacy single-pass SHA-256 hashes rehash to // Argon2id after a successful verification. A failed save keeps the // legacy hash and retries on the next login. if player.NeedsRehash(acc.PasswordHash) { if hash, hashErr := player.HashPassword(input); hashErr == nil { - acc.PasswordHash = hash - if err := g.AccountStore.SaveAccount(acc); err != nil { - log.Printf("login: password rehash save for %s: %v", acc.Name, err) - } + auth.rehash = hash } else { log.Printf("login: password rehash for %s: %v", acc.Name, hashErr) } } + g.applyPasswordAuth(sess, auth) +} + +// passwordAuth carries the result of the EXPENSIVE password work (Argon2id +// verification and any rehash), which runs on the caller's connection +// goroutine — never on the owner, where a burst of logins would stall +// every command and tick behind password hashing. +type passwordAuth struct { + ok bool + // loadErr marks an account read failure off the owner: the owner task + // renders it distinctly from a wrong password. + loadErr bool + // stored is the hash that was verified; the owner task re-checks the + // file against it so a password change, rename, or delete made during + // verification cannot authorize a stale login. + stored string + // rehash is an upgraded hash to persist after a successful check. + rehash string +} + +// handlePasswordOffOwner verifies the password on the caller's connection +// goroutine and submits one owner task for the (cheap) state transition. +// The production entry point; handlePassword stays for direct HandleSession +// callers (tests) but hashes on the owner there. +func (g *Game) handlePasswordOffOwner(sess *net.Session, input string) { + if sess.Account == nil { + // Defensive: password prompts only follow an account-name step. + g.RunOnOwner(func() { + if g.isShuttingDown() { + return + } + g.handlePassword(sess, input) + }) + return + } + if input == "" || len(input) > 128 { + tooshort := input == "" + g.RunOnOwner(func() { + if g.isShuttingDown() { + return + } + if tooshort { + // Echo stays disabled: the next attempt is also a password. + sess.Write("Password: ") + return + } + sess.SetEcho(true) + sess.WriteLine("Wrong password.") + sess.State = net.StateAccountName + sess.Write("Account name: ") + }) + return + } + auth := passwordAuth{} + acc, err := g.AccountStore.LoadAccount(sess.Account.Name) + switch { + case err != nil: + auth.loadErr = true + case player.CheckPassword(input, acc.PasswordHash): + auth.ok, auth.stored = true, acc.PasswordHash + // Transparent upgrade: legacy single-pass SHA-256 hashes rehash to + // Argon2id after a successful verification. A failed rehash keeps + // the legacy hash and retries on the next login. + if player.NeedsRehash(acc.PasswordHash) { + if hash, hashErr := player.HashPassword(input); hashErr == nil { + auth.rehash = hash + } else { + log.Printf("login: password rehash for %s: %v", acc.Name, hashErr) + } + } + } + g.RunOnOwner(func() { + if g.isShuttingDown() { + return + } + g.applyPasswordAuth(sess, auth) + }) +} + +// applyPasswordAuth completes (or rejects) a password login on the owner. +func (g *Game) applyPasswordAuth(sess *net.Session, auth passwordAuth) { sess.SetEcho(true) + reject := func(message string) { + sess.WriteLine(message) + sess.State = net.StateAccountName + sess.Write("Account name: ") + } + if auth.loadErr { + reject("Error loading account.") + return + } + if !auth.ok { + reject("Wrong password.") + return + } + acc, err := g.AccountStore.LoadAccount(sess.Account.Name) + if err != nil { + reject("Error loading account.") + return + } + if acc.PasswordHash != auth.stored { + // The account changed while the hash was being verified (password + // change, rename, or delete): the verified hash is stale. + reject("Account changed; sign in again.") + return + } + if auth.rehash != "" { + acc.PasswordHash = auth.rehash + g.saveAccount(acc) + } sess.Account = &player.Account{ Name: acc.Name, PasswordHash: acc.PasswordHash, @@ -136,9 +243,38 @@ func (g *Game) handleNewAccount(sess *net.Session, input string) { } hash, err := player.HashPassword(input) - if err != nil { + g.applyNewAccount(sess, hash, err) +} + +// handleNewAccountOffOwner hashes the confirmed password on the caller's +// connection goroutine (Argon2id is expensive) and submits one owner task +// for the existence re-check and save. +func (g *Game) handleNewAccountOffOwner(sess *net.Session, input string) { + input = strings.TrimSpace(input) + if input == "" || input != sess.PendingPass { + g.RunOnOwner(func() { + if g.isShuttingDown() { + return + } + g.handleNewAccount(sess, input) + }) + return + } + hash, hashErr := player.HashPassword(input) + g.RunOnOwner(func() { + if g.isShuttingDown() { + return + } + g.applyNewAccount(sess, hash, hashErr) + }) +} + +// applyNewAccount creates the account on the owner. The password was +// already hashed by the caller (off the owner where possible). +func (g *Game) applyNewAccount(sess *net.Session, hash string, hashErr error) { + if hashErr != nil { sess.SetEcho(true) - sess.WriteLine(fmt.Sprintf("Error creating account: %v", err)) + sess.WriteLine(fmt.Sprintf("Error creating account: %v", hashErr)) sess.Account = nil sess.State = net.StateAccountName sess.Write("Account name: ") diff --git a/internal/game/economy.go b/internal/game/economy.go index 0d5f08f..fc1b289 100644 --- a/internal/game/economy.go +++ b/internal/game/economy.go @@ -181,7 +181,9 @@ func (j *economyJournal) startKind(kind string, refund bool, p *player.Player, c ChipsBefore: chipsBefore, ChipsAfter: chipsAfter, } - if err := j.append(*rec); err != nil { + // Retry a bounded number of times: a transient append failure must not + // drop the record that makes the wager or owed payout durable. + if err := j.appendRetry(*rec, 5); err != nil { return nil, err } if rec.Tracked { @@ -240,7 +242,13 @@ func (j *economyJournal) openStakes(player string) []*economyRecord { // payout evidence, or — if the round was a loss with no settlement // payout — refunds it under the documented refund-on-crash policy. A // stake is only forgotten once its marker is durable. -func (j *economyJournal) ResolveStakes(player string) { +// +// It returns the records whose settled marker could NOT be made durable. +// Callers holding the player should record those IDs as resolved-stake +// evidence (player.Player.ResolvedStakes) so recovery still honors the +// resolution even though the journal could not persist its marker. +func (j *economyJournal) ResolveStakes(player string) []*economyRecord { + var unresolved []*economyRecord remaining := j.stakes[player][:0] for _, rec := range j.stakes[player] { rec.Status = "settled" @@ -249,6 +257,7 @@ func (j *economyJournal) ResolveStakes(player string) { // (or a later attempt) still resolves it. rec.Status = "open" remaining = append(remaining, rec) + unresolved = append(unresolved, rec) } } if len(remaining) == 0 { @@ -256,6 +265,7 @@ func (j *economyJournal) ResolveStakes(player string) { } else { j.stakes[player] = remaining } + return unresolved } // economyKey is the identity a player's journal records are indexed by: @@ -275,6 +285,41 @@ func (rec *economyRecord) key() string { return rec.Player } +// resolvedStakeRecorded reports whether the character's durable evidence +// list claims rec's stake was resolved by the session (its settled marker +// could not be journaled; see player.Player.ResolvedStakes). +func resolvedStakeRecorded(p *player.Player, id string) bool { + for _, existing := range p.ResolvedStakes { + if existing == id { + return true + } + } + return false +} + +// pruneResolvedStakes drops resolved-stake evidence IDs that no longer +// correspond to an open wager record (settled or void). It reports whether +// anything changed, so callers only pay for a save when the file would +// actually differ. +func pruneResolvedStakes(openStakeIDs map[string]bool, p *player.Player) bool { + if len(p.ResolvedStakes) == 0 { + return false + } + kept := p.ResolvedStakes[:0] + changed := false + for _, id := range p.ResolvedStakes { + if openStakeIDs[id] { + kept = append(kept, id) + } else { + changed = true + } + } + if changed { + p.ResolvedStakes = kept + } + return changed +} + // payoutIdentifies reports whether two journal records belong to the same // character identity. ID-carrying records match only on the stable ID: a // different character that reused the display name must never supply (or @@ -295,11 +340,16 @@ func payoutIdentifies(a, b economyRecord) bool { // reloadStakes rebuilds the outstanding-stake index from the journal file: // open wager records remain tracked (and refundable/resolvable) after -// recovery compaction. -func (j *economyJournal) reloadStakes() { +// recovery compaction. A read failure leaves the previous indexes intact +// (never a wiped index over a live journal); the caller logs loudly. +func (j *economyJournal) reloadStakes() error { + folded, err := j.loadFolded() + if err != nil { + return err + } j.stakes = make(map[string][]*economyRecord) j.openPayouts = make(map[string]bool) - for _, fr := range j.loadFolded() { + for _, fr := range folded { if fr.rec.Kind == "wager" && fr.rec.Status == "open" { rec := fr.rec j.registerStake(&rec) @@ -308,18 +358,23 @@ func (j *economyJournal) reloadStakes() { j.openPayouts[fr.rec.ID] = true } } + return nil } // loadOpen folds the journal and returns the still-open records. -func (j *economyJournal) loadOpen() []*economyRecord { +func (j *economyJournal) loadOpen() ([]*economyRecord, error) { var open []*economyRecord - for _, fr := range j.loadFolded() { + folded, err := j.loadFolded() + if err != nil { + return nil, err + } + for _, fr := range folded { if fr.rec.Status == "open" { rec := fr.rec open = append(open, &rec) } } - return open + return open, nil } // foldedRecord is the latest state of one record ID together with the @@ -330,11 +385,15 @@ type foldedRecord struct { } // loadFolded folds the journal (latest line per record ID wins) and -// returns the surviving records in journal append order. -func (j *economyJournal) loadFolded() []foldedRecord { +// returns the surviving records in journal append order. A read failure +// (oversized or unreadable region) is an ERROR, never a silent truncation: +// callers must not compact or compensate over a partially-read journal, +// because compaction would permanently erase every record beyond the +// unreadable point — including owed payouts and outstanding stakes. +func (j *economyJournal) loadFolded() ([]foldedRecord, error) { f, err := os.Open(j.path) if err != nil { - return nil + return nil, nil // no journal yet: nothing to fold } defer f.Close() latest := map[string]*foldedRecord{} @@ -362,16 +421,29 @@ func (j *economyJournal) loadFolded() []foldedRecord { order = append(order, fr) } } + if err := scanner.Err(); err != nil { + // A scanner error means the fold is INCOMPLETE (a line exceeded the + // buffer or the file became unreadable mid-scan). Returning a + // partial fold would let compaction erase valid records; surface + // the failure instead. + return nil, fmt.Errorf("scan economy journal: %w", err) + } folded := make([]foldedRecord, 0, len(order)) for _, fr := range order { folded = append(folded, *fr) } - return folded + return folded, nil } -// compact rewrites the journal keeping only still-open records. +// compact rewrites the journal keeping only still-open records. A fold +// failure aborts the rewrite: compacting over a journal that could not be +// fully read would silently erase every record beyond the unreadable point. func (j *economyJournal) compact() { - open := j.loadOpen() + open, err := j.loadOpen() + if err != nil { + fmt.Fprintf(os.Stderr, "economy compaction aborted: %v (journal preserved for inspection)\n", err) + return + } tmp := j.path + ".compact" f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) if err != nil { @@ -468,6 +540,27 @@ func (g *Game) applyOpenPayout(p *player.Player, rec *economyRecord) error { return nil } +// grantCredits adds credits from ordinary gameplay (shop sales, credit +// pickups, activity payouts). The economy cap is enforced by clamping: the +// cap is far above any reachable balance, so hitting it means a data bug — +// clamp (never wrap) and log loudly for the operator. +func (g *Game) grantCredits(p *player.Player, amount int) { + if amount <= 0 { + return + } + sum, err := addCreditsChecked(p.Credits, amount) + if err != nil { + fmt.Fprintf(os.Stderr, "credit grant for %s clamped at the economy cap: %v\n", p.Name, err) + p.Credits = maxCreditsValue + return + } + p.Credits = sum +} + +// maxCreditsValue mirrors casino.MaxCredits; kept local to avoid a +// dependency cycle (see addCreditsChecked). +const maxCreditsValue = 1_000_000_000_000 + // 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) { @@ -555,7 +648,14 @@ func (g *Game) recoverEconomy() { if g.Economy == nil { return } - folded := g.Economy.loadFolded() + folded, err := g.Economy.loadFolded() + if err != nil { + // The journal could not be fully read. Compensation over a partial + // fold could double-pay or erase liabilities, and compaction would + // truncate the file. Preserve everything for operator inspection. + fmt.Fprintf(os.Stderr, "economy recovery aborted: %v (journal and character files preserved for inspection)\n", err) + return + } if g.Economy.openPayouts == nil { g.Economy.openPayouts = make(map[string]bool) } else { @@ -585,12 +685,21 @@ 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 prunes stale open-payout evidence and resolved-stake + // 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 lists stay small and meaningful. + openStakeIDs := make(map[string]bool) + for i := range folded { + rec := &folded[i].rec + if rec.Kind == "wager" && rec.Status == "open" { + openStakeIDs[rec.ID] = true + } + } finalizeLoaded := func(p *player.Player) *player.Player { - if pruneOpenPayouts(g.Economy, p) { + prunedPayouts := pruneOpenPayouts(g.Economy, p) + prunedStakes := pruneResolvedStakes(openStakeIDs, p) + if prunedPayouts || prunedStakes { if err := g.AccountStore.SaveCharacter(p); err != nil { fmt.Fprintf(os.Stderr, "economy recovery: evidence prune save %s: %v\n", p.Name, err) } @@ -830,11 +939,23 @@ func (g *Game) recoverEconomy() { } for k := len(openWagers) - 1; k >= 0; k-- { rec := &folded[openWagers[k]].rec - resolvedByPayout, resolvedBySettlement := laterPayoutEvidence(rec, folded[openWagers[k]].seq) p := loadPlayer(rec) if p == nil { continue } + // File-side resolution evidence: the session resolved this stake + // but its settled marker could not be journaled (e.g. a broken + // economy path), so the character file carries the resolution ID. + // The round resolved — the stake must not be refunded, and its chip + // part must not return in kind on top of a settlement that already + // returned it as credits (a refund, by contrast, already gave the + // chips back in the file). + if resolvedStakeRecorded(p, rec.ID) { + g.Economy.settle(rec) + compensated++ + continue + } + resolvedByPayout, resolvedBySettlement := laterPayoutEvidence(rec, folded[openWagers[k]].seq) // Checkpoint comparisons use the DURABLE state, captured before any // in-memory chip restoration below. creditsNow, chipsNow := p.Credits, countChips(p) @@ -887,8 +1008,19 @@ func (g *Game) recoverEconomy() { // The debit is durable but the round was lost in the crash: // refund the stake (approved refund-on-recovery policy). The // chips (if owed) are already restored in memory; one durable - // save covers both the chip return and the credit refund. - p.Credits += rec.Credit + // save covers both the chip return and the credit refund. The + // credit refund is checked: a record can never push the + // balance past the economy cap. + sum, capErr := addCreditsChecked(creditsNow, rec.Credit) + if capErr != nil { + if chipsOwed { + p.RemoveItem("chips", rec.Chips) + } + fmt.Fprintf(os.Stderr, "economy recovery: refund %s for %s blocked (%v) — kept open for operator inspection\n", + rec.ID, rec.Player, capErr) + continue + } + p.Credits = sum if err := g.AccountStore.SaveCharacter(p); err != nil { // Revert the in-memory refund so later records for this // player still compare against the durable balance; the @@ -918,7 +1050,40 @@ func (g *Game) recoverEconomy() { } } g.Economy.compact() + // Final evidence prune: the phases above settled or voided records, so + // evidence IDs that no longer correspond to an open record are stale + // (a settled stake's resolution evidence, an applied payout's entry). + // Drop them now so the character files stay small and meaningful; a + // failed save only defers the prune to the next startup (finalizeLoaded). + stillOpen := make(map[string]bool) + for i := range folded { + rec := &folded[i].rec + if rec.Kind == "wager" && rec.Status == "open" { + stillOpen[rec.ID] = true + } + } + pruneSeen := make(map[*player.Player]bool) + prunePlayer := func(p *player.Player) { + if p == nil || pruneSeen[p] { + return + } + pruneSeen[p] = true + if pruneOpenPayouts(g.Economy, p) || pruneResolvedStakes(stillOpen, p) { + if err := g.AccountStore.SaveCharacter(p); err != nil { + fmt.Fprintf(os.Stderr, "economy recovery: final evidence prune save %s: %v\n", p.Name, err) + } + } + } + for _, p := range playersByID { + prunePlayer(p) + } + for _, p := range playersByName { + prunePlayer(p) + } // Rebuild the outstanding-stake index from whatever open wager records - // remain (normally none; ambiguous ones stay refundable/resolvable). - g.Economy.reloadStakes() + // remain (normally none; ambiguous ones stay refundable/resolvable). A + // fold failure leaves the previous index intact and was already logged. + if err := g.Economy.reloadStakes(); err != nil { + fmt.Fprintf(os.Stderr, "economy recovery: stake index rebuild failed: %v\n", err) + } } diff --git a/internal/game/economy_journal_test_helpers_test.go b/internal/game/economy_journal_test_helpers_test.go new file mode 100644 index 0000000..e65c622 --- /dev/null +++ b/internal/game/economy_journal_test_helpers_test.go @@ -0,0 +1,14 @@ +package game + +import "testing" + +// mustLoadOpen is the test-side loadOpen: a journal fold failure is a test +// failure, not a nil slice to accidentally treat as "nothing open". +func (j *economyJournal) mustLoadOpen(t *testing.T) []*economyRecord { + t.Helper() + open, err := j.loadOpen() + if err != nil { + t.Fatalf("loadOpen: %v", err) + } + return open +} diff --git a/internal/game/economy_open_payout_list_test.go b/internal/game/economy_open_payout_list_test.go index 893022f..a16f73b 100644 --- a/internal/game/economy_open_payout_list_test.go +++ b/internal/game/economy_open_payout_list_test.go @@ -82,7 +82,7 @@ func TestRecoveryReappliesPayoutWhenUnrelatedCreditsMaskTheBalance(t *testing.T) 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 { + if open := g.Economy.mustLoadOpen(t); len(open) != 0 { t.Fatalf("records left open after recovery: %+v", open) } } @@ -114,7 +114,7 @@ func TestRecoverySettlesDurablePayoutWithoutReapplying(t *testing.T) { 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 { + if open := g.Economy.mustLoadOpen(t); len(open) != 0 { t.Fatalf("records left open after recovery: %+v", open) } } diff --git a/internal/game/economy_recovery_identity_test.go b/internal/game/economy_recovery_identity_test.go index c2d3d0a..04d6fed 100644 --- a/internal/game/economy_recovery_identity_test.go +++ b/internal/game/economy_recovery_identity_test.go @@ -51,7 +51,7 @@ func TestRecoveryDoesNotPayReusedCharacterName(t *testing.T) { if loaded.Credits != 90 { t.Errorf("replacement identity received someone else's payout: credits=%d, want 90", loaded.Credits) } - if open := h.g.Economy.loadOpen(); len(open) != 1 { + if open := h.g.Economy.mustLoadOpen(t); len(open) != 1 { t.Errorf("original identity's unresolved liability was discarded: open records=%d", len(open)) } } @@ -123,6 +123,6 @@ func TestEconomyRecoveryRunsAfterLifecycleRollback(t *testing.T) { } if loaded.Credits != 110 { t.Errorf("lifecycle rollback erased recovered payout: credits=%d, want 110; open liabilities=%d", - loaded.Credits, len(restarted.g.Economy.loadOpen())) + loaded.Credits, len(restarted.g.Economy.mustLoadOpen(t))) } } diff --git a/internal/game/economy_recovery_regression_test.go b/internal/game/economy_recovery_regression_test.go index f9d3b37..207e01d 100644 --- a/internal/game/economy_recovery_regression_test.go +++ b/internal/game/economy_recovery_regression_test.go @@ -104,7 +104,7 @@ func TestRecoveryReappliesLostPayoutWithEvidence(t *testing.T) { if loaded.Credits != 110 { t.Fatalf("balance after recovery = %d, want 110 (payout re-applied once, no double stake refund)", loaded.Credits) } - if open := h.g.Economy.loadOpen(); len(open) != 0 { + if open := h.g.Economy.mustLoadOpen(t); len(open) != 0 { t.Fatalf("records left open after recovery: %+v", open) } } @@ -166,7 +166,7 @@ func TestRecoveryReappliesIdenticalFailedPayouts(t *testing.T) { if loaded.Credits != 130 { t.Fatalf("balance after recovery = %d, want 130 (both identical failed payouts re-applied)", loaded.Credits) } - if open := h.g.Economy.loadOpen(); len(open) != 0 { + if open := h.g.Economy.mustLoadOpen(t); len(open) != 0 { t.Fatalf("records left open after recovery: %+v", open) } } @@ -247,7 +247,7 @@ func TestRecoveryKeepsWagerOpenWhenChipsDoNotFit(t *testing.T) { if got := countChips(loaded); got != 0 { t.Fatalf("chips after recovery = %d, want 0 (no inventory room; nothing delivered)", got) } - if open := h.g.Economy.loadOpen(); len(open) != 1 { + if open := h.g.Economy.mustLoadOpen(t); len(open) != 1 { t.Fatalf("chip wager must stay open for operator inspection, open records: %+v", open) } } @@ -287,7 +287,7 @@ func TestRecoveryPreservesAmbiguousPayout(t *testing.T) { if loaded.Credits != 95 { t.Fatalf("ambiguous recovery mutated the balance: %d, want 95", loaded.Credits) } - if open := h.g.Economy.loadOpen(); len(open) != 1 { + if open := h.g.Economy.mustLoadOpen(t); len(open) != 1 { t.Fatalf("ambiguous payout must stay open for operator inspection: %+v", open) } } @@ -321,7 +321,7 @@ func TestRecoveryKeepsUnpaidSiblingsOpenAtCreditCap(t *testing.T) { if loaded.Credits != casino.MaxCredits-10 { t.Fatalf("balance after capped recovery = %d, want %d (one payout applied)", loaded.Credits, casino.MaxCredits-10) } - if open := h.g.Economy.loadOpen(); len(open) != 1 { + if open := h.g.Economy.mustLoadOpen(t); len(open) != 1 { t.Fatalf("unpaid liability must stay open for operator inspection, open records: %+v", open) } } @@ -364,9 +364,9 @@ func TestRecoveryRestoresChipsFromMixedRefundCrash(t *testing.T) { } if loaded.Credits != 100 || countChips(loaded) != 10 { t.Fatalf("mixed refund after recovery = (%d credits, %d chips), want (100, 10); open records: %+v", - loaded.Credits, countChips(loaded), h.g.Economy.loadOpen()) + loaded.Credits, countChips(loaded), h.g.Economy.mustLoadOpen(t)) } - if open := h.g.Economy.loadOpen(); len(open) != 0 { + if open := h.g.Economy.mustLoadOpen(t); len(open) != 0 { t.Fatalf("nothing may stay owed after recovery: %+v", open) } } @@ -429,13 +429,13 @@ func TestRecoveryUnpaidLiabilitySurvivesLaterRecoveries(t *testing.T) { } } h.g.recoverEconomy() - if len(h.g.Economy.loadOpen()) != 1 { - t.Errorf("setup: expected one unpaid record after capped recovery, got %d", len(h.g.Economy.loadOpen())) + if len(h.g.Economy.mustLoadOpen(t)) != 1 { + t.Errorf("setup: expected one unpaid record after capped recovery, got %d", len(h.g.Economy.mustLoadOpen(t))) return } // A later startup over the same files: the liability stays owed. h.g.recoverEconomy() - if got := len(h.g.Economy.loadOpen()); got != 1 { + if got := len(h.g.Economy.mustLoadOpen(t)); got != 1 { t.Errorf("second recovery erased the unpaid 20-credit liability: open=%d, want 1", got) } }) @@ -481,8 +481,8 @@ func TestCapBlockedPayoutIsJournaled(t *testing.T) { // Recovery: the owed win cannot be applied (cap), so the liability // stays open for operator inspection, never erased; the consumed // stake is settled against the payout evidence. - if got := len(restarted.g.Economy.loadOpen()); got != 1 { - t.Fatalf("owed cap-blocked payout must stay open after recovery, open records: %+v", restarted.g.Economy.loadOpen()) + if got := len(restarted.g.Economy.mustLoadOpen(t)); got != 1 { + t.Fatalf("owed cap-blocked payout must stay open after recovery, open records: %+v", restarted.g.Economy.mustLoadOpen(t)) } if loaded.Credits != casino.MaxCredits-10 { t.Fatalf("recovery mutated a capped balance: %d", loaded.Credits) @@ -523,7 +523,7 @@ func TestRecoveryReappliesPartiallyPaidIdenticalPayouts(t *testing.T) { t.Error(err) return } - first := h.g.Economy.loadOpen()[0] + first := h.g.Economy.mustLoadOpen(t)[0] if first.ID == second.ID { t.Fatal("setup: expected two distinct records") } @@ -539,7 +539,7 @@ func TestRecoveryReappliesPartiallyPaidIdenticalPayouts(t *testing.T) { if loaded.Credits != 130 { t.Errorf("recovered balance = %d, want 130 (remaining owed payout re-applied)", loaded.Credits) } - if open := h.g.Economy.loadOpen(); len(open) != 0 { + if open := h.g.Economy.mustLoadOpen(t); len(open) != 0 { t.Errorf("records left open after recovery: %+v", open) } }) diff --git a/internal/game/economy_shutdown_test.go b/internal/game/economy_shutdown_test.go index efdd5fd..27190fb 100644 --- a/internal/game/economy_shutdown_test.go +++ b/internal/game/economy_shutdown_test.go @@ -146,7 +146,7 @@ func TestEconomyFailedSaveRevertsAndVoids(t *testing.T) { } // The record is void, not open: recovery must not compensate. os.Chmod(charDir, 0700) - if open := h.g.Economy.loadOpen(); len(open) != 0 { + if open := h.g.Economy.mustLoadOpen(t); len(open) != 0 { t.Fatalf("void record remained open: %v", open) } } @@ -182,7 +182,7 @@ func TestEconomyRecoveryRefundsDurableLostWager(t *testing.T) { if loaded.Credits != 100 { t.Fatalf("credits after recovery = %d, want 100 (refund)", loaded.Credits) } - if open := h.g.Economy.loadOpen(); len(open) != 0 { + if open := h.g.Economy.mustLoadOpen(t); len(open) != 0 { t.Fatalf("recovered record left open: %v", open) } } @@ -217,7 +217,7 @@ func TestEconomyRecoveryAppliesLostPayout(t *testing.T) { if loaded.Credits != 75 { t.Fatalf("credits after recovery = %d, want 75 (payout re-applied)", loaded.Credits) } - if open := h.g.Economy.loadOpen(); len(open) != 0 { + if open := h.g.Economy.mustLoadOpen(t); len(open) != 0 { t.Fatalf("recovered record left open: %v", open) } } diff --git a/internal/game/game.go b/internal/game/game.go index 096e070..7255042 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -291,6 +291,20 @@ func (g *Game) HandleSessionOnOwner(sess *net.Session, input string) { if g.isShuttingDown() { return } + // Password work is expensive (Argon2id, ~64 MiB and tens of + // milliseconds per call) and must never run on the owner goroutine: + // the owner serializes every command and tick, so a burst of logins + // would stall the whole world. Verify/hash on the caller's connection + // goroutine, then submit one owner task for the (cheap) state + // transition. + switch sess.State { + case net.StatePassword: + g.handlePasswordOffOwner(sess, input) + return + case net.StateNewAccount: + g.handleNewAccountOffOwner(sess, input) + return + } g.RunOnOwner(func() { // Re-checked at task execution: the flag may flip between // submission and execution, and RunOnOwner must never fall back diff --git a/internal/game/hacking/wumpus.go b/internal/game/hacking/wumpus.go index 1f0a434..1d4b371 100644 --- a/internal/game/hacking/wumpus.go +++ b/internal/game/hacking/wumpus.go @@ -200,9 +200,12 @@ func (w *WumpusGame) checkRoom() (string, bool, bool) { if cur == i { sb.WriteString("ICE detected! You're relocated to a random node...\n") w.player = rand.Intn(20) - chained, _, _ := w.checkRoom() + // The chained check can terminate the game (landing on the + // rogue AI or a data trap): propagate its result instead of + // discarding the terminal state. + chained, done, won := w.checkRoom() sb.WriteString(chained) - return sb.String(), false, false + return sb.String(), done, won } } sb.WriteString(w.roomDesc()) diff --git a/internal/game/hacking/wumpus_regression_test.go b/internal/game/hacking/wumpus_regression_test.go new file mode 100644 index 0000000..b27a7f9 --- /dev/null +++ b/internal/game/hacking/wumpus_regression_test.go @@ -0,0 +1,20 @@ +package hacking + +import "testing" + +// A teleport into a terminal hazard (rogue AI or data trap) must end the +// hacking session: the chained room check's terminal result is propagated +// instead of discarded. +func TestICETeleportTerminalResultIsPropagated(t *testing.T) { + for i := 0; i < 2000; i++ { + w := &WumpusGame{player: 0, wumpus: 19, pits: [2]int{2, 3}, ice: [2]int{1, 4}, probes: 5} + text, done, won := w.HandleInput("move 2") + if w.gameOver { + if !done || won != w.won { + t.Fatalf("terminal state not propagated: done=%v won=%v internal gameOver=%v; output=%s", done, won, w.gameOver, text) + } + return + } + } + t.Fatal("probe never encountered a terminal teleport") +} diff --git a/internal/game/journal_failure_regression_test.go b/internal/game/journal_failure_regression_test.go new file mode 100644 index 0000000..683b996 --- /dev/null +++ b/internal/game/journal_failure_regression_test.go @@ -0,0 +1,228 @@ +package game + +// Journal-failure regressions: a broken economy journal (persistent append +// failure) must never lose money silently. Settlements pay through the +// character file, stake resolutions are recorded as file evidence, and an +// unreadable journal aborts recovery/compaction instead of erasing records. + +import ( + "os" + "strings" + "testing" + "time" + + "thehouseoficarus/internal/casino" + "thehouseoficarus/internal/player" +) + +// failJournalDuring redirects the economy journal path for the duration of +// fn, simulating a persistent append failure, and restores it afterwards. +func failJournalDuring(h *harness, fn func()) { + path := h.g.Economy.path + h.g.Economy.path = h.dir + defer func() { h.g.Economy.path = path }() + fn() +} + +// TestSettlementFallbackKeepsWholeBalance: a full table round whose payout +// journal append fails still ends with the player whole — the settlement is +// paid through the character file, the stake resolution is recorded as +// evidence, and recovery leaves the balance exact. +func TestSettlementFallbackKeepsWholeBalance(t *testing.T) { + h := newHarness(t) + h.seedAccount("alice") + p := h.seedCharacter("alice", "alice") + h.g.RunOnOwner(func() { + p.Credits = 1000 + h.g.Casino.Join(1, casino.TableConfig{ID: "b", Game: casino.GameBaccarat}, "alice") + var totalPaid int + failed := false + w := casinoWallet{g: h.g, p: p} + // Wrap the wallet so exactly one settlement hits a broken journal. + wallet := &fallbackProbeWallet{harness: h, inner: w, onPay: func(credits int, failedCall bool) { + totalPaid += credits + if failedCall { + failed = true + } + }} + rounds := 0 + for rounds < 100 && !failed { + rounds++ + h.g.Casino.Bet(1, "b", "alice", "player", 10, wallet) + } + if !failed { + t.Fatal("probe never observed a settlement payout") + return + } + // One more full round after the failure: the table keeps working. + rounds++ + h.g.Casino.Bet(1, "b", "alice", "player", 10, wallet) + h.g.recoverEconomy() + loaded, err := h.g.AccountStore.LoadCharacter("alice") + if err != nil { + t.Error(err) + return + } + want := 1000 - 10*rounds + totalPaid + if loaded.Credits != want { + t.Errorf("balance after journal-failure round: credits=%d, want %d (wagers %d, paid %d)", + loaded.Credits, want, 10*rounds, totalPaid) + } + if open := h.g.Economy.mustLoadOpen(t); len(open) != 0 { + t.Errorf("records left open: %+v", open) + } + }) +} + +// fallbackProbeWallet routes PayCredits through the real wallet once with a +// broken journal (the first settlement), then normally. +type fallbackProbeWallet struct { + harness *harness + inner casinoWallet + onPay func(credits int, failedCall bool) + broke bool +} + +func (w *fallbackProbeWallet) Wager(amount int) (casino.Wager, error) { return w.inner.Wager(amount) } + +func (w *fallbackProbeWallet) PayCredits(amount int) error { + if w.broke { + w.onPay(amount, false) + return w.inner.PayCredits(amount) + } + w.broke = true + w.onPay(amount, true) + failJournalDuring(w.harness, func() { + _ = w.inner.PayCredits(amount) + }) + return nil +} + +func (w *fallbackProbeWallet) ResolveStakes() { w.inner.ResolveStakes() } +func (w *fallbackProbeWallet) RefundOutstanding() (int, int, error) { + return w.inner.RefundOutstanding() +} + +// TestChipStakeSettlementFallbackNoDoublePay: a chip-funded stake whose win +// is paid through the character-file fallback must not ALSO get its chips +// returned in kind by recovery — the settlement returned the stake as +// credits inside the payout. +func TestChipStakeSettlementFallbackNoDoublePay(t *testing.T) { + h := newHarness(t) + h.seedAccount("alice") + p := h.seedCharacter("alice", "alice") + h.g.RunOnOwner(func() { + p.Credits = 100 + p.SetInvSlot(0, &player.InventorySlot{ItemID: "chips", Quantity: 10}) + w := casinoWallet{g: h.g, p: p} + if _, err := w.Wager(10); err != nil { + t.Error(err) + return + } + // The journal is broken for BOTH the settlement and the resolution: + // the win lands in the character file, the resolution in evidence. + failJournalDuring(h, func() { + if err := w.PayCredits(20); err != nil { + t.Errorf("fallback settlement failed: %v", err) + return + } + w.ResolveStakes() + }) + h.g.recoverEconomy() + loaded, err := h.g.AccountStore.LoadCharacter("alice") + if err != nil { + t.Error(err) + return + } + if loaded.Credits != 120 || countChips(loaded) != 0 { + t.Errorf("after recovery: %d credits / %d chips, want 120 / 0 (chip stake debited, win of 20 paid once in credits; no in-kind chip return on top)", + loaded.Credits, countChips(loaded)) + } + if open := h.g.Economy.mustLoadOpen(t); len(open) != 0 { + t.Errorf("open records: %+v", open) + } + }) +} + +// TestCorruptJournalAbortsRecoveryWithoutErasure: an unreadable journal +// region must abort compensation and compaction — the owed payout record +// beyond the corruption stays in the file for operator inspection instead +// of being compacted away. +func TestCorruptJournalAbortsRecoveryWithoutErasure(t *testing.T) { + h := newHarness(t) + h.seedAccount("alice") + p := h.seedCharacter("alice", "alice") + h.g.RunOnOwner(func() { + p.Credits = 100 + if err := h.g.AccountStore.SaveCharacter(p); err != nil { + t.Error(err) + return + } + // Corrupt the journal with an oversized line, then journal an owed + // payout BEYOND it (as appends would after the corruption). + f, err := os.OpenFile(h.g.Economy.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600) + if err != nil { + t.Error(err) + return + } + _, werr := f.WriteString(strings.Repeat("x", 1024*1024+1) + "\n") + f.Close() + if werr != nil { + t.Error(werr) + return + } + if _, err := h.g.Economy.start("payout", p, 0, 20, 100, 120, 0, 0); err != nil { + t.Errorf("payout could not be journaled: %v", err) + return + } + h.g.recoverEconomy() + // The owed payout record is preserved verbatim: no compensation ran + // (credits unchanged) and no compaction erased the record. + loaded, err := h.g.AccountStore.LoadCharacter("alice") + if err != nil { + t.Error(err) + return + } + data, err := os.ReadFile(h.g.Economy.path) + if err != nil { + t.Fatal(err) + } + if loaded.Credits != 100 { + t.Errorf("aborted recovery paid the payout: credits=%d, want 100 (preserved for inspection)", loaded.Credits) + } + if !strings.Contains(string(data), `"kind":"payout"`) { + t.Error("corrupt journal recovery erased the owed payout record instead of preserving it") + } + }) +} + +// TestBankWithdrawalHonorsCreditCap: a withdrawal that would push the +// on-hand balance past the shared economy cap is rejected; neither balance +// changes. +func TestBankWithdrawalHonorsCreditCap(t *testing.T) { + h := newHarness(t) + h.seedAccount("alice") + p := h.seedCharacter("alice", "alice") + p.Credits = casino.MaxCredits + p.BankedCredits = 10 + if err := h.g.AccountStore.SaveCharacter(p); err != nil { + t.Fatal(err) + } + sess, conn := h.connect("alice") + h.g.RunOnOwner(func() { + h.g.doBankWithdrawCredits(sess, []string{"withdraw", "credits", "10"}) + }) + if sess.Player.Credits != casino.MaxCredits || sess.Player.BankedCredits != 10 { + t.Errorf("rejected withdrawal changed balances: credits=%d banked=%d", + sess.Player.Credits, sess.Player.BankedCredits) + } + // The session writer goroutine flushes asynchronously; wait for the + // rejection to arrive instead of racing it. + deadline := time.Now().Add(2 * time.Second) + for !conn.Contains("cannot hold") && time.Now().Before(deadline) { + time.Sleep(time.Millisecond) + } + if !conn.Contains("cannot hold") { + t.Errorf("withdrawal rejection not reported; output=%s", conn.Bytes()) + } +} diff --git a/internal/game/login_offowner_regression_test.go b/internal/game/login_offowner_regression_test.go new file mode 100644 index 0000000..327de7e --- /dev/null +++ b/internal/game/login_offowner_regression_test.go @@ -0,0 +1,122 @@ +package game + +// Login regressions for the off-owner password path: the production entry +// point verifies passwords and hashes new-account passwords on the +// connection goroutine (never the owner), then completes the state +// transition on the owner. The observable login behavior is unchanged. + +import ( + "crypto/sha256" + "encoding/base64" + "strings" + "testing" + "time" + + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" +) + +// waitForOutput polls the queued session writer until substr appears or the +// deadline passes. +func waitForOutput(t *testing.T, conn *chanConn, substr string) { + t.Helper() + deadline := time.Now().Add(3 * time.Second) + for !conn.Contains(substr) && time.Now().Before(deadline) { + time.Sleep(time.Millisecond) + } + if !conn.Contains(substr) { + t.Fatalf("output %q never arrived; got:\n%s", substr, conn.Bytes()) + } +} + +// TestLoginThroughServerEntryUsesOffOwnerPathAndUpgradesLegacyHash drives +// the real server entry point (HandleSessionOnOwner) through: legacy-hash +// account creation, a WRONG password attempt, and a correct login that +// rehashes the legacy SHA-256 hash to Argon2id durably. +func TestLoginThroughServerEntryUsesOffOwnerPathAndUpgradesLegacyHash(t *testing.T) { + h := newHarness(t) + h.seedAccount("alice") + h.seedCharacter("alice", "alice") + acc, err := h.g.AccountStore.LoadAccount("alice") + if err != nil { + t.Fatal(err) + } + // Downgrade to a legacy salted SHA-256 hash so the login exercises the + // transparent rehash. + salt := []byte("0123456789abcdef") + sum := sha256.Sum256(append(append([]byte{}, salt...), []byte("pw")...)) + acc.PasswordHash = "sha256:" + base64.RawStdEncoding.EncodeToString(salt) + ":" + strings.ToLower(sha256HashHex(sum)) + if err := h.g.AccountStore.SaveAccount(acc); err != nil { + t.Fatal(err) + } + + conn := newChanConn() + sess := &net.Session{Conn: conn, State: net.StateAccountName} + sess.StartWriter() + lines := make(chan string, 8) + go h.input(sess, lines) + + lines <- "alice" // existing account → password prompt (echo off) + lines <- "wrongpw" // rejected, echo restored, back to account name + waitForOutput(t, conn, "Wrong password.") + lines <- "alice" // account name again + lines <- "pw" // correct password through the off-owner path + waitForOutput(t, conn, "Welcome back to Vesta, alice.") + + if sess.Account == nil || sess.Account.Name != "alice" || len(sess.Account.Characters) != 1 { + t.Fatalf("login did not complete: %+v", sess.Account) + } + upgraded, err := h.g.AccountStore.LoadAccount("alice") + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(upgraded.PasswordHash, "argon2id:") { + t.Errorf("legacy hash not upgraded on login: %s", upgraded.PasswordHash[:12]) + } + if !player.CheckPassword("pw", upgraded.PasswordHash) { + t.Error("upgraded hash no longer verifies the password") + } +} + +// TestNewAccountThroughServerEntryHashesOffOwner drives account creation +// through the real server entry point. +func TestNewAccountThroughServerEntryHashesOffOwner(t *testing.T) { + h := newHarness(t) + conn := newChanConn() + sess := &net.Session{Conn: conn, State: net.StateAccountName} + sess.StartWriter() + lines := make(chan string, 8) + go h.input(sess, lines) + + lines <- "newbie" // unknown name → new-account password prompt + lines <- "hunter2pass" + lines <- "hunter2pw" // mismatch → restart account entry (Account reset) + waitForOutput(t, conn, "Passwords do not match.") + lines <- "newbie" // account name again + lines <- "hunter2pw" + lines <- "hunter2pw" + lines <- "n" // color prompt + waitForOutput(t, conn, "Welcome back to Vesta, newbie.") + + created, err := h.g.AccountStore.LoadAccount("newbie") + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(created.PasswordHash, "argon2id:") { + t.Errorf("created account did not hash with Argon2id: %s", created.PasswordHash[:12]) + } + if !player.CheckPassword("hunter2pw", created.PasswordHash) { + t.Error("created hash does not verify the password") + } +} + +// sha256HashHex formats like hex.EncodeToString for the legacy hash check. +func sha256HashHex(sum [32]byte) string { + const hexdigits = "0123456789abcdef" + out := make([]byte, 64) + for i, b := range sum { + out[i*2] = hexdigits[b>>4] + out[i*2+1] = hexdigits[b&0x0f] + } + return string(out) +} diff --git a/internal/game/readiness_regression_test.go b/internal/game/readiness_regression_test.go index 7fa2b2e..3d21390 100644 --- a/internal/game/readiness_regression_test.go +++ b/internal/game/readiness_regression_test.go @@ -196,17 +196,19 @@ func TestRecoverySaveFailureRetriesOnNextStartup(t *testing.T) { return } if loaded.Credits != 110 { - t.Errorf("after fixing the disk and retrying: %d credits, want 110; open=%+v", loaded.Credits, h.g.Economy.loadOpen()) + t.Errorf("after fixing the disk and retrying: %d credits, want 110; open=%+v", loaded.Credits, h.g.Economy.mustLoadOpen(t)) } }) } -// TestLostResolutionMarkerSurfacesAndRefunds: a resolution marker that -// cannot be journaled (persistent append failure) leaves the stake OPEN — -// never silently forgotten — and recovery then applies the documented -// refund-on-lost-state policy (the player is never the loser of a lost -// marker). -func TestLostResolutionMarkerSurfacesAndRefunds(t *testing.T) { +// TestResolvedStakeEvidenceSurvivesJournalFailure: a resolution marker +// that cannot be journaled (persistent append failure) leaves the stake +// OPEN — never silently forgotten — and records the resolution in the +// character file as durable evidence. Recovery then settles the stake +// WITHOUT refunding it: the round genuinely resolved, and the file is the +// proof. When the file save also fails, no evidence exists and recovery +// applies the documented refund-on-lost-state policy instead. +func TestResolvedStakeEvidenceSurvivesJournalFailure(t *testing.T) { h := newHarness(t) h.seedAccount("alice") p := h.seedCharacter("alice", "alice") @@ -224,28 +226,40 @@ func TestLostResolutionMarkerSurfacesAndRefunds(t *testing.T) { if open := h.g.Economy.openStakes(economyKey(p)); len(open) != 1 { t.Errorf("unresolvable stake was silently dropped: open=%d", len(open)) } - h.g.recoverEconomy() + // The resolution is recorded durably in the character file. loaded, err := h.g.AccountStore.LoadCharacter("alice") if err != nil { t.Error(err) return } - if loaded.Credits != 100 { - t.Errorf("after recovery: credits=%d, want 100 (stake refunded; marker lost)", loaded.Credits) + if len(loaded.ResolvedStakes) != 1 { + t.Errorf("resolved-stake evidence not recorded: %+v", loaded.ResolvedStakes) + return + } + h.g.recoverEconomy() + loaded, err = h.g.AccountStore.LoadCharacter("alice") + if err != nil { + t.Error(err) + return + } + if loaded.Credits != 90 { + t.Errorf("after recovery: credits=%d, want 90 (round resolved; evidence prevents a refund)", loaded.Credits) } - if open := h.g.Economy.loadOpen(); len(open) != 0 { + if open := h.g.Economy.mustLoadOpen(t); len(open) != 0 { t.Errorf("records left open after recovery: %+v", open) } + if len(loaded.ResolvedStakes) != 0 { + t.Errorf("stale resolved-stake evidence not pruned: %+v", loaded.ResolvedStakes) + } }) } -// TestUnjournalablePayoutRefundsStake: a payout that cannot even be -// journaled (persistent append failure) is not announced as paid, the -// balance is unchanged, and recovery applies the documented fallback: the -// stake is refunded. The unrecorded win beyond the stake cannot be -// protected, but the player never loses money and no win is announced that -// is not durable. -func TestUnjournalablePayoutRefundsStake(t *testing.T) { +// TestUnjournalablePayoutPaidThroughCharacterFile: a payout that cannot be +// journaled (persistent append failure) is still made durable through the +// character file — the save is the commit point, so the win is real and +// announced. The resolved stake is recorded as file evidence so recovery +// neither refunds the stake (double pay) nor drops the round. +func TestUnjournalablePayoutPaidThroughCharacterFile(t *testing.T) { h := newHarness(t) h.seedAccount("alice") p := h.seedCharacter("alice", "alice") @@ -260,23 +274,25 @@ func TestUnjournalablePayoutRefundsStake(t *testing.T) { h.g.Economy.path = h.dir err := w.PayCredits(20) h.g.Economy.path = path - if err == nil { - t.Error("injected write failure did not fail") + if err != nil { + t.Errorf("character-file fallback failed: %v", err) return } - if p.Credits != 90 { - t.Errorf("failed payout mutated the balance: %d", p.Credits) + if p.Credits != 110 { + t.Errorf("fallback payout did not land: credits=%d, want 110", p.Credits) } + // As the games do after a successful settlement: resolve the stake. + w.ResolveStakes() h.g.recoverEconomy() loaded, err := h.g.AccountStore.LoadCharacter("alice") if err != nil { t.Error(err) return } - if loaded.Credits != 100 { - t.Errorf("stake refund after unjournalable payout: credits=%d, want 100", loaded.Credits) + if loaded.Credits != 110 { + t.Errorf("after recovery: credits=%d, want 110 (win durable once; no stake refund on top)", loaded.Credits) } - if open := h.g.Economy.loadOpen(); len(open) != 0 { + if open := h.g.Economy.mustLoadOpen(t); len(open) != 0 { t.Errorf("open records: %+v", open) } }) diff --git a/internal/player/password.go b/internal/player/password.go index f8ce3cc..9be39fb 100644 --- a/internal/player/password.go +++ b/internal/player/password.go @@ -31,13 +31,31 @@ const ( argonSaltLen = 16 ) +// hashLimiter bounds concurrent Argon2id computations. Each hash costs +// ~64 MiB and tens of milliseconds, so an unbounded burst of logins (or +// login attempts) would spike memory and CPU in parallel. Password work +// runs on connection/HTTP goroutines (never the game owner); this only +// caps how many run at once. Callers acquire per computation, never +// nested, so the limited channel cannot deadlock. +var hashLimiter = make(chan struct{}, 4) + +func acquireHashSlot() { + hashLimiter <- struct{}{} +} + +func releaseHashSlot() { + <-hashLimiter +} + // HashPassword derives an Argon2id password hash. func HashPassword(password string) (string, error) { salt := make([]byte, argonSaltLen) if _, err := rand.Read(salt); err != nil { return "", err } + acquireHashSlot() key := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen) + releaseHashSlot() return fmt.Sprintf("argon2id:%d:%d:%d:%s:%s", argonTime, argonMemory, argonThreads, base64.RawStdEncoding.EncodeToString(salt), hex.EncodeToString(key)), nil @@ -63,7 +81,9 @@ func CheckPassword(password, stored string) bool { if errT != nil || errM != nil || errP != nil || errSalt != nil || errKey != nil { return false } + acquireHashSlot() key := argon2.IDKey([]byte(password), salt, uint32(t), uint32(m), uint8(p), uint32(len(expected))) + releaseHashSlot() return subtle.ConstantTimeCompare(key, expected) == 1 case "sha256": legacy := strings.SplitN(stored, ":", 3) diff --git a/internal/player/player.go b/internal/player/player.go index 708588c..0a48b04 100644 --- a/internal/player/player.go +++ b/internal/player/player.go @@ -176,7 +176,15 @@ type Player struct { // amounts are ALREADY INCLUDED in this file's credit balance. Recovery // uses membership to prove a payout became durable instead of guessing // from balance checkpoints, which unrelated credit changes can fake. - OpenPayouts []string `yaml:"open_payouts,omitempty"` + OpenPayouts []string `yaml:"open_payouts,omitempty"` + // ResolvedStakes is durable evidence for the economy journal (see + // internal/game/economy.go): the IDs of outstanding wager records the + // session RESOLVED (won, lost, pushed, refunded) but whose settled + // markers could not be appended to the journal (e.g. a broken economy + // path). Recovery treats membership as proof the round resolved — the + // stake must not be refunded and its chip part must not return in kind + // on top of a settlement that already returned it as credits. + ResolvedStakes []string `yaml:"resolved_stakes,omitempty"` Description string `yaml:"description"` AttackStyle AttackStyle `yaml:"attack_style"` Prompt string `yaml:"prompt"` |
