diff options
| author | historia <[not public]> | 2026-09-16 02:56:56 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-09-16 02:56:56 -0400 |
| commit | f2d44f10f52d3fe68fdb19b518a330f7af9e1ed2 (patch) | |
| tree | 9db54e8d4fd2f9bd30c651700f7cf246eef1b017 | |
| parent | ad60843fbf577eda80105e3916822d663aaefcb3 (diff) | |
| download | thehouseoficarus-f2d44f10f52d3fe68fdb19b518a330f7af9e1ed2.tar.gz | |
fix: disconnect race, rename ownership, reserved pickup, economy recovery retry/refund semantics
| -rw-r--r-- | internal/game/account_txn.go | 50 | ||||
| -rw-r--r-- | internal/game/casino.go | 16 | ||||
| -rw-r--r-- | internal/game/cmd_get.go | 17 | ||||
| -rw-r--r-- | internal/game/core_login_account.go | 5 | ||||
| -rw-r--r-- | internal/game/core_login_char.go | 6 | ||||
| -rw-r--r-- | internal/game/economy.go | 242 | ||||
| -rw-r--r-- | internal/game/economy_recovery_regression_test.go | 8 | ||||
| -rw-r--r-- | internal/game/owner_disconnect_test.go | 3 | ||||
| -rw-r--r-- | internal/game/readiness_regression_test.go | 283 | ||||
| -rw-r--r-- | internal/game/tick.go | 5 | ||||
| -rw-r--r-- | internal/net/server.go | 35 | ||||
| -rw-r--r-- | internal/world/ground.go | 59 |
12 files changed, 635 insertions, 94 deletions
diff --git a/internal/game/account_txn.go b/internal/game/account_txn.go index 488a5f6..2ed62bb 100644 --- a/internal/game/account_txn.go +++ b/internal/game/account_txn.go @@ -162,6 +162,11 @@ func (g *Game) renameCharacterTxn(accountName, oldName, newName string) error { return err } p.Name = newName + if p.ID == "" { + // Assign the stable identity at rename time so journal records + // written after this point resolve the renamed character by ID. + p.ID = newCharacterID() + } data, err := g.AccountStore.MarshalCharacter(p) if err != nil { return err @@ -235,8 +240,11 @@ func (g *Game) purgeAccountTxn(accountName string, characters []string) error { return txn.Commit() } -// renameAccountTxn moves the account file and rewrites its embedded name in -// one transaction. +// renameAccountTxn moves the account file, rewrites its embedded name, +// and rewrites every owned character file's owner field in one +// transaction. Without the character rewrite the renamed account loses +// access to its characters: membership authorization compares the +// character file's owner against the account name. func (g *Game) renameAccountTxn(oldName, newName string) error { txn := g.newTxn() if err := txn.Move(g.AccountStore.AccountPath(oldName), g.AccountStore.AccountPath(newName)); err != nil { @@ -254,6 +262,24 @@ func (g *Game) renameAccountTxn(oldName, newName string) error { if err := txn.Replace(g.AccountStore.AccountPath(newName), accData, 0600); err != nil { return err } + // Re-point each owned character at the new account name inside the + // same transaction. Legacy or unreadable character files are skipped: + // membership falls back to the account list, which is already + // rewritten above. + for _, charName := range acc.Characters { + p, err := g.AccountStore.LoadCharacter(charName) + if err != nil || p.Account == "" || !equalsFold(p.Account, oldName) { + continue + } + p.Account = newName + charData, err := g.AccountStore.MarshalCharacter(p) + if err != nil { + return err + } + if err := txn.Replace(g.AccountStore.CharPath(charName), charData, 0600); err != nil { + return err + } + } return txn.Commit() } @@ -284,9 +310,14 @@ func (g *Game) isMemberOf(accountName, characterName string) bool { // state to every session of that account, resetting stale pending menu // selections (pending character names, menu choices) after lifecycle // mutations. Called from the owner goroutine only. -func (g *Game) publishAccountUpdate(accountName string) { - for _, sess := range g.allSessionsOfAccount(accountName) { - acc, err := g.AccountStore.LoadAccount(accountName) +// +// oldName and newName differ only for an account rename: sessions +// authenticated before the rename still carry the OLD cached account +// name, so both must match or stale sessions would be left bound to a +// name that no longer exists on disk. +func (g *Game) publishAccountUpdate(oldName, newName string) { + for _, sess := range g.allSessionsOfEitherAccount(oldName, newName) { + acc, err := g.AccountStore.LoadAccount(newName) if err != nil { // The account file vanished (purge from another session); the // session is stale and must leave the menu. @@ -338,12 +369,19 @@ func (g *Game) publishAccountUpdate(accountName string) { // allSessionsOfAccount returns every session authenticated to accountName, // including sessions sitting in menus (not just in-game characters). func (g *Game) allSessionsOfAccount(accountName string) []*net.Session { + return g.allSessionsOfEitherAccount(accountName, accountName) +} + +// allSessionsOfEitherAccount matches sessions authenticated to either +// account name (an account rename leaves stale sessions under the old +// name until they are republished). +func (g *Game) allSessionsOfEitherAccount(oldName, newName string) []*net.Session { if g.Hub == nil { return nil } var out []*net.Session for _, sess := range g.Hub.AllSessions() { - if sess.Account != nil && equalsFold(sess.Account.Name, accountName) { + if sess.Account != nil && (equalsFold(sess.Account.Name, oldName) || equalsFold(sess.Account.Name, newName)) { out = append(out, sess) } } diff --git a/internal/game/casino.go b/internal/game/casino.go index bc1e690..0441903 100644 --- a/internal/game/casino.go +++ b/internal/game/casino.go @@ -77,6 +77,14 @@ func (w casinoWallet) Wager(amount int) (casino.Wager, error) { // if the save fails, the record stays open and startup recovery re-pays it // (the player is owed the settlement). func (w casinoWallet) PayCredits(amount int) error { + return w.payCreditsFlagged(amount, false) +} + +// payCreditsFlagged journals and applies a credit payout; a settlement +// payout (refund=false) proves the round resolved during recovery, while a +// refund payout (refund=true) marks a stake RETURNED at shutdown/eviction +// (its chip part returns in chips, not through this credit payout). +func (w casinoWallet) payCreditsFlagged(amount int, refund bool) error { if amount <= 0 { return nil } @@ -88,7 +96,7 @@ func (w casinoWallet) PayCredits(amount int) error { // With the cap blocking, the balance is unchanged and the open record // keeps the owed amount durable; startup recovery re-applies it once // the balance has room below the cap. - rec, err := w.g.Economy.start("payout", w.p, 0, amount, + rec, err := w.g.Economy.startKind("payout", refund, w.p, 0, amount, creditsBefore, creditsAfter, chipsBefore, chipsBefore) if err != nil { return casino.ErrWalletPersistence @@ -111,7 +119,7 @@ func (w casinoWallet) PayCredits(amount int) error { // round resolved (won, lost, pushed, or forfeited), so startup recovery // must no longer refund it. func (w casinoWallet) ResolveStakes() { - w.g.Economy.ResolveStakes(w.p.Name) + w.g.Economy.ResolveStakes(economyKey(w.p)) } // RefundOutstanding returns every outstanding stake to the player: chips @@ -119,7 +127,7 @@ func (w casinoWallet) ResolveStakes() { // are resolved only once the return is durable; on failure the chip return // is rolled back out of memory and the open records drive recovery instead. func (w casinoWallet) RefundOutstanding() (chips, credits int, err error) { - for _, rec := range w.g.Economy.openStakes(w.p.Name) { + for _, rec := range w.g.Economy.openStakes(economyKey(w.p)) { chips += rec.Chips credits += rec.Credit } @@ -138,7 +146,7 @@ func (w casinoWallet) RefundOutstanding() (chips, credits int, err error) { } } if credits > 0 { - if err := w.PayCredits(credits); err != nil { + if err := w.payCreditsFlagged(credits, true); err != nil { if chips > 0 { // Roll the chip return back out of memory: the durable // refund will re-apply both amounts from the open records. diff --git a/internal/game/cmd_get.go b/internal/game/cmd_get.go index afd9ae6..d538afc 100644 --- a/internal/game/cmd_get.go +++ b/internal/game/cmd_get.go @@ -11,7 +11,10 @@ import ( func (g *Game) doGet(sess *net.Session, input string) { p := sess.Player g.cancelAction(p) - ground := g.World.GroundItems(p.RoomID) + // Only stock this player may actually take counts: entries reserved + // for another player are invisible to the pickup, so a blocked pickup + // can never half-remove the ground stack. + ground := g.World.AccessibleGround(p.RoomID, p.Name) if len(ground) == 0 { sess.WriteLine("There's nothing on the ground to pick up.") return @@ -75,11 +78,12 @@ func (g *Game) doGet(sess *net.Session, input string) { for i := 0; i < 28; i++ { slot := p.InvSlot(i) if slot != nil && slot.ItemID == itemID { - if _, ok := g.World.RemoveReservedItem(p.RoomID, itemID, qty, p.Name); !ok { + removed, ok := g.World.RemoveReservedItem(p.RoomID, itemID, qty, p.Name) + if !ok { sess.WriteLine("That's not yours!") return } - slot.Quantity += qty + slot.Quantity += removed g.AccountStore.SaveCharacter(p) sess.WriteLine(fmt.Sprintf("You pick up %d x %s. (now %d)", qty, g.itemColorize(sess, def, def.Name), slot.Quantity)) return @@ -130,7 +134,8 @@ func (g *Game) doGet(sess *net.Session, input string) { func (g *Game) doGetAllNamed(sess *net.Session, input string) { p := sess.Player g.cancelAction(p) - ground := g.World.GroundItems(p.RoomID) + // Only takeable stock counts (see doGet). + ground := g.World.AccessibleGround(p.RoomID, p.Name) if len(ground) == 0 { sess.WriteLine("There's nothing on the ground to pick up.") return @@ -252,7 +257,9 @@ func (g *Game) doGetAllNamed(sess *net.Session, input string) { func (g *Game) doGetAll(sess *net.Session) { p := sess.Player g.cancelAction(p) - ground := g.World.GroundItems(p.RoomID) + // Only takeable stock counts (see doGet); the all-or-nothing + // RemoveReservedItem then never aborts a partial pickup. + ground := g.World.AccessibleGround(p.RoomID, p.Name) if len(ground) == 0 { sess.WriteLine("There's nothing on the ground to pick up.") return diff --git a/internal/game/core_login_account.go b/internal/game/core_login_account.go index 60cca2c..c5f1064 100644 --- a/internal/game/core_login_account.go +++ b/internal/game/core_login_account.go @@ -368,8 +368,9 @@ func (g *Game) handleRenameAccount(sess *net.Session, input string) { // Republish account state to EVERY session of this account: other // authenticated sessions must not retain stale account objects or - // pending menu selections bound to the old name. - g.publishAccountUpdate(newName) + // pending menu selections bound to the old name. Stale sessions still + // carry the old cached name, so match both. + g.publishAccountUpdate(oldName, newName) sess.WriteLine(fmt.Sprintf("Account renamed to %s.", newName)) g.showMenu(sess) diff --git a/internal/game/core_login_char.go b/internal/game/core_login_char.go index 68a0d58..8388bfb 100644 --- a/internal/game/core_login_char.go +++ b/internal/game/core_login_char.go @@ -52,7 +52,7 @@ func (g *Game) handleNewCharName(sess *net.Session, input string) { } _ = p sess.Account.Characters = append(sess.Account.Characters, name) - g.publishAccountUpdate(sess.Account.Name) + g.publishAccountUpdate(sess.Account.Name, sess.Account.Name) g.connectCharacter(sess, name) @@ -225,7 +225,7 @@ func (g *Game) handleRenameCharName(sess *net.Session, input string) { sess.WriteLine(fmt.Sprintf("%s renamed to %s.", actualOld, newName)) // Refresh every session of this account so no stale character list or // pending selection survives the rename. - g.publishAccountUpdate(sess.Account.Name) + g.publishAccountUpdate(sess.Account.Name, sess.Account.Name) g.showMenu(sess) } @@ -299,6 +299,6 @@ func (g *Game) handleDeleteChar(sess *net.Session, input string) { sess.WriteLine(fmt.Sprintf("%s has been deleted.", actualName)) sess.PendingChar = "" - g.publishAccountUpdate(sess.Account.Name) + g.publishAccountUpdate(sess.Account.Name, sess.Account.Name) g.showMenu(sess) } diff --git a/internal/game/economy.go b/internal/game/economy.go index afdcdd8..e917fa6 100644 --- a/internal/game/economy.go +++ b/internal/game/economy.go @@ -42,19 +42,33 @@ import ( "fmt" "os" "path/filepath" + "strings" "time" "thehouseoficarus/internal/player" ) type economyRecord struct { - ID string `json:"id"` - Player string `json:"player"` - Kind string `json:"kind"` // "wager" or "payout" - Chips int `json:"chips"` - Credit int `json:"credits"` - Status string `json:"status"` // "open", "settled", "void" - TS time.Time `json:"ts"` + ID string `json:"id"` + Player string `json:"player"` + // PlayerID is the character's stable UUID (player.Player.ID), assigned + // at creation and backfilled at first startup. Records resolve their + // subject by ID at recovery so a character RENAME can never strand an + // owed payout or outstanding stake; Player (the name) is kept for + // operator inspection and for legacy records written before IDs. + PlayerID string `json:"player_id,omitempty"` + Kind string `json:"kind"` // "wager" or "payout" + Chips int `json:"chips"` + Credit int `json:"credits"` + Status string `json:"status"` // "open", "settled", "void" + TS time.Time `json:"ts"` + // Refund marks a payout record as a SHUTDOWN/EVICTION REFUND (the + // stake being returned), not a game settlement. Recovery must not + // treat the chip part of a refund as paid in credits: a refund's chip + // stake returns in kind, so a crash mid-refund still owes the chips. + // Settlement payouts (a win, push, or surrender return) instead prove + // the round resolved, closing the stake without any chip return. + Refund bool `json:"refund,omitempty"` // Balance checkpoints used to decide recovery compensation. CreditsBefore int `json:"credits_before"` @@ -65,8 +79,9 @@ type economyRecord struct { type economyJournal struct { path string - // stakes indexes outstanding wager records per player name. All - // journal mutations happen on the game owner (or during startup + // stakes indexes outstanding wager records per player identity key + // (economyKey: the character's stable ID when known, else its name). + // All journal mutations happen on the game owner (or during startup // recovery before any session connects), so no lock is needed. stakes map[string][]*economyRecord } @@ -107,12 +122,37 @@ func (j *economyJournal) append(rec economyRecord) error { return nil } +// appendRetry appends one durable line, retrying a bounded number of +// times: a transient write failure must not permanently lose a marker +// that money correctness depends on. +func (j *economyJournal) appendRetry(rec economyRecord, attempts int) error { + var err error + for i := 0; i < max(attempts, 1); i++ { + if err = j.append(rec); err == nil { + return nil + } + } + return err +} + // start opens a durable record for the in-flight transaction. func (j *economyJournal) start(kind string, p *player.Player, chips, credits, creditsBefore, creditsAfter, chipsBefore, chipsAfter int) (*economyRecord, error) { + return j.startKind(kind, false, p, chips, credits, creditsBefore, creditsAfter, chipsBefore, chipsAfter) +} + +// startRefund opens a refund payout record (stake returned at shutdown or +// eviction; see economyRecord.Refund). +func (j *economyJournal) startRefund(p *player.Player, credits, creditsBefore, creditsAfter int) (*economyRecord, error) { + return j.startKind("payout", true, p, 0, credits, creditsBefore, creditsAfter, 0, 0) +} + +func (j *economyJournal) startKind(kind string, refund bool, p *player.Player, chips, credits, creditsBefore, creditsAfter, chipsBefore, chipsAfter int) (*economyRecord, error) { rec := &economyRecord{ ID: newEconomyRecordID(), Player: p.Name, + PlayerID: p.ID, Kind: kind, + Refund: refund, Chips: chips, Credit: credits, Status: "open", @@ -143,22 +183,55 @@ func (j *economyJournal) void(rec *economyRecord) { // registerStake tracks an outstanding wager record for its player. Wager // records stay open (refundable) until the round resolves. func (j *economyJournal) registerStake(rec *economyRecord) { - j.stakes[rec.Player] = append(j.stakes[rec.Player], rec) + j.stakes[rec.key()] = append(j.stakes[rec.key()], rec) } -// openStakes returns the outstanding stake records for a player. +// openStakes returns the outstanding stake records for a player identity +// key (see economyKey). func (j *economyJournal) openStakes(player string) []*economyRecord { return j.stakes[player] } // ResolveStakes closes every outstanding stake for a player with a settled // marker per record: the round resolved, so recovery must not refund them. +// A marker whose append cannot be made durable leaves that stake OPEN +// (never dropped from the index): recovery then settles it from later +// 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) { + remaining := j.stakes[player][:0] for _, rec := range j.stakes[player] { rec.Status = "settled" - _ = j.append(*rec) + if err := j.appendRetry(*rec, 3); err != nil { + // The marker is not durable: the stake stays open so recovery + // (or a later attempt) still resolves it. + rec.Status = "open" + remaining = append(remaining, rec) + } + } + if len(remaining) == 0 { + delete(j.stakes, player) + } else { + j.stakes[player] = remaining } - delete(j.stakes, player) +} + +// economyKey is the identity a player's journal records are indexed by: +// the stable character ID when known, else the (renameable) name. +func economyKey(p *player.Player) string { + if p.ID != "" { + return p.ID + } + return p.Name +} + +// economyRecordKey mirrors economyKey for a raw record. +func (rec *economyRecord) key() string { + if rec.PlayerID != "" { + return rec.PlayerID + } + return rec.Player } // reloadStakes rebuilds the outstanding-stake index from the journal file: @@ -289,6 +362,8 @@ func (g *Game) giveChips(p *player.Player, qty int) bool { } // payCreditsDurable applies a checked credit increase and persists it. +// A failed save reverts the in-memory mutation: recovery must keep +// comparing later records against the DURABLE balance. func (g *Game) payCreditsDurable(p *player.Player, amount int) error { if amount < 0 { return fmt.Errorf("negative payout %d", amount) @@ -297,8 +372,13 @@ func (g *Game) payCreditsDurable(p *player.Player, amount int) error { if err != nil { return err } + before := p.Credits p.Credits = sum - return g.AccountStore.SaveCharacter(p) + if err := g.AccountStore.SaveCharacter(p); err != nil { + p.Credits = before + return err + } + return nil } // addCreditsChecked is the game-side checked addition against the economy @@ -337,37 +417,75 @@ func (g *Game) recoverEconomy() { return } folded := g.Economy.loadFolded() - players := make(map[string]*player.Player) - loadPlayer := func(name string) *player.Player { - if p, ok := players[name]; ok { - return p + // Player records resolve by the STABLE character ID (a rename must + // never strand an owed payout); the name lookup remains for legacy + // records written before IDs existed. Every character file is loaded + // up front so records resolve by stable ID without filename guessing. + playersByID := make(map[string]*player.Player) + playersByName := make(map[string]*player.Player) + if names, err := g.AccountStore.ListCharacterNames(); err == nil { + for _, name := range names { + if p, err := g.AccountStore.LoadCharacter(name); err == nil { + if p.ID != "" { + playersByID[p.ID] = p + } + playersByName[strings.ToLower(p.Name)] = p + } } - p, err := g.AccountStore.LoadCharacter(name) - if err != nil { - // Character file unreadable: leave the record open so a later - // attempt (or operator inspection) can resolve it; do not - // silently drop money. - fmt.Fprintf(os.Stderr, "economy recovery: load %s: %v\n", name, err) - return nil + } + loadPlayer := func(rec *economyRecord) *player.Player { + if rec.PlayerID != "" { + if p, ok := playersByID[rec.PlayerID]; ok { + return p + } + } + if rec.Player != "" { + if p, ok := playersByName[strings.ToLower(rec.Player)]; ok { + return p + } } - players[name] = p - return p + if rec.PlayerID != "" { + // A character file added since the index was built: load every + // file once and match by stable ID. + if names, err := g.AccountStore.ListCharacterNames(); err == nil { + for _, name := range names { + if p, err := g.AccountStore.LoadCharacter(name); err == nil && p.ID == rec.PlayerID { + playersByID[p.ID] = p + playersByName[strings.ToLower(p.Name)] = p + return p + } + } + } + } + // Character file unreadable: leave the record open so a later + // attempt (or operator inspection) can resolve it; do not + // silently drop money. + fmt.Fprintf(os.Stderr, "economy recovery: load %s (id %q): character not found\n", rec.Player, rec.PlayerID) + return nil } // laterPayoutEvidence reports whether any non-void payout record was // journaled for the player after seq — evidence the round resolved and - // its settlement was recorded — together with the total credits those - // records promise. A payout covering the stake's full value returned - // the stake inside the payout (win, push, or refund); a smaller total - // (e.g. the credit side of a crashed mixed refund) leaves the chip - // side of the stake outstanding. - laterPayoutEvidence := func(name string, seq int) (resolved bool, credits int) { + // its settlement was recorded. + // + // settlement separately reports whether a SETTLEMENT payout (not a + // refund) was journaled later: a settlement — even a partial one like + // a surrendered hand or the first hand of a multi-hand win — proves + // the round resolved, so the stake must NOT be refunded and its chip + // part must NOT return in kind on top of the settlement (the chips + // were lost at settlement, by the rules of the game). A REFUND payout + // instead marks a refund in progress: its credit part is re-applied by + // phase 1 and the chip part (if the debit was durable) still comes + // back in chips. + laterPayoutEvidence := func(name string, seq int) (resolved bool, settlement bool) { for _, fr := range folded { if fr.rec.Kind == "payout" && fr.rec.Player == name && fr.rec.Status != "void" && fr.seq > seq { resolved = true - credits += fr.rec.Credit + if !fr.rec.Refund { + settlement = true + } } } - return resolved, credits + return resolved, settlement } compensated := 0 @@ -399,7 +517,7 @@ func (g *Game) recoverEconomy() { if rec.Kind != "payout" || rec.Status != "open" || settled[rec.ID] { continue } - p := loadPlayer(rec.Player) + p := loadPlayer(rec) if p == nil { continue } @@ -413,7 +531,12 @@ func (g *Game) recoverEconomy() { // failure (e.g. the credit cap) leaves the unpaid remainder open // for operator inspection — a liability is never erased. paid := 0 + capBlocked := false for m := 0; m < len(group); m++ { + if _, err := addCreditsChecked(p.Credits, rec.Credit); err != nil { + capBlocked = true + break + } if err := g.payCreditsDurable(p, rec.Credit); err != nil { fmt.Fprintf(os.Stderr, "economy recovery: payout save %s: %v\n", p.Name, err) break @@ -427,17 +550,26 @@ func (g *Game) recoverEconomy() { settled[other.ID] = true continue } - // An unpaid sibling (the save failed, or the credit cap - // blocked re-application) stays owed. Re-anchor its - // checkpoints to the current durable balance so a later - // recovery re-applies it against a balance it can actually - // be paid from — without this, the balance a PAID sibling - // moved reads as this record's own durability and the - // liability is silently settled instead of paid. - other.CreditsBefore = p.Credits - other.CreditsAfter = p.Credits + other.Credit + if capBlocked { + // The credit cap blocked re-application. Re-anchor the + // record's checkpoints to the current durable balance so + // a later recovery re-applies it against a balance it can + // actually be paid from — without this, the balance a PAID + // sibling moved reads as this record's own durability and + // the liability is silently settled instead of paid. + other.CreditsBefore = p.Credits + other.CreditsAfter = p.Credits + other.Credit + settled[other.ID] = true // handled for the rest of this run + _ = g.Economy.append(*other) + continue + } + // A TRANSIENT save failure blocked re-application and the + // balance still has room below the cap: keep the record open + // with its ORIGINAL checkpoints. Re-anchoring here would make + // the record unreadable against the unchanged durable balance + // (before-checkpoint mismatch) and permanently ambiguous; the + // original checkpoints let a later recovery retry cleanly. settled[other.ID] = true // handled for the rest of this run - _ = g.Economy.append(*other) } case p.Credits == rec.CreditsAfter && len(group) == 1: g.Economy.settle(rec) @@ -457,8 +589,8 @@ func (g *Game) recoverEconomy() { } for k := len(openWagers) - 1; k >= 0; k-- { rec := &folded[openWagers[k]].rec - resolvedByPayout, laterPayoutCredits := laterPayoutEvidence(rec.Player, folded[openWagers[k]].seq) - p := loadPlayer(rec.Player) + resolvedByPayout, resolvedBySettlement := laterPayoutEvidence(rec.Player, folded[openWagers[k]].seq) + p := loadPlayer(rec) if p == nil { continue } @@ -467,12 +599,16 @@ func (g *Game) recoverEconomy() { creditsNow, chipsNow := p.Credits, countChips(p) // Chips are recovered independently of the credit checkpoints. Any // state where the chip debit is durable but the chips have not come - // back (post-debit chip balance) still owes the chips — unless the - // resolving payout already returned the stake's full value in - // credits (a win, push, or refund includes the stake): returning - // the chips in kind on top would double-refund the round. + // back (post-debit chip balance) still owes the chips — unless a + // SETTLEMENT payout was journaled later: a settlement proves the + // round resolved, so the chips were lost at settlement by the rules + // of the game, and returning them in kind on top would double-pay + // the round (e.g. a surrendered hand paying half the stake must not + // ALSO return the full chip stake). A REFUND payout instead marks a + // refund in progress: its credit part was re-applied by phase 1 and + // the chip part still returns in chips. chipsOwed := rec.Chips > 0 && chipsNow == rec.ChipsAfter && - (!resolvedByPayout || laterPayoutCredits < rec.Chips+rec.Credit) + !resolvedBySettlement if chipsOwed && !g.giveChips(p, rec.Chips) { // No inventory room for the chip return: keep the record open // for operator inspection rather than silently dropping the diff --git a/internal/game/economy_recovery_regression_test.go b/internal/game/economy_recovery_regression_test.go index 0e764ca..1bb3293 100644 --- a/internal/game/economy_recovery_regression_test.go +++ b/internal/game/economy_recovery_regression_test.go @@ -339,9 +339,11 @@ func TestRecoveryRestoresChipsFromMixedRefundCrash(t *testing.T) { t.Error(err) return } - // Crash during RefundOutstanding, after the credit-side payout was - // journaled but before anything beyond the debit was durable. - if _, err := h.g.Economy.start("payout", p, 0, 10, 90, 100, 10, 10); err != nil { + // Crash during RefundOutstanding, after the credit-side REFUND + // payout was journaled but before anything beyond the debit was + // durable. The record is refund-flagged: recovery re-applies the + // refund credit AND returns the chip part of the stake in chips. + if _, err := h.g.Economy.startRefund(p, 10, 90, 100); err != nil { t.Error(err) return } diff --git a/internal/game/owner_disconnect_test.go b/internal/game/owner_disconnect_test.go index e43a341..d19b194 100644 --- a/internal/game/owner_disconnect_test.go +++ b/internal/game/owner_disconnect_test.go @@ -23,8 +23,7 @@ func TestDisconnectTickHardRemoveCompletesOnOwner(t *testing.T) { done := make(chan struct{}) h.g.RunOnOwner(func() { - sess.Disconnecting = true - sess.DisconnectTicks = 1 + sess.StartDisconnect(1) }) h.barrier() diff --git a/internal/game/readiness_regression_test.go b/internal/game/readiness_regression_test.go new file mode 100644 index 0000000..752b755 --- /dev/null +++ b/internal/game/readiness_regression_test.go @@ -0,0 +1,283 @@ +package game + +// Readiness regressions for the account-rename, disconnect, and durable-money +// paths reviewed before world/casino expansion: a transport disconnect must +// be race-free, renames must not break ownership or strand owed payouts, +// reserved-loot pickups must never destroy items, and recovery failure paths +// must retry or surface instead of silently dropping money. + +import ( + "os" + "path/filepath" + "testing" + + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" +) + +// TestDisconnectRaceIsFreeAndNeverLost drives DisconnectTick (owner) while a +// transport goroutine arms the disconnect (exactly net.Server.handleSession's +// deferred Hub.Remove path). Guards the atomic disconnect flags: a plain +// bool/int pair is a data race and can lose the disconnect entirely. +func TestDisconnectRaceIsFreeAndNeverLost(t *testing.T) { + h := newHarness(t) + h.seedAccount("alice", "pw", false) + h.seedCharacter("alice", "alice") + sess, _ := h.connect("alice") + + started, finished := make(chan struct{}), make(chan struct{}) + go func() { + h.g.RunOnOwner(func() { + close(started) + for i := 0; i < 10000; i++ { + h.g.DisconnectTick() + } + }) + close(finished) + }() + <-started + h.hub.Remove(sess) // transport goroutine; never on the owner + <-finished + + if sessions := h.g.Hub.AllSessions(); len(sessions) != 0 { + t.Fatalf("disconnect lost under the race: %d sessions remain", len(sessions)) + } +} + +// TestReservedPickupDoesNotDestroyItems: picking up a ground stack that +// shares a name with another player's reserved stack must take only the +// takeable stock — never half-remove the ground with nothing delivered. +func TestReservedPickupDoesNotDestroyItems(t *testing.T) { + h := newHarness(t) + h.seedAccount("alice", "pw", false) + p := h.seedCharacter("alice", "alice") + writeSeedFile(t, filepath.Join(h.dir, "items", "chips.yaml"), []byte("id: chips\nname: chips\nstackable: true\n")) + sess := &net.Session{Conn: newChanConn(), Player: p, State: net.StateGame} + h.g.RunOnOwner(func() { + h.g.ItemStore.Reload(h.dir) + if _, err := h.g.ItemStore.Load("chips"); err != nil { + t.Error(err) + return + } + h.g.World.AddGroundItem(1, "chips", 10) + // A reserved stack of the same item sits after the takeable one: + // the old mid-loop abort destroyed the already-removed half. + h.g.World.AddReservedItem(1, "chips", 10, "bob") + h.g.doGet(sess, "chips") + total := countChips(p) + h.g.World.GroundItems(1)["chips"] + if total != 20 { + t.Errorf("pickup destroyed items: inventory+ground=%d, want 20", total) + } + }) +} + +// TestOwedPayoutSurvivesCharacterRename: economy records resolve their +// subject by the character's STABLE ID, so renaming the character between +// the crash and recovery still delivers the owed payout. +func TestOwedPayoutSurvivesCharacterRename(t *testing.T) { + h := newHarness(t) + h.seedAccount("acct", "pw", false) + p := h.seedCharacter("alice", "acct") + h.g.RunOnOwner(func() { + p.ID = "stable-char-id" // production characters carry a stable ID + p.Credits = 90 + if err := h.g.AccountStore.SaveCharacter(p); err != nil { + t.Error(err) + return + } + if _, err := h.g.Economy.start("payout", p, 0, 20, 90, 110, 0, 0); err != nil { + t.Error(err) + return + } + if err := h.g.renameCharacterTxn("acct", "alice", "alicia"); err != nil { + t.Error(err) + return + } + h.g.recoverEconomy() + loaded, err := h.g.AccountStore.LoadCharacter("alicia") + if err != nil { + t.Error(err) + return + } + if loaded.Credits != 110 { + t.Errorf("renamed character never receives owed payout: credits=%d, want 110", loaded.Credits) + } + }) +} + +// TestAccountRenamePreservesAccessAndOwnership: renaming an account +// rewrites the owned character files' owner field in the same transaction +// and republishes stale sessions (still carrying the old cached name) so +// both sessions stay authenticated and keep character access. +func TestAccountRenamePreservesAccessAndOwnership(t *testing.T) { + h := newHarness(t) + s1, s2 := setupAcct(t, h) + h.g.RunOnOwner(func() { + h.g.handleRenameAccount(s1, "renamed") + if !h.g.AccountStore.AccountExists("renamed") { + t.Error("rename did not succeed") + return + } + if s1.Account.Name != "renamed" || s2.Account.Name != "renamed" { + t.Errorf("sessions remain bound to %q/%q, want renamed/renamed", s1.Account.Name, s2.Account.Name) + } + if !h.g.isMemberOf("renamed", "alice") { + t.Error("renamed account cannot access its own character") + } + }) +} + +// TestSettledSurrenderDoesNotRefundChipStake: a settlement payout — even a +// partial one like a surrendered hand paying half a chip stake — proves the +// round resolved, so recovery must not ALSO return the chip stake in kind. +func TestSettledSurrenderDoesNotRefundChipStake(t *testing.T) { + h := newHarness(t) + h.seedAccount("alice", "pw", false) + 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 + } + // Surrender settles for half the stake; the crash lands before the + // resolution marker. + if err := w.PayCredits(5); err != nil { + t.Error(err) + return + } + h.g.recoverEconomy() + loaded, err := h.g.AccountStore.LoadCharacter("alice") + if err != nil { + t.Error(err) + return + } + if loaded.Credits != 105 || countChips(loaded) != 0 { + t.Errorf("settled surrender recovered %d credits / %d chips; want 105 / 0", loaded.Credits, countChips(loaded)) + } + }) +} + +// TestRecoverySaveFailureRetriesOnNextStartup: a TRANSIENT save failure +// during recovery must not re-anchor the owed record to unreadable +// checkpoints — the record keeps its original checkpoints and a later +// recovery (after the disk is fixed) re-applies the owed payout. +func TestRecoverySaveFailureRetriesOnNextStartup(t *testing.T) { + h := newHarness(t) + h.seedAccount("alice", "pw", false) + p := h.seedCharacter("alice", "alice") + charDir := filepath.Join(h.dir, "players", "characters") + t.Cleanup(func() { os.Chmod(charDir, 0755) }) + h.g.RunOnOwner(func() { + p.Credits = 90 + if err := h.g.AccountStore.SaveCharacter(p); err != nil { + t.Error(err) + return + } + if _, err := h.g.Economy.start("payout", p, 0, 20, 90, 110, 0, 0); err != nil { + t.Error(err) + return + } + if err := os.Chmod(charDir, 0500); err != nil { + t.Error(err) + return + } + h.g.recoverEconomy() // every save fails + if err := os.Chmod(charDir, 0755); err != nil { + t.Error(err) + return + } + h.g.recoverEconomy() // retry with a healthy disk + loaded, err := h.g.AccountStore.LoadCharacter("alice") + if err != nil { + t.Error(err) + 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()) + } + }) +} + +// 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) { + h := newHarness(t) + h.seedAccount("alice", "pw", false) + 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 // persistent append failure + w.ResolveStakes() + h.g.Economy.path = path + 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() + 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 open := h.g.Economy.loadOpen(); len(open) != 0 { + t.Errorf("records left open after recovery: %+v", open) + } + }) +} + +// 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) { + h := newHarness(t) + h.seedAccount("alice", "pw", false) + 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 + err := w.PayCredits(20) + h.g.Economy.path = path + if err == nil { + t.Error("injected write failure did not fail") + return + } + if p.Credits != 90 { + t.Errorf("failed payout mutated the balance: %d", p.Credits) + } + 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 open := h.g.Economy.loadOpen(); len(open) != 0 { + t.Errorf("open records: %+v", open) + } + }) +} diff --git a/internal/game/tick.go b/internal/game/tick.go index 8910137..3f6a8b2 100644 --- a/internal/game/tick.go +++ b/internal/game/tick.go @@ -14,7 +14,7 @@ func (g *Game) DisconnectTick() { return } for _, sess := range g.Hub.AllSessions() { - if !sess.Disconnecting { + if !sess.IsDisconnecting() { continue } p := sess.Player @@ -25,8 +25,7 @@ func (g *Game) DisconnectTick() { if g.Combat.Get(p.Name) != nil { continue } - sess.DisconnectTicks-- - if sess.DisconnectTicks <= 0 { + if sess.TickDisconnectCountdown() { g.restoreGodPlayer(p) g.AccountStore.SaveCharacter(p) g.Hub.HardRemove(sess) diff --git a/internal/net/server.go b/internal/net/server.go index 42077a1..adc828c 100644 --- a/internal/net/server.go +++ b/internal/net/server.go @@ -67,13 +67,20 @@ type Session struct { PendingSkill string PendingLastFlag string PendingBackground bool - Disconnecting bool - DisconnectTicks int PendingUndigDir string PendingUndigRoom int promptVisible bool writeMu sync.Mutex + // Disconnect bookkeeping is written by the connection goroutine that + // discovered the dead transport (Hub.Remove's deferred path) and read + // by the game's per-tick DisconnectTick on the owner goroutine, so both + // fields are atomics. Plain fields would be a data race (and, worse, a + // lost disconnect: the owner's tick could spin forever on a stale + // value). + disconnecting atomic.Bool + disconnectTicks atomic.Int64 + // Outbound queueing. Transport sessions (telnet/TLS/websocket) are put // into queued mode by startWriter before their input goroutine begins: // Write* then enqueues into outbox, and the dedicated writer goroutine @@ -198,6 +205,25 @@ func NewHub() *Hub { } } +// StartDisconnect arms the graceful-disconnect countdown. Called by the +// connection goroutine that discovered the dead transport; DisconnectTick +// (on the game owner) counts the session down and hard-removes it. +func (sess *Session) StartDisconnect(ticks int) { + sess.disconnectTicks.Store(int64(ticks)) + sess.disconnecting.Store(true) +} + +// IsDisconnecting reports whether the graceful-disconnect countdown has +// been armed. Safe to call from any goroutine. +func (sess *Session) IsDisconnecting() bool { return sess.disconnecting.Load() } + +// TickDisconnectCountdown decrements the armed countdown and reports +// whether it has expired. Called only from the game's DisconnectTick +// (owner goroutine); safe against a concurrent StartDisconnect. +func (sess *Session) TickDisconnectCountdown() bool { + return sess.disconnectTicks.Add(-1) <= 0 +} + func (h *Hub) OnRemove(cb func(*Session)) { h.mu.Lock() defer h.mu.Unlock() @@ -219,9 +245,8 @@ func (h *Hub) Add(s *Session) { func (h *Hub) Remove(s *Session) { h.mu.Lock() - if s.Player != nil && !s.Disconnecting { - s.Disconnecting = true - s.DisconnectTicks = 10 + if s.Player != nil && !s.IsDisconnecting() { + s.StartDisconnect(10) onDisconnect := h.onDisconnect h.mu.Unlock() if onDisconnect != nil { diff --git a/internal/world/ground.go b/internal/world/ground.go index 879cc71..2d5ea7b 100644 --- a/internal/world/ground.go +++ b/internal/world/ground.go @@ -87,23 +87,47 @@ func (w *World) AddGroundItem(roomID int, itemID string, qty int) { w.groundItems[roomID] = append(w.groundItems[roomID], e) } +// RemoveReservedItem removes up to qty of itemID from the room's ground, +// refusing entries reserved for another player. It is ALL-OR-NOTHING: +// when any part of the quantity is unobtainable (a reserved-for-others +// entry, or too little accessible stock) nothing is taken and (0, false) +// is returned. Callers must never lose already-removed items to a failed +// pickup, so the removal is verified before any state is touched. func (w *World) RemoveReservedItem(roomID int, itemID string, qty int, owner string) (int, bool) { w.mu.Lock() defer w.mu.Unlock() - removed := 0 - remaining := qty - for _, e := range w.groundItems[roomID] { - if remaining <= 0 { + entries := w.groundItems[roomID] + + // Pass 1: verify the full quantity is takable by this owner without + // mutating anything, so a blocked pickup can never leave the ground + // half-emptied with nothing delivered. + available := 0 + for _, e := range entries { + if available >= qty { break } - if !strings.EqualFold(e.itemID, itemID) { + if e.quantity <= 0 || !strings.EqualFold(e.itemID, itemID) { continue } - if e.quantity <= 0 { + if e.reserveTimer > 0 && e.reservedFor != "" && e.reservedFor != owner { continue } - if e.reserveTimer > 0 && e.reservedFor != "" && e.reservedFor != owner { - return removed, false + available += e.quantity + } + if available < qty { + return 0, false + } + + // Pass 2: take. Every entry reachable here is takeable, so no + // mid-loop abort is possible. + removed := 0 + remaining := qty + for _, e := range entries { + if remaining <= 0 { + break + } + if e.quantity <= 0 || !strings.EqualFold(e.itemID, itemID) { + continue } take := e.quantity if take > remaining { @@ -119,6 +143,25 @@ func (w *World) RemoveReservedItem(roomID int, itemID string, qty int, owner str return removed, true } +// AccessibleGround returns the ground items in roomID this owner may take: +// entries reserved for another player are excluded. Pickup flows use this +// to size requests and match names against takeable stock only. +func (w *World) AccessibleGround(roomID int, owner string) map[string]int { + w.mu.Lock() + defer w.mu.Unlock() + out := make(map[string]int) + for _, e := range w.groundItems[roomID] { + if e.quantity <= 0 { + continue + } + if e.reserveTimer > 0 && e.reservedFor != "" && e.reservedFor != owner { + continue + } + out[e.itemID] += e.quantity + } + return out +} + func (w *World) RemoveGroundItem(roomID int, itemID string, qty int) int { w.mu.Lock() defer w.mu.Unlock() |
