diff options
| -rw-r--r-- | AGENTS.md | 2 | ||||
| -rw-r--r-- | building_guide/rooms.md | 30 | ||||
| -rw-r--r-- | internal/config/config.go | 1 | ||||
| -rw-r--r-- | internal/game/act_room.go | 104 | ||||
| -rw-r--r-- | internal/game/cmd_move.go | 4 | ||||
| -rw-r--r-- | internal/game/core_login_char.go | 12 | ||||
| -rw-r--r-- | internal/game/render_color.go | 2 | ||||
| -rw-r--r-- | internal/game/triggers.go | 31 | ||||
| -rw-r--r-- | internal/player/player.go | 2 | ||||
| -rw-r--r-- | internal/validate/checks.go | 60 | ||||
| -rw-r--r-- | internal/validate/validate.go | 1 | ||||
| -rw-r--r-- | internal/world/room.go | 20 |
12 files changed, 219 insertions, 50 deletions
@@ -94,7 +94,7 @@ The pattern is: lock, snapshot/unmarshal, unlock, then process. The engine creat ## Room Scripting -- **`on_enter` steps** fire when a player enters. Each has a `message` + optional `condition`. A step may also have a `delay` (ticks) and/or `set_flags`/`set_player_flags`. If any surviving step is timed (has a delay or sets a flag) the sequence runs as a scheduled **cutscene** (driven by `EnterSeqTick`); otherwise messages print synchronously. Step conditions are snapshotted once at entry. A cutscene persists `Player.CutsceneRoom` and **resumes on reconnect** if interrupted; plain on_enter never runs on login. +- **`on_enter` steps** fire when a player enters. Each has a `message` + optional `condition`. A step may also have a `delay` (ticks) and/or `set_flags`/`set_player_flags`. If any surviving step is timed (has a delay or sets a flag) the sequence runs as a scheduled **enter sequence** (driven by `EnterSeqTick`); otherwise messages print synchronously. Step conditions are snapshotted once at entry. An enter sequence persists `Player.EnterSeqRoom` and **resumes on reconnect** if interrupted; plain on_enter never runs on login (except the first room for new characters). - **Conditional descriptions** (`description: [{text, condition}]`) on rooms and objects pick the first variant whose condition passes (per-looker), else the first entry with no condition. Accepts either a plain string or a list. For an **object**, if it has a `description` list and no variant matches, the object is **absent** for that player (`look` falls through). Objects also support `aliases: [...]` for extra names. `look` lists candidates when multiple distinct objects match. - **Exits** support `set_flags`/`set_player_flags`, applied only on a successful traverse. diff --git a/building_guide/rooms.md b/building_guide/rooms.md index 5a414a3..ebdf37d 100644 --- a/building_guide/rooms.md +++ b/building_guide/rooms.md @@ -127,7 +127,7 @@ objects: - id: iron_gate # hidden object (see below) ``` -### On-enter scripts — messages and timed cutscenes when a player arrives +### On-enter scripts — messages and timed sequences when a player arrives Each `on_enter` step shows a `message`, optionally gated by a `condition`. Steps whose condition fails are skipped. @@ -144,9 +144,9 @@ on_enter: player_flag: talked_to_guard # subsequent visits ``` -**Timed cutscenes.** A step may also carry a `delay` (ticks to wait before it +**Timed sequences.** A step may also carry a `delay` (ticks to wait before it fires) and/or set flags (`set_flags` / `set_player_flags`). If any surviving step -has a delay or sets a flag, the whole sequence runs as a scheduled cutscene; +has a delay or sets a flag, the whole sequence runs as a scheduled enter sequence; plain message-only scripts still print instantly. ```yaml @@ -163,11 +163,31 @@ on_enter: message: "The crowd forms a single-file line." ``` +**Step actions.** On-enter steps support all the same actions as trigger steps: +`broadcast`, `broadcast_global`, `spawn_mob`, `despawn_mob`, `give_item`, +`take_item`, `teleport`, and `heal`. See the [trigger step actions](triggers.md#step-actions) +table for details. + +```yaml +on_enter: + - condition: { player_flag: boss_summoned } + delay: 10 + broadcast: "The ground trembles..." + - condition: { player_flag: boss_summoned } + delay: 20 + broadcast: "A massive guardian emerges from the shadows!" + spawn_mob: + id: altar_guardian + owner_only: true + despawn_on_leave: true +``` + Notes: - `delay` counts ticks before the step fires; `delay: 0` (or omitted) fires on the next tick. - Conditions are evaluated **once** on entry, so a flag a step sets won't cancel a later step in the same sequence. -- Gate a cutscene on a flag the sequence itself sets (above, `lined_up`) so it doesn't replay on a return visit. -- If a player disconnects mid-cutscene it resumes on reconnect, so they can't get stuck behind an exit the cutscene was meant to open. Plain `on_enter` messages never replay on login. +- Gate a sequence on a flag the sequence itself sets (above, `lined_up`) so it doesn't replay on a return visit. +- If a player disconnects mid-sequence it resumes on reconnect, so they can't get stuck behind an exit the sequence was meant to open. Plain `on_enter` messages never replay on login (except the first room for new characters). +- Setting player flags from on_enter steps fires room triggers watching those flags — this is how room 1001's touchdown sequence works. ### Conditional room descriptions diff --git a/internal/config/config.go b/internal/config/config.go index 28093fc..33e25fd 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -43,6 +43,7 @@ func DefaultColors() ColorsConfig { "global": "45", "dialog": "117", "broadcast": "215", + "sequence": "off", "fire": "208", "eat_food": "156", "drop_message": "186", diff --git a/internal/game/act_room.go b/internal/game/act_room.go index fec7304..ff7a780 100644 --- a/internal/game/act_room.go +++ b/internal/game/act_room.go @@ -4,13 +4,14 @@ import ( "fmt" "strings" + "thehouseoficarus/internal/color" "thehouseoficarus/internal/net" "thehouseoficarus/internal/player" "thehouseoficarus/internal/world" ) -// enterSeq is an in-flight on_enter cutscene for a single player. It is driven -// one step at a time by EnterSeqTick. +// enterSeq is an in-flight on_enter stepped sequence for a single player. It is +// driven one step at a time by EnterSeqTick. type enterSeq struct { sess *net.Session playerName string @@ -22,8 +23,8 @@ type enterSeq struct { // runEnterSteps evaluates a room's on_enter script for the player. Steps whose // condition fails are dropped. If none of the surviving steps are "timed" -// (no delay, no flag mutation) the messages are printed synchronously, exactly -// as before. Otherwise the surviving steps become a scheduled cutscene driven +// (no delay, no flag mutation) the messages are printed synchronously. +// Otherwise the surviving steps become a scheduled enter sequence driven // by EnterSeqTick. func (g *Game) runEnterSteps(sess *net.Session, roomID int) { room, err := g.World.LoadRoom(roomID) @@ -36,21 +37,21 @@ func (g *Game) runEnterSteps(sess *net.Session, roomID int) { } var steps []world.EnterStep - cutscene := false + sequenced := false for _, step := range room.OnEnter { if step.Condition != nil && !g.checkCondition(sess, step.Condition) { continue } steps = append(steps, step) if step.IsTimed() { - cutscene = true + sequenced = true } } if len(steps) == 0 { return } - if !cutscene { + if !sequenced { for _, step := range steps { if step.Message != "" { sess.WriteLine(step.Message) @@ -59,16 +60,16 @@ func (g *Game) runEnterSteps(sess *net.Session, roomID int) { return } - g.startEnterCutscene(sess, p, roomID, steps) + g.startEnterSeq(sess, p, roomID, steps) } -// startEnterCutscene registers a scheduled on_enter sequence and persists a -// marker on the player so the cutscene can be resumed on reconnect if it is -// interrupted (disconnect, server restart). -func (g *Game) startEnterCutscene(sess *net.Session, p *player.Player, roomID int, steps []world.EnterStep) { +// startEnterSeq registers a scheduled on_enter sequence and persists a marker +// on the player so the sequence can be resumed on reconnect if interrupted +// (disconnect, server restart). +func (g *Game) startEnterSeq(sess *net.Session, p *player.Player, roomID int, steps []world.EnterStep) { g.cancelEnterSeq(p.Name) - if p.CutsceneRoom != roomID { - p.CutsceneRoom = roomID + if p.EnterSeqRoom != roomID { + p.EnterSeqRoom = roomID g.AccountStore.SaveCharacter(p) } g.enterMu.Lock() @@ -82,9 +83,9 @@ func (g *Game) startEnterCutscene(sess *net.Session, p *player.Player, roomID in g.enterMu.Unlock() } -// EnterSeqTick advances every in-flight cutscene by one tick. Sequences for a -// player who is no longer connected are paused (left for resume); sequences are -// removed from the map on completion or cancellation. +// EnterSeqTick advances every in-flight enter sequence by one tick. Sequences +// for a player who is no longer connected are paused (left for resume); +// sequences are removed from the map on completion or cancellation. func (g *Game) EnterSeqTick() { g.enterMu.Lock() seqs := make([]*enterSeq, 0, len(g.enterSeqs)) @@ -137,20 +138,71 @@ func (g *Game) fireEnterStep(seq *enterSeq, step world.EnterStep) { if p == nil || p.RoomID != seq.roomID { return } + seqSpec := g.resolveColor(seq.sess, "sequence") + broadcastSpec := g.resolveColor(seq.sess, "broadcast") + mode := g.colorMode(seq.sess) if step.Message != "" { - // Break from the prompt on the first line only, so a multi-line - // cutscene reads as tight consecutive lines. + msg := expandTemplate(step.Message, p.Name, nil) + msg = color.ExpandTagsDefault(mode, seqSpec, msg) if !seq.started { - seq.sess.WriteLine("\n" + step.Message) + seq.sess.WriteLine("\n" + msg) seq.started = true } else { - seq.sess.WriteLine(step.Message) + seq.sess.WriteLine(msg) + } + } + if step.Broadcast != "" && g.Hub != nil { + msg := expandTemplate(step.Broadcast, p.Name, nil) + msg = color.ExpandTagsDefault(mode, broadcastSpec, msg) + for _, other := range g.Hub.PlayersInRoom(seq.roomID) { + other.WriteLine(g.colorize(other, "broadcast", "\n"+msg)) + } + } + if step.BroadcastGlobal != "" && g.Hub != nil { + msg := expandTemplate(step.BroadcastGlobal, p.Name, nil) + msg = color.ExpandTagsDefault(mode, broadcastSpec, msg) + for _, other := range g.Hub.AllSessions() { + if other.Player != nil { + other.WriteLine(g.colorize(other, "broadcast", "\n"+msg)) + } } } if len(step.SetFlags) > 0 || len(step.SetPlayerFlags) > 0 { g.applyFlagMutations(p, step.SetFlags, step.SetPlayerFlags) g.AccountStore.SaveCharacter(p) } + if step.SpawnMob != nil { + g.spawnTriggerMob(seq.sess, p, step.SpawnMob, seq.roomID) + } + if step.DespawnMob != "" { + g.despawnTriggerMobs(step.DespawnMob, p.Name) + } + if step.GiveItem != "" { + slot := p.FirstFreeSlot() + if slot == -1 { + seq.sess.WriteLine("Your inventory is too full to receive that.") + } else { + p.SetInvSlot(slot, &player.InventorySlot{ItemID: step.GiveItem, Quantity: 1}) + g.AccountStore.SaveCharacter(p) + } + } + if step.TakeItem != "" { + if p.HasItem(step.TakeItem) { + p.RemoveItem(step.TakeItem, 1) + g.AccountStore.SaveCharacter(p) + } + } + if step.Heal > 0 { + p.HP += step.Heal + if maxHP := p.MaxHP(); p.HP > maxHP { + p.HP = maxHP + } + g.AccountStore.SaveCharacter(p) + seq.sess.WriteLine(fmt.Sprintf("You regain %d hitpoints.", step.Heal)) + } + if step.Teleport > 0 { + g.teleportPlayer(seq.sess, p, step.Teleport) + } } func (g *Game) completeEnterSeq(seq *enterSeq) { @@ -161,16 +213,16 @@ func (g *Game) completeEnterSeq(seq *enterSeq) { if p == nil { return } - if p.CutsceneRoom == seq.roomID { - p.CutsceneRoom = 0 + if p.EnterSeqRoom == seq.roomID { + p.EnterSeqRoom = 0 g.AccountStore.SaveCharacter(p) } g.writePrompt(seq.sess) } -// cancelEnterSeq drops any in-flight cutscene for the player. It does not touch -// the persisted CutsceneRoom marker, so a cutscene cancelled by disconnect can -// still be resumed on reconnect. +// cancelEnterSeq drops any in-flight enter sequence for the player. It does not +// touch the persisted EnterSeqRoom marker, so a sequence cancelled by +// disconnect can still be resumed on reconnect. func (g *Game) cancelEnterSeq(name string) { g.enterMu.Lock() delete(g.enterSeqs, name) diff --git a/internal/game/cmd_move.go b/internal/game/cmd_move.go index 63f9bcd..2939c34 100644 --- a/internal/game/cmd_move.go +++ b/internal/game/cmd_move.go @@ -149,8 +149,8 @@ func (g *Game) completeMove(sess *net.Session, p *player.Player) { p.Stats.RecordRoomVisit(targetID) g.cancelEnterSeq(p.Name) - if p.CutsceneRoom != 0 && p.CutsceneRoom == oldRoom { - p.CutsceneRoom = 0 + if p.EnterSeqRoom != 0 && p.EnterSeqRoom == oldRoom { + p.EnterSeqRoom = 0 } if len(pendingFlags) > 0 || len(pendingPlayerFlags) > 0 { g.applyFlagMutations(p, pendingFlags, pendingPlayerFlags) diff --git a/internal/game/core_login_char.go b/internal/game/core_login_char.go index dec6dae..5724c7b 100644 --- a/internal/game/core_login_char.go +++ b/internal/game/core_login_char.go @@ -57,6 +57,10 @@ func (g *Game) handleNewCharName(sess *net.Session, input string) { sess.Account.Characters = acc.Characters g.connectCharacter(sess, name) + + if sess.Player != nil { + g.runEnterSteps(sess, sess.Player.RoomID) + } } func (g *Game) connectCharacter(sess *net.Session, name string) { @@ -102,18 +106,18 @@ func (g *Game) connectCharacter(sess *net.Session, name string) { g.doLook(sess) g.checkAggro(sess) - // Resume an interrupted on_enter cutscene (e.g. disconnected mid-sequence). + // Resume an interrupted on_enter sequence (e.g. disconnected mid-sequence). // Only fires when a persisted marker matches the room the player is in, so // ordinary on_enter scripts never run on login. - if p.CutsceneRoom != 0 { - if p.CutsceneRoom == p.RoomID { + if p.EnterSeqRoom != 0 { + if p.EnterSeqRoom == p.RoomID { g.runEnterSteps(sess, p.RoomID) } g.enterMu.Lock() _, active := g.enterSeqs[p.Name] g.enterMu.Unlock() if !active { - p.CutsceneRoom = 0 + p.EnterSeqRoom = 0 g.AccountStore.SaveCharacter(p) } } diff --git a/internal/game/render_color.go b/internal/game/render_color.go index c6f154c..a5ab8fa 100644 --- a/internal/game/render_color.go +++ b/internal/game/render_color.go @@ -16,7 +16,7 @@ func (g *Game) colorMode(sess *net.Session) string { } func (g *Game) resolveColor(sess *net.Session, category string) color.ColorSpec { - if sess.Account != nil && sess.Account.Colors != nil { + if sess != nil && sess.Account != nil && sess.Account.Colors != nil { if val, ok := sess.Account.Colors[category]; ok { if val == "off" { return color.NoColor() diff --git a/internal/game/triggers.go b/internal/game/triggers.go index 76ce9cd..3b6f609 100644 --- a/internal/game/triggers.go +++ b/internal/game/triggers.go @@ -4,6 +4,7 @@ import ( "fmt" "strings" + "thehouseoficarus/internal/color" "thehouseoficarus/internal/engine" "thehouseoficarus/internal/net" "thehouseoficarus/internal/player" @@ -91,8 +92,14 @@ func (g *Game) processPlayerTriggerSequences() { } } + firstMsg := true for _, step := range jobs { - g.executeTriggerStep(sess, p, &step, seq.RoomID, seq.FlagValue) + s := step + if firstMsg && s.Message != "" { + s.Message = "\n" + s.Message + firstMsg = false + } + g.executeTriggerStep(sess, p, &s, seq.RoomID, seq.FlagValue) } if len(seq.Steps) == 0 { @@ -133,21 +140,29 @@ func (g *Game) processWorldTriggerSequences() { } func (g *Game) executeTriggerStep(sess *net.Session, p *player.Player, step *world.TriggerStep, roomID int, flagValue any) { + seqSpec := g.resolveColor(sess, "sequence") + broadcastSpec := g.resolveColor(sess, "broadcast") + playerMode := g.colorMode(sess) if step.Message != "" { msg := expandTemplate(step.Message, p.Name, flagValue) + msg = color.ExpandTagsDefault(playerMode, seqSpec, msg) sess.WriteLine(msg) } if step.Broadcast != "" && g.Hub != nil { msg := expandTemplate(step.Broadcast, p.Name, flagValue) for _, other := range g.Hub.PlayersInRoom(roomID) { - other.WriteLine(g.colorize(other, "broadcast", "\n"+msg)) + otherMode := g.colorMode(other) + rendered := color.ExpandTagsDefault(otherMode, broadcastSpec, msg) + other.WriteLine(g.colorize(other, "broadcast", "\n"+rendered)) } } if step.BroadcastGlobal != "" && g.Hub != nil { msg := expandTemplate(step.BroadcastGlobal, p.Name, flagValue) for _, other := range g.Hub.AllSessions() { if other.Player != nil { - other.WriteLine(g.colorize(other, "broadcast", "\n"+msg)) + otherMode := g.colorMode(other) + rendered := color.ExpandTagsDefault(otherMode, broadcastSpec, msg) + other.WriteLine(g.colorize(other, "broadcast", "\n"+rendered)) } } } @@ -197,15 +212,21 @@ func (g *Game) executeTriggerStep(sess *net.Session, p *player.Player, step *wor func (g *Game) executeWorldTriggerStep(step *world.TriggerStep, roomID int, flagValue any) { if step.Broadcast != "" && g.Hub != nil { msg := expandTemplate(step.Broadcast, "", flagValue) + broadcastSpec := g.resolveColor(nil, "broadcast") for _, other := range g.Hub.PlayersInRoom(roomID) { - other.WriteLine(g.colorize(other, "broadcast", "\n"+msg)) + otherMode := g.colorMode(other) + rendered := color.ExpandTagsDefault(otherMode, broadcastSpec, msg) + other.WriteLine(g.colorize(other, "broadcast", "\n"+rendered)) } } if step.BroadcastGlobal != "" && g.Hub != nil { msg := expandTemplate(step.BroadcastGlobal, "", flagValue) + broadcastSpec := g.resolveColor(nil, "broadcast") for _, other := range g.Hub.AllSessions() { if other.Player != nil { - other.WriteLine(g.colorize(other, "broadcast", "\n"+msg)) + otherMode := g.colorMode(other) + rendered := color.ExpandTagsDefault(otherMode, broadcastSpec, msg) + other.WriteLine(g.colorize(other, "broadcast", "\n"+rendered)) } } } diff --git a/internal/player/player.go b/internal/player/player.go index cb823aa..14ed06e 100644 --- a/internal/player/player.go +++ b/internal/player/player.go @@ -171,7 +171,7 @@ type Player struct { Prompt string `yaml:"prompt"` Options map[string]any `yaml:"-"` Flags map[string]any `yaml:"flags"` - CutsceneRoom int `yaml:"cutscene_room,omitempty"` + EnterSeqRoom int `yaml:"enter_seq_room,omitempty"` RegenerateTick int AmmoQty int `yaml:"ammo_qty,omitempty"` Action *behavior.Action `yaml:"-"` diff --git a/internal/validate/checks.go b/internal/validate/checks.go index 197b8d3..9cff717 100644 --- a/internal/validate/checks.go +++ b/internal/validate/checks.go @@ -521,6 +521,66 @@ func validateCourses(s Source) []Issue { return issues } +func validateRoomEnterSteps(s Source) []Issue { + var issues []Issue + roomIndex := s.World.RoomIndex() + itemIDs := s.Items.IDSet() + mobIDs := s.Mobs.AllDefIDs() + + for id := range roomIndex { + room, err := s.World.LoadRoom(id) + if err != nil || len(room.OnEnter) == 0 { + continue + } + for si, step := range room.OnEnter { + stepPrefix := fmt.Sprintf("Room %d: on_enter[%d]", id, si) + if step.SpawnMob != nil && step.SpawnMob.ID != "" { + if _, ok := mobIDs[step.SpawnMob.ID]; !ok { + issues = append(issues, Issue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("%s: spawn_mob %q does not exist", stepPrefix, step.SpawnMob.ID), + }) + } + for _, wr := range step.SpawnMob.DespawnRooms { + if _, ok := roomIndex[wr]; !ok { + issues = append(issues, Issue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("%s: spawn_mob despawn_rooms references nonexistent room %d", stepPrefix, wr), + }) + } + } + } + if step.GiveItem != "" && !itemIDs[step.GiveItem] { + issues = append(issues, Issue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("%s: give_item %q does not exist", stepPrefix, step.GiveItem), + }) + } + if step.TakeItem != "" && !itemIDs[step.TakeItem] { + issues = append(issues, Issue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("%s: take_item %q does not exist", stepPrefix, step.TakeItem), + }) + } + if step.Teleport > 0 { + if _, ok := roomIndex[step.Teleport]; !ok { + issues = append(issues, Issue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("%s: teleport to nonexistent room %d", stepPrefix, step.Teleport), + }) + } + } + } + } + + return issues +} + func validateRoomTriggers(s Source) []Issue { var issues []Issue roomIndex := s.World.RoomIndex() diff --git a/internal/validate/validate.go b/internal/validate/validate.go index 3918c95..36969ed 100644 --- a/internal/validate/validate.go +++ b/internal/validate/validate.go @@ -77,6 +77,7 @@ func Run(s Source) []Issue { issues = append(issues, validateDropTables(s)...) issues = append(issues, validateCourses(s)...) issues = append(issues, validateRoomTriggers(s)...) + issues = append(issues, validateRoomEnterSteps(s)...) issues = append(issues, validateTechs(s)...) issues = append(issues, validateRoomWiring(s)...) diff --git a/internal/world/room.go b/internal/world/room.go index 22e7f79..aced969 100644 --- a/internal/world/room.go +++ b/internal/world/room.go @@ -103,14 +103,24 @@ type EnterStep struct { Delay int `yaml:"delay"` SetFlags map[string]any `yaml:"set_flags"` SetPlayerFlags map[string]any `yaml:"set_player_flags"` + Broadcast string `yaml:"broadcast"` + BroadcastGlobal string `yaml:"broadcast_global"` + SpawnMob *SpawnMobConfig `yaml:"spawn_mob"` + DespawnMob string `yaml:"despawn_mob"` + GiveItem string `yaml:"give_item"` + TakeItem string `yaml:"take_item"` + Teleport int `yaml:"teleport"` + Heal int `yaml:"heal"` } -// IsTimed reports whether the step carries cutscene semantics: a tick delay or -// a flag mutation. A room whose qualifying on_enter steps are all non-timed is -// printed synchronously; any timed step turns the sequence into a scheduled -// cutscene. +// IsTimed reports whether the step carries enter-sequence semantics (any +// non-zero field means it needs per-tick scheduling rather than synchronous +// printing). func (e EnterStep) IsTimed() bool { - return e.Delay > 0 || len(e.SetFlags) > 0 || len(e.SetPlayerFlags) > 0 + return e.Delay > 0 || len(e.SetFlags) > 0 || len(e.SetPlayerFlags) > 0 || + e.Message != "" || e.Broadcast != "" || e.BroadcastGlobal != "" || + e.SpawnMob != nil || e.DespawnMob != "" || e.GiveItem != "" || + e.TakeItem != "" || e.Teleport != 0 || e.Heal != 0 } type RoomObject struct { |
