aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-16 19:57:45 -0400
committerhistoria <[not public]>2026-09-16 19:57:45 -0400
commit6ef40a5739f1723ec26cc32dfc48fe764f776dca (patch)
treee215a4dc96ea639bcea07bb589aae3e31c3609bb
parent1dad15698845d370e998f7cc5feb8aece3fab58f (diff)
downloadthehouseoficarus-6ef40a5739f1723ec26cc32dfc48fe764f776dca.tar.gz
fix: durable payouts survive journal failure, cap bank withdrawals, hash passwords off the owner loop.
-rw-r--r--building_guide/concurrency.md36
-rw-r--r--internal/game/act_effects.go4
-rw-r--r--internal/game/act_steal.go2
-rw-r--r--internal/game/casino.go46
-rw-r--r--internal/game/cmd_bank.go6
-rw-r--r--internal/game/cmd_get.go4
-rw-r--r--internal/game/cmd_shop.go2
-rw-r--r--internal/game/cmd_trigger_utility.go2
-rw-r--r--internal/game/core_login_account.go148
-rw-r--r--internal/game/economy.go215
-rw-r--r--internal/game/economy_journal_test_helpers_test.go14
-rw-r--r--internal/game/economy_open_payout_list_test.go4
-rw-r--r--internal/game/economy_recovery_identity_test.go4
-rw-r--r--internal/game/economy_recovery_regression_test.go28
-rw-r--r--internal/game/economy_shutdown_test.go6
-rw-r--r--internal/game/game.go14
-rw-r--r--internal/game/hacking/wumpus.go7
-rw-r--r--internal/game/hacking/wumpus_regression_test.go20
-rw-r--r--internal/game/journal_failure_regression_test.go228
-rw-r--r--internal/game/login_offowner_regression_test.go122
-rw-r--r--internal/game/readiness_regression_test.go66
-rw-r--r--internal/player/password.go20
-rw-r--r--internal/player/player.go10
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"`