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/owner.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/owner.go')
| -rw-r--r-- | internal/game/owner.go | 119 |
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() + } + }) +} |
