diff options
| author | historia <[not public]> | 2026-09-15 15:35:52 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-09-15 15:35:52 -0400 |
| commit | 0b0b25222b3131ada79ff78c28eb8bf07d977deb (patch) | |
| tree | 2ceaef5203595044f66a8f00064cd8e017f8f48d /internal/game/game.go | |
| parent | 396c3facb8287b9a34076cae8f3d7b300d366d99 (diff) | |
| download | thehouseoficarus-0b0b25222b3131ada79ff78c28eb8bf07d977deb.tar.gz | |
feat: single-owner game state, transactional lifecycle, durable money on server stop
Diffstat (limited to 'internal/game/game.go')
| -rw-r--r-- | internal/game/game.go | 217 |
1 files changed, 199 insertions, 18 deletions
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)) } |
