aboutsummaryrefslogtreecommitdiff
path: root/internal/game/game.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/game/game.go')
-rw-r--r--internal/game/game.go131
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 != "" {