aboutsummaryrefslogtreecommitdiff
path: root/internal/game
diff options
context:
space:
mode:
Diffstat (limited to 'internal/game')
-rw-r--r--internal/game/account_txn.go380
-rw-r--r--internal/game/baseline_race_test.go132
-rw-r--r--internal/game/casino.go67
-rw-r--r--internal/game/casino_test.go3
-rw-r--r--internal/game/cmd_quit.go2
-rw-r--r--internal/game/cmd_registry.go10
-rw-r--r--internal/game/cmd_room_remove_test.go3
-rw-r--r--internal/game/cmd_shutdown.go115
-rw-r--r--internal/game/combat_attack.go4
-rw-r--r--internal/game/combat_mob.go4
-rw-r--r--internal/game/core_course.go6
-rw-r--r--internal/game/core_login_account.go56
-rw-r--r--internal/game/core_login_char.go132
-rw-r--r--internal/game/economy.go289
-rw-r--r--internal/game/economy_shutdown_test.go263
-rw-r--r--internal/game/exit_migrate.go39
-rw-r--r--internal/game/game.go217
-rw-r--r--internal/game/harness_test.go266
-rw-r--r--internal/game/lifecycle_txn_test.go160
-rw-r--r--internal/game/migrate_rooms_test.go115
-rw-r--r--internal/game/owner.go119
-rw-r--r--internal/game/owner_concurrency_test.go154
-rw-r--r--internal/game/session_registry.go19
-rw-r--r--internal/game/sys_combat.go2
-rw-r--r--internal/game/sys_escape_test.go3
-rw-r--r--internal/game/sys_safespot.go2
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