aboutsummaryrefslogtreecommitdiff
path: root/internal/game
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-16 21:05:39 -0400
committerhistoria <[not public]>2026-09-16 21:05:39 -0400
commit35ddd0ad2215760bb1d3dc7724480f179ea33fa6 (patch)
tree3e9acf5e8a690544ecee1ec03af888ee28dd05b8 /internal/game
parent6ef40a5739f1723ec26cc32dfc48fe764f776dca (diff)
downloadthehouseoficarus-35ddd0ad2215760bb1d3dc7724480f179ea33fa6.tar.gz
fix: login account race, casino chip double-refund, room-create rollback, rename collision, fatal transport errors
Diffstat (limited to 'internal/game')
-rw-r--r--internal/game/casino.go37
-rw-r--r--internal/game/core_login_account.go51
-rw-r--r--internal/game/login_race_regression_test.go77
-rw-r--r--internal/game/payout_evidence_regression_test.go76
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")
+ }
+ })
+}