aboutsummaryrefslogtreecommitdiff
path: root/internal/game/game.go
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-15 15:35:52 -0400
committerhistoria <[not public]>2026-09-15 15:35:52 -0400
commit0b0b25222b3131ada79ff78c28eb8bf07d977deb (patch)
tree2ceaef5203595044f66a8f00064cd8e017f8f48d /internal/game/game.go
parent396c3facb8287b9a34076cae8f3d7b300d366d99 (diff)
downloadthehouseoficarus-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.go217
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))
}