// 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 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() // Economy is the durable money journal; see economy.go. Economy *economyJournal // 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(), Casino: casino.NewManager(time.Now().UnixNano()), 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), } 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 g.Economy = newEconomyJournal(dataDir) // Lifecycle-journal rollback MUST run before economy recovery: a rolled- // back rename transaction restores an older character before-image, and // if money compensation ran first its settled/compacted records would no // longer be recoverable — the rollback would silently erase a payout // that was already re-applied. Rollback also precedes every store read // below (courses, mods, techs, craft index) that a leftover transaction // may reference. See the TestEconomyRecoveryRunsAfterLifecycleRollback // regression. g.recoverJournals() // Compensate open money records from any prior crash before any // session can connect. g.recoverEconomy() 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 { g.restoreGodPlayer(p) g.persistLockedOnDisconnect(p) g.saveCharacter(p) p.DeactivateAllTechs() g.detachSession(p.Name) delete(g.hackingStates, p.Name) if ss, ok := g.safespot.Get(p.Name); ok { g.forceLeaveSafespot(sess, p, &ss, "") } } }) }) hub.OnDisconnect(func(sess *net.Session) { g.run(func() { 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 } // 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 { log.Printf("shutdown: final save failed: %v", saveErr) } g.stopOwner() if g.transportStop != nil { g.transportStop() } log.Println("shutdown complete") } // 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 } 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.StateSmithProduct, net.StateFletchProduct, net.StateCraftProduct, 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) } } // 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 := false g.run(func() { keep = cb() }) 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)) } } 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 { 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 { 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 { 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 }