diff options
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/admin/api_files.go | 28 | ||||
| -rw-r--r-- | internal/admin/api_rooms.go | 8 | ||||
| -rw-r--r-- | internal/admin/api_rooms_casino_evict_test.go | 27 | ||||
| -rw-r--r-- | internal/game/casino.go | 14 | ||||
| -rw-r--r-- | internal/game/cmd_room_remove.go | 4 | ||||
| -rw-r--r-- | internal/game/cmd_undig.go | 3 | ||||
| -rw-r--r-- | internal/game/core_login_account.go | 26 | ||||
| -rw-r--r-- | internal/game/economy.go | 181 | ||||
| -rw-r--r-- | internal/game/economy_open_payout_list_test.go | 120 | ||||
| -rw-r--r-- | internal/game/economy_recovery_regression_test.go | 11 | ||||
| -rw-r--r-- | internal/net/conn.go | 17 | ||||
| -rw-r--r-- | internal/net/server.go | 108 | ||||
| -rw-r--r-- | internal/net/session_close_test.go | 73 | ||||
| -rw-r--r-- | internal/net/wsconn.go | 14 | ||||
| -rw-r--r-- | internal/player/player.go | 42 |
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:"-"` |
