diff options
Diffstat (limited to 'internal/game')
| -rw-r--r-- | internal/game/casino.go | 37 | ||||
| -rw-r--r-- | internal/game/core_login_account.go | 51 | ||||
| -rw-r--r-- | internal/game/login_race_regression_test.go | 77 | ||||
| -rw-r--r-- | internal/game/payout_evidence_regression_test.go | 76 |
4 files changed, 217 insertions, 24 deletions
diff --git a/internal/game/casino.go b/internal/game/casino.go index f9eb771..79d324a 100644 --- a/internal/game/casino.go +++ b/internal/game/casino.go @@ -103,16 +103,28 @@ func (w casinoWallet) payCreditsFlagged(amount int, refund bool) error { // 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. + // resolution evidence (recordStakeEvidence) recorded in that same + // file 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 + // The character file is the only durable record, so it must also + // carry the resolution evidence for this player's open stakes: a + // crash before the games' own ResolveStakes call would otherwise + // leave the wager records open with checkpoint balances that no + // longer match, and recovery would refund chip stakes the paid + // settlement had already consumed. The single save below is the + // commit point for the payout AND the resolution. + resolved := w.recordStakeEvidence() if serr := w.g.AccountStore.SaveCharacter(w.p); serr != nil { w.p.Credits = creditsBefore + if resolved > 0 { + w.p.ResolvedStakes = w.p.ResolvedStakes[:len(w.p.ResolvedStakes)-resolved] + } return casino.ErrWalletPersistence } kind := "settlement" @@ -174,6 +186,23 @@ func (w casinoWallet) ResolveStakes() { } } +// recordStakeEvidence adds every currently open stake of the player to the +// file-side resolution evidence list and returns how many IDs were newly +// recorded. Used when the economy journal is unavailable: the open wager +// record IDs in the character file prove the round resolved even though no +// settled marker (or payout record) could ever be journaled. The caller +// must persist the character file for the evidence to count. +func (w casinoWallet) recordStakeEvidence() int { + added := 0 + for _, rec := range w.g.Economy.openStakes(economyKey(w.p)) { + if !resolvedStakeRecorded(w.p, rec.ID) { + w.p.ResolvedStakes = append(w.p.ResolvedStakes, rec.ID) + added++ + } + } + return added +} + // RefundOutstanding returns every outstanding stake to the player: chips // come back as chips, credits through the durable payout path. The stakes // are resolved only once the return is durable; on failure the chip return diff --git a/internal/game/core_login_account.go b/internal/game/core_login_account.go index 371c287..f6e5631 100644 --- a/internal/game/core_login_account.go +++ b/internal/game/core_login_account.go @@ -19,6 +19,7 @@ func (g *Game) handleAccountName(sess *net.Session, input string) { actualName, err := g.AccountStore.FindAccount(name) if err == nil { sess.Account = &player.Account{Name: actualName} + sess.SetAuthName(actualName) sess.State = net.StatePassword sess.SetEcho(false) sess.Write("Password: ") @@ -29,6 +30,7 @@ func (g *Game) handleAccountName(sess *net.Session, input string) { return } sess.Account = &player.Account{Name: name} + sess.SetAuthName(name) sess.State = net.StateNewAccountPass sess.SetEcho(false) sess.WriteLine("\r\nOh, a new visitor to this rock!") @@ -99,17 +101,9 @@ type passwordAuth struct { // 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 { + // These checks touch no session state, so they are race-free off + // the owner; they also keep Argon2id off absurd inputs. tooshort := input == "" g.RunOnOwner(func() { if g.isShuttingDown() { @@ -127,8 +121,24 @@ func (g *Game) handlePasswordOffOwner(sess *net.Session, input string) { }) return } + // The account name comes from the race-free authName snapshot (set by + // the owner when the password prompt was offered), NOT from + // sess.Account: the owner goroutine can replace Account at any moment + // (account republish after a rename/purge), and reading a plain field + // the owner concurrently writes is a data race. + name := sess.AuthName() + if name == "" { + // Defensive: password prompts only follow an account-name step. + g.RunOnOwner(func() { + if g.isShuttingDown() { + return + } + g.handlePassword(sess, input) + }) + return + } auth := passwordAuth{} - acc, err := g.AccountStore.LoadAccount(sess.Account.Name) + acc, err := g.AccountStore.LoadAccount(name) switch { case err != nil: auth.loadErr = true @@ -251,20 +261,21 @@ func (g *Game) handleNewAccount(sess *net.Session, input string) { // 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 unconditionally on the connection goroutine (Argon2id is + // expensive); the owner task below re-checks the empty/mismatch cases + // against owner-owned session state, so PendingPass is never read off + // the owner. A wasted hash only happens on the rare mismatched-confirm + // path, where handleNewAccount's own validation rejects it without a + // second hash. hash, hashErr := player.HashPassword(input) g.RunOnOwner(func() { if g.isShuttingDown() { return } + if input == "" || input != sess.PendingPass { + g.handleNewAccount(sess, input) + return + } g.applyNewAccount(sess, hash, hashErr) }) } diff --git a/internal/game/login_race_regression_test.go b/internal/game/login_race_regression_test.go new file mode 100644 index 0000000..6448cf5 --- /dev/null +++ b/internal/game/login_race_regression_test.go @@ -0,0 +1,77 @@ +package game + +// The off-owner login paths must never read owner-owned session fields: +// the owner goroutine replaces Session.Account at any moment (account +// republish after a rename or purge races any concurrent login), so the +// connection goroutine verifies passwords against the race-free authName +// snapshot instead. + +import ( + "testing" + + "thehouseoficarus/internal/net" +) + +// TestOffOwnerLoginDoesNotRaceAccountPublish drives the real server entry +// point through a login while a republish loop keeps replacing the +// session's cached account on the owner — the exact race a second +// authenticated session (or an admin-triggered account update) creates +// while the first session is typing its password. Run with -race. +func TestOffOwnerLoginDoesNotRaceAccountPublish(t *testing.T) { + h := newHarness(t) + h.seedAccount("alice") + h.seedCharacter("alice", "alice") + + sess := &net.Session{Conn: newChanConn(), State: net.StateAccountName} + sess.StartWriter() + defer sess.Close() + h.hub.Add(sess) + + done := make(chan struct{}) + go func() { + defer close(done) + for i := 0; i < 200; i++ { + h.g.RunOnOwner(func() { h.g.publishAccountUpdate("alice", "alice") }) + } + }() + + h.g.HandleSessionOnOwner(sess, "alice") // owner: offers the password prompt + h.g.HandleSessionOnOwner(sess, "pw") // off-owner: verifies the password + <-done + + if sess.AuthName() != "alice" { + t.Fatalf("password prompt lost its account name: %q", sess.AuthName()) + } + if sess.Account == nil || sess.Account.Name != "alice" { + t.Fatalf("login did not complete: %+v", sess.Account) + } +} + +// TestPasswordPromptRejectsEmptyAndOversizedInput keeps the documented +// fast-path behavior of the off-owner password handler after the +// authName rework: an empty line re-prompts (echo still off) and an +// oversized line is rejected without running Argon2id on it. +func TestPasswordPromptRejectsEmptyAndOversizedInput(t *testing.T) { + h := newHarness(t) + h.seedAccount("alice") + + sess := &net.Session{Conn: newChanConn(), State: net.StateAccountName} + sess.StartWriter() + defer sess.Close() + h.hub.Add(sess) + + h.g.HandleSessionOnOwner(sess, "alice") + if sess.State != net.StatePassword { + t.Fatalf("state after account name = %v, want StatePassword", sess.State) + } + + h.g.HandleSessionOnOwner(sess, "") + if sess.State != net.StatePassword { + t.Fatalf("empty password line must stay in StatePassword, got %v", sess.State) + } + + h.g.HandleSessionOnOwner(sess, string(make([]byte, 200))) + if sess.State != net.StateAccountName { + t.Fatalf("oversized password must return to StateAccountName, got %v", sess.State) + } +} diff --git a/internal/game/payout_evidence_regression_test.go b/internal/game/payout_evidence_regression_test.go new file mode 100644 index 0000000..b58bf9d --- /dev/null +++ b/internal/game/payout_evidence_regression_test.go @@ -0,0 +1,76 @@ +package game + +// Regression: when the economy journal is unavailable, a payout paid +// through the character file must record the round's stake-resolution +// evidence in that same file. Otherwise a crash before the games' own +// ResolveStakes call leaves the wager records open with checkpoint +// balances that no longer match, and recovery refunds chip stakes the +// paid settlement had already consumed — duplicating the round's value. + +import ( + "testing" + + "thehouseoficarus/internal/player" +) + +func TestUnjournalablePayoutCrashDoesNotRefundChips(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 + } + path := h.g.Economy.path + h.g.Economy.path = h.dir // persistent append failure + if err := w.PayCredits(20); err != nil { + t.Errorf("fallback payout failed: %v", err) + return + } + h.g.Economy.path = path + // Process loss BEFORE the game's own ResolveStakes call: the + // fallback payout itself must be the durable resolution. + 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("recovered credits=%d chips=%d; want 120 credits and no chips (the paid settlement consumed the stake)", loaded.Credits, countChips(loaded)) + } + if open := h.g.Economy.mustLoadOpen(t); len(open) != 0 { + t.Errorf("records left open after recovery: %+v", open) + } + }) +} + +// The stakes-open count is a proxy for "recovery did not silently refund": +// with the fix the wager record settles through the file-side evidence. +func TestUnjournalablePayoutEvidenceRecorded(t *testing.T) { + h := newHarness(t) + h.seedAccount("alice") + p := h.seedCharacter("alice", "alice") + h.g.RunOnOwner(func() { + p.Credits = 100 + w := casinoWallet{g: h.g, p: p} + if _, err := w.Wager(10); err != nil { + t.Error(err) + return + } + path := h.g.Economy.path + h.g.Economy.path = h.dir + if err := w.PayCredits(20); err != nil { + t.Errorf("fallback payout failed: %v", err) + return + } + h.g.Economy.path = path + if len(p.ResolvedStakes) == 0 { + t.Error("fallback payout recorded no stake-resolution evidence; a crash before ResolveStakes would double-refund chips") + } + }) +} |
