aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/admin/api_files.go28
-rw-r--r--internal/admin/api_rooms.go8
-rw-r--r--internal/admin/api_rooms_casino_evict_test.go27
-rw-r--r--internal/game/casino.go14
-rw-r--r--internal/game/cmd_room_remove.go4
-rw-r--r--internal/game/cmd_undig.go3
-rw-r--r--internal/game/core_login_account.go26
-rw-r--r--internal/game/economy.go181
-rw-r--r--internal/game/economy_open_payout_list_test.go120
-rw-r--r--internal/game/economy_recovery_regression_test.go11
-rw-r--r--internal/net/conn.go17
-rw-r--r--internal/net/server.go108
-rw-r--r--internal/net/session_close_test.go73
-rw-r--r--internal/net/wsconn.go14
-rw-r--r--internal/player/player.go42
15 files changed, 609 insertions, 67 deletions
diff --git a/internal/admin/api_files.go b/internal/admin/api_files.go
index f2ef928..ea75249 100644
--- a/internal/admin/api_files.go
+++ b/internal/admin/api_files.go
@@ -6,6 +6,7 @@ import (
"net/http"
"os"
"path/filepath"
+ "strconv"
"strings"
)
@@ -20,6 +21,32 @@ func resolveDataPath(dataDir, relPath string) (string, error) {
return filepath.Join(dataDir, clean), nil
}
+// evictDeletedRoomFile drops cached room state and any cached in-room games
+// when the raw file manager deletes a room YAML (rooms/<dir>/<id>.yaml).
+// Best-effort: only numeric .yaml stems under rooms/ count as room files.
+func (s *AdminServer) evictDeletedRoomFile(relPath string) {
+ dir, base := filepath.Split(relPath)
+ idStr, ok := strings.CutSuffix(base, ".yaml")
+ if !ok || idStr == "" {
+ return
+ }
+ dir = strings.TrimSuffix(dir, "/")
+ if dir == "rooms" || strings.HasPrefix(dir, "rooms"+string(filepath.Separator)) {
+ id, err := strconv.Atoi(idStr)
+ if err != nil {
+ return
+ }
+ s.world.RebuildRoomIndex(s.dataDir)
+ s.world.ClearRoomState(id)
+ if s.mobStore != nil {
+ s.mobStore.RemoveMobsInRoom(id)
+ }
+ if s.evictRoom != nil {
+ s.evictRoom(id)
+ }
+ }
+}
+
func (s *AdminServer) handleFileRename(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
@@ -204,6 +231,7 @@ func (s *AdminServer) handleFiles(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]any{"error": "delete error: " + err.Error()})
return
}
+ s.evictDeletedRoomFile(relPath)
s.undoStack.Push(ChangeDesc{
Description: "Deleted file " + relPath,
FilePath: fullPath,
diff --git a/internal/admin/api_rooms.go b/internal/admin/api_rooms.go
index 3c96d53..01b7b9a 100644
--- a/internal/admin/api_rooms.go
+++ b/internal/admin/api_rooms.go
@@ -320,6 +320,11 @@ func (s *AdminServer) handleRoomByID(w http.ResponseWriter, r *http.Request) {
s.world.RebuildRoomIndex(s.dataDir)
s.world.ClearRoomState(id)
+ // Drop any cached in-room games (tables/machines) and refund their
+ // outstanding wagers — same eviction semantics as bulk delete.
+ if s.evictRoom != nil {
+ s.evictRoom(id)
+ }
allIDs, _ := listRoomIDs(s.dataDir)
var extraFiles []ExtraFile
@@ -1178,6 +1183,9 @@ func (s *AdminServer) handleResolveDuplicate(w http.ResponseWriter, r *http.Requ
return
}
s.world.RebuildRoomIndex(s.dataDir)
+ // No casino eviction: a duplicate-ID file was never in the room index,
+ // so no cached game is keyed to it (games are keyed by the indexed
+ // room's ID, which survives here untouched).
s.undoStack.Push(ChangeDesc{
Description: fmt.Sprintf("delete duplicate room file %s", body.Path),
FilePath: absPath,
diff --git a/internal/admin/api_rooms_casino_evict_test.go b/internal/admin/api_rooms_casino_evict_test.go
new file mode 100644
index 0000000..426c13a
--- /dev/null
+++ b/internal/admin/api_rooms_casino_evict_test.go
@@ -0,0 +1,27 @@
+package admin
+
+// Regression: deleting a single room through the editor API must evict the
+// room's cached casino games and refund their outstanding wagers, exactly
+// like the bulk-delete path does. Skipping eviction strands seated players'
+// wagers in games whose room no longer exists.
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+func TestRoomDeleteEvictsCasinoGames(t *testing.T) {
+ s := newTestAdminServer(t, map[int]string{1: "id: 1\nname: Room\n"})
+ var evicted []int
+ s.evictRoom = func(id int) { evicted = append(evicted, id) }
+
+ rec := httptest.NewRecorder()
+ s.handleRoomByID(rec, httptest.NewRequest(http.MethodDelete, "/api/rooms/1", nil))
+ if _, ok := s.world.GetRoomPath(1); ok {
+ t.Fatalf("room was not removed: %s", rec.Body.String())
+ }
+ if len(evicted) != 1 || evicted[0] != 1 {
+ t.Fatalf("room deletion evicted %v, want [1] (outstanding wagers were not resolved)", evicted)
+ }
+}
diff --git a/internal/game/casino.go b/internal/game/casino.go
index 0441903..5268d34 100644
--- a/internal/game/casino.go
+++ b/internal/game/casino.go
@@ -105,13 +105,25 @@ func (w casinoWallet) payCreditsFlagged(amount int, refund bool) error {
return capErr
}
w.p.Credits = creditsAfter
+ // Record the payout in the character's open-payout evidence list: the
+ // save below is the commit point, and the file itself then proves the
+ // amount is included in the balance. Recovery reads this list instead
+ // of guessing from balance checkpoints, which unrelated credit changes
+ // (a shop sale, a bank deposit) can replicate by coincidence.
+ w.p.OpenPayouts = append(w.p.OpenPayouts, rec.ID)
if err := w.g.AccountStore.SaveCharacter(w.p); err != nil {
w.p.Credits = creditsBefore
+ removeOpenPayout(w.p, rec.ID)
// Keep the record open: recovery re-pays on next startup, since a
// reported win must be durable.
return casino.ErrWalletPersistence
}
- w.g.Economy.settle(rec)
+ // The evidence entry may only be dropped once the settled marker is
+ // durable; while the marker is still open, the file's membership is
+ // what proves durability to recovery.
+ if w.g.Economy.settleDurable(rec) {
+ removeOpenPayout(w.p, rec.ID)
+ }
return nil
}
diff --git a/internal/game/cmd_room_remove.go b/internal/game/cmd_room_remove.go
index 187ec34..af745fd 100644
--- a/internal/game/cmd_room_remove.go
+++ b/internal/game/cmd_room_remove.go
@@ -216,6 +216,10 @@ func (g *Game) roomRemove(sess *net.Session, args []string) {
g.World.RebuildRoomIndex(g.DataDir)
g.MobStore.RemoveMobsInRoom(bID)
g.World.ClearRoomState(bID)
+ // Drop any cached in-room games and refund their outstanding wagers
+ // before the room stops existing (same eviction semantics as the
+ // admin editor's room deletion).
+ g.EvictCasinoRoom(bID)
// Relocate any other players standing in B to A (the current player is in
// A and unaffected). Mirrors performUndig's relocate tail.
diff --git a/internal/game/cmd_undig.go b/internal/game/cmd_undig.go
index 2215c51..a41cb08 100644
--- a/internal/game/cmd_undig.go
+++ b/internal/game/cmd_undig.go
@@ -241,6 +241,9 @@ func (g *Game) performUndig(sess *net.Session, p *player.Player, targetID int) {
g.MobStore.RemoveMobsInRoom(targetID)
g.World.ClearRoomState(targetID)
+ // Drop any cached in-room games and refund their outstanding wagers
+ // (same eviction semantics as the other room-removal paths).
+ g.EvictCasinoRoom(targetID)
sess.WriteLine(fmt.Sprintf("Deleted room %s and %d exit(s) pointing to it.", roomName, cleaned))
}
diff --git a/internal/game/core_login_account.go b/internal/game/core_login_account.go
index c5f1064..1acd1a6 100644
--- a/internal/game/core_login_account.go
+++ b/internal/game/core_login_account.go
@@ -20,7 +20,7 @@ func (g *Game) handleAccountName(sess *net.Session, input string) {
if err == nil {
sess.Account = &player.Account{Name: actualName}
sess.State = net.StatePassword
- sess.Conn.SetEcho(false)
+ sess.SetEcho(false)
sess.Write("Password: ")
} else {
if verr := player.ValidName(name); verr != nil {
@@ -30,7 +30,7 @@ func (g *Game) handleAccountName(sess *net.Session, input string) {
}
sess.Account = &player.Account{Name: name}
sess.State = net.StateNewAccountPass
- sess.Conn.SetEcho(false)
+ sess.SetEcho(false)
sess.WriteLine("\r\nOh, a new visitor to this rock!")
sess.Write("Choose password: ")
}
@@ -42,7 +42,7 @@ func (g *Game) handlePassword(sess *net.Session, input string) {
return
}
if len(input) > 128 {
- sess.Conn.SetEcho(true)
+ sess.SetEcho(true)
sess.WriteLine("Wrong password.")
sess.State = net.StateAccountName
sess.Write("Account name: ")
@@ -50,14 +50,14 @@ func (g *Game) handlePassword(sess *net.Session, input string) {
}
acc, err := g.AccountStore.LoadAccount(sess.Account.Name)
if err != nil {
- sess.Conn.SetEcho(true)
+ sess.SetEcho(true)
sess.WriteLine("Error loading account.")
sess.State = net.StateAccountName
sess.Write("Account name: ")
return
}
if !player.CheckPassword(input, acc.PasswordHash) {
- sess.Conn.SetEcho(true)
+ sess.SetEcho(true)
sess.WriteLine("Wrong password.")
sess.State = net.StateAccountName
sess.Write("Account name: ")
@@ -76,7 +76,7 @@ func (g *Game) handlePassword(sess *net.Session, input string) {
log.Printf("login: password rehash for %s: %v", acc.Name, hashErr)
}
}
- sess.Conn.SetEcho(true)
+ sess.SetEcho(true)
sess.Account = &player.Account{
Name: acc.Name,
PasswordHash: acc.PasswordHash,
@@ -101,7 +101,7 @@ func (g *Game) handlePassword(sess *net.Session, input string) {
func (g *Game) handleNewPass(sess *net.Session, input string) {
input = strings.TrimSpace(input)
if input == "" {
- sess.Conn.SetEcho(true)
+ sess.SetEcho(true)
sess.Account = nil
sess.State = net.StateAccountName
sess.Write("Account name: ")
@@ -120,14 +120,14 @@ func (g *Game) handleNewPass(sess *net.Session, input string) {
func (g *Game) handleNewAccount(sess *net.Session, input string) {
input = strings.TrimSpace(input)
if input == "" {
- sess.Conn.SetEcho(true)
+ sess.SetEcho(true)
sess.Account = nil
sess.State = net.StateAccountName
sess.Write("Account name: ")
return
}
if input != sess.PendingPass {
- sess.Conn.SetEcho(true)
+ sess.SetEcho(true)
sess.WriteLine("Passwords do not match.")
sess.Account = nil
sess.State = net.StateAccountName
@@ -137,7 +137,7 @@ func (g *Game) handleNewAccount(sess *net.Session, input string) {
hash, err := player.HashPassword(input)
if err != nil {
- sess.Conn.SetEcho(true)
+ sess.SetEcho(true)
sess.WriteLine(fmt.Sprintf("Error creating account: %v", err))
sess.Account = nil
sess.State = net.StateAccountName
@@ -150,7 +150,7 @@ func (g *Game) handleNewAccount(sess *net.Session, input string) {
// the admin API) may have created it in the meantime; saving without
// this check would overwrite that account's password and characters.
if _, findErr := g.AccountStore.FindAccount(sess.Account.Name); findErr == nil {
- sess.Conn.SetEcho(true)
+ sess.SetEcho(true)
sess.Account = nil
sess.State = net.StateAccountName
sess.WriteLine("That account name was just taken; please sign in instead.")
@@ -163,7 +163,7 @@ func (g *Game) handleNewAccount(sess *net.Session, input string) {
PasswordHash: hash,
}
if err := g.AccountStore.SaveAccount(acc); err != nil {
- sess.Conn.SetEcho(true)
+ sess.SetEcho(true)
sess.WriteLine(fmt.Sprintf("Error creating account: %v", err))
sess.Account = nil
sess.State = net.StateAccountName
@@ -171,7 +171,7 @@ func (g *Game) handleNewAccount(sess *net.Session, input string) {
return
}
- sess.Conn.SetEcho(true)
+ sess.SetEcho(true)
sess.Account = &player.Account{
Name: acc.Name,
PasswordHash: acc.PasswordHash,
diff --git a/internal/game/economy.go b/internal/game/economy.go
index 3d48605..0d5f08f 100644
--- a/internal/game/economy.go
+++ b/internal/game/economy.go
@@ -13,7 +13,14 @@ package game
// state was lost in the crash, so the stake returns to the player.
// - "payout": money movement (winnings, pushes, refunds). The record
// closes when the balance mutation is durable; an open payout at
-// startup is owed money and is re-applied.
+// startup is owed money and is re-applied. Durability is proven by
+// the character file itself, not by balance checkpoints: the same
+// save that includes the payout in the balance records the record's
+// ID in the character's open_payouts evidence list, and the entry is
+// dropped once the settled marker is durable. Checkpoints alone are
+// ambiguous — an unrelated credit change (shop sale, bank deposit)
+// can coincidentally land on the record's CreditsAfter balance, and
+// settling on that coincidence silently erases an owed payout.
//
// A crash at any point therefore leaves either:
// - an open record with the character file at its pre-transaction state
@@ -70,6 +77,13 @@ type economyRecord struct {
// the round resolved, closing the stake without any chip return.
Refund bool `json:"refund,omitempty"`
+ // Tracked marks a payout written under the character-file evidence
+ // scheme: the record's ID is recorded in the character's open_payouts
+ // list by the same save that includes its amount in the balance, and
+ // recovery uses that membership — not balance checkpoints — to decide
+ // whether the payout became durable.
+ Tracked bool `json:"tracked,omitempty"`
+
// Balance checkpoints used to decide recovery compensation.
CreditsBefore int `json:"credits_before"`
CreditsAfter int `json:"credits_after"`
@@ -84,12 +98,17 @@ type economyJournal struct {
// 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
+ // openPayouts indexes payout records that are still open (journaled but
+ // not provably durable). It drives evidence pruning: a character-file
+ // ID absent from this set is settled or void and must not be trusted as
+ // durability evidence.
+ openPayouts map[string]bool
}
func newEconomyJournal(dataDir string) *economyJournal {
dir := filepath.Join(dataDir, "economy")
_ = os.MkdirAll(dir, 0700)
- return &economyJournal{path: filepath.Join(dir, "journal.jsonl"), stakes: make(map[string][]*economyRecord)}
+ return &economyJournal{path: filepath.Join(dir, "journal.jsonl"), stakes: make(map[string][]*economyRecord), openPayouts: make(map[string]bool)}
}
func newEconomyRecordID() string {
@@ -153,6 +172,7 @@ func (j *economyJournal) startKind(kind string, refund bool, p *player.Player, c
PlayerID: p.ID,
Kind: kind,
Refund: refund,
+ Tracked: kind == "payout",
Chips: chips,
Credit: credits,
Status: "open",
@@ -164,13 +184,33 @@ func (j *economyJournal) startKind(kind string, refund bool, p *player.Player, c
if err := j.append(*rec); err != nil {
return nil, err
}
+ if rec.Tracked {
+ j.openPayouts[rec.ID] = true
+ }
return rec, nil
}
// settle marks a record settled (the balance mutation is durable).
+// Fire-and-forget: callers whose recovery semantics depend on the marker
+// being durable use settleDurable instead.
func (j *economyJournal) settle(rec *economyRecord) {
rec.Status = "settled"
_ = j.append(*rec)
+ delete(j.openPayouts, rec.ID)
+}
+
+// settleDurable marks a record settled and persists the marker with retries,
+// reporting whether the marker became durable. A false return leaves the
+// record open in the journal: callers keeping file-side evidence for this
+// record must NOT drop that evidence until this returns true.
+func (j *economyJournal) settleDurable(rec *economyRecord) bool {
+ rec.Status = "settled"
+ if err := j.appendRetry(*rec, 3); err != nil {
+ rec.Status = "open"
+ return false
+ }
+ delete(j.openPayouts, rec.ID)
+ return true
}
// void marks a record void (the mutation was reverted in memory and never
@@ -178,6 +218,7 @@ func (j *economyJournal) settle(rec *economyRecord) {
func (j *economyJournal) void(rec *economyRecord) {
rec.Status = "void"
_ = j.append(*rec)
+ delete(j.openPayouts, rec.ID)
}
// registerStake tracks an outstanding wager record for its player. Wager
@@ -257,11 +298,15 @@ func payoutIdentifies(a, b economyRecord) bool {
// recovery compaction.
func (j *economyJournal) reloadStakes() {
j.stakes = make(map[string][]*economyRecord)
+ j.openPayouts = make(map[string]bool)
for _, fr := range j.loadFolded() {
if fr.rec.Kind == "wager" && fr.rec.Status == "open" {
rec := fr.rec
j.registerStake(&rec)
}
+ if fr.rec.Kind == "payout" && fr.rec.Status == "open" {
+ j.openPayouts[fr.rec.ID] = true
+ }
}
}
@@ -399,6 +444,30 @@ func (g *Game) payCreditsDurable(p *player.Player, amount int) error {
return nil
}
+// applyOpenPayout re-applies an open TRACKED payout record during recovery:
+// checked credit increase plus a character save that records the record ID
+// in the file's open_payouts evidence list. A failed save reverts the
+// in-memory mutation (credits and evidence) so the record stays open and a
+// later recovery attempt retries cleanly.
+func (g *Game) applyOpenPayout(p *player.Player, rec *economyRecord) error {
+ if rec.Credit < 0 {
+ return fmt.Errorf("negative payout %d", rec.Credit)
+ }
+ sum, err := addCreditsChecked(p.Credits, rec.Credit)
+ if err != nil {
+ return err
+ }
+ before := p.Credits
+ p.Credits = sum
+ p.OpenPayouts = append(p.OpenPayouts, rec.ID)
+ if err := g.AccountStore.SaveCharacter(p); err != nil {
+ p.Credits = before
+ removeOpenPayout(p, rec.ID)
+ return err
+ }
+ return nil
+}
+
// addCreditsChecked is the game-side checked addition against the economy
// cap (mirrors casino.MaxCredits; kept local to avoid a dependency cycle).
func addCreditsChecked(a, b int) (int, error) {
@@ -412,6 +481,54 @@ func addCreditsChecked(a, b int) (int, error) {
return a + b, nil
}
+// openPayoutRecorded reports whether the character's durable evidence list
+// claims rec's amount is included in the balance.
+func openPayoutRecorded(p *player.Player, id string) bool {
+ for _, existing := range p.OpenPayouts {
+ if existing == id {
+ return true
+ }
+ }
+ return false
+}
+
+// removeOpenPayout drops id from the in-memory evidence list; the on-disk
+// copy is corrected by the next character save.
+func removeOpenPayout(p *player.Player, id string) {
+ kept := p.OpenPayouts[:0]
+ for _, existing := range p.OpenPayouts {
+ if existing != id {
+ kept = append(kept, existing)
+ }
+ }
+ p.OpenPayouts = kept
+}
+
+// pruneOpenPayouts drops evidence IDs that no longer correspond to an open
+// payout record (settled or void). It reports whether anything changed, so
+// callers only pay for a save when the file would actually differ.
+func pruneOpenPayouts(j *economyJournal, p *player.Player) bool {
+ if j == nil || len(p.OpenPayouts) == 0 {
+ return false
+ }
+ if j.openPayouts == nil {
+ j.openPayouts = make(map[string]bool)
+ }
+ kept := p.OpenPayouts[:0]
+ changed := false
+ for _, id := range p.OpenPayouts {
+ if j.openPayouts[id] {
+ kept = append(kept, id)
+ } else {
+ changed = true
+ }
+ }
+ if changed {
+ p.OpenPayouts = kept
+ }
+ return changed
+}
+
// recoverEconomy compensates open records from the last crash. It is called
// once from New, before any session connects.
//
@@ -439,6 +556,19 @@ func (g *Game) recoverEconomy() {
return
}
folded := g.Economy.loadFolded()
+ if g.Economy.openPayouts == nil {
+ g.Economy.openPayouts = make(map[string]bool)
+ } else {
+ for id := range g.Economy.openPayouts {
+ delete(g.Economy.openPayouts, id)
+ }
+ }
+ for i := range folded {
+ rec := &folded[i].rec
+ if rec.Kind == "payout" && rec.Status == "open" {
+ g.Economy.openPayouts[rec.ID] = true
+ }
+ }
// 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
@@ -455,6 +585,18 @@ func (g *Game) recoverEconomy() {
}
}
}
+ // finalizeLoaded prunes stale open-payout evidence the first time a
+ // character file is touched: IDs the journal shows as settled or void
+ // cannot prove anything and are dropped (with a durable save) so the
+ // list stays small and meaningful.
+ finalizeLoaded := func(p *player.Player) *player.Player {
+ if pruneOpenPayouts(g.Economy, p) {
+ if err := g.AccountStore.SaveCharacter(p); err != nil {
+ fmt.Fprintf(os.Stderr, "economy recovery: evidence prune save %s: %v\n", p.Name, err)
+ }
+ }
+ return p
+ }
loadPlayer := func(rec *economyRecord) *player.Player {
if rec.PlayerID != "" {
// A record carrying a stable ID resolves to THAT identity only.
@@ -463,7 +605,7 @@ func (g *Game) recoverEconomy() {
// character's payout and the original liability would be
// settled as paid. See TestRecoveryDoesNotPayReusedCharacterName.
if p, ok := playersByID[rec.PlayerID]; ok {
- return p
+ return finalizeLoaded(p)
}
// A character file added since the index was built: load every
// file once and match by stable ID.
@@ -472,7 +614,7 @@ func (g *Game) recoverEconomy() {
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
+ return finalizeLoaded(p)
}
}
}
@@ -486,7 +628,7 @@ func (g *Game) recoverEconomy() {
// name is the only identity available.
if rec.Player != "" {
if p, ok := playersByName[strings.ToLower(rec.Player)]; ok {
- return p
+ return finalizeLoaded(p)
}
}
fmt.Fprintf(os.Stderr, "economy recovery: load %s: character not found\n", rec.Player)
@@ -623,6 +765,35 @@ func (g *Game) recoverEconomy() {
if p == nil {
continue
}
+ if rec.Tracked {
+ // Tracked records carry durable evidence in the character file
+ // itself: the same save that includes the amount in the balance
+ // records the record ID in open_payouts. Membership decides.
+ // Balance checkpoints are NEVER consulted for tracked records:
+ // unrelated credit changes (shop sales, bank deposits, other
+ // games) can coincidentally land on CreditsAfter, and settling
+ // on that coincidence silently erases an owed payout.
+ if openPayoutRecorded(p, rec.ID) {
+ // The save landed; the amount is already in the balance.
+ g.Economy.settle(rec)
+ settled[rec.ID] = true
+ continue
+ }
+ if err := g.applyOpenPayout(p, rec); err != nil {
+ // Cap-blocked or transiently failed: the record stays open
+ // (no evidence entry was added) and a later recovery
+ // retries. A liability is never silently dropped.
+ fmt.Fprintf(os.Stderr, "economy recovery: tracked payout %s for %s not re-applied (%v) — kept open for operator inspection\n",
+ rec.ID, rec.Player, err)
+ } else {
+ if g.Economy.settleDurable(rec) {
+ removeOpenPayout(p, rec.ID)
+ }
+ compensated++
+ }
+ settled[rec.ID] = true
+ continue
+ }
sig := payoutSig{rec.key(), rec.CreditsBefore, rec.CreditsAfter, rec.Credit}
group := siblings[sig]
switch {
diff --git a/internal/game/economy_open_payout_list_test.go b/internal/game/economy_open_payout_list_test.go
new file mode 100644
index 0000000..893022f
--- /dev/null
+++ b/internal/game/economy_open_payout_list_test.go
@@ -0,0 +1,120 @@
+package game
+
+// Regressions for the character-file payout-evidence scheme: an owed payout
+// must never be erased because an unrelated credit change coincidentally
+// lands on the record's post-payout balance, and a payout whose save became
+// durable must never be re-applied (double-paid) just because its settled
+// journal marker was lost.
+
+import (
+ "os"
+ "testing"
+
+ "thehouseoficarus/internal/casino"
+ "thehouseoficarus/internal/player"
+)
+
+// newEvidenceHarness builds a minimal Game with just the account store and
+// economy journal — enough for casinoWallet and recoverEconomy.
+func newEvidenceHarness(t *testing.T) (*Game, *player.Player) {
+ t.Helper()
+ dir := t.TempDir()
+ if err := os.MkdirAll(dir+"/players/characters", 0o700); err != nil {
+ t.Fatal(err)
+ }
+ g := &Game{
+ Deps: Deps{AccountStore: player.NewAccountStore(dir)},
+ Economy: newEconomyJournal(dir),
+ }
+ p := player.New("alice")
+ p.ID = "stable-identity"
+ p.Credits = 90
+ if err := g.AccountStore.SaveCharacter(p); err != nil {
+ t.Fatal(err)
+ }
+ return g, p
+}
+
+// TestRecoveryReappliesPayoutWhenUnrelatedCreditsMaskTheBalance: a payout
+// whose save failed leaves an OPEN record at balance 90 (want 110). A later,
+// unrelated 20-credit gain brings the durable balance to 110 — the record's
+// CreditsAfter. Recovery must read the character-file evidence (absent: the
+// save never landed) and re-apply the payout, not settle it as "already
+// durable" and silently erase the 20 owed credits.
+func TestRecoveryReappliesPayoutWhenUnrelatedCreditsMaskTheBalance(t *testing.T) {
+ g, p := newEvidenceHarness(t)
+
+ // Force the payout save to fail without touching its journal.
+ path := g.AccountStore.CharPath(p.Name)
+ backup := path + ".backup"
+ if err := os.Rename(path, backup); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Mkdir(path, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ w := casinoWallet{g: g, p: p}
+ if err := w.PayCredits(20); err != casino.ErrWalletPersistence {
+ t.Fatalf("PayCredits error = %v, want ErrWalletPersistence", err)
+ }
+ if err := os.Remove(path); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Rename(backup, path); err != nil {
+ t.Fatal(err)
+ }
+ if p.Credits != 90 {
+ t.Fatalf("failed payout was paid: credits=%d, want 90", p.Credits)
+ }
+
+ // The unrelated credit change: a shop sale, a bank withdrawal, anything.
+ p.Credits += 20
+ if err := g.AccountStore.SaveCharacter(p); err != nil {
+ t.Fatal(err)
+ }
+
+ g.recoverEconomy()
+
+ loaded, err := g.AccountStore.LoadCharacter(p.Name)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if loaded.Credits != 130 {
+ t.Fatalf("balance after recovery = %d, want 130 (owed payout re-applied once)", loaded.Credits)
+ }
+ if open := g.Economy.loadOpen(); len(open) != 0 {
+ t.Fatalf("records left open after recovery: %+v", open)
+ }
+}
+
+// TestRecoverySettlesDurablePayoutWithoutReapplying: the payout save landed
+// (the character file records the record ID in open_payouts) but the settled
+// journal marker was lost in the crash. Recovery must settle from the file
+// evidence — re-applying would double-pay the win.
+func TestRecoverySettlesDurablePayoutWithoutReapplying(t *testing.T) {
+ g, p := newEvidenceHarness(t)
+
+ rec, err := g.Economy.start("payout", p, 0, 20, 90, 110, 0, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ // The save landed: balance and evidence both durable.
+ p.Credits = 110
+ p.OpenPayouts = append(p.OpenPayouts, rec.ID)
+ if err := g.AccountStore.SaveCharacter(p); err != nil {
+ t.Fatal(err)
+ }
+
+ g.recoverEconomy()
+
+ loaded, err := g.AccountStore.LoadCharacter(p.Name)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if loaded.Credits != 110 {
+ t.Fatalf("balance after recovery = %d, want 110 (durable payout not re-applied)", loaded.Credits)
+ }
+ if open := g.Economy.loadOpen(); len(open) != 0 {
+ t.Fatalf("records left open after recovery: %+v", open)
+ }
+}
diff --git a/internal/game/economy_recovery_regression_test.go b/internal/game/economy_recovery_regression_test.go
index 241b235..f818037 100644
--- a/internal/game/economy_recovery_regression_test.go
+++ b/internal/game/economy_recovery_regression_test.go
@@ -266,9 +266,18 @@ func TestRecoveryPreservesAmbiguousPayout(t *testing.T) {
if err := h.g.AccountStore.SaveCharacter(p); err != nil {
t.Error(err)
}
- if _, err := h.g.Economy.start("payout", p, 0, 20, 100, 120, 0, 0); err != nil {
+ rec, err := h.g.Economy.start("payout", p, 0, 20, 100, 120, 0, 0)
+ if err != nil {
t.Error(err)
+ return
}
+ // A LEGACY record (no character-file evidence, as written before the
+ // open_payouts scheme existed): checkpoint matching is the only
+ // evidence, balance 95 matches neither checkpoint, and the record
+ // stays open for operator inspection. Tracked records decide by
+ // file evidence instead — see economy_open_payout_list_test.go.
+ rec.Tracked = false
+ h.g.Economy.append(*rec)
h.g.recoverEconomy()
})
loaded, err := h.g.AccountStore.LoadCharacter("alice")
diff --git a/internal/net/conn.go b/internal/net/conn.go
index 6d78d8f..a7c53ec 100644
--- a/internal/net/conn.go
+++ b/internal/net/conn.go
@@ -92,6 +92,23 @@ func (t *tcpConn) SetEcho(on bool) error {
return t.writeTelnetCmd(will, echo)
}
+// EchoSequence returns the telnet negotiation bytes that switch the client's
+// echo state. Queued sessions transmit these through the session writer so a
+// stalled client cannot block the caller (see Session.SetEcho); the local
+// echo state is applied separately via SetEchoState.
+func (t *tcpConn) EchoSequence(on bool) []byte {
+ if on {
+ return []byte{iac, wont, echo}
+ }
+ return []byte{iac, will, echo}
+}
+
+// SetEchoState records the echo state without writing to the network; used
+// when the negotiation bytes are queued instead of written inline.
+func (t *tcpConn) SetEchoState(on bool) {
+ t.echoOff = !on
+}
+
const (
iac = 255
will = 251
diff --git a/internal/net/server.go b/internal/net/server.go
index adc828c..dd65f6e 100644
--- a/internal/net/server.go
+++ b/internal/net/server.go
@@ -89,7 +89,12 @@ type Session struct {
// world; an overflowing queue disconnects only that client. Sessions
// without a writer (tests, synthetic conns) keep the legacy synchronous
// path so message construction is shared and behavior identical.
- outbox chan string
+ outbox chan string
+ // closeCh is closed by Close() to tell the writer goroutine to drain the
+ // queue and then close the transport. It is ONLY ever closed, never sent
+ // to: enqueues select on it to drop late writes, so a close can never
+ // race a concurrent send the way closing outbox itself did.
+ closeCh chan struct{}
writerOnce sync.Once
closeOnce sync.Once
writeDone chan struct{}
@@ -143,16 +148,18 @@ const (
// startWriter puts the session into queued mode: writes are enqueued and a
// dedicated goroutine drains the queue to the network with per-message write
-// deadlines. The goroutine closes the transport after the queue drains, so a
-// Close() during shutdown flushes pending output before the socket drops.
+// deadlines. The goroutine exits after Close() — draining whatever was
+// queued first — and closes the transport, so a Close() during shutdown
+// flushes pending output before the socket drops.
// Must be called before the session's input goroutine starts.
func (sess *Session) StartWriter() {
sess.writerOnce.Do(func() {
sess.outbox = make(chan string, maxQueuedMessages)
+ sess.closeCh = make(chan struct{})
sess.writeDone = make(chan struct{})
go func() {
defer close(sess.writeDone)
- for msg := range sess.outbox {
+ writeOne := func(msg string) bool {
if dl, ok := sess.Conn.(interface {
SetWriteDeadline(time.Time) error
}); ok {
@@ -164,32 +171,52 @@ func (sess *Session) StartWriter() {
// goroutine notices the closed connection on its next
// read and performs normal removal.
sess.Conn.Close()
- return
+ return false
}
+ return true
}
- // Queue closed and drained: flush any final bytes and close.
- if dl, ok := sess.Conn.(interface {
- SetWriteDeadline(time.Time) error
- }); ok {
- _ = dl.SetWriteDeadline(time.Now().Add(closeFlushTimeout))
+ for {
+ select {
+ case msg := <-sess.outbox:
+ if !writeOne(msg) {
+ return
+ }
+ case <-sess.closeCh:
+ // Drain whatever was queued before the close, then
+ // flush any final bytes and close the transport.
+ for {
+ select {
+ case msg := <-sess.outbox:
+ if !writeOne(msg) {
+ return
+ }
+ default:
+ if dl, ok := sess.Conn.(interface {
+ SetWriteDeadline(time.Time) error
+ }); ok {
+ _ = dl.SetWriteDeadline(time.Now().Add(closeFlushTimeout))
+ }
+ sess.Conn.Close()
+ return
+ }
+ }
+ }
}
- sess.Conn.Close()
}()
})
}
// enqueue appends msg to the outbound queue. When the client's queue is full
// the session is disconnected instead of blocking the caller: the world must
-// never wait on a single slow client.
-// enqueue appends msg to the outbound queue. When the client's queue is
-// full the session is disconnected instead of blocking the caller: the world
-// must never wait on a single slow client.
+// never wait on a single slow client. Writes racing a Close() are dropped,
+// never panicked on: closeCh is only ever closed (never sent to), and outbox
+// itself is never closed, so no close/send race exists.
func (sess *Session) enqueue(msg string) {
- // A broadcast can race Close(): the queue may already be (or become)
- // closed mid-send. Such late writes are dropped, never panicked on —
- // Close() closes the queue so the writer goroutine drains whatever was
- // queued before the close and then flushes the transport.
- defer func() { _ = recover() }()
+ select {
+ case <-sess.closeCh:
+ return
+ default:
+ }
select {
case sess.outbox <- msg:
default:
@@ -628,6 +655,31 @@ func (sess *Session) dispatch(msg string) {
sess.Conn.Write([]byte(msg))
}
+// echoController is implemented by transports whose echo negotiation can be
+// expressed as bytes: queued sessions send those bytes through the session
+// writer so a stalled client cannot block the caller (the game owner),
+// while the local echo state is applied immediately via SetEchoState.
+type echoController interface {
+ EchoSequence(on bool) []byte
+ SetEchoState(on bool)
+}
+
+// SetEcho toggles client echo. Queued sessions transmit the negotiation
+// through the outbound queue (keeping prompt bookkeeping untouched); direct
+// sessions and transports without byte-expressible negotiation write inline.
+func (sess *Session) SetEcho(on bool) {
+ if sess.outbox != nil {
+ if ec, ok := sess.Conn.(echoController); ok {
+ ec.SetEchoState(on)
+ sess.writeMu.Lock()
+ defer sess.writeMu.Unlock()
+ sess.dispatch(string(ec.EchoSequence(on)))
+ return
+ }
+ }
+ _ = sess.Conn.SetEcho(on)
+}
+
// Write sends raw text with no wrapping. Used for prompts and partial lines.
func (sess *Session) Write(msg string) {
sess.writeMu.Lock()
@@ -690,15 +742,17 @@ func (sess *Session) Reprompt(prompt string) {
sess.promptVisible = true
}
-// Close stops the session. In queued mode it closes the queue so the writer
-// goroutine drains pending messages and then closes the transport; in direct
+// Close stops the session. In queued mode it signals the writer goroutine,
+// which drains pending messages and then closes the transport; in direct
// mode it closes the transport immediately. Idempotent.
func (sess *Session) Close() error {
- if sess.outbox != nil {
- sess.closeOnce.Do(func() { close(sess.outbox) })
- return nil
- }
var err error
- sess.closeOnce.Do(func() { err = sess.Conn.Close() })
+ sess.closeOnce.Do(func() {
+ if sess.closeCh != nil {
+ close(sess.closeCh)
+ return
+ }
+ err = sess.Conn.Close()
+ })
return err
}
diff --git a/internal/net/session_close_test.go b/internal/net/session_close_test.go
new file mode 100644
index 0000000..7cb54b6
--- /dev/null
+++ b/internal/net/session_close_test.go
@@ -0,0 +1,73 @@
+package net
+
+// Close/broadcast race regression: a broadcast racing Session.Close() used
+// to send on the outbound channel while Close closed the same channel — a
+// genuine data race (closechan vs chansend) that the old panic-recover only
+// hid. Closing is now signaled through a separate channel that is only ever
+// closed, never sent to.
+
+import (
+ stdnet "net"
+ "sync"
+ "testing"
+ "time"
+)
+
+// newTestSocketPair returns a synchronous, unbuffered connection pair: a
+// write blocks until the peer reads, which is exactly the stall condition
+// the echo test has to reproduce.
+func newTestSocketPair() (stdnet.Conn, stdnet.Conn) { return stdnet.Pipe() }
+
+func testTimeout() <-chan time.Time { return time.After(200 * time.Millisecond) }
+
+func TestConcurrentWriteCloseIsRaceFree(t *testing.T) {
+ for i := 0; i < 50; i++ {
+ sess := &Session{Conn: &testConn{}}
+ sess.StartWriter()
+ var wg sync.WaitGroup
+ wg.Add(2)
+ start := make(chan struct{})
+ go func() {
+ defer wg.Done()
+ <-start
+ for j := 0; j < 100; j++ {
+ sess.WriteLine("broadcast")
+ }
+ }()
+ go func() {
+ defer wg.Done()
+ <-start
+ sess.Close()
+ }()
+ close(start)
+ wg.Wait()
+ <-sess.writeDone
+ }
+}
+
+// Echo negotiation regression: login flips telnet echo on the game owner's
+// goroutine. Writing the negotiation inline made that write a direct socket
+// I/O, so a client that never read (full receive window, stalled socket)
+// blocked the entire game loop behind one connection. Queued sessions must
+// route the negotiation through the outbound writer instead.
+func TestSetEchoQueuedDoesNotBlockCaller(t *testing.T) {
+ local, peer := newTestSocketPair()
+ defer local.Close()
+ defer peer.Close()
+ sess := &Session{Conn: newTCPConn(local)}
+ sess.StartWriter()
+ defer sess.Close()
+
+ done := make(chan struct{})
+ go func() {
+ sess.SetEcho(false) // what the login path calls for password entry
+ close(done)
+ }()
+ select {
+ case <-done:
+ case <-testTimeout():
+ peer.Close() // unblock the writer so the test can exit
+ <-done
+ t.Fatal("SetEcho blocked the caller instead of queueing the negotiation")
+ }
+}
diff --git a/internal/net/wsconn.go b/internal/net/wsconn.go
index 34663ba..29d1947 100644
--- a/internal/net/wsconn.go
+++ b/internal/net/wsconn.go
@@ -38,8 +38,18 @@ func (w *wsConn) Close() error {
}
func (w *wsConn) SetEcho(on bool) error {
+ return w.conn.WriteMessage(websocket.TextMessage, w.EchoSequence(on))
+}
+
+// EchoSequence returns the browser echo-control bytes for the websocket
+// transport; queued sessions send these through the session writer so a
+// stalled browser cannot block the caller (see Session.SetEcho).
+func (w *wsConn) EchoSequence(on bool) []byte {
if on {
- return w.conn.WriteMessage(websocket.TextMessage, []byte("\x1b]10;0\x07"))
+ return []byte("\x1b]10;0\x07")
}
- return w.conn.WriteMessage(websocket.TextMessage, []byte("\x1b]10;1\x07"))
+ return []byte("\x1b]10;1\x07")
}
+
+// SetEchoState is a no-op: the websocket transport keeps no local echo state.
+func (w *wsConn) SetEchoState(bool) {}
diff --git a/internal/player/player.go b/internal/player/player.go
index ae147b2..daa7799 100644
--- a/internal/player/player.go
+++ b/internal/player/player.go
@@ -158,27 +158,33 @@ func GetOptionDef(name string) *OptionDef {
}
type Player struct {
- Name string `yaml:"name"`
+ Name string `yaml:"name"`
// ID is a stable identifier assigned at creation. Filenames are
// renameable; the ID is not, so cached references authorize by ID.
- ID string `yaml:"id,omitempty"`
+ ID string `yaml:"id,omitempty"`
// Account is the owning account name (case-canonical at write time).
- Account string `yaml:"account,omitempty"`
- Skills map[SkillName]int `yaml:"skills"`
- Inventory map[int]*InventorySlot `yaml:"inventory"`
- Bank map[int]*InventorySlot `yaml:"bank"`
- Equipment map[item.EquipSlot]string `yaml:"equipment"`
- EquipQuality map[string]int `yaml:"equip_quality,omitempty"`
- RoomID int `yaml:"room_id"`
- HP int `yaml:"hp"`
- Credits int `yaml:"credits"`
- BankedCredits int `yaml:"banked_credits"`
- Description string `yaml:"description"`
- AttackStyle AttackStyle `yaml:"attack_style"`
- Prompt string `yaml:"prompt"`
- Options map[string]any `yaml:"-"`
- Flags map[string]any `yaml:"flags"`
- PendingSequence *PendingSequence `yaml:"pending_sequence,omitempty"`
+ Account string `yaml:"account,omitempty"`
+ Skills map[SkillName]int `yaml:"skills"`
+ Inventory map[int]*InventorySlot `yaml:"inventory"`
+ Bank map[int]*InventorySlot `yaml:"bank"`
+ Equipment map[item.EquipSlot]string `yaml:"equipment"`
+ EquipQuality map[string]int `yaml:"equip_quality,omitempty"`
+ RoomID int `yaml:"room_id"`
+ HP int `yaml:"hp"`
+ Credits int `yaml:"credits"`
+ BankedCredits int `yaml:"banked_credits"`
+ // OpenPayouts is durable evidence for the economy journal (see
+ // internal/game/economy.go): the IDs of journaled payout records whose
+ // amounts are ALREADY INCLUDED in this file's credit balance. Recovery
+ // uses membership to prove a payout became durable instead of guessing
+ // from balance checkpoints, which unrelated credit changes can fake.
+ OpenPayouts []string `yaml:"open_payouts,omitempty"`
+ Description string `yaml:"description"`
+ AttackStyle AttackStyle `yaml:"attack_style"`
+ Prompt string `yaml:"prompt"`
+ Options map[string]any `yaml:"-"`
+ Flags map[string]any `yaml:"flags"`
+ PendingSequence *PendingSequence `yaml:"pending_sequence,omitempty"`
RegenerateTick int
AmmoQty int `yaml:"ammo_qty,omitempty"`
Action *behavior.Action `yaml:"-"`