diff options
| author | historia <[not public]> | 2026-09-18 05:31:09 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-09-18 05:31:09 -0400 |
| commit | 153acb7f644d755c45533b7c5311e3ffa47e8973 (patch) | |
| tree | 7645e6f7b65bc3830b078e3b9336d2e6b199d584 /internal/game/game.go | |
| parent | 87d08f0151e643951769e323995f1c5ed89ee200 (diff) | |
| download | thehouseoficarus-153acb7f644d755c45533b7c5311e3ffa47e8973.tar.gz | |
fix: harden engine across casino, economy, concurrency, security, and content
Diffstat (limited to 'internal/game/game.go')
| -rw-r--r-- | internal/game/game.go | 131 |
1 files changed, 126 insertions, 5 deletions
diff --git a/internal/game/game.go b/internal/game/game.go index ef48b63..de383a6 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -88,6 +88,13 @@ type Game struct { shutdownHook func(reason string) transportStop func() + // shutdownSaveMu guards shutdownSaveErr, the error of the final save + // runShutdown performs (nil when it succeeded or no shutdown ran). main + // reads it through ShutdownSaveError after the transports close, so a + // failed final save exits non-zero instead of only logging. + shutdownSaveMu sync.Mutex + shutdownSaveErr error + // Economy is the durable money journal; see economy.go. Economy *economyJournal @@ -117,9 +124,12 @@ func New(dataDir string, colorConfig *config.ColorsConfig, constantColorConfig * CardArt: cardart.Load(filepath.Join(dataDir, "cards.yaml")), DataDir: dataDir, }, - GlobalFlags: NewGlobalFlagStore(), - Combat: combat.NewTracker(), - Casino: casino.NewManager(time.Now().UnixNano()), + GlobalFlags: NewGlobalFlagStore(), + Combat: combat.NewTracker(), + // Entropy-seeded, not clock-seeded: a wall-clock seed makes every + // shoe and reel stream reconstructible. casino.NewManager(seed) stays + // the deterministic test seam. + Casino: casino.NewManagerEntropy(), flagIndex: newFlagTriggerIndex(), queue: NewCommandQueue(), safespot: NewSafespotManager(), @@ -228,6 +238,27 @@ func (g *Game) SetTransportStop(stop func()) { g.transportStop = stop } +// ShutdownSaveError reports the failure of the final online-character save +// performed by runShutdown. It is nil when that save succeeded or when no +// shutdown sequence ran. main calls it after (*net.Server).ListenAndServe +// returns (the transports close inside the shutdown sequence) and exits +// non-zero on a non-nil error: the transport return value reports "server +// stopped", never data loss. +func (g *Game) ShutdownSaveError() error { + g.shutdownSaveMu.Lock() + defer g.shutdownSaveMu.Unlock() + return g.shutdownSaveErr +} + +// recordShutdownSaveErr stores the final-save failure for ShutdownSaveError. +// The shutdown sequence runs on its own goroutine, so the field is mutex +// guarded against the reader in main. +func (g *Game) recordShutdownSaveErr(err error) { + g.shutdownSaveMu.Lock() + g.shutdownSaveErr = err + g.shutdownSaveMu.Unlock() +} + // runShutdown executes the ordered shutdown sequence: // // 1. Admissions stop: setShuttingDown already makes session input and @@ -256,6 +287,10 @@ func (g *Game) runShutdown() { } }) if saveErr != nil { + // Recorded as well as logged: main reads it after the transports + // close and exits non-zero, so an operator's supervisor can see + // that online characters were not persisted. + g.recordShutdownSaveErr(saveErr) log.Printf("shutdown: final save failed: %v", saveErr) } @@ -394,8 +429,25 @@ func (g *Game) SubscribeTicks(interval int, cb func() bool) uint64 { return g.Ticks.Subscribe(interval, cb) } return g.Ticks.Subscribe(interval, func() bool { - keep := false - g.run(func() { keep = cb() }) + // keep defaults to TRUE and is only read once the callback has run to + // completion. The owner loop's panic barrier (owner.runTask) contains + // a panic in cb, so returning false on a contained panic would tell + // the engine to DELETE this subscription - permanently unsubscribing + // the master subscriber main.go registers (RunTickSystems) on the + // first panic inside any tick system, leaving the process alive and + // accepting logins while commands never drain and casino rounds + // never settle. A subscription is only dropped on an explicit false. + // g.run also drops the task outright during shutdown; that is not a + // failure either. + ran := false + keep := true + g.run(func() { + keep = cb() + ran = true + }) + if !ran { + return true + } return keep }) } @@ -424,6 +476,67 @@ func (g *Game) EvictCasinoRoom(roomID int) { } } +// AdminPlayerFileOnline reports whether the data-relative player file path +// belongs to a character or account that is currently online, so the admin +// web editor (admin.AdminServer.SetPlayerFileOnlineCheck) refuses to +// overwrite or delete a file the game still owns in memory. relPath takes +// the form the editor passes, e.g. "players/characters/alice.yaml" or +// "players/accounts/alice.yaml"; any other path is not a player file and +// reports false. A missing account file also reports false (nothing is +// online under it). +// +// It reads the owner-safe registry rather than the owner-only fields of a +// session: character liveness comes from the mutex-guarded loggedInChars +// map (see characterOnline, which matches names case-insensitively) and +// account liveness from the account's on-disk +// character list, the same membership source the account purge uses. That +// keeps the hook safe whether the caller runs on the owner (where the admin +// file editor executes) or off it. +func (g *Game) AdminPlayerFileOnline(relPath string) bool { + name, kind, ok := playerDataFileName(relPath) + if !ok { + return false + } + switch kind { + case "characters": + return g.characterOnline(name) + case "accounts": + acc, err := g.AccountStore.LoadAccount(name) + if err != nil { + return false + } + // characterOnline compares case-insensitively, like the game's own + // membership check (isMemberOf): the account file's character list is + // hand-editable and need not spell a name the way the live session + // does, so "Alice" here must still find the online "alice". + for _, charName := range acc.Characters { + if g.characterOnline(charName) { + return true + } + } + } + return false +} + +// playerDataFileName splits a data-relative player file path into its base +// name and its kind ("characters" or "accounts"). It reports false for any +// path that is not players/<kind>/<name>.yaml, including a bare directory or +// a non-YAML file, so callers never treat an unrelated file as a player file. +func playerDataFileName(relPath string) (name, kind string, ok bool) { + clean := filepath.ToSlash(filepath.Clean(strings.TrimPrefix(strings.TrimPrefix(relPath, "/"), "data/"))) + name, found := strings.CutSuffix(filepath.Base(clean), ".yaml") + if !found || name == "" { + return "", "", false + } + switch filepath.Dir(clean) { + case "players/characters": + return name, "characters", true + case "players/accounts": + return name, "accounts", true + } + return "", "", false +} + func (g *Game) writePrompt(sess *net.Session) { sess.WritePrompt(g.promptStr(sess)) } @@ -533,6 +646,14 @@ func (g *Game) ProcessQueuedCommands() { cmds := g.queue.DrainFree(p.Name) for _, qc := range cmds { + // The queued command records the session it was typed on, which + // can be a departed session by now (logout, disconnect, or a + // re-login on a new connection): never execute against it, or + // a nil Player dereference below kills the owner goroutine. + // Mirrors the nil guard in the actives loop. + if qc.Session == nil || qc.Session.Player == nil { + continue + } g.cancelRest(p.Name) raw := qc.Command if qc.Args != "" { |
