diff options
| author | historia <[not public]> | 2026-09-15 15:35:52 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-09-15 15:35:52 -0400 |
| commit | 0b0b25222b3131ada79ff78c28eb8bf07d977deb (patch) | |
| tree | 2ceaef5203595044f66a8f00064cd8e017f8f48d /internal/game | |
| parent | 396c3facb8287b9a34076cae8f3d7b300d366d99 (diff) | |
| download | thehouseoficarus-0b0b25222b3131ada79ff78c28eb8bf07d977deb.tar.gz | |
feat: single-owner game state, transactional lifecycle, durable money on server stop
Diffstat (limited to 'internal/game')
26 files changed, 2379 insertions, 183 deletions
diff --git a/internal/game/account_txn.go b/internal/game/account_txn.go new file mode 100644 index 0000000..488a5f6 --- /dev/null +++ b/internal/game/account_txn.go @@ -0,0 +1,380 @@ +package game + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "log" + "os" + "path/filepath" + "strings" + + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/persistence" + "thehouseoficarus/internal/player" +) + +// Account/character lifecycle operations. Each is one journal transaction +// over the affected files (account list, character files), executed with the +// game's single-owner guarantee: the online check, file mutation, registry +// update, and cross-session refresh happen without any interleaved session +// input, closing the check-then-rename gap. Save/remove errors are +// propagated to the caller, never ignored. +// +// All handlers here are invoked from session input (already on the owner). + +// journalDir is the persistence journal directory under the game data dir. +func (g *Game) journalDir() string { + return filepath.Join(g.DataDir, ".journal") +} + +// newTxn starts a lifecycle transaction. +func (g *Game) newTxn() *persistence.Transaction { + return persistence.NewTransaction(g.journalDir()) +} + +// adoptCharacterIdentity backfills stable identity fields on a loaded +// character: a UUID (assigned once at first load and persisted) and the +// owning account name. Legacy characters without these fields get them at +// first connection and are re-saved so subsequent authorizations can use +// them. Called from the owner goroutine only. +func (g *Game) adoptCharacterIdentity(p *player.Player, accountName string) { + if p.ID == "" { + p.ID = newCharacterID() + } + if p.Account == "" { + p.Account = accountName + } + if err := g.AccountStore.SaveCharacter(p); err != nil { + // Identity backfill is best-effort: the character remains usable, + // but membership validation falls back to the account list. + log.Printf("identity backfill for %s: %v", p.Name, err) + } +} + +// newCharacterID returns a random UUID (v4, formatted without dashes is +// unnecessary; standard hyphenated form is used). +func newCharacterID() string { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + panic(fmt.Sprintf("character id generation: %v", err)) + } + b[6] = (b[6] & 0x0f) | 0x40 // version 4 + b[8] = (b[8] & 0x3f) | 0x80 // RFC 4122 variant + s := hex.EncodeToString(b[:]) + return s[0:8] + "-" + s[8:12] + "-" + s[12:16] + "-" + s[16:20] + "-" + s[20:32] +} + +// backfillCharacterIDs migrates every character file at startup: assigns +// missing IDs and owner fields (owner derived from account character lists +// for legacy files) and persists them. +func (g *Game) backfillCharacterIDs() { + names, err := g.AccountStore.ListCharacterNames() + if err != nil { + return + } + owners := map[string]string{} // character (lower) -> owning account + accounts, _ := g.AccountStore.ListAccountNames() + for _, accName := range accounts { + acc, err := g.AccountStore.LoadAccount(accName) + if err != nil { + continue + } + for _, c := range acc.Characters { + owners[strings.ToLower(c)] = acc.Name + } + } + for _, name := range names { + p, err := g.AccountStore.LoadCharacter(name) + if err != nil { + log.Printf("character migration load %s: %v", name, err) + continue + } + changed := false + if p.ID == "" { + p.ID = newCharacterID() + changed = true + } + if p.Account == "" { + if owner, ok := owners[strings.ToLower(p.Name)]; ok { + p.Account = owner + changed = true + } + } + if changed { + if err := g.AccountStore.SaveCharacter(p); err != nil { + log.Printf("character migration save %s: %v", p.Name, err) + } + } + } +} + +// canonicAccount returns the account name in its canonical on-disk form. +func (g *Game) canonicalAccount(name string) (string, error) { + return g.AccountStore.FindAccount(name) +} + +// canonicChar returns the character name in its canonical on-disk form. +func (g *Game) canonicalChar(name string) (string, error) { + return g.AccountStore.FindCharacter(name) +} + +// createCharacterTxn creates the character file and appends it to the +// owning account in a single transaction. Returns the created player. +func (g *Game) createCharacterTxn(accountName, charName string, startingRoom int) (*player.Player, error) { + p := player.New(charName) + p.RoomID = startingRoom + p.Stats.RecordRoomVisit(startingRoom) + p.ID = newCharacterID() + p.Account = accountName + data, err := g.AccountStore.MarshalCharacter(p) + if err != nil { + return nil, err + } + txn := g.newTxn() + if err := txn.Replace(g.AccountStore.CharPath(charName), data, 0600); err != nil { + return nil, err + } + acc, err := g.AccountStore.LoadAccount(accountName) + if err != nil { + return nil, err + } + acc.Characters = append(acc.Characters, charName) + accData, err := g.AccountStore.MarshalAccount(acc) + if err != nil { + return nil, err + } + if err := txn.Replace(g.AccountStore.AccountPath(accountName), accData, 0600); err != nil { + return nil, err + } + return p, txn.Commit() +} + +// renameCharacterTxn atomically renames the character file, persists the new +// name inside it, and updates the owning account's character list. +func (g *Game) renameCharacterTxn(accountName, oldName, newName string) error { + txn := g.newTxn() + if err := txn.Move(g.AccountStore.CharPath(oldName), g.AccountStore.CharPath(newName)); err != nil { + return err + } + p, err := g.AccountStore.LoadCharacter(oldName) + if err != nil { + return err + } + p.Name = newName + data, err := g.AccountStore.MarshalCharacter(p) + if err != nil { + return err + } + if err := txn.Replace(g.AccountStore.CharPath(newName), data, 0600); err != nil { + return err + } + acc, err := g.AccountStore.LoadAccount(accountName) + if err != nil { + return err + } + chars := make([]string, 0, len(acc.Characters)) + for _, c := range acc.Characters { + if c == oldName { + chars = append(chars, newName) + } else { + chars = append(chars, c) + } + } + acc.Characters = chars + accData, err := g.AccountStore.MarshalAccount(acc) + if err != nil { + return err + } + if err := txn.Replace(g.AccountStore.AccountPath(accountName), accData, 0600); err != nil { + return err + } + return txn.Commit() +} + +// deleteCharacterTxn removes the character file and updates the account list +// atomically. +func (g *Game) deleteCharacterTxn(accountName, charName string) error { + txn := g.newTxn() + if err := txn.Delete(g.AccountStore.CharPath(charName)); err != nil { + return err + } + acc, err := g.AccountStore.LoadAccount(accountName) + if err != nil { + return err + } + var chars []string + for _, c := range acc.Characters { + if c != charName { + chars = append(chars, c) + } + } + acc.Characters = chars + accData, err := g.AccountStore.MarshalAccount(acc) + if err != nil { + return err + } + if err := txn.Replace(g.AccountStore.AccountPath(accountName), accData, 0600); err != nil { + return err + } + return txn.Commit() +} + +// purgeAccountTxn removes the account file and every character file in one +// transaction. +func (g *Game) purgeAccountTxn(accountName string, characters []string) error { + txn := g.newTxn() + for _, name := range characters { + if err := txn.Delete(g.AccountStore.CharPath(name)); err != nil { + return err + } + } + if err := txn.Delete(g.AccountStore.AccountPath(accountName)); err != nil { + return err + } + return txn.Commit() +} + +// renameAccountTxn moves the account file and rewrites its embedded name in +// one transaction. +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 { + return err + } + acc, err := g.AccountStore.LoadAccount(oldName) + if err != nil { + return err + } + acc.Name = newName + accData, err := g.AccountStore.MarshalAccount(acc) + if err != nil { + return err + } + if err := txn.Replace(g.AccountStore.AccountPath(newName), accData, 0600); err != nil { + return err + } + return txn.Commit() +} + +// isMemberOf reports whether characterName belongs to accountName by +// checking the character file's owner field (falling back to the account's +// character list for legacy files), case-insensitively. +func (g *Game) isMemberOf(accountName, characterName string) bool { + p, err := g.AccountStore.LoadCharacter(characterName) + if err != nil { + return false + } + if p.Account != "" { + return equalsFold(p.Account, accountName) + } + acc, err := g.AccountStore.LoadAccount(accountName) + if err != nil { + return false + } + for _, c := range acc.Characters { + if equalsFold(c, characterName) { + return true + } + } + return false +} + +// publishAccountUpdate reloads the account from disk and republishes its +// 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) + if err != nil { + // The account file vanished (purge from another session); the + // session is stale and must leave the menu. + sess.Account = nil + sess.PendingChar = "" + sess.PendingMenu = nil + sess.WriteLine("\nYour account no longer exists.") + sess.Close() + continue + } + sess.Account = &player.Account{ + Name: acc.Name, + PasswordHash: acc.PasswordHash, + Characters: acc.Characters, + Aliases: acc.Aliases, + Colors: acc.Colors, + Options: acc.Options, + Admin: acc.Admin, + } + if sess.Account.Aliases == nil { + sess.Account.Aliases = make(map[string]string) + } + if sess.Account.Colors == nil { + sess.Account.Colors = make(map[string]string) + } + if sess.Account.Options == nil { + sess.Account.Options = make(map[string]any) + } + // Any pending selection referencing a deleted/renamed character is + // stale now; revalidate before reuse. + sess.PendingMenu = nil + if sess.PendingChar != "" && sess.PendingChar != "connect" { + found := false + for _, c := range sess.Account.Characters { + if equalsFold(c, sess.PendingChar) { + found = true + break + } + } + if !found { + sess.PendingChar = "" + sess.State = net.StateMenu + g.showMenu(sess) + } + } + } +} + +// 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 { + 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) { + out = append(out, sess) + } + } + return out +} + +// equalsFold is a small case-insensitive string equality helper. +func equalsFold(a, b string) bool { + if len(a) != len(b) { + return false + } + for i := 0; i < len(a); i++ { + ca, cb := a[i], b[i] + if 'A' <= ca && ca <= 'Z' { + ca += 'a' - 'A' + } + if 'A' <= cb && cb <= 'Z' { + cb += 'a' - 'A' + } + if ca != cb { + return false + } + } + return true +} + +// recoverJournals rolls back leftover lifecycle journals at startup and +// surfaces unrecoverable ones. +func (g *Game) recoverJournals() { + if err := persistence.Recover(g.journalDir()); err != nil { + fmt.Fprintf(os.Stderr, "lifecycle journal recovery failed: %v\n", err) + os.Exit(1) + } +} diff --git a/internal/game/baseline_race_test.go b/internal/game/baseline_race_test.go new file mode 100644 index 0000000..de5b7b4 --- /dev/null +++ b/internal/game/baseline_race_test.go @@ -0,0 +1,132 @@ +package game + +// Owner-boundary concurrency regressions. These exercise the competing +// runtime entry points the way production does (connection input, ticks, +// disconnects, admin mutations, owner-routed wallet operations) and must +// pass under -race with the single-owner design in place. + +import ( + "fmt" + "sync" + "testing" + "thehouseoficarus/internal/net" +) + +// TestBaselineRaceSessionInputTickDisconnect exercises the three competing +// runtime entry points against each other: connection-goroutine input, the +// ticker goroutine, and hub disconnect callbacks. +func TestBaselineRaceSessionInputTickDisconnect(t *testing.T) { + h := newHarness(t) + h.seedAccount("alice", "pw", false) + h.seedAccount("bob", "pw", false) + h.seedCharacter("alice", "alice") + h.seedCharacter("bob", "bob") + + aSess, _ := h.connect("alice") + bSess, _ := h.connect("bob") + + aLines := h.goInput(aSess, 1) + bLines := h.goInput(bSess, 1) + + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + for i := 0; i < 200; i++ { + aLines <- "look" + } + close(aLines) + }() + go func() { + defer wg.Done() + for i := 0; i < 200; i++ { + bLines <- "say hi" + } + close(bLines) + }() + + // Disconnect bob from a third goroutine while everything else runs. + go func() { + for i := 0; i < 50; i++ { + h.hub.PlayersInRoom(1) + if i == 25 { + h.hub.Remove(bSess) + } + } + }() + + wg.Wait() +} + +// TestBaselineRaceAdminRewriteWhilePlaying hammers Game.RewriteRoomIDs (the +// admin entry point) while players keep moving and ticking. +func TestBaselineRaceAdminRewriteWhilePlaying(t *testing.T) { + h := newHarness(t) + h.seedAccount("carol", "pw", false) + h.seedCharacter("carol", "carol") + sess, _ := h.connect("carol") + lines := h.goInput(sess, 1) + + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + for i := 0; i < 200; i++ { + lines <- fmt.Sprintf("go north") + } + close(lines) + }() + + // Concurrent admin room-ID rewrites targeting the player's live state. + for i := 0; i < 20; i++ { + h.g.RewriteRoomIDs(map[int]int{2: 2 + i + 1, 2 + i: 2 + i + 1}) + } + + wg.Wait() +} + +// TestBaselineRaceCasinoWallet drives concurrent casino activity through +// the owner boundary (as production does): wallet debits/credits from +// multiple players' session tasks plus session input, all on one owner. +func TestBaselineRaceCasinoWallet(t *testing.T) { + h := newHarness(t) + for _, n := range []string{"dave", "erin", "frank"} { + h.seedAccount(n, "pw", false) + p := h.seedCharacter(n, n) + p.Credits = 100000 + } + sessions := make([]*net.Session, 0, 3) + for _, n := range []string{"dave", "erin", "frank"} { + s, _ := h.connect(n) + sessions = append(sessions, s) + } + + var wg sync.WaitGroup + for _, s := range sessions { + wallet := casinoWallet{g: h.g, p: s.Player} + wg.Add(1) + go func() { + defer wg.Done() + for i := 0; i < 100; i++ { + h.g.RunOnOwner(func() { + wallet.Wager(1) + wallet.PayCredits(1) + }) + } + }() + + // Session input competes with the wallet operations. + lines := h.goInput(s, 1) + wg.Add(1) + go func() { + defer wg.Done() + for i := 0; i < 100; i++ { + lines <- "look" + } + close(lines) + }() + } + + wg.Wait() + h.barrier() +} diff --git a/internal/game/casino.go b/internal/game/casino.go index eb6fa4a..7442178 100644 --- a/internal/game/casino.go +++ b/internal/game/casino.go @@ -18,29 +18,76 @@ type casinoWallet struct { p *player.Player } -func (w casinoWallet) Wager(amount int) (casino.Wager, bool) { +// Wager debits the stake durably. Order of operations: durable journal +// record FIRST (with balance checkpoints), then the in-memory mutation, +// then the character save, then the settlement marker. A failed save +// reverts the in-memory mutation and voids the record: no charge is kept +// in memory, and the error distinguishes persistence failure from +// insufficient funds. +func (w casinoWallet) Wager(amount int) (casino.Wager, error) { if amount <= 0 { - return casino.Wager{}, false + return casino.Wager{}, casino.ErrInsufficientFunds } - chips := countChips(w.p) + creditsBefore := w.p.Credits + chipsBefore := countChips(w.p) + chips := chipsBefore if chips > amount { chips = amount } credits := amount - chips - if w.p.Credits < credits { - return casino.Wager{}, false + if creditsBefore < credits { + return casino.Wager{}, casino.ErrInsufficientFunds + } + rec, err := w.g.Economy.start("wager", w.p, chips, credits, + creditsBefore, creditsBefore-credits, chipsBefore, chipsBefore-chips) + if err != nil { + return casino.Wager{}, casino.ErrWalletPersistence } if chips > 0 { w.p.RemoveItem("chips", chips) } w.p.Credits -= credits - w.g.AccountStore.SaveCharacter(w.p) - return casino.Wager{Total: amount, Chips: chips, Credits: credits}, true + if err := w.g.AccountStore.SaveCharacter(w.p); err != nil { + // Revert the in-memory mutation: the play must not proceed on a + // stake that could not be made durable. + if chips > 0 { + w.g.giveChips(w.p, chips) + } + w.p.Credits += credits + w.g.Economy.void(rec) + return casino.Wager{}, casino.ErrWalletPersistence + } + w.g.Economy.settle(rec) + return casino.Wager{Total: amount, Chips: chips, Credits: credits}, nil } -func (w casinoWallet) PayCredits(amount int) { - w.p.Credits += amount - w.g.AccountStore.SaveCharacter(w.p) +// PayCredits applies a payout durably. The journal record is written first; +// 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 { + if amount <= 0 { + return nil + } + creditsBefore := w.p.Credits + chipsBefore := countChips(w.p) + creditsAfter, err := addCreditsChecked(creditsBefore, amount) + if err != nil { + return err + } + rec, err := w.g.Economy.start("payout", w.p, 0, amount, + creditsBefore, creditsAfter, chipsBefore, chipsBefore) + if err != nil { + return casino.ErrWalletPersistence + } + w.p.Credits = creditsAfter + if err := w.g.AccountStore.SaveCharacter(w.p); err != nil { + w.p.Credits = creditsBefore + // 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) + return nil } // casinoAffordable is the most a player can wager: chips are spent first, diff --git a/internal/game/casino_test.go b/internal/game/casino_test.go index e43eb05..026415f 100644 --- a/internal/game/casino_test.go +++ b/internal/game/casino_test.go @@ -3,6 +3,7 @@ package game import ( "strings" "testing" + "time" "thehouseoficarus/internal/casino" "thehouseoficarus/internal/color" @@ -19,6 +20,8 @@ func (c *casinoTestConn) Write(b []byte) (int, error) { c.data = append(c.data, b...) return len(b), nil } +func (c *casinoTestConn) SetWriteDeadline(time.Time) error { return nil } + func (c *casinoTestConn) Close() error { return nil } func (c *casinoTestConn) SetEcho(bool) error { return nil } diff --git a/internal/game/cmd_quit.go b/internal/game/cmd_quit.go index 0eb8fb9..80ad351 100644 --- a/internal/game/cmd_quit.go +++ b/internal/game/cmd_quit.go @@ -30,7 +30,7 @@ func (g *Game) doQuit(sess *net.Session) { g.broadcastAction(sess, "%s sits down to rest.", p.Name) ticksLeft := 10 - id := g.Ticks.Subscribe(engine.ToTicks(1), func() bool { + id := g.SubscribeTicks(engine.ToTicks(1), func() bool { switch ticksLeft { case 10: sess.WriteLine("You sit down to rest...") diff --git a/internal/game/cmd_registry.go b/internal/game/cmd_registry.go index 0fee89e..9a42d1e 100644 --- a/internal/game/cmd_registry.go +++ b/internal/game/cmd_registry.go @@ -181,7 +181,10 @@ func classifyCommand(cmd string) CommandClass { if _, ok := verbAliases[cmd]; ok { return ClassActive } - if obstacleVerbs[cmd] { + obstacleVerbsMu.RLock() + isObstacle := obstacleVerbs[cmd] + obstacleVerbsMu.RUnlock() + if isObstacle { return ClassActive } return ClassUnknown @@ -211,7 +214,10 @@ func (g *Game) executeCommand(sess *net.Session, cmd string, args []string, rawI return } - if obstacleVerbs[cmd] { + obstacleVerbsMu.RLock() + isObstacle := obstacleVerbs[cmd] + obstacleVerbsMu.RUnlock() + if isObstacle { g.doObstacle(sess, cmd) return } diff --git a/internal/game/cmd_room_remove_test.go b/internal/game/cmd_room_remove_test.go index 8bd8470..cd9569d 100644 --- a/internal/game/cmd_room_remove_test.go +++ b/internal/game/cmd_room_remove_test.go @@ -6,6 +6,7 @@ import ( "strconv" "strings" "testing" + "time" "thehouseoficarus/internal/combat" "thehouseoficarus/internal/net" @@ -21,6 +22,8 @@ func (c *removeTestConn) Write(b []byte) (int, error) { c.buf = append(c.buf, b...) return len(b), nil } +func (c *removeTestConn) SetWriteDeadline(time.Time) error { return nil } + func (c *removeTestConn) Close() error { return nil } func (c *removeTestConn) SetEcho(bool) error { return nil } diff --git a/internal/game/cmd_shutdown.go b/internal/game/cmd_shutdown.go index 0bb0f8f..0030649 100644 --- a/internal/game/cmd_shutdown.go +++ b/internal/game/cmd_shutdown.go @@ -1,14 +1,24 @@ package game +// The shutdown command schedules a graceful server shutdown. Signals and +// this command converge on Game.RequestShutdown, so both paths drain work, +// resolve outstanding wagers, and save owned state before transports close. + import ( "fmt" - "os" "strconv" "time" "thehouseoficarus/internal/net" ) +// scheduledShutdown carries one countdown run's captured cancel channel, so +// a later run (or cancel) can never close or read a replacement run's +// channel. +type scheduledShutdown struct { + cancel chan struct{} +} + func (g *Game) executeShutdown(sess *net.Session, args []string, rawInput string) { if !g.checkAdmin(sess) { sess.WriteLine("Unknown command.") @@ -21,20 +31,17 @@ func (g *Game) executeShutdown(sess *net.Session, args []string, rawInput string if args[0] == "cancel" { g.shutdownMu.Lock() - if !g.shutdownActive { - g.shutdownMu.Unlock() + run := g.shutdownRun + g.shutdownMu.Unlock() + if run == nil { sess.WriteLine("No shutdown in progress.") return } - close(g.shutdownCancel) - g.shutdownActive = false + close(run.cancel) + g.shutdownMu.Lock() + g.shutdownRun = nil g.shutdownMu.Unlock() - - for _, other := range g.Hub.AllSessions() { - if other.Player != nil { - other.WriteLine("[Server] Shutdown cancelled.") - } - } + g.broadcastLoggedIn("[Server] Shutdown cancelled.") return } @@ -45,58 +52,62 @@ func (g *Game) executeShutdown(sess *net.Session, args []string, rawInput string } g.shutdownMu.Lock() - if g.shutdownActive { + if g.shutdownRun != nil { g.shutdownMu.Unlock() sess.WriteLine("A shutdown is already in progress.") return } - g.shutdownActive = true - g.shutdownCancel = make(chan struct{}) + run := &scheduledShutdown{cancel: make(chan struct{})} + g.shutdownRun = run g.shutdownMu.Unlock() - go func() { - remaining := minutes * 60 - ticker := time.NewTicker(30 * time.Second) - defer ticker.Stop() + go g.runShutdownCountdown(run, minutes) + sess.WriteLine(fmt.Sprintf("Shutdown scheduled in %d minute(s).", minutes)) +} - for _, other := range g.Hub.AllSessions() { - if other.Player != nil { - other.WriteLine(fmt.Sprintf("[Server] Shutting down in %d minute(s).", minutes)) - } - } +// runShutdownCountdown announces the countdown and requests the shutdown +// at expiry. Cancellation is handled through the run's captured channel; +// nothing reads a replaceable shared field here. +func (g *Game) runShutdownCountdown(run *scheduledShutdown, minutes int) { + remaining := minutes * 60 + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() - for { - select { - case <-ticker.C: - remaining -= 30 - if remaining <= 0 { - for _, other := range g.Hub.AllSessions() { - if other.Player != nil { - other.WriteLine("[Server] Shutting down NOW.") - } - } - os.Exit(0) - } - mins := remaining / 60 - secs := remaining % 60 - if secs == 0 { - for _, other := range g.Hub.AllSessions() { - if other.Player != nil { - other.WriteLine(fmt.Sprintf("[Server] Shutting down in %d minute(s).", mins)) - } - } - } else { - for _, other := range g.Hub.AllSessions() { - if other.Player != nil { - other.WriteLine(fmt.Sprintf("[Server] Shutting down in %d minute(s) %d seconds.", mins, secs)) - } - } + g.broadcastLoggedIn(fmt.Sprintf("[Server] Shutting down in %d minute(s).", minutes)) + + for { + select { + case <-ticker.C: + remaining -= 30 + if remaining <= 0 { + g.broadcastLoggedIn("[Server] Shutting down NOW.") + if g.shutdownHook != nil { + g.shutdownHook("scheduled shutdown") } - case <-g.shutdownCancel: return } + mins := remaining / 60 + secs := remaining % 60 + if secs == 0 { + g.broadcastLoggedIn(fmt.Sprintf("[Server] Shutting down in %d minute(s).", mins)) + } else { + g.broadcastLoggedIn(fmt.Sprintf("[Server] Shutting down in %d minute(s) %d seconds.", mins, secs)) + } + case <-run.cancel: + return } - }() + } +} - sess.WriteLine(fmt.Sprintf("Shutdown scheduled in %d minute(s).", minutes)) +// broadcastLoggedIn writes one line to every logged-in player. Read-only +// over Hub membership (mutex-protected); writes go through outbound queues. +func (g *Game) broadcastLoggedIn(msg string) { + if g.Hub == nil { + return + } + for _, other := range g.Hub.AllSessions() { + if other.Player != nil { + other.WriteLine(msg) + } + } } diff --git a/internal/game/combat_attack.go b/internal/game/combat_attack.go index 3544fcc..bd16999 100644 --- a/internal/game/combat_attack.go +++ b/internal/game/combat_attack.go @@ -193,7 +193,7 @@ func (g *Game) startCombat(sess *net.Session, p *player.Player, mob *world.MobIn g.broadcastAction(sess, "%s attacks %s!", p.Name, mobDisplayName(mob, true)) } - g.Ticks.Subscribe(1, func() bool { + g.SubscribeTicks(1, func() bool { cs := g.Combat.Get(p.Name) if cs == nil || !cs.Active { return false @@ -255,7 +255,7 @@ func (g *Game) startCombat(sess *net.Session, p *player.Player, mob *world.MobIn }) if !isTask { - g.Ticks.Subscribe(engine.ToTicks(mob.Speed), func() bool { + g.SubscribeTicks(engine.ToTicks(mob.Speed), func() bool { cs := g.Combat.Get(p.Name) if cs == nil || !cs.Active { return false diff --git a/internal/game/combat_mob.go b/internal/game/combat_mob.go index dabb7d7..7d60a11 100644 --- a/internal/game/combat_mob.go +++ b/internal/game/combat_mob.go @@ -318,7 +318,7 @@ func (g *Game) awardKillDrops(sess *net.Session, p *player.Player, mob *world.Mo func (g *Game) scheduleMobRespawn(mob *world.MobInstance) { instanceID := mob.InstanceID if mob.SpawnedByTrigger { - g.Ticks.Subscribe(10, func() bool { + g.SubscribeTicks(10, func() bool { g.MobStore.RemoveInstance(instanceID) return false }) @@ -328,7 +328,7 @@ func (g *Game) scheduleMobRespawn(mob *world.MobInstance) { if respawnTicks <= 0 { respawnTicks = engine.ToTicks(30) } - g.Ticks.Subscribe(respawnTicks, func() bool { + g.SubscribeTicks(respawnTicks, func() bool { g.respawnMob(instanceID) return false }) diff --git a/internal/game/core_course.go b/internal/game/core_course.go index 232e6dc..15bd47e 100644 --- a/internal/game/core_course.go +++ b/internal/game/core_course.go @@ -70,6 +70,10 @@ type ObstacleInfo struct { var obstacleVerbs = map[string]bool{} +// obstacleVerbsMu guards the global obstacle-verb set: course loading +// rebuilds it, and command classification reads it from session tasks. +var obstacleVerbsMu sync.RWMutex + var verbGerund = map[string]string{ "scramble": "scrambling", "jump": "jumping", @@ -251,5 +255,7 @@ func (cs *CourseStore) loadAllLocked() { return nil }) + obstacleVerbsMu.Lock() obstacleVerbs = localVerbs + obstacleVerbsMu.Unlock() } diff --git a/internal/game/core_login_account.go b/internal/game/core_login_account.go index 77b72d3..a846f4d 100644 --- a/internal/game/core_login_account.go +++ b/internal/game/core_login_account.go @@ -2,7 +2,6 @@ package game import ( "fmt" - "os" "strings" "thehouseoficarus/internal/color" @@ -332,34 +331,19 @@ func (g *Game) handleRenameAccount(sess *net.Session, input string) { return } - oldPath := g.AccountStore.AccountPath(oldName) - newPath := g.AccountStore.AccountPath(newName) - if err := os.Rename(oldPath, newPath); err != nil { + // One journal transaction: file move + embedded name rewrite. Any + // failure rolls back, so the account is never half-renamed. + if err := g.renameAccountTxn(oldName, newName); err != nil { sess.WriteLine(fmt.Sprintf("Error renaming account: %v", err)) g.showMenu(sess) return } - // Persist the new name inside the account file itself; otherwise the next - // login adopts the old name again and account lookup drifts apart from - // the filename. Roll the rename back if the file cannot be updated so the - // account never ends up in a half-renamed state. - acc, err := g.AccountStore.LoadAccount(newName) - if err != nil { - sess.WriteLine(fmt.Sprintf("Error renaming account: %v", err)) - os.Rename(newPath, oldPath) - g.showMenu(sess) - return - } - acc.Name = newName - if err := g.AccountStore.SaveAccount(acc); err != nil { - sess.WriteLine(fmt.Sprintf("Error renaming account: %v", err)) - os.Rename(newPath, oldPath) - g.showMenu(sess) - return - } + // 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) - sess.Account.Name = newName sess.WriteLine(fmt.Sprintf("Account renamed to %s.", newName)) g.showMenu(sess) } @@ -373,8 +357,10 @@ func (g *Game) handlePurgeAccount(sess *net.Session, input string) { return } - // Refuse to purge while any character is connected: a live session would - // keep playing (and saving) a character whose file is about to vanish. + // Refuse to purge while any character is connected: a live session + // would keep playing (and saving) a character whose file is about to + // vanish. The online check and the purge run as one owner step, so no + // connection can start a character between check and deletion. for _, name := range sess.Account.Characters { if g.characterOnline(name) { sess.WriteLine(fmt.Sprintf("%s is logged in. Log all characters out before purging.", name)) @@ -383,12 +369,24 @@ func (g *Game) handlePurgeAccount(sess *net.Session, input string) { } } - for _, name := range sess.Account.Characters { - os.Remove(g.AccountStore.CharPath(name)) + if err := g.purgeAccountTxn(sess.Account.Name, sess.Account.Characters); err != nil { + sess.WriteLine(fmt.Sprintf("Error purging account: %v", err)) + g.showMenu(sess) + return } - os.Remove(g.AccountStore.AccountPath(sess.Account.Name)) - sess.WriteLine(fmt.Sprintf("Account %s has been purged. Thanks for playing.", sess.Account.Name)) + // Other authenticated sessions of this account are stale now; tell + // them and close them (they cannot safely keep using cached state). + for _, other := range g.allSessionsOfAccount(sess.Account.Name) { + if other == sess { + continue + } + other.Account = nil + other.PendingChar = "" + other.PendingMenu = nil + other.WriteLine("\nYour account has been purged from another session.") + other.Close() + } sess.Close() } diff --git a/internal/game/core_login_char.go b/internal/game/core_login_char.go index 9d7c211..68a0d58 100644 --- a/internal/game/core_login_char.go +++ b/internal/game/core_login_char.go @@ -2,7 +2,6 @@ package game import ( "fmt" - "os" "strings" "thehouseoficarus/internal/net" @@ -34,33 +33,26 @@ func (g *Game) handleNewCharName(sess *net.Session, input string) { return } + // Everything below runs on the owner goroutine (session input): the + // existence check, file creation, and account publication are one + // transaction with no interleaved session input, so a concurrent + // creation of the same name cannot slip through the check. if g.AccountStore.CharacterExists(name) { sess.WriteLine("A character with that name already exists.") sess.Write("Character name: ") return } - p := player.New(name) - p.RoomID = g.StartingRoom - p.Stats.RecordRoomVisit(g.StartingRoom) - - if err := g.AccountStore.SaveCharacter(p); err != nil { - sess.WriteLine(fmt.Sprintf("Error creating character: %v", err)) - sess.State = net.StateMenu - g.showMenu(sess) - return - } - - acc, err := g.AccountStore.LoadAccount(sess.Account.Name) + p, err := g.createCharacterTxn(sess.Account.Name, name, g.StartingRoom) if err != nil { sess.WriteLine(fmt.Sprintf("Error creating character: %v", err)) sess.State = net.StateMenu g.showMenu(sess) return } - acc.Characters = append(acc.Characters, name) - g.AccountStore.SaveAccount(acc) - sess.Account.Characters = acc.Characters + _ = p + sess.Account.Characters = append(sess.Account.Characters, name) + g.publishAccountUpdate(sess.Account.Name) g.connectCharacter(sess, name) @@ -70,6 +62,29 @@ func (g *Game) handleNewCharName(sess *net.Session, input string) { } func (g *Game) connectCharacter(sess *net.Session, name string) { + // This whole function runs as one owner step (invoked from session + // input): membership validation, the online check, registry + // publication, and room entry are serialized against every other + // lifecycle operation, closing the check-then-register gap. + + // Authorize by ownership, not just existence: the character file's + // owner must match this account, so a stale cached name cannot connect + // a character reassigned to another account. + actualName, err := g.canonicalChar(name) + if err != nil { + sess.WriteLine(fmt.Sprintf("Error loading character: %v", err)) + sess.State = net.StateMenu + g.showMenu(sess) + return + } + if !g.isMemberOf(sess.Account.Name, actualName) { + sess.WriteLine("That character does not belong to this account.") + sess.State = net.StateMenu + g.showMenu(sess) + return + } + name = actualName + g.charsMu.Lock() if existing := g.loggedInChars[name]; existing != nil { g.charsMu.Unlock() @@ -87,6 +102,7 @@ func (g *Game) connectCharacter(sess *net.Session, name string) { g.showMenu(sess) return } + g.adoptCharacterIdentity(p, sess.Account.Name) sess.Player = p sess.State = net.StateGame @@ -171,49 +187,45 @@ func (g *Game) handleRenameCharName(sess *net.Session, input string) { g.showMenu(sess) return } - if g.AccountStore.CharacterExists(newName) { - sess.WriteLine("A character with that name already exists.") - sess.Write("New name: ") + + // Checks and mutation run as one owner step: no session input can + // interleave between the membership/online checks and the file + // rename, and the rename is a single journal transaction. + actualOld, err := g.canonicalChar(oldName) + if err != nil { + sess.WriteLine(fmt.Sprintf("Error renaming: %v", err)) + g.showMenu(sess) return } - if g.characterOnline(oldName) { - sess.WriteLine("That character is logged in elsewhere. Log them out first.") + if !g.isMemberOf(sess.Account.Name, actualOld) { + sess.WriteLine(fmt.Sprintf("No character named %s on this account.", oldName)) sess.PendingChar = "" g.showMenu(sess) return } - - oldPath := g.AccountStore.CharPath(oldName) - newPath := g.AccountStore.CharPath(newName) - if err := os.Rename(oldPath, newPath); err != nil { - sess.WriteLine(fmt.Sprintf("Error renaming: %v", err)) - g.showMenu(sess) + if newName != actualOld && g.AccountStore.CharacterExists(newName) { + sess.WriteLine("A character with that name already exists.") + sess.Write("New name: ") return } - - p, _ := g.AccountStore.LoadCharacter(newName) - if p != nil { - p.Name = newName - g.AccountStore.SaveCharacter(p) + if g.characterOnline(actualOld) { + sess.WriteLine("That character is logged in elsewhere. Log them out first.") + sess.PendingChar = "" + g.showMenu(sess) + return } - acc, err := g.AccountStore.LoadAccount(sess.Account.Name) - if err != nil { + if err := g.renameCharacterTxn(sess.Account.Name, actualOld, newName); err != nil { sess.WriteLine(fmt.Sprintf("Error renaming: %v", err)) g.showMenu(sess) return } - for i, c := range acc.Characters { - if c == oldName { - acc.Characters[i] = newName - break - } - } - g.AccountStore.SaveAccount(acc) - sess.Account.Characters = acc.Characters sess.PendingChar = "" - sess.WriteLine(fmt.Sprintf("%s renamed to %s.", oldName, newName)) + 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.showMenu(sess) } @@ -256,39 +268,37 @@ func (g *Game) handleDeleteChar(sess *net.Session, input string) { return } - if g.characterOnline(sess.PendingChar) { - sess.WriteLine("That character is logged in elsewhere. Log them out first.") + // Checks and mutation run as one owner step; the delete is a single + // journal transaction (character file + account list). + actualName, err := g.canonicalChar(sess.PendingChar) + if err != nil { + sess.WriteLine(fmt.Sprintf("Error deleting: %v", err)) sess.PendingChar = "" g.showMenu(sess) return } - - charPath := g.AccountStore.CharPath(sess.PendingChar) - if err := os.Remove(charPath); err != nil { - sess.WriteLine(fmt.Sprintf("Error deleting: %v", err)) + if !g.isMemberOf(sess.Account.Name, actualName) { + sess.WriteLine(fmt.Sprintf("No character named %s on this account.", sess.PendingChar)) + sess.PendingChar = "" + g.showMenu(sess) + return + } + if g.characterOnline(actualName) { + sess.WriteLine("That character is logged in elsewhere. Log them out first.") sess.PendingChar = "" g.showMenu(sess) return } - acc, err := g.AccountStore.LoadAccount(sess.Account.Name) - if err != nil { + if err := g.deleteCharacterTxn(sess.Account.Name, actualName); err != nil { sess.WriteLine(fmt.Sprintf("Error deleting: %v", err)) sess.PendingChar = "" g.showMenu(sess) return } - var newChars []string - for _, c := range acc.Characters { - if c != sess.PendingChar { - newChars = append(newChars, c) - } - } - acc.Characters = newChars - g.AccountStore.SaveAccount(acc) - sess.Account.Characters = newChars - sess.WriteLine(fmt.Sprintf("%s has been deleted.", sess.PendingChar)) + sess.WriteLine(fmt.Sprintf("%s has been deleted.", actualName)) sess.PendingChar = "" + g.publishAccountUpdate(sess.Account.Name) g.showMenu(sess) } diff --git a/internal/game/economy.go b/internal/game/economy.go new file mode 100644 index 0000000..90c24ed --- /dev/null +++ b/internal/game/economy.go @@ -0,0 +1,289 @@ +package game + +// The economy journal: durable, idempotent money movement. Every casino +// wager and payout writes a journal record BEFORE the balance mutation, +// then the character save makes the balance durable, then the record is +// settled. A crash at any point leaves either: +// - an open record with the character file at its pre-transaction state +// (crash before save) — recovery settles it as void (debit) or applies +// it (payout, which the player is owed); or +// - an open record with the character file at its post-transaction state +// (crash after save) — recovery refunds open debit records (the round +// state was lost in the crash, so the stake returns) and voids payout +// records that are already durable. +// +// The reducer folds journal lines by record ID (later lines win), so +// settlement markers are idempotent appends. Recovery runs once at startup +// before any session can join; compaction rewrites the journal keeping only +// open records (normally none). + +import ( + "bufio" + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "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"` + + // Balance checkpoints used to decide recovery compensation. + CreditsBefore int `json:"credits_before"` + CreditsAfter int `json:"credits_after"` + ChipsBefore int `json:"chips_before"` + ChipsAfter int `json:"chips_after"` +} + +type economyJournal struct { + path string +} + +func newEconomyJournal(dataDir string) *economyJournal { + dir := filepath.Join(dataDir, "economy") + _ = os.MkdirAll(dir, 0700) + return &economyJournal{path: filepath.Join(dir, "journal.jsonl")} +} + +func newEconomyRecordID() string { + var b [8]byte + if _, err := rand.Read(b[:]); err != nil { + panic(fmt.Sprintf("economy record id: %v", err)) + } + return hex.EncodeToString(b[:]) +} + +// append writes one durable line. Errors are returned to the caller, which +// must treat the transaction as failed. +func (j *economyJournal) append(rec economyRecord) error { + rec.TS = time.Now() + data, err := json.Marshal(rec) + if err != nil { + return err + } + f, err := os.OpenFile(j.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600) + if err != nil { + return fmt.Errorf("open economy journal: %w", err) + } + defer f.Close() + if _, err := f.Write(append(data, '\n')); err != nil { + return fmt.Errorf("append economy journal: %w", err) + } + if err := f.Sync(); err != nil { + return fmt.Errorf("sync economy journal: %w", err) + } + return nil +} + +// 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) { + rec := &economyRecord{ + ID: newEconomyRecordID(), + Player: p.Name, + Kind: kind, + Chips: chips, + Credit: credits, + Status: "open", + CreditsBefore: creditsBefore, + CreditsAfter: creditsAfter, + ChipsBefore: chipsBefore, + ChipsAfter: chipsAfter, + } + if err := j.append(*rec); err != nil { + return nil, err + } + return rec, nil +} + +// settle marks a record settled (the balance mutation is durable). +func (j *economyJournal) settle(rec *economyRecord) { + rec.Status = "settled" + _ = j.append(*rec) +} + +// void marks a record void (the mutation was reverted in memory and never +// became durable; recovery must not compensate). +func (j *economyJournal) void(rec *economyRecord) { + rec.Status = "void" + _ = j.append(*rec) +} + +// loadOpen folds the journal and returns the still-open records. +func (j *economyJournal) loadOpen() []*economyRecord { + f, err := os.Open(j.path) + if err != nil { + return nil + } + defer f.Close() + latest := map[string]*economyRecord{} + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + for scanner.Scan() { + line := scanner.Bytes() + if len(line) == 0 { + continue + } + var rec economyRecord + if err := json.Unmarshal(line, &rec); err != nil { + // A torn final line (crash mid-append) is not a valid record; + // skip it. Appends are small and single-line, so only the last + // line can be torn. + continue + } + latest[rec.ID] = &rec + } + var open []*economyRecord + for _, rec := range latest { + if rec.Status == "open" { + open = append(open, rec) + } + } + return open +} + +// compact rewrites the journal keeping only still-open records. +func (j *economyJournal) compact() { + open := j.loadOpen() + tmp := j.path + ".compact" + f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) + if err != nil { + return + } + defer os.Remove(tmp) + w := bufio.NewWriter(f) + for _, rec := range open { + rec.TS = time.Now() + data, err := json.Marshal(rec) + if err != nil { + continue + } + w.Write(data) + w.WriteByte('\n') + } + if err := w.Flush(); err != nil { + f.Close() + return + } + if err := f.Sync(); err != nil { + f.Close() + return + } + f.Close() + // Best-effort atomic-ish replacement; a torn compaction leaves the + // original file intact. + if err := os.Rename(tmp, j.path); err != nil { + os.Remove(tmp) + } +} + +// giveChips re-credits chips to a player's inventory (used by recovery +// refunds). Stacks onto an existing chips slot when one exists. +func (g *Game) giveChips(p *player.Player, qty int) { + for _, slot := range p.Inventory { + if slot != nil && slot.ItemID == "chips" { + slot.Quantity += qty + return + } + } + slot := p.FirstFreeSlot() + if slot >= 0 { + p.SetInvSlot(slot, &player.InventorySlot{ItemID: "chips", Quantity: qty}) + } +} + +// payCreditsDurable applies a checked credit increase and persists it. +func (g *Game) payCreditsDurable(p *player.Player, amount int) error { + if amount < 0 { + return fmt.Errorf("negative payout %d", amount) + } + sum, err := addCreditsChecked(p.Credits, amount) + if err != nil { + return err + } + p.Credits = sum + return g.AccountStore.SaveCharacter(p) +} + +// 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) { + const maxCredits = 1_000_000_000_000 + if b > 0 && a > maxCredits-b { + return maxCredits, fmt.Errorf("credit overflow: %d + %d exceeds %d", a, b, maxCredits) + } + if b < 0 { + return 0, fmt.Errorf("negative credit add %d", b) + } + return a + b, nil +} + +// recoverEconomy compensates open records from the last crash. It is called +// once from New, before any session connects. +func (g *Game) recoverEconomy() { + if g.Economy == nil { + return + } + records := g.Economy.loadOpen() + compensated := 0 + for _, rec := range records { + p, err := g.AccountStore.LoadCharacter(rec.Player) + 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", rec.Player, err) + continue + } + switch { + case rec.Kind == "wager" && p.Credits == rec.CreditsBefore: + // The debit never became durable: void it. + g.Economy.void(rec) + case rec.Kind == "wager" && p.Credits == rec.CreditsAfter: + // The debit is durable but the round was lost in the crash: + // refund the stake (approved refund-on-recovery policy). + chips, credits := rec.Chips, rec.Credit + p.Credits += credits + if chips > 0 { + g.giveChips(p, chips) + } + if err := g.AccountStore.SaveCharacter(p); err != nil { + fmt.Fprintf(os.Stderr, "economy recovery: refund save %s: %v\n", p.Name, err) + continue + } + g.Economy.settle(rec) + compensated++ + case rec.Kind == "wager": + // Balance moved past the recorded checkpoint: cannot compensate + // reliably; surface for operator inspection. + fmt.Fprintf(os.Stderr, "economy recovery: ambiguous wager %s for %s (credits %d, want before %d or after %d)\n", + rec.ID, rec.Player, p.Credits, rec.CreditsBefore, rec.CreditsAfter) + g.Economy.void(rec) + case rec.Kind == "payout" && p.Credits == rec.CreditsBefore: + // The payout was lost in the crash: the player is owed it. + if err := g.payCreditsDurable(p, rec.Credit); err != nil { + fmt.Fprintf(os.Stderr, "economy recovery: payout save %s: %v\n", p.Name, err) + continue + } + g.Economy.settle(rec) + compensated++ + case rec.Kind == "payout" && p.Credits == rec.CreditsAfter: + g.Economy.settle(rec) + case rec.Kind == "payout": + fmt.Fprintf(os.Stderr, "economy recovery: ambiguous payout %s for %s (credits %d, want before %d or after %d)\n", + rec.ID, rec.Player, p.Credits, rec.CreditsBefore, rec.CreditsAfter) + g.Economy.settle(rec) + } + } + g.Economy.compact() +} diff --git a/internal/game/economy_shutdown_test.go b/internal/game/economy_shutdown_test.go new file mode 100644 index 0000000..bd6c253 --- /dev/null +++ b/internal/game/economy_shutdown_test.go @@ -0,0 +1,263 @@ +package game + +// Durable-money and graceful-shutdown regressions: journal-first wagers +// with revert on save failure, startup recovery that refunds lost wagers +// and re-pays lost payouts, checked currency arithmetic, and a shutdown +// that persists online characters. + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "thehouseoficarus/internal/casino" + "thehouseoficarus/internal/config" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" + + "gopkg.in/yaml.v3" +) + +// harnessFromDir builds a Game over an existing directory (used when tests +// must pre-seed character files and journal records before startup). +func harnessFromDir(t *testing.T, dir string) *harness { + t.Helper() + colors := &config.ColorsConfig{} + g := New(dir, colors, colors, config.ValidationConfig{}, 1) + hub := net.NewHub() + g.SetHub(hub) + stopTick := make(chan struct{}) + done := make(chan struct{}) + go func() { + defer close(done) + tick := time.NewTicker(2 * time.Millisecond) + defer tick.Stop() + for { + select { + case <-tick.C: + g.Ticks.Tick() + case <-stopTick: + return + } + } + }() + h := &harness{ + t: t, dir: dir, g: g, hub: hub, + sessions: make(map[string]*net.Session), + conns: make(map[string]*chanConn), + } + h.stop = func() { close(stopTick); <-done } + t.Cleanup(h.stop) + return h +} + +func writeSeedFile(t *testing.T, path string, data []byte) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data, 0600); err != nil { + t.Fatal(err) + } +} + +func writeEconomyJournal(t *testing.T, dir string, records []economyRecord) { + t.Helper() + if err := os.MkdirAll(filepath.Join(dir, "economy"), 0700); err != nil { + t.Fatal(err) + } + var data []byte + for _, rec := range records { + line, err := json.Marshal(rec) + if err != nil { + t.Fatal(err) + } + data = append(data, line...) + } + if err := os.WriteFile(filepath.Join(dir, "economy", "journal.jsonl"), data, 0600); err != nil { + t.Fatal(err) + } +} + +func TestEconomyWagerDebitsDurallyAndSettles(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} + + var wagerErr error + h.g.RunOnOwner(func() { + _, wagerErr = w.Wager(10) + }) + h.barrier() + if wagerErr != nil { + t.Fatalf("wager failed: %v", wagerErr) + } + if p.Credits != 90 { + t.Fatalf("credits after wager = %d, want 90", p.Credits) + } + // The journal must show the record settled (debit is durable). + if open := h.g.Economy.loadOpen(); len(open) != 0 { + t.Fatalf("open records remain after a successful wager: %v", open) + } +} + +func TestEconomyFailedSaveRevertsAndVoids(t *testing.T) { + h := newHarness(t) + h.seedAccount("alice", "pw", false) + p := h.seedCharacter("alice", "alice") + h.g.RunOnOwner(func() { + p.Credits = 100 + }) + + // Make character saves fail: read-only characters directory. + charDir := filepath.Join(h.dir, "players", "characters") + if err := os.Chmod(charDir, 0500); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.Chmod(charDir, 0700) }) + + var wagerErr error + h.g.RunOnOwner(func() { + _, wagerErr = casinoWallet{g: h.g, p: p}.Wager(10) + }) + h.barrier() + if wagerErr != casino.ErrWalletPersistence { + t.Fatalf("wager error = %v, want ErrWalletPersistence", wagerErr) + } + // The in-memory mutation must have been reverted: nothing was charged. + if p.Credits != 100 { + t.Fatalf("credits after failed wager = %d, want 100 (reverted)", p.Credits) + } + // The record is void, not open: recovery must not compensate. + os.Chmod(charDir, 0700) + if open := h.g.Economy.loadOpen(); len(open) != 0 { + t.Fatalf("void record remained open: %v", open) + } +} + +func TestEconomyRecoveryRefundsDurableLostWager(t *testing.T) { + dir := t.TempDir() + seedWorld(t, dir) + + // Character with the debit already durable (crash after save). + hash, _ := player.HashPassword("pw") + accData, _ := yaml.Marshal(&player.Account{Name: "alice", PasswordHash: hash}) + writeSeedFile(t, filepath.Join(dir, "players", "accounts", "alice.yaml"), accData) + p := player.New("alice") + p.Credits = 90 // post-debit balance + p.Account = "alice" + pData, _ := yaml.Marshal(p) + writeSeedFile(t, filepath.Join(dir, "players", "characters", "alice.yaml"), pData) + + // Open wager record: 10 credits debited (before=100, after=90). + writeEconomyJournal(t, dir, []economyRecord{{ + ID: "lost-wager", Player: "alice", Kind: "wager", + Chips: 0, Credit: 10, Status: "open", + CreditsBefore: 100, CreditsAfter: 90, + ChipsBefore: 0, ChipsAfter: 0, + }}) + + h := harnessFromDir(t, dir) + // Recovery must refund the stake: credits back to 100. + loaded, err := h.g.AccountStore.LoadCharacter("alice") + if err != nil { + t.Fatal(err) + } + if loaded.Credits != 100 { + t.Fatalf("credits after recovery = %d, want 100 (refund)", loaded.Credits) + } + if open := h.g.Economy.loadOpen(); len(open) != 0 { + t.Fatalf("recovered record left open: %v", open) + } +} + +func TestEconomyRecoveryAppliesLostPayout(t *testing.T) { + dir := t.TempDir() + seedWorld(t, dir) + + // Character at pre-payout balance; the payout was journaled but the + // save never happened (crash before save). The player is owed it. + hash, _ := player.HashPassword("pw") + accData, _ := yaml.Marshal(&player.Account{Name: "bob", PasswordHash: hash}) + writeSeedFile(t, filepath.Join(dir, "players", "accounts", "bob.yaml"), accData) + p := player.New("bob") + p.Credits = 50 + p.Account = "bob" + pData, _ := yaml.Marshal(p) + writeSeedFile(t, filepath.Join(dir, "players", "characters", "bob.yaml"), pData) + + writeEconomyJournal(t, dir, []economyRecord{{ + ID: "lost-payout", Player: "bob", Kind: "payout", + Chips: 0, Credit: 25, Status: "open", + CreditsBefore: 50, CreditsAfter: 75, + ChipsBefore: 0, ChipsAfter: 0, + }}) + + h := harnessFromDir(t, dir) + loaded, err := h.g.AccountStore.LoadCharacter("bob") + if err != nil { + t.Fatal(err) + } + if loaded.Credits != 75 { + t.Fatalf("credits after recovery = %d, want 75 (payout re-applied)", loaded.Credits) + } + if open := h.g.Economy.loadOpen(); len(open) != 0 { + t.Fatalf("recovered record left open: %v", open) + } +} + +func TestShutdownSavesOnlineCharactersAndRefundsWagers(t *testing.T) { + h := newHarness(t) + h.seedAccount("alice", "pw", false) + h.seedAccount("bob", "pw", false) + h.seedCharacter("alice", "alice") + h.seedCharacter("bob", "bob") + sA, _ := h.connect("alice") + pA := sA.Player + sB, _ := h.connect("bob") + pB := sB.Player + + // bob sits at a machine with a committed stake; shutdown must refund. + h.g.RunOnOwner(func() { + pB.Credits = 100 + h.g.Casino.JoinMachine(1, casino.MachineConfig{ID: "slots", Game: casino.GameSlots, MinBet: 1}, "bob") + h.g.Casino.SetMachineBet(1, "slots", "bob", 20, casinoWallet{g: h.g, p: pB}) + }) + h.barrier() + if pB.Credits != 80 { + t.Fatalf("bob's credits after wager = %d, want 80", pB.Credits) + } + + transportClosed := make(chan struct{}) + h.g.SetTransportStop(func() { close(transportClosed) }) + h.g.RequestShutdown("test") + // The shutdown goroutine closes the transports at the end of the + // ordered sequence; block for it. + select { + case <-transportClosed: + case <-time.After(5 * time.Second): + t.Fatal("transport stop was never invoked during shutdown") + } + + // Both characters must be saved with the wager refunded. + loadedA, err := h.g.AccountStore.LoadCharacter("alice") + if err != nil { + t.Fatal(err) + } + if loadedA.Credits != pA.Credits { + t.Fatalf("alice save mismatch: file %d, memory %d", loadedA.Credits, pA.Credits) + } + loadedB, err := h.g.AccountStore.LoadCharacter("bob") + if err != nil { + t.Fatal(err) + } + if loadedB.Credits != 100 { + t.Fatalf("bob's file = %d, want 100 (stake refunded at shutdown)", loadedB.Credits) + } +} diff --git a/internal/game/exit_migrate.go b/internal/game/exit_migrate.go index ff905f6..db70c8d 100644 --- a/internal/game/exit_migrate.go +++ b/internal/game/exit_migrate.go @@ -112,11 +112,11 @@ func rekeyIntMap[V any](m map[int]V, idMap map[int]int) bool { return true } -// RewriteRoomIDs is the shared entry point called by every room-ID change path -// (the swapid command and the admin web GUI rename) to migrate room-ID-embedded +// RewriteRoomIDs is the shared entry point called by every room-ID change +// path (the swapid command and the admin web GUI rename) to migrate room-ID-embedded // state across all characters. Online players are updated in memory (and -// re-saved); offline characters are loaded, rewritten, and saved. It follows -// the same cross-goroutine mutation precedent as cmd_undig/cmd_summon. +// re-saved); offline characters are loaded, rewritten, and saved. Callers +// must be on the owner goroutine (use MigrateRoomIDs from other goroutines). func (g *Game) RewriteRoomIDs(idMap map[int]int) { if len(idMap) == 0 { return @@ -150,3 +150,34 @@ func (g *Game) RewriteRoomIDs(idMap map[int]int) { } } } + +// MigrateRoomIDs is the complete room-ID migration entry point for other +// goroutines (the admin API and swapid command): player state, Hub room +// buckets, casino room keys, world room-keyed runtime state, and live mob +// instances all move together under one owner task. This is what makes a +// live rename leave NO gameplay or broadcast attached to the old location. +func (g *Game) MigrateRoomIDs(idMap map[int]int) { + if len(idMap) == 0 { + return + } + g.RunOnOwner(func() { + g.migrateRoomIDsLocked(idMap) + }) +} + +// migrateRoomIDsLocked performs the full runtime migration; caller must +// already be on the owner goroutine. +func (g *Game) migrateRoomIDsLocked(idMap map[int]int) { + if g.Hub != nil { + g.Hub.RekeyRooms(idMap) + } + if g.Casino != nil { + g.Casino.RekeyRooms(idMap) + } + g.World.RekeyRooms(idMap) + g.MobStore.RekeyRooms(idMap) + // The file paths changed under the world: rebuild its room index so + // lookups agree with the post-migration layout. + g.World.RebuildRoomIndex(g.DataDir) + g.RewriteRoomIDs(idMap) +} diff --git a/internal/game/game.go b/internal/game/game.go index 822042e..7f29d1f 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -2,6 +2,7 @@ package game import ( "fmt" + "log" "path/filepath" "sort" "strings" @@ -76,9 +77,26 @@ type Game struct { sequences map[string]*sequence globalSeqs []*sequence - shutdownCancel chan struct{} - shutdownActive bool - shutdownMu sync.Mutex + shutdownMu sync.Mutex + + // shutdownOnce guards RequestShutdown; transportStop is the registered + // hook that closes the network transports (set by main at startup). + shutdownOnce sync.Once + shutdownRun *scheduledShutdown + shutdownHook func(reason string) + transportStop func() + + // Economy is the durable money journal; see economy.go. + Economy *economyJournal + + // owner serializes all live-state mutations; see owner.go. + owner *owner + ownerStop sync.Once + + // shuttingDown disables disconnect forfeit/save callbacks while the + // graceful shutdown sequence owns final persistence. + shuttingDownMu sync.Mutex + shuttingDown bool } func New(dataDir string, colorConfig *config.ColorsConfig, constantColorConfig *config.ColorsConfig, valConfig config.ValidationConfig, startingRoom int) *Game { @@ -111,12 +129,25 @@ func New(dataDir string, colorConfig *config.ColorsConfig, constantColorConfig * hackingStates: make(map[string]*hacking.Session), sequences: make(map[string]*sequence), } + g.owner = newOwner() + // Runtime assignment (not initializer-level) so the command registry, + // which statically references executeShutdown, cannot form an + // initialization cycle with the shutdown coordinator. + g.shutdownHook = g.RequestShutdown + g.Economy = newEconomyJournal(dataDir) + // Compensate open money records from any prior crash before any + // session can connect. + g.recoverEconomy() g.CourseStore.SetWorld(g.World) g.CourseStore.LoadAll() g.LoadMods() g.LoadTechs() g.buildCraftIndex() g.loadAllFlagTriggers() + // Journal recovery before any store reads files a leftover transaction + // may reference, then one-time stable-identity migration. + g.recoverJournals() + g.backfillCharacterIDs() g.ValidateAndLog() return g } @@ -124,30 +155,141 @@ func New(dataDir string, colorConfig *config.ColorsConfig, constantColorConfig * func (g *Game) SetHub(hub *net.Hub) { g.Hub = hub hub.OnRemove(func(sess *net.Session) { - if p := sess.Player; p != nil { - g.restoreGodPlayer(p) - g.persistLockedOnDisconnect(p) - g.AccountStore.SaveCharacter(p) - p.DeactivateAllTechs() - g.charsMu.Lock() - delete(g.loggedInChars, p.Name) - g.charsMu.Unlock() - delete(g.hackingStates, p.Name) - if ss, ok := g.safespot.Get(p.Name); ok { - g.forceLeaveSafespot(sess, p, &ss, "") + // Runs on the connection/tick goroutine that removed the session; + // the callback itself mutates live game state, so it executes on + // the owner. During shutdown this callback is suppressed: the + // shutdown sequence has already persisted everyone. + g.run(func() { + if g.isShuttingDown() { + return } - } + if p := sess.Player; p != nil { + g.restoreGodPlayer(p) + g.persistLockedOnDisconnect(p) + g.AccountStore.SaveCharacter(p) + p.DeactivateAllTechs() + g.detachSession(p.Name) + delete(g.hackingStates, p.Name) + if ss, ok := g.safespot.Get(p.Name); ok { + g.forceLeaveSafespot(sess, p, &ss, "") + } + } + }) }) hub.OnDisconnect(func(sess *net.Session) { - if sess.Player != nil { - g.emitCasinoEvents(g.Casino.MarkDisconnected(sess.Player.Name)) - } + g.run(func() { + if sess.Player != nil { + g.emitCasinoEvents(g.Casino.MarkDisconnected(sess.Player.Name)) + } + }) }) g.GlobalFlags.OnChange(func(name string, value any) { + // Fire flag triggers synchronously on the caller; global-flag writes + // all happen on the owner (game commands) or via the admin API which + // is routed through the owner, so a nested run() would deadlock. g.fireGlobalFlagTriggers(name, value) }) } +// RequestShutdown begins the graceful shutdown sequence exactly once; both +// SIGINT/SIGTERM and the in-game shutdown command converge here. It returns +// immediately: the sequence runs on a fresh goroutine (never the owner, so +// its owner tasks cannot deadlock), performs the ordered drain, and closes +// the transports at the end. main then simply returns. +func (g *Game) RequestShutdown(reason string) { + g.shutdownOnce.Do(func() { + g.setShuttingDown() + go func() { + log.Printf("shutdown requested: %s", reason) + g.runShutdown() + }() + }) +} + +// SetTransportStop registers the hook that closes network transports during +// shutdown. main wires it to (*net.Server).Shutdown. +func (g *Game) SetTransportStop(stop func()) { + g.transportStop = stop +} + +// runShutdown executes the ordered shutdown sequence: +// +// 1. Admissions stop: setShuttingDown already makes session input and +// disconnect forfeit/save callbacks no-ops (all handlers are owner +// tasks; the owner still drains what was accepted). +// 2. Stop the tick engine AND join the in-flight callback. +// 3. On the owner: drain remaining queued commands, refund outstanding +// wagers, save every online character. Disconnect-forfeit does not run: +// this is a server-initiated shutdown, not a player disconnect. +// 4. Stop the owner loop. +// 5. Close transports (sessions flush their outbound queues first). +func (g *Game) runShutdown() { + g.broadcastLoggedIn("[Server] Shutting down. Your progress is being saved.") + + // Join the running tick so the last tick's mutations are complete. + g.Ticks.StopAndWait() + + var saveErr error + g.RunOnOwner(func() { + g.ProcessQueuedCommands() + if g.Casino != nil { + g.emitCasinoEvents(g.Casino.RefundAll()) + } + if err := g.saveAllOnlineCharacters(); err != nil { + saveErr = err + } + }) + if saveErr != nil { + log.Printf("shutdown: final save failed: %v", saveErr) + } + + g.stopOwner() + + if g.transportStop != nil { + g.transportStop() + } + log.Println("shutdown complete") +} + +// saveAllOnlineCharacters persists every online player exactly once under +// owner ownership. The first error is reported to the operator; remaining +// characters are still saved. +func (g *Game) saveAllOnlineCharacters() error { + if g.Hub == nil { + return nil + } + var firstErr error + for _, sess := range g.Hub.AllSessions() { + if p := sess.Player; p != nil { + g.restoreGodPlayer(p) + g.persistLockedOnDisconnect(p) + if err := g.AccountStore.SaveCharacter(p); err != nil && firstErr == nil { + firstErr = fmt.Errorf("save %s: %w", p.Name, err) + } + } + } + return firstErr +} + +// HandleSessionOnOwner is the entry point handed to the network server: +// connection goroutines submit one task per input line, so all game state +// mutation executes on the owner goroutine. +func (g *Game) HandleSessionOnOwner(sess *net.Session, input string) { + g.RunOnOwner(func() { + g.HandleSession(sess, input) + }) +} + +// RunOnOwner executes fn on the owner goroutine and waits for completion. +// Network and admin goroutines must route every live-state mutation through +// this; code already running on the owner must not call it (it would +// deadlock waiting for itself). Alias of Game.run with an exported name. +func (g *Game) RunOnOwner(fn func()) { + g.run(fn) +} + +// HandleSession dispatches one line of session input by state. Callers must +// already be on the owner goroutine (use HandleSessionOnOwner otherwise). func (g *Game) HandleSession(sess *net.Session, input string) { input = player.StripControlCharacters(input) switch sess.State { @@ -200,6 +342,45 @@ func (g *Game) HandleSession(sess *net.Session, input string) { } } +// SubscribeTicks registers a dynamic per-tick callback that executes on the +// owner goroutine, preserving registration-ID order relative to the master +// subscriber and other subscribers. Game code must use this instead of +// engine.Subscribe directly so tick callbacks cannot race session input. +func (g *Game) SubscribeTicks(interval int, cb func() bool) uint64 { + if g.owner == nil { + return g.Ticks.Subscribe(interval, cb) + } + return g.Ticks.Subscribe(interval, func() bool { + keep := false + g.run(func() { keep = cb() }) + return keep + }) +} + +// isShuttingDown reports whether the graceful shutdown sequence has taken +// ownership of final persistence. +func (g *Game) isShuttingDown() bool { + g.shuttingDownMu.Lock() + defer g.shuttingDownMu.Unlock() + return g.shuttingDown +} + +// setShuttingDown flips the shutdown ownership flag exactly once. +func (g *Game) setShuttingDown() { + g.shuttingDownMu.Lock() + g.shuttingDown = true + g.shuttingDownMu.Unlock() +} + +// EvictCasinoRoom evicts every cached table/machine in roomID (refund +// semantics per casino.Manager.EvictRoom). Callers must be on the owner +// goroutine. +func (g *Game) EvictCasinoRoom(roomID int) { + if g.Casino != nil { + g.emitCasinoEvents(g.Casino.EvictRoom(roomID)) + } +} + func (g *Game) writePrompt(sess *net.Session) { sess.WritePrompt(g.promptStr(sess)) } diff --git a/internal/game/harness_test.go b/internal/game/harness_test.go new file mode 100644 index 0000000..7898af2 --- /dev/null +++ b/internal/game/harness_test.go @@ -0,0 +1,266 @@ +package game + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "thehouseoficarus/internal/config" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" +) + +// chanConn is a test Conn whose reads come from a channel, so a session's +// input loop can run on its own goroutine exactly like the real server's +// connection goroutine. Writes are recorded in a mutex-guarded buffer and may +// optionally be gated to simulate a slow/stalled client. +type chanConn struct { + mu sync.Mutex + out []byte + in chan string + closed chan struct{} + once sync.Once + + // writeGate, when non-nil, blocks every Write until released. + writeGate chan struct{} +} + +func newChanConn() *chanConn { + return &chanConn{in: make(chan string, 64), closed: make(chan struct{})} +} + +func (c *chanConn) ReadMessage() (string, error) { + select { + case line := <-c.in: + return line, nil + case <-c.closed: + return "", fmt.Errorf("connection closed") + } +} + +func (c *chanConn) Write(b []byte) (int, error) { + if c.writeGate != nil { + select { + case <-c.writeGate: + case <-c.closed: + return 0, fmt.Errorf("write on closed conn") + } + } + c.mu.Lock() + defer c.mu.Unlock() + c.out = append(c.out, b...) + return len(b), nil +} + +func (c *chanConn) Close() error { + c.once.Do(func() { close(c.closed) }) + return nil +} + +func (c *chanConn) SetWriteDeadline(time.Time) error { return nil } + +func (c *chanConn) SetEcho(bool) error { return nil } + +func (c *chanConn) Bytes() []byte { + c.mu.Lock() + defer c.mu.Unlock() + return append([]byte(nil), c.out...) +} + +func (c *chanConn) Contains(substr string) bool { + return strings.Contains(string(c.Bytes()), substr) +} + +// send queues an input line for ReadMessage. No-op after close. +func (c *chanConn) send(line string) { + select { + case c.in <- line: + case <-c.closed: + } +} + +// harness wires a real Game (temp data dir) to fake transports driven from +// per-connection goroutines plus a competing tick goroutine, reproducing the +// production entry-point concurrency: session input, disconnect callbacks, and +// tick callbacks all touching shared state at once. +type harness struct { + t *testing.T + dir string + g *Game + hub *net.Hub + stop func() + + mu sync.Mutex + sessions map[string]*net.Session + conns map[string]*chanConn +} + +func newHarness(t *testing.T) *harness { + t.Helper() + dir := t.TempDir() + seedWorld(t, dir) + colors := &config.ColorsConfig{} + g := New(dir, colors, colors, config.ValidationConfig{}, 1) + hub := net.NewHub() + g.SetHub(hub) + + stopTick := make(chan struct{}) + done := make(chan struct{}) + go func() { + defer close(done) + tick := time.NewTicker(2 * time.Millisecond) + defer tick.Stop() + for { + select { + case <-tick.C: + g.Ticks.Tick() + case <-stopTick: + return + } + } + }() + + h := &harness{ + t: t, dir: dir, g: g, hub: hub, + sessions: make(map[string]*net.Session), + conns: make(map[string]*chanConn), + } + h.stop = func() { close(stopTick); <-done } + t.Cleanup(h.stop) + return h +} + +// seedWorld writes the minimal on-disk content a Game needs: three rooms. +func seedWorld(t *testing.T, dir string) { + t.Helper() + for _, d := range []string{"rooms", "players/accounts", "players/characters"} { + if err := os.MkdirAll(filepath.Join(dir, d), 0755); err != nil { + t.Fatalf("mkdir %s: %v", d, err) + } + } + files := map[string]string{ + "rooms/1.yaml": "id: 1\nname: Central\nexits:\n north:\n room: 2\n", + "rooms/2.yaml": "id: 2\nname: North\nexits:\n south:\n room: 1\n", + "rooms/3.yaml": "id: 3\nname: East\n", + } + for path, content := range files { + if err := os.WriteFile(filepath.Join(dir, path), []byte(content), 0644); err != nil { + t.Fatalf("seed %s: %v", path, err) + } + } +} + +// seedAccount writes an account file directly (bypassing the login menu). +func (h *harness) seedAccount(name, password string, admin bool) { + t := h.t + hash, err := player.HashPassword(password) + if err != nil { + t.Fatalf("hash: %v", err) + } + acc := &player.Account{Name: name, PasswordHash: hash, Admin: admin} + if err := h.g.AccountStore.SaveAccount(acc); err != nil { + t.Fatalf("seed account %s: %v", name, err) + } +} + +// seedCharacter writes a character file owned by the named account. +func (h *harness) seedCharacter(charName, accountName string) *player.Player { + t := h.t + p := player.New(charName) + p.RoomID = 1 + p.Account = accountName + if err := h.g.AccountStore.SaveCharacter(p); err != nil { + t.Fatalf("seed character %s: %v", charName, err) + } + acc, err := h.g.AccountStore.LoadAccount(accountName) + if err != nil { + t.Fatalf("load account %s: %v", accountName, err) + } + acc.Characters = append(acc.Characters, charName) + if err := h.g.AccountStore.SaveAccount(acc); err != nil { + t.Fatalf("save account: %v", err) + } + return p +} + +// connect registers a live in-game session directly, bypassing the login +// menu. The session is put into queued-write mode (like real transports) and +// its input is dispatched through the owner entry point, matching production +// concurrency exactly. Tests that exercise the menu drive handleAccountName +// themselves. +func (h *harness) connect(name string) (*net.Session, *chanConn) { + t := h.t + acc, err := h.g.AccountStore.LoadAccount(name) + if err != nil { + t.Fatalf("connect %s: %v", name, err) + } + p, err := h.g.AccountStore.LoadCharacter(name) + if err != nil { + t.Fatalf("connect %s: %v", name, err) + } + conn := newChanConn() + sess := &net.Session{Conn: conn, Account: acc, Player: p, State: net.StateGame} + sess.StartWriter() + h.hub.Add(sess) + h.hub.EnterRoom(sess, p.RoomID) + h.g.attachSession(sess) + h.mu.Lock() + h.sessions[name] = sess + h.conns[name] = conn + h.mu.Unlock() + return sess, conn +} + +// connectMenu registers a session authenticated to accountName sitting in +// the character menu (no character loaded). +func (h *harness) connectMenu(accountName string) *net.Session { + acc, err := h.g.AccountStore.LoadAccount(accountName) + if err != nil { + h.t.Fatalf("connectMenu %s: %v", accountName, err) + } + conn := newChanConn() + sess := &net.Session{Conn: conn, Account: acc, State: net.StateMenu} + sess.StartWriter() + h.hub.Add(sess) + h.mu.Lock() + h.sessions[accountName+"-menu"] = sess + h.conns[accountName+"-menu"] = conn + h.mu.Unlock() + return sess +} + +// barrier blocks until every task submitted so far has executed on the +// owner goroutine. +func (h *harness) barrier() { + h.g.RunOnOwner(func() {}) +} + +// input mirrors net.Server.handleSession: a per-connection goroutine reading +// lines and dispatching them to HandleSession until the channel closes. +func (h *harness) input(sess *net.Session, lines <-chan string) { + for line := range lines { + h.g.HandleSessionOnOwner(sess, line) + } +} + +// goInput starts input goroutine(s) for a session, returning the send channel. +func (h *harness) goInput(sess *net.Session, workers int) chan<- string { + lines := make(chan string, 64) + for i := 0; i < workers; i++ { + go h.input(sess, lines) + } + return lines +} + +const workers = 1 + +// sessionFor returns the registered session for a character name. +func (h *harness) sessionFor(name string) *net.Session { + h.mu.Lock() + defer h.mu.Unlock() + return h.sessions[name] +} diff --git a/internal/game/lifecycle_txn_test.go b/internal/game/lifecycle_txn_test.go new file mode 100644 index 0000000..f575298 --- /dev/null +++ b/internal/game/lifecycle_txn_test.go @@ -0,0 +1,160 @@ +package game + +// Account lifecycle transaction regressions: same-account races, stale +// session state, and injected filesystem failures must preserve +// identity/membership with no orphaned or duplicated characters. + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "thehouseoficarus/internal/net" +) + +// lifecycle setup: account "acct" with two characters and two menu-state +// sessions authenticated to it. +func setupAcct(t *testing.T, h *harness) (*net.Session, *net.Session) { + t.Helper() + h.seedAccount("acct", "pw", false) + h.seedCharacter("alice", "acct") + h.seedCharacter("bob", "acct") + s1 := h.connectMenu("acct") + s2 := h.connectMenu("acct") + return s1, s2 +} + +func assertCharFiles(t *testing.T, h *harness, want ...string) { + t.Helper() + names, err := h.g.AccountStore.ListCharacterNames() + if err != nil { + t.Fatalf("list characters: %v", err) + } + have := map[string]bool{} + for _, n := range names { + have[n] = true + } + for _, w := range want { + if !have[w] { + t.Fatalf("expected character file %s to exist; files: %v", w, names) + } + } + if len(names) != len(want) { + t.Fatalf("unexpected character files: got %v, want %v", names, want) + } +} + +// TestLifecycleConcurrentRenameNoOrphan: two sessions of the same account +// rename the same character at once. Exactly one succeeds; files and the +// account list stay consistent (no orphaned characters, no duplicate list +// entries). +func TestLifecycleConcurrentRenameNoOrphan(t *testing.T) { + h := newHarness(t) + s1, s2 := setupAcct(t, h) + + h.g.RunOnOwner(func() { h.g.handleRenameChar(s1, "alice") }) + h.g.RunOnOwner(func() { h.g.handleRenameChar(s2, "alice") }) + + // Both sessions now hold a pending rename of alice; drive them to + // submit new names concurrently. + h.g.RunOnOwner(func() { h.g.handleRenameCharName(s1, "alicia") }) + h.g.RunOnOwner(func() { h.g.handleRenameCharName(s2, "alicia") }) + + h.barrier() + // Alicia exists exactly once, alice does not. + assertCharFiles(t, h, "alicia", "bob") + if !h.g.AccountStore.CharacterExists("alicia") { + t.Fatal("renamed character missing") + } +} + +// TestLifecycleStaleMenuAfterDelete: deleting a character from one session +// refreshes the other session's pending selection (it cannot confirm-delete +// a stale name afterwards). +func TestLifecycleStaleMenuAfterDelete(t *testing.T) { + h := newHarness(t) + s1, s2 := setupAcct(t, h) + + // Session 2 selects alice for deletion (pending). + h.g.RunOnOwner(func() { + h.g.handleDeleteChar(s2, "alice") // asks for confirmation + }) + if s2.PendingChar != "alice" { + t.Fatalf("expected pending delete of alice, got %q", s2.PendingChar) + } + + // Session 1 confirms the delete first (its own pending selection is + // "alice", mirroring the same menu state). + h.g.RunOnOwner(func() { + s1.PendingChar = "alice" + h.g.handleDeleteChar(s1, "DELETE alice") + }) + h.barrier() + + // Session 2's pending selection must have been invalidated. + if s2.PendingChar == "alice" { + t.Fatalf("stale pending delete survived the delete from another session") + } + if s2.Account != nil { + for _, c := range s2.Account.Characters { + if strings.EqualFold(c, "alice") { + t.Fatalf("deleted character still in another session's account list") + } + } + } +} + +// TestLifecycleRenameBeatsConnect: a character cannot be renamed while it +// is connected (the online check holds and no stale-name rename goes +// through). +func TestLifecycleRenameBeatsConnect(t *testing.T) { + h := newHarness(t) + h.seedAccount("carl", "pw", false) + h.seedCharacter("carl", "carl") + sess, conn := h.connect("carl") + + h.g.RunOnOwner(func() { + // Drive the rename menu directly while the character is online. + sess.PendingChar = "carl" + sess.State = net.StateRenameCharName + h.g.handleRenameCharName(sess, "carla") + }) + h.barrier() + if h.g.AccountStore.CharacterExists("carla") { + t.Fatalf("rename succeeded while character was online") + } + if !h.g.AccountStore.CharacterExists("carl") { + t.Fatalf("original character file vanished") + } + _ = conn +} + +// TestLifecycleInjectedFailureRollsBack: a failure mid-transaction (the +// account file cannot be written because its directory is read-only... the +// journal lives elsewhere, so use a permissions failure on the account dir) +// must leave both files untouched. +func TestLifecycleInjectedFailureRollsBack(t *testing.T) { + h := newHarness(t) + s1, _ := setupAcct(t, h) + + accDir := filepath.Join(h.dir, "players", "accounts") + if err := os.Chmod(accDir, 0500); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.Chmod(accDir, 0700) }) + + // Rename via the transaction path (invoked from a menu session whose + // account dir is unwritable): the move succeeds on disk but the account + // list rewrite fails, so the whole transaction must roll back. + h.g.RunOnOwner(func() { + s1.PendingChar = "alice" + s1.State = net.StateRenameCharName + h.g.handleRenameCharName(s1, "alicia") + }) + h.barrier() + + os.Chmod(accDir, 0700) + // The character must be untouched: no alicia, alice still present. + assertCharFiles(t, h, "alice", "bob") +} diff --git a/internal/game/migrate_rooms_test.go b/internal/game/migrate_rooms_test.go new file mode 100644 index 0000000..eb4a44c --- /dev/null +++ b/internal/game/migrate_rooms_test.go @@ -0,0 +1,115 @@ +package game + +// Runtime room-ID migration regressions: Hub buckets, casino room keys, +// world room-keyed state, mob instances, and player state all move +// together through Game.MigrateRoomIDs (owner-executed). + +import ( + "testing" + + "thehouseoficarus/internal/casino" +) + +// TestMigrateRoomIDsMovesEverythingTogether: a live player in room 2, a +// casino machine in room 2, and seeded world state all migrate to room 4 +// in one owner task; nothing stays attached to room 2. +func TestMigrateRoomIDsMovesEverythingTogether(t *testing.T) { + h := newHarness(t) + h.seedAccount("alice", "pw", false) + p := h.seedCharacter("alice", "alice") + sess, _ := h.connect("alice") + p = sess.Player + + h.g.RunOnOwner(func() { + p.RoomID = 2 + h.hub.EnterRoom(sess, 2) + // A machine in room 2 with a committed stake. + h.g.Casino.JoinMachine(2, casino.MachineConfig{ID: "slots", Game: casino.GameSlots, MinBet: 1}, "alice") + }) + + // Migrate 2 -> 4 through the full runtime entry point. + h.g.MigrateRoomIDs(map[int]int{2: 4}) + h.barrier() + + h.g.RunOnOwner(func() { + if p.RoomID != 4 { + t.Fatalf("player room = %d, want 4", p.RoomID) + } + if room := h.hub.PlayersInRoom(4); len(room) != 1 || room[0] != sess { + t.Fatalf("hub bucket for room 4 = %v, want [alice]", len(room)) + } + if room := h.hub.PlayersInRoom(2); len(room) != 0 { + t.Fatalf("stale hub bucket for room 2: %d players", len(room)) + } + if _, playing := h.g.Casino.Participation("alice"); !playing { + t.Fatal("casino participation lost during migration") + } + // The machine's events must route to room 4 now: emit one and + // check the room target. + machine, ok := h.g.Casino.MachineFor("alice") + if !ok { + t.Fatal("machine lost during migration") + } + if machine.RoomID != 4 { + t.Fatalf("machine room = %d, want 4", machine.RoomID) + } + }) +} + +// TestMigrateRoomIDsRoundTrip preserves participation both directions. +func TestMigrateRoomIDsRoundTrip(t *testing.T) { + h := newHarness(t) + h.seedAccount("alice", "pw", false) + h.seedCharacter("alice", "alice") + sess, _ := h.connect("alice") + p := sess.Player + + h.g.RunOnOwner(func() { + p.RoomID = 3 + h.hub.EnterRoom(sess, 3) + }) + h.g.MigrateRoomIDs(map[int]int{3: 9}) + h.g.RunOnOwner(func() { + if p.RoomID != 9 { + t.Fatalf("forward migration: player room = %d, want 9", p.RoomID) + } + }) + h.g.MigrateRoomIDs(map[int]int{9: 3}) + h.g.RunOnOwner(func() { + if p.RoomID != 3 { + t.Fatalf("inverse migration: player room = %d, want 3", p.RoomID) + } + if room := h.hub.PlayersInRoom(3); len(room) != 1 { + t.Fatalf("inverse migration: hub bucket room 3 = %d players", len(room)) + } + }) +} + +// TestMigrateRoomIDsPersistsOnlinePlayer: the migrated player state is +// saved to disk (RoomID rewritten in the character file). +func TestMigrateRoomIDsPersistsOnlinePlayer(t *testing.T) { + h := newHarness(t) + h.seedAccount("alice", "pw", false) + h.seedCharacter("alice", "alice") + sess, _ := h.connect("alice") + p := sess.Player + + h.g.RunOnOwner(func() { + p.RoomID = 2 + p.Flags["hidden_exit_2_north"] = true + h.hub.EnterRoom(sess, 2) + }) + h.g.MigrateRoomIDs(map[int]int{2: 4}) + h.barrier() + + loaded, err := h.g.AccountStore.LoadCharacter("alice") + if err != nil { + t.Fatal(err) + } + if loaded.RoomID != 4 { + t.Fatalf("saved room = %d, want 4", loaded.RoomID) + } + if _, ok := loaded.Flags["hidden_exit_4_north"]; !ok { + t.Fatalf("discovered-exit flag not rewritten: %v", loaded.Flags) + } +} diff --git a/internal/game/owner.go b/internal/game/owner.go new file mode 100644 index 0000000..df87653 --- /dev/null +++ b/internal/game/owner.go @@ -0,0 +1,119 @@ +package game + +import "sync" + +// owner is the single execution authority for live game state. Every +// mutation of players, sessions, the Hub membership, runtime maps, and all +// tick callbacks runs on exactly one goroutine (the loop below). Network and +// admin goroutines submit tasks; they never touch live objects directly. +// +// Rules: +// - Game.run blocks the caller until the closure has executed on the +// owner goroutine. Connection goroutines use it per input line, which +// gives natural per-client backpressure without ever blocking another +// client: the owner stays free because network I/O happens outside +// (Session writes go through outbound queues, saves serialize copies). +// - Code already executing on the owner must NOT call Game.run (it would +// deadlock waiting for itself). Owner-executed code calls functions +// directly. +// - Task ordering is FIFO: submitted input lines, disconnect callbacks, +// and admin mutations execute in submission order. Tick callbacks +// serialize against these tasks the same way (the engine's ticker +// goroutine submits each callback), preserving the documented tick +// order relative to subscriber registration IDs. +type owner struct { + mu sync.Mutex + closed bool + tasks chan func() + done chan struct{} +} + +const ownerTaskQueue = 4096 + +func newOwner() *owner { + o := &owner{ + tasks: make(chan func(), ownerTaskQueue), + done: make(chan struct{}), + } + go o.loop() + return o +} + +func (o *owner) loop() { + defer close(o.done) + for task := range o.tasks { + task() + } +} + +// submit enqueues a task; it returns false once the owner has stopped. +// The closed flag is guarded by the mutex so a submit racing with stop +// cannot send on a closed channel. +func (o *owner) submit(fn func()) bool { + o.mu.Lock() + defer o.mu.Unlock() + if o.closed { + return false + } + select { + case o.tasks <- fn: + return true + default: + return false + } +} + +func (o *owner) stop() { + o.mu.Lock() + if !o.closed { + o.closed = true + close(o.tasks) + } + o.mu.Unlock() + <-o.done +} + +// run executes fn on the owner goroutine and waits for it. When no owner +// exists (synthetic Games in unit tests) fn runs inline on the caller's +// goroutine; production Games always have one, started by New. +func (g *Game) run(fn func()) { + o := g.owner + if o == nil { + fn() + return + } + task := make(chan struct{}) + if !o.submit(func() { + fn() + close(task) + }) { + // The owner loop has stopped (graceful shutdown in progress). + // Admissions are already closed, so only stragglers reach this: + // execute inline rather than block forever. + fn() + return + } + <-task +} + +// tryRun executes fn on the owner if it is currently idle-draining; it is +// not currently used for correctness paths but exists for future non-blocking +// administrative reads. It returns false when the queue is full. +func (g *Game) tryRun(fn func()) bool { + o := g.owner + if o == nil { + fn() + return true + } + return o.submit(fn) +} + +// stopOwner drains and stops the owner loop. Called once during graceful +// shutdown after all entry points have stopped submitting. +func (g *Game) stopOwner() { + g.ownerStop.Do(func() { + if g.owner != nil { + g.owner.stop() + } + }) +} diff --git a/internal/game/owner_concurrency_test.go b/internal/game/owner_concurrency_test.go new file mode 100644 index 0000000..58e426b --- /dev/null +++ b/internal/game/owner_concurrency_test.go @@ -0,0 +1,154 @@ +package game + +// Owner-loop concurrency regressions. These exercise the competing runtime +// entry points (connection input, ticks, disconnects, admin mutations) the +// way production does and must pass under -race now that all live-state +// mutation executes on the owner goroutine. The pre-owner baselines live in +// baseline_race_test.go behind the baselinerace build tag. + +import ( + "sync" + "testing" + "time" +) + +// TestOwnerSerializesEntryPoints runs session input, the ticker goroutine, +// and hub disconnects concurrently. +func TestOwnerSerializesEntryPoints(t *testing.T) { + h := newHarness(t) + h.seedAccount("alice", "pw", false) + h.seedAccount("bob", "pw", false) + h.seedCharacter("alice", "alice") + h.seedCharacter("bob", "bob") + + aSess, _ := h.connect("alice") + bSess, _ := h.connect("bob") + + aLines := h.goInput(aSess, 1) + bLines := h.goInput(bSess, 1) + + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + for i := 0; i < 200; i++ { + aLines <- "look" + } + close(aLines) + }() + go func() { + defer wg.Done() + for i := 0; i < 200; i++ { + bLines <- "say hi" + } + close(bLines) + }() + + go func() { + for i := 0; i < 50; i++ { + h.hub.PlayersInRoom(1) + if i == 25 { + h.hub.Remove(bSess) + } + } + }() + + wg.Wait() + h.barrier() +} + +// TestOwnerAdminRewriteWhilePlaying runs Game.RewriteRoomIDs (admin entry) +// concurrently with live movement and ticking. +func TestOwnerAdminRewriteWhilePlaying(t *testing.T) { + h := newHarness(t) + h.seedAccount("carol", "pw", false) + h.seedCharacter("carol", "carol") + sess, _ := h.connect("carol") + lines := h.goInput(sess, 1) + + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + for i := 0; i < 100; i++ { + lines <- "look" + } + close(lines) + }() + go func() { + defer wg.Done() + // Rewrite 2->4, then back; exercise both directions while the + // player commands run. + for i := 0; i < 10; i++ { + h.g.RunOnOwner(func() { h.g.RewriteRoomIDs(map[int]int{2: 4}) }) + h.g.RunOnOwner(func() { h.g.RewriteRoomIDs(map[int]int{4: 2}) }) + } + }() + wg.Wait() + h.barrier() +} + +// TestSlowClientDoesNotStallTheWorld proves the write-queue boundary: a +// session whose transport blocks forever on Write must not prevent other +// players from receiving output and progressing. +func TestSlowClientDoesNotStallTheWorld(t *testing.T) { + h := newHarness(t) + h.seedAccount("alice", "pw", false) + h.seedAccount("bob", "pw", false) + h.seedCharacter("alice", "alice") + h.seedCharacter("bob", "bob") + + // Alice's writer goroutine blocks at the transport (full socket buffer + // equivalent); her queue absorbs or disconnects her, but never blocks + // the owner goroutine. + aSess, aConn := h.connect("alice") + bSess, bConn := h.connect("bob") + aConn.writeGate = make(chan struct{}) + + bLines := h.goInput(bSess, 1) + + for i := 0; i < 300; i++ { + aSess.WriteLine("spam for the stalled client") + } + // Bob keeps playing and must still see output while alice is stuck. + bLines <- "say hello" + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + if bConn.Contains("hello") { + return + } + time.Sleep(5 * time.Millisecond) + } + t.Fatalf("bob's output never arrived while alice's client was stalled") +} + +// TestOwnerDisconnectDuringInput removes a session from a separate goroutine +// while its input goroutine is mid-stream, proving the remove callback +// (save + registry cleanup) serializes with session input on the owner. +func TestOwnerDisconnectDuringInput(t *testing.T) { + h := newHarness(t) + h.seedAccount("dave", "pw", false) + h.seedCharacter("dave", "dave") + sess, conn := h.connect("dave") + + lines := h.goInput(sess, 1) + done := make(chan struct{}) + go func() { + defer close(done) + for i := 0; i < 50; i++ { + lines <- "look" + } + close(lines) + }() + // Hard-remove while input is streaming; the onRemove callback mutates + // player state and must not race with HandleSession. + for i := 0; i < 50; i++ { + h.hub.PlayersInRoom(1) + if i == 10 { + h.hub.HardRemove(sess) + } + } + <-done + h.barrier() + _ = conn +} diff --git a/internal/game/session_registry.go b/internal/game/session_registry.go new file mode 100644 index 0000000..0e47f82 --- /dev/null +++ b/internal/game/session_registry.go @@ -0,0 +1,19 @@ +package game + +import "thehouseoficarus/internal/net" + +// attachSession registers a fully-logged-in session in the game's +// character registry. Menu flows call it through connectCharacter; the test +// harness uses it to skip the menu. +func (g *Game) attachSession(sess *net.Session) { + g.charsMu.Lock() + g.loggedInChars[sess.Player.Name] = sess + g.charsMu.Unlock() +} + +// detachSession removes a character's registry entry. +func (g *Game) detachSession(name string) { + g.charsMu.Lock() + delete(g.loggedInChars, name) + g.charsMu.Unlock() +} diff --git a/internal/game/sys_combat.go b/internal/game/sys_combat.go index b37459e..26dac2a 100644 --- a/internal/game/sys_combat.go +++ b/internal/game/sys_combat.go @@ -129,7 +129,7 @@ func (g *Game) checkAggro(sess *net.Session) { if !keepRolling() { break } - g.Ticks.Subscribe(1, keepRolling) + g.SubscribeTicks(1, keepRolling) break } } diff --git a/internal/game/sys_escape_test.go b/internal/game/sys_escape_test.go index a9842ba..0a64666 100644 --- a/internal/game/sys_escape_test.go +++ b/internal/game/sys_escape_test.go @@ -3,6 +3,7 @@ package game import ( "os" "testing" + "time" "thehouseoficarus/internal/combat" "thehouseoficarus/internal/engine" @@ -22,6 +23,8 @@ func (c *escapeTestConn) Write(b []byte) (int, error) { c.data = append(c.data, b...) return len(b), nil } +func (c *escapeTestConn) SetWriteDeadline(time.Time) error { return nil } + func (c *escapeTestConn) Close() error { return nil } func (c *escapeTestConn) SetEcho(bool) error { return nil } diff --git a/internal/game/sys_safespot.go b/internal/game/sys_safespot.go index d833b62..2529471 100644 --- a/internal/game/sys_safespot.go +++ b/internal/game/sys_safespot.go @@ -227,7 +227,7 @@ func (g *Game) scheduleSafespotRespawn(st *world.ObjState, objDef *object.Object } roomID := st.RoomID defID := objDef.ID - g.Ticks.Subscribe(engine.ToTicks(cfg.RespawnTicks), func() bool { + g.SubscribeTicks(engine.ToTicks(cfg.RespawnTicks), func() bool { respawnSt := g.World.GetObjState(roomID, defID, 0) if respawnSt == nil { return false |
