aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/game/account_txn.go50
-rw-r--r--internal/game/casino.go16
-rw-r--r--internal/game/cmd_get.go17
-rw-r--r--internal/game/core_login_account.go5
-rw-r--r--internal/game/core_login_char.go6
-rw-r--r--internal/game/economy.go242
-rw-r--r--internal/game/economy_recovery_regression_test.go8
-rw-r--r--internal/game/owner_disconnect_test.go3
-rw-r--r--internal/game/readiness_regression_test.go283
-rw-r--r--internal/game/tick.go5
-rw-r--r--internal/net/server.go35
-rw-r--r--internal/world/ground.go59
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()