aboutsummaryrefslogtreecommitdiff
path: root/internal/game/owner.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/owner.go
parent396c3facb8287b9a34076cae8f3d7b300d366d99 (diff)
downloadthehouseoficarus-0b0b25222b3131ada79ff78c28eb8bf07d977deb.tar.gz
feat: single-owner game state, transactional lifecycle, durable money on server stop
Diffstat (limited to 'internal/game/owner.go')
-rw-r--r--internal/game/owner.go119
1 files changed, 119 insertions, 0 deletions
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()
+ }
+ })
+}