aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-06-25 21:23:34 -0400
committerhistoria <[not public]>2026-06-25 21:23:34 -0400
commite2d5b081f27141bb3dd89dbe595860b8a1345d55 (patch)
tree3090f8150580fe85a872caa04165ff70613bbaa8 /internal
parent84d74ca4351a97148ad8339676f9df7946bc21fb (diff)
downloadthehouseoficarus-e2d5b081f27141bb3dd89dbe595860b8a1345d55.tar.gz
feat: on_enter features ported over to triggers, cosmetic fixes with trigger messages
Diffstat (limited to 'internal')
-rw-r--r--internal/config/config.go1
-rw-r--r--internal/game/act_room.go104
-rw-r--r--internal/game/cmd_move.go4
-rw-r--r--internal/game/core_login_char.go12
-rw-r--r--internal/game/render_color.go2
-rw-r--r--internal/game/triggers.go31
-rw-r--r--internal/player/player.go2
-rw-r--r--internal/validate/checks.go60
-rw-r--r--internal/validate/validate.go1
-rw-r--r--internal/world/room.go20
10 files changed, 193 insertions, 44 deletions
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 {