// Package game contains the MUD itself: commands, skilling actions, combat, // triggers, rendering, economy, and persistence glue around the world. package game import ( "fmt" "log" "path/filepath" "sort" "strings" "sync" "time" "thehouseoficarus/internal/cardart" "thehouseoficarus/internal/casino" "thehouseoficarus/internal/combat" "thehouseoficarus/internal/config" "thehouseoficarus/internal/engine" "thehouseoficarus/internal/game/hacking" "thehouseoficarus/internal/item" "thehouseoficarus/internal/net" "thehouseoficarus/internal/object" "thehouseoficarus/internal/player" "thehouseoficarus/internal/world" ) type CommandClass int const ( ClassInstant CommandClass = iota ClassFree ClassActive ClassUnknown ) // Deps holds the long-lived data stores and engines a Game depends on. They are // constructed once at startup and never replaced. Deps is embedded in Game, so // game code accesses them directly (e.g. g.World, g.ItemStore). type Deps struct { World *world.World ObjectStore *object.ObjectStore ItemStore *item.ItemStore AccountStore *player.AccountStore MobStore *world.MobStore CraftIndex *CraftIndex CourseStore *CourseStore Ticks *engine.Engine ColorConfig *config.ColorsConfig ConstantColorConfig *config.ColorsConfig CardArt *cardart.Store DataDir string } // Game is the central orchestrator: it owns the data stores (via the embedded // Deps), the shared mutable game state (global flags, combat tracker, command queue, // safespots), and the per-session runtime bookkeeping. type Game struct { Deps Hub *net.Hub GlobalFlags *GlobalFlagStore Combat *combat.Tracker Casino *casino.Manager flagIndex *flagTriggerIndex queue *CommandQueue safespot *SafespotManager ValidationConfig config.ValidationConfig StartingRoom int // Per-tick / per-session runtime bookkeeping. charsMu sync.Mutex loggedInChars map[string]*net.Session restTimers map[string]uint64 guardWatchTimers map[string]int hackingStates map[string]*hacking.Session pendingDepletions []pendingDepletion farmTickCounter int seqMu sync.Mutex sequences map[string]*sequence globalSeqs []*sequence // disconnectDeferrals counts, per session, how many ticks its pending // disconnect has been deferred for combat. Per-Game (not package-level) // so a second Game in one process cannot inherit stale counters, and // pruned each tick so a session hard-removed elsewhere cannot pin its // Player in memory for the life of the process. disconnectDeferrals map[*net.Session]int 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() // shutdownDone is closed after runShutdown's ordered sequence (drain, // refund, final save, transport close) completes. main waits on it on the // transport-failure path before reading ShutdownSaveError, so it cannot // exit while the final save is still in flight. shutdownDone chan struct{} // 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 // loginThrottle applies an escalating per-(account, IP) delay to failed // interactive password attempts. Lazily created so Game literals built // directly in tests work without it. loginThrottleOnce sync.Once loginThrottle *loginThrottle // 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 { g := &Game{ Deps: Deps{ World: world.New(dataDir), ObjectStore: object.NewObjectStore(dataDir), ItemStore: item.NewItemStore(dataDir), AccountStore: player.NewAccountStore(dataDir), MobStore: world.NewMobStore(dataDir), CraftIndex: NewCraftIndex(), CourseStore: NewCourseStore(dataDir), Ticks: engine.New(), ColorConfig: colorConfig, ConstantColorConfig: constantColorConfig, CardArt: cardart.Load(filepath.Join(dataDir, "cards.yaml")), DataDir: dataDir, }, 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(), ValidationConfig: valConfig, StartingRoom: startingRoom, loggedInChars: make(map[string]*net.Session), restTimers: make(map[string]uint64), guardWatchTimers: make(map[string]int), hackingStates: make(map[string]*hacking.Session), sequences: make(map[string]*sequence), disconnectDeferrals: make(map[*net.Session]int), shutdownDone: make(chan struct{}), } 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 // Lifecycle-journal rollback MUST run before pending-wager recovery: a // rolled-back rename transaction restores an older character // before-image, and a refund applied to the post-rename file would be // lost when the rollback replaced it with the before-image. Rollback // also precedes every store read below (courses, mods, techs, craft // index) that a leftover transaction may reference. g.recoverJournals() // Refund any stake left in flight by an ungraceful stop before any // session can connect. The character file is the only money record. g.recoverPendingWagers() g.CourseStore.SetWorld(g.World) g.CourseStore.LoadAll() g.LoadMods() g.LoadTechs() g.buildCraftIndex() g.loadAllFlagTriggers() // One-time stable-identity migration. g.backfillCharacterIDs() g.ValidateAndLog() return g } func (g *Game) SetHub(hub *net.Hub) { g.Hub = hub hub.OnRemove(func(sess *net.Session) { // 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 { // The session is leaving the world: end any live fight, cancel // a running logout countdown, and step out of any safespot // cover first, so nothing keeps acting on a detached session // (ghost combat that saves over a reconnected character, a // duplicate logout that drops the reconnected session's // registry entry, or a phantom cover occupant). g.releaseCharacterRuntime(sess, p) g.restoreGodPlayer(p) g.persistLockedOnDisconnect(p) g.saveCharacter(p) p.DeactivateAllTechs() g.detachSession(p.Name) } }) }) hub.OnDisconnect(func(sess *net.Session) { g.run(func() { // During graceful shutdown the shutdown sequence owns final // persistence and refunds outstanding wagers; a disconnect // forfeit here would destroy a stake the shutdown policy // promises to return. Mirrors the OnRemove guard. if g.isShuttingDown() { return } 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 } // 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 } // ShutdownDone returns a channel that is closed once the graceful shutdown // sequence (including the final online-character save and the transport // close) has completed. A caller that starts shutdown itself (the // transport-failure path) waits on it before reading ShutdownSaveError and // exiting, so the save is not skipped. func (g *Game) ShutdownDone() <-chan struct{} { return g.shutdownDone } // 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 // 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 { // 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) } g.stopOwner() if g.transportStop != nil { g.transportStop() } log.Println("shutdown complete") // Publish completion last: main waits on this before reading // ShutdownSaveError on the transport-failure path. close(g.shutdownDone) } // 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. Once shutdown has begun, new // input is REJECTED (not queued and not executed inline): the shutdown // sequence owns final persistence, and the transports keep draining until // they close, so an accepted line could mutate characters after the final // save or race the refund/save barrier. func (g *Game) HandleSessionOnOwner(sess *net.Session, input string) { if g.isShuttingDown() { return } // Password work is expensive (Argon2id, ~64 MiB and tens of // milliseconds per call) and must never run on the owner goroutine: // the owner serializes every command and tick, so a burst of logins // would stall the whole world. Verify/hash on the caller's connection // goroutine, then submit one owner task for the (cheap) state // transition. switch sess.State() { case net.StatePassword: g.handlePasswordOffOwner(sess, input) return case net.StateNewAccount: g.handleNewAccountOffOwner(sess, input) return } g.RunOnOwner(func() { // Re-checked at task execution: the flag may flip between // submission and execution, and RunOnOwner must never fall back // to inline execution after the owner has stopped. if g.isShuttingDown() { return } 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() { case net.StateAccountName: g.handleAccountName(sess, input) case net.StatePassword: g.handlePassword(sess, input) case net.StateNewAccountPass: g.handleNewPass(sess, input) case net.StateNewAccount: g.handleNewAccount(sess, input) case net.StateMenu: g.handleMenu(sess, input) case net.StateNewCharName: g.handleNewCharName(sess, input) case net.StateRenameAccount: g.handleRenameAccount(sess, input) case net.StateRenameChar: g.handleRenameChar(sess, input) case net.StateRenameCharName: g.handleRenameCharName(sess, input) case net.StateDeleteChar: g.handleDeleteChar(sess, input) case net.StatePurgeAccount: g.handlePurgeAccount(sess, input) case net.StateGame: g.handleGameCommand(sess, input) case net.StateChangeDesc: g.handleDescChange(sess, input) case net.StateTalk, net.StateTalkSequence: g.handleTalkInput(sess, input) case net.StateBank: g.handleBankInput(sess, input) case net.StateDropAllConfirm: g.handleDropAll(sess, input) case net.StateRecipeChoice: g.handleRecipeChoice(sess, input) case net.StateHowMany: g.handleHowMany(sess, input) case net.StateProductChoice: g.handleProductChoice(sess, input) case net.StateColorChoice: g.handleColorChoice(sess, input) case net.StateHacking: g.handleHackingInput(sess, input) case net.StateDangerConfirm: g.handleDangerConfirm(sess, input) case net.StateUndigConfirm: g.handleUndigConfirm(sess, input) default: // A declared state with no handler would otherwise silently swallow // every line. Re-prompt and log so a future menu flow cannot strand // the player on a dead input state. log.Printf("unhandled session state %v", sess.State()) sess.WriteLine("Something went wrong; please try again.") } } // 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 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 }) } // 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)) } } // 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//.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)) } func (g *Game) reprompt(sess *net.Session) { sess.Reprompt(g.promptStr(sess)) } func (g *Game) handleGameCommand(sess *net.Session, input string) { if input == "" { g.reprompt(sess) return } if sess.Player.OptionString("prompt_break") == "off" { sess.ClearPrompt() } if sess.Account != nil && sess.Account.Aliases != nil { firstSpace := strings.Index(input, " ") var firstWord, rest string if firstSpace > 0 { firstWord = input[:firstSpace] rest = strings.TrimLeft(input[firstSpace:], " ") } else { firstWord = input } if expansion, ok := sess.Account.Aliases[strings.ToLower(firstWord)]; ok { if rest != "" { input = expansion + " " + rest } else { input = expansion } } } parts := strings.Fields(strings.ToLower(input)) if len(parts) == 0 { g.reprompt(sess) return } cmd := parts[0] if len(parts) == 1 { switch cmd { case "equip", "eq", "equipment", "wear", "wield": g.doEquipment(sess) g.writePrompt(sess) return } } class := classifyCommand(cmd) if class == ClassInstant { g.executeCommand(sess, cmd, parts[1:], input) g.writePrompt(sess) return } p := sess.Player if p == nil { return } if class == ClassFree { g.queue.EnqueueFree(p.Name, QueuedCommand{ Session: sess, Command: cmd, Args: strings.Join(parts[1:], " "), Timestamp: time.Now(), }) if p.OptionBool("show_queued_cmds") { sess.WriteLine(fmt.Sprintf("You prepare to %s.", cmd)) } return } if class == ClassUnknown { sess.WriteLine("Unknown command.") g.writePrompt(sess) return } g.queue.EnqueueActive(p.Name, QueuedCommand{ Session: sess, Command: cmd, Args: strings.Join(parts[1:], " "), Timestamp: time.Now(), }) g.cancelRest(p.Name) if p.OptionBool("show_queued_cmds") { sess.WriteLine(fmt.Sprintf("You prepare to %s.", cmd)) } } func (g *Game) ProcessQueuedCommands() { if g.Hub == nil { return } for _, sess := range g.Hub.AllSessions() { p := sess.Player if p == nil { continue } 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 != "" { raw += " " + qc.Args } parts := strings.Fields(raw) if len(parts) > 0 { g.executeCommand(qc.Session, parts[0], parts[1:], raw) } if p.BackgroundAction == nil { g.writePrompt(qc.Session) } } if len(p.WalkSequence) > 0 { g.advanceWalk(sess, p) if len(p.WalkSequence) == 0 && p.MoveTicks == 0 { g.writePrompt(sess) } } } actives := g.queue.DrainActive() sort.Slice(actives, func(i, j int) bool { return actives[i].Timestamp.Before(actives[j].Timestamp) }) for _, qc := range actives { // A queued command can outlive its session (logout, disconnect, or a // re-login on a new connection): never dereference a departed one on // the owner goroutine. Mirrors the free-queue guard above. if qc.Session == nil || qc.Session.Player == nil { continue } raw := qc.Command if qc.Args != "" { raw += " " + qc.Args } parts := strings.Fields(raw) p := qc.Session.Player if p == nil { continue } if p.MoveTicks > 0 { p.ClearMoveState() } if len(parts) > 0 { g.executeCommand(qc.Session, parts[0], parts[1:], raw) } ss, _ := g.safespot.Get(p.Name) isHiding := ss.Active isBusy := p.Action != nil || len(p.WalkSequence) > 0 || g.Combat.Get(p.Name) != nil || p.MoveTicks > 0 || isHiding isCasinoSpin := g.Casino != nil && g.Casino.MachineBusy(p.Name) _, isResting := g.restTimers[p.Name] if !isResting && !isBusy && !isCasinoSpin && qc.Session.State() == net.StateGame { g.writePrompt(qc.Session) } } g.flushPendingDepletions() } func (g *Game) buildCraftIndex() { items, err := g.ItemStore.LoadAll() if err != nil { // A malformed item file must not silently empty every crafting menu; // surface it so the operator can fix the file (the validator also // reports it). log.Printf("craft index: item load failed; craft index left empty: %v", err) return } g.CraftIndex.Build(items) } // RebuildCraftIndex refreshes the craft index after item definitions change // on disk (wired into the admin web editor's item mutation paths). func (g *Game) RebuildCraftIndex() { g.buildCraftIndex() } type pendingDepletion struct { instanceKey string objDefID string targetName string playerNames []string }