aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--AGENTS.md2
-rw-r--r--building_guide/objects.md59
-rw-r--r--building_guide/triggers.md12
-rw-r--r--data/objects/unique/1001_door.yaml3
-rw-r--r--data/objects/unique/1001_instruments.yaml4
-rw-r--r--data/objects/unique/1001_pilot.yaml3
-rw-r--r--data/objects/unique/1001_sign.yaml28
-rw-r--r--data/objects/unique/1001_window.yaml3
-rw-r--r--data/rooms/intro/1001.yaml46
-rw-r--r--internal/game/act.go8
-rw-r--r--internal/game/cmd_move.go13
-rw-r--r--internal/game/cmd_verbs.go2
-rw-r--r--internal/game/look_entities.go2
-rw-r--r--internal/game/look_target.go6
-rw-r--r--internal/game/object_def.go33
-rw-r--r--internal/validate/checks.go158
-rw-r--r--internal/validate/inline_test.go134
-rw-r--r--internal/world/room.go55
-rw-r--r--internal/world/room_test.go92
19 files changed, 593 insertions, 70 deletions
diff --git a/AGENTS.md b/AGENTS.md
index 394f355..eb8c218 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -19,7 +19,7 @@ File prefixes in `internal/game/`: `game.go/cmd_registry.go/tick.go` (core), `co
## Key Conventions
-**YAML-driven.** Rooms, items, objects, mobs, drops, modules, courses under `data/`. Read from disk on every access — edit and it takes effect immediately. **The filename is the ID** for every data type: lookup by filename stem. **Do not put a top-level `id:` field** (ignored). A nested `- id:` under a room's `objects:`/`mobs:` is a *reference* and is still required. Subdirectories are organizational only.
+**YAML-driven.** Rooms, items, objects, mobs, drops, modules, courses under `data/`. Read from disk on every access — edit and it takes effect immediately. **The filename is the ID** for every data type: lookup by filename stem. **Do not put a top-level `id:` field** (ignored). A nested `- id:` under a room's `objects:`/`mobs:` is a *reference* and is still required. Subdirectories are organizational only. **Inline objects:** a room `objects:` entry that carries content fields (`name`/`description`/`aliases`/`inroom_description`/`color`/`hidden`/`on_look`) is a fully inline, room-scoped object def instead of a reference — passive subset only (no gather/talk/use/safespot/steal/guard_mob/use_interactions). Inline identity derives from the normalized `name` (lowercased, whitespace-collapsed, spaces kept); `id:` is reserved for references and ignored+warned on inline. Each object in a room must have a unique name (startup error on collision, incl. inline-name vs referenced file id); names may repeat across different rooms.
**Live state.** Player data persisted to YAML on every change. Ground items, mob instances, object states are in-memory only.
diff --git a/building_guide/objects.md b/building_guide/objects.md
index aaa13e8..37b8e23 100644
--- a/building_guide/objects.md
+++ b/building_guide/objects.md
@@ -15,15 +15,68 @@ and is still required there.)
- **Generic, shared objects** (rocks, trees, altars, stations) use a semantic name and live
flat in `data/objects/` — e.g. `data/objects/copper_rock.yaml`.
-- **Room-specific one-offs** (signs, set-dressing, scenery for a single room) are prefixed
- with the room number and placed in `data/objects/unique/` — e.g.
- `data/objects/unique/1001_sign.yaml` (ID `1001_sign`, referenced from room `1001`).
+- **Room-specific one-offs** (signs, set-dressing, scenery for a single room): if they are
+ description-only (the passive subset), prefer defining them **inline in the room** (see
+ [Inline objects](#inline-objects-defined-in-the-room) below). If a one-off needs
+ interactable behavior (`gather`/`talk`/`use`/etc.) it must be a file — prefix it with the
+ room number and place it in `data/objects/unique/`, e.g. `data/objects/unique/1002_sign.yaml`
+ (ID `1002_sign`, referenced from room `1002`).
Loading walks `data/objects/` recursively, so subdirectories are purely organizational and
need no changes to room references when a file is moved. They do **not** namespace IDs: every
filename stem must be globally unique across all of `data/objects/`. Use `hidden: true` for
signage/scenery so it doesn't appear in room listings.
+### Inline objects (defined in the room)
+
+Room-specific **description / scenery** objects can be defined directly inside the room's
+`objects:` list instead of as separate files in `data/objects/unique/`. An entry in the list
+is treated as **inline** when it carries a `name:` (or any other content field —
+`description`, `aliases`, `inroom_description`, `color`, `hidden`, `on_look`). An entry with
+only `- id: <file>` is a plain **reference** to a file object, as before. `id:` is reserved
+for references; **inline objects do not take an `id:`** — their identity comes from the name.
+
+```yaml
+# in data/rooms/intro/1001.yaml
+objects:
+ - id: workbench # reference: resolves to data/objects/workbench.yaml
+ - name: window # inline: identity derived from the name, no file
+ hidden: true
+ description: "A thick trim with rounded bolt heads frames the tiny window."
+ - name: sign
+ aliases: [safety card, frame]
+ inroom_description: A large framed sign is mounted near the cockpit door.
+ description: |-
+ Conditional or multi-line descriptions work exactly like file objects.
+ on_look:
+ set_player_flags:
+ 1001_look_sign: true
+```
+
+- **Identity comes from `name`.** Internally the object's id is the name, normalized
+ (lowercased, surrounding/redundant spaces trimmed, spaces kept — `"Instrument Panel"` →
+ `instrument panel`). You never write an `id:`; doing so on an inline entry is ignored and
+ warned about at startup.
+- **Identity is room-scoped.** Two different rooms may each have an object named `sign`
+ without conflict — no room-number prefixing needed (unlike the old `data/objects/unique/`
+ files).
+- **Each object in a room must have a unique name.** Two *distinct* objects in the same room
+ with the same exact name — whether both inline, both file references, or one of each — is a
+ startup **error** (the player could not disambiguate them). Multiple instances of the *same*
+ object are still fine via repeated references (e.g. two `- id: copper_rock`).
+- **Partial-name siblings are fine.** `rusty sign` and `shiny sign` can coexist; `look sign`
+ matches both and prompts *"which one?"*, while `look rusty sign` resolves directly.
+- Inline objects support only the **passive subset**: `name`, `aliases`, `color`, `hidden`,
+ `inroom_description`, `description` (including conditional variants), and `on_look`.
+- Interactable / stateful behavior (`gather`, `talk`, `use`, `safespot`, `steal`, `guard_mob`,
+ `removal_item`, `use_interactions`, craft stations) **must** be a standalone object file;
+ startup validation errors if those appear inline.
+- Inline objects are re-read from the room file on every access, so edits take effect
+ immediately (file objects are cached after first load).
+
+Prefer inline for one-room flavor; keep generic/reusable/interactable objects as files.
+
+
Gathering object (mining):
```yaml
name: copper rock
diff --git a/building_guide/triggers.md b/building_guide/triggers.md
index fa97e3a..8d6454c 100644
--- a/building_guide/triggers.md
+++ b/building_guide/triggers.md
@@ -32,13 +32,15 @@ triggers:
```
The sign object itself is unchanged — its `on_look` simply sets the flag. The trigger
-and the object are cleanly separated:
+and the object are cleanly separated. The sign is an inline object in room `1001`:
```yaml
-# data/objects/unique/1001_sign.yaml
-on_look:
- set_player_flags:
- 1001_look_sign: true
+# data/rooms/intro/1001.yaml
+objects:
+ - name: sign
+ on_look:
+ set_player_flags:
+ 1001_look_sign: true
```
This separation is deliberate. The object sets flags. The trigger watches flags.
diff --git a/data/objects/unique/1001_door.yaml b/data/objects/unique/1001_door.yaml
deleted file mode 100644
index f9dbb20..0000000
--- a/data/objects/unique/1001_door.yaml
+++ /dev/null
@@ -1,3 +0,0 @@
-name: door
-hidden: true
-description: "A sturdy, locked steel door that keeps passengers out of the cockpit. There is a tiny, thin window at eye level."
diff --git a/data/objects/unique/1001_instruments.yaml b/data/objects/unique/1001_instruments.yaml
deleted file mode 100644
index 2d50c4f..0000000
--- a/data/objects/unique/1001_instruments.yaml
+++ /dev/null
@@ -1,4 +0,0 @@
-name: instrument panel
-aliases: [cockpit]
-hidden: true
-description: "The cramped cockpit is surrounded by buttons, swiches, dials, and analog gauges. The parts are a mix of technologies from the last century. Part of the panel is obscured by a draped cloth as if to hide it from view. Odd."
diff --git a/data/objects/unique/1001_pilot.yaml b/data/objects/unique/1001_pilot.yaml
deleted file mode 100644
index fccb61f..0000000
--- a/data/objects/unique/1001_pilot.yaml
+++ /dev/null
@@ -1,3 +0,0 @@
-name: pilot
-hidden: true
-description: "That's the guy who took you from the Passenger Barge Denali down here to Vesta. A middle-aged man in a neat white jumpsuit with a matching white sort of conductor's hat. He's following a written procedure checklist and seems to take his job seriously."
diff --git a/data/objects/unique/1001_sign.yaml b/data/objects/unique/1001_sign.yaml
deleted file mode 100644
index 8b9ea0d..0000000
--- a/data/objects/unique/1001_sign.yaml
+++ /dev/null
@@ -1,28 +0,0 @@
-name: sign
-aliases: [safety card, safety instructions, frame]
-inroom_description: A large framed sign is mounted near the cockpit door.
-description: |-
- We hope you enjoy your trip on our shuttle. Your pilot is [TYLER].
-
- When you arrive at your final destination, please be aware of the following basic commands:
-
- {0B bold}Movement:{/} {0A}north{/}, {0A}south{/}, {0A}east{/}, {0A}west{/}, {0A}up{/}, {0A}down{/}
- {0B bold}Look:{/} {0A}look{/}, {0A}look <item/object/mob>{/}, and {0A}map{/}
- {0B bold}Items:{/} {0A}inv{/} to see your 28-slot inventory
- {0A}get <item>{/}, {0A}drop <item>{/}, {0A}wear <item>{/}, {0A}remove <item>{/}
- {0A}use <item> on <item/object>{/}
- {0A}eq{/} for equiped gear
- {0B bold}Comms:{/} {0A}talk <mob>{/} for NPC chat
- {0A}say <msg>{/} and {0A}global <msg>{/} for human chat
- {0B bold}Character:{/} {0A}score{/} and {0A}skills{/} for stats.
- {0B bold}Combat{/} {0A}attack <mob>{/}
- {0B bold}Skills{/} Many skills have shortcuts like {0A}mine{/}, {0A}fish{/}, or {0A}chop{/}
- {0B bold}Options{/} {0A}options{/} for a list of account options
- {0A}options <opt> <value>{/} to change an option
-
- Type {0C bold}what{/} for a list of possible common actions
-
- Make sure to check the {0A}help{/} pages, especially {0A}help newplayer{/}!
-on_look:
- set_player_flags:
- 1001_look_sign: true
diff --git a/data/objects/unique/1001_window.yaml b/data/objects/unique/1001_window.yaml
deleted file mode 100644
index e8d76f8..0000000
--- a/data/objects/unique/1001_window.yaml
+++ /dev/null
@@ -1,3 +0,0 @@
-name: window
-hidden: true
-description: "A thick trim with rounded bolt heads frames the tiny window. It looks like it was cut into the door at some point, potentially as a simple replacement to computerized security systems. Inside the cockpit there's a pilot finishing up landing procedures on a large panel of analog instruments."
diff --git a/data/rooms/intro/1001.yaml b/data/rooms/intro/1001.yaml
index 2f5ffe0..8e2f573 100644
--- a/data/rooms/intro/1001.yaml
+++ b/data/rooms/intro/1001.yaml
@@ -6,11 +6,47 @@ description:
not: true
- text: "A dim, windowless shuttle interior with a few uncomfortable rows of steel seats facing forward. A large framed sign hangs next to the closed cockpit door. A piston-powered staircase leads down out the back of the small craft with a smiling attendant ready to help disembark."
objects:
- - id: 1001_sign
- - id: 1001_door
- - id: 1001_window
- - id: 1001_instruments
- - id: 1001_pilot
+ - name: sign
+ aliases: [safety card, safety instructions, frame]
+ inroom_description: A large framed sign is mounted near the cockpit door.
+ description: |-
+ We hope you enjoy your trip on our shuttle. Your pilot is [TYLER].
+
+ When you arrive at your final destination, please be aware of the following basic commands:
+
+ {0B bold}Movement:{/} {0A}north{/}, {0A}south{/}, {0A}east{/}, {0A}west{/}, {0A}up{/}, {0A}down{/}
+ {0B bold}Look:{/} {0A}look{/}, {0A}look <item/object/mob>{/}, and {0A}map{/}
+ {0B bold}Items:{/} {0A}inv{/} to see your 28-slot inventory
+ {0A}get <item>{/}, {0A}drop <item>{/}, {0A}wear <item>{/}, {0A}remove <item>{/}
+ {0A}use <item> on <item/object>{/}
+ {0A}eq{/} for equiped gear
+ {0B bold}Comms:{/} {0A}talk <mob>{/} for NPC chat
+ {0A}say <msg>{/} and {0A}global <msg>{/} for human chat
+ {0B bold}Character:{/} {0A}score{/} and {0A}skills{/} for stats.
+ {0B bold}Combat{/} {0A}attack <mob>{/}
+ {0B bold}Skills{/} Many skills have shortcuts like {0A}mine{/}, {0A}fish{/}, or {0A}chop{/}
+ {0B bold}Options{/} {0A}options{/} for a list of account options
+ {0A}options <opt> <value>{/} to change an option
+
+ Type {0C bold}what{/} for a list of possible common actions
+
+ Make sure to check the {0A}help{/} pages, especially {0A}help newplayer{/}!
+ on_look:
+ set_player_flags:
+ 1001_look_sign: true
+ - name: door
+ hidden: true
+ description: "A sturdy, locked steel door that keeps passengers out of the cockpit. There is a tiny, thin window at eye level."
+ - name: window
+ hidden: true
+ description: "A thick trim with rounded bolt heads frames the tiny window. It looks like it was cut into the door at some point, potentially as a simple replacement to computerized security systems. Inside the cockpit there's a pilot finishing up landing procedures on a large panel of analog instruments."
+ - name: instrument panel
+ aliases: [cockpit]
+ hidden: true
+ description: "The cramped cockpit is surrounded by buttons, swiches, dials, and analog gauges. The parts are a mix of technologies from the last century. Part of the panel is obscured by a draped cloth as if to hide it from view. Odd."
+ - name: pilot
+ hidden: true
+ description: "That's the guy who took you from the Passenger Barge Denali down here to Vesta. A middle-aged man in a neat white jumpsuit with a matching white sort of conductor's hat. He's following a written procedure checklist and seems to take his job seriously."
mobs:
- flight_attendant
exits:
diff --git a/internal/game/act.go b/internal/game/act.go
index 78f298d..654b65c 100644
--- a/internal/game/act.go
+++ b/internal/game/act.go
@@ -105,8 +105,12 @@ func (g *Game) startAction(sess *net.Session, verb, target string) {
return
}
var err error
- obj, err = g.ObjectStore.Load(chosen.DefID)
- if err != nil || !obj.IsInteractable() {
+ obj, err = g.resolveObjectDef(p.RoomID, chosen.DefID)
+ if err != nil {
+ sess.WriteLine(fmt.Sprintf("There's nothing here to %s.", verb))
+ return
+ }
+ if !obj.IsInteractable() {
sess.WriteLine(fmt.Sprintf("You can't %s the %s.", verb, obj.Name))
return
}
diff --git a/internal/game/cmd_move.go b/internal/game/cmd_move.go
index a1b492e..4bf227c 100644
--- a/internal/game/cmd_move.go
+++ b/internal/game/cmd_move.go
@@ -7,6 +7,7 @@ import (
"thehouseoficarus/internal/engine"
"thehouseoficarus/internal/item"
"thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/object"
"thehouseoficarus/internal/player"
)
@@ -245,9 +246,15 @@ func (g *Game) seedRoomObjects(roomID int) {
}
g.World.EnsureObjectStates(roomID, ids)
for _, obj := range room.Objects {
- def, err := g.ObjectStore.Load(obj.ID)
- if err != nil {
- continue
+ var def *object.ObjectDef
+ if obj.Inline != nil {
+ def = obj.Inline
+ } else {
+ var err error
+ def, err = g.ObjectStore.Load(obj.ID)
+ if err != nil {
+ continue
+ }
}
g.World.SetObjName(roomID, obj.ID, def.Name)
if len(def.Aliases) > 0 {
diff --git a/internal/game/cmd_verbs.go b/internal/game/cmd_verbs.go
index 2ba1a7e..f2c4a18 100644
--- a/internal/game/cmd_verbs.go
+++ b/internal/game/cmd_verbs.go
@@ -56,7 +56,7 @@ func (g *Game) executeVerbs(sess *net.Session, args []string, rawInput string) {
var sectionGround []string
for _, st := range g.World.AllObjInstances(p.RoomID) {
- def, err := g.ObjectStore.Load(st.DefID)
+ def, err := g.resolveObjectDef(p.RoomID, st.DefID)
if err != nil || def.Hidden {
continue
}
diff --git a/internal/game/look_entities.go b/internal/game/look_entities.go
index f6b58a1..def505a 100644
--- a/internal/game/look_entities.go
+++ b/internal/game/look_entities.go
@@ -88,7 +88,7 @@ func (g *Game) showRoomObjects(sess *net.Session, p *player.Player, room *world.
}
for _, objID := range order {
count := grouped[objID]
- def, err := g.ObjectStore.Load(objID)
+ def, err := g.resolveObjectDef(p.RoomID, objID)
if err != nil {
continue
}
diff --git a/internal/game/look_target.go b/internal/game/look_target.go
index c5fb072..743844f 100644
--- a/internal/game/look_target.go
+++ b/internal/game/look_target.go
@@ -104,7 +104,7 @@ func (g *Game) doLookTarget(sess *net.Session, input string) {
byDef := map[string][]world.ObjState{}
descByDef := map[string]string{}
for _, ist := range rawInstances {
- def, err := g.ObjectStore.Load(ist.DefID)
+ def, err := g.resolveObjectDef(p.RoomID, ist.DefID)
if err != nil {
continue
}
@@ -122,7 +122,7 @@ func (g *Game) doLookTarget(sess *net.Session, input string) {
if len(presentDefs) > 1 {
sess.WriteLine("That's ambiguous, which one?")
for _, defID := range presentDefs {
- if def, err := g.ObjectStore.Load(defID); err == nil {
+ if def, err := g.resolveObjectDef(p.RoomID, defID); err == nil {
sess.WriteLine(fmt.Sprintf(" %s", def.Name))
}
}
@@ -133,7 +133,7 @@ func (g *Game) doLookTarget(sess *net.Session, input string) {
instances := byDef[presentDefs[0]]
objDescText := descByDef[presentDefs[0]]
st := &instances[0]
- def, _ := g.ObjectStore.Load(st.DefID)
+ def, _ := g.resolveObjectDef(p.RoomID, st.DefID)
if st.DefID == "estate_directory" {
g.lookEstateDirectory(sess)
diff --git a/internal/game/object_def.go b/internal/game/object_def.go
new file mode 100644
index 0000000..e27aeb0
--- /dev/null
+++ b/internal/game/object_def.go
@@ -0,0 +1,33 @@
+package game
+
+import "thehouseoficarus/internal/object"
+
+// resolveObjectDef returns the ObjectDef for a room object instance. File
+// objects (the common case) resolve through the cached object store with no
+// disk I/O on a hit, and a store miss is just a map lookup; only inline objects
+// — which never live in the store — fall through to a freshly-read room, so
+// they keep live-edit semantics without slowing file-object lookups. defID is
+// the ObjState DefID (a normalized name for inline objects, a file id
+// otherwise).
+//
+// Use this from display or generic-handling sites. Sites that look up a
+// specific interactable behavior (gather/use-station/safespot/steal/etc.) may
+// call ObjectStore.Load directly: inline objects are guaranteed non-interactable
+// and simply fall through those sites' existing error guards.
+func (g *Game) resolveObjectDef(roomID int, defID string) (*object.ObjectDef, error) {
+ def, err := g.ObjectStore.Load(defID)
+ if err == nil {
+ return def, nil
+ }
+ if room, rerr := g.World.LoadRoom(roomID); rerr == nil {
+ for i := range room.Objects {
+ ro := &room.Objects[i]
+ if ro.Inline != nil && ro.ID == defID {
+ d := *ro.Inline
+ d.ID = defID
+ return &d, nil
+ }
+ }
+ }
+ return nil, err
+}
diff --git a/internal/validate/checks.go b/internal/validate/checks.go
index b2e5449..7a495a0 100644
--- a/internal/validate/checks.go
+++ b/internal/validate/checks.go
@@ -102,14 +102,31 @@ func validateRooms(s Source) []Issue {
}
}
- for _, robj := range room.Objects {
- if robj.ID != "" && !objIDs[robj.ID] {
+ var collEntries []roomObjEntry
+
+ for idx, robj := range room.Objects {
+ if robj.Inline != nil {
+ collEntries = append(collEntries, roomObjEntry{
+ defKey: fmt.Sprintf("inline:#%d", idx),
+ displayName: world.NormalizeObjectName(robj.Inline.Name),
+ effDefID: robj.ID,
+ })
+ issues = append(issues, validateInlineObject(id, robj, itemIDs, roomIndex)...)
+ } else if robj.ID != "" && !objIDs[robj.ID] {
issues = append(issues, Issue{
Level: "ERROR",
Type: "reference",
Message: fmt.Sprintf("Room %d: references nonexistent object %q",
id, robj.ID),
})
+ } else if robj.ID != "" {
+ if def, err := s.Objects.Load(robj.ID); err == nil {
+ collEntries = append(collEntries, roomObjEntry{
+ defKey: "file:" + robj.ID,
+ displayName: world.NormalizeObjectName(def.Name),
+ effDefID: robj.ID,
+ })
+ }
}
for _, wr := range robj.WanderRooms {
if _, ok := roomIndex[wr]; !ok {
@@ -123,6 +140,8 @@ func validateRooms(s Source) []Issue {
}
}
+ issues = append(issues, validateRoomObjectCollisions(id, collEntries)...)
+
if room.Hazard != "" && !hazardIDs[room.Hazard] {
issues = append(issues, Issue{
Level: "ERROR",
@@ -136,6 +155,141 @@ func validateRooms(s Source) []Issue {
return issues
}
+// validateInlineObject checks a room-inline object definition. Inline objects
+// are restricted to the passive subset (name/aliases/color/hidden/
+// inroom_description/description/on_look); interactable or stateful behavior
+// must be defined as a standalone object file instead.
+func validateInlineObject(roomID int, robj world.RoomObject, itemIDs map[string]bool, roomIndex map[int]bool) []Issue {
+ var issues []Issue
+ def := robj.Inline
+ prefix := fmt.Sprintf("Room %d: inline object %q", roomID, robj.ID)
+
+ if def.Name == "" {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: prefix + ": has no name",
+ })
+ }
+ if def.ID != "" {
+ issues = append(issues, Issue{
+ Level: "WARN",
+ Type: "reference",
+ Message: prefix + ": `id:` is ignored on inline objects — identity derives from `name`",
+ })
+ }
+
+ var bad []string
+ if def.Gather != nil {
+ bad = append(bad, "gather")
+ }
+ if def.Talk != nil {
+ bad = append(bad, "talk")
+ }
+ if def.Use != nil {
+ bad = append(bad, "use")
+ }
+ if def.Safespot != nil {
+ bad = append(bad, "safespot")
+ }
+ if len(def.UseInteractions) > 0 {
+ bad = append(bad, "use_interactions")
+ }
+ if def.StealTable != "" || def.StealLevel != 0 || def.StealXP != 0 || def.StealSpeed != 0 {
+ bad = append(bad, "steal")
+ }
+ if def.GuardMob != "" {
+ bad = append(bad, "guard_mob")
+ }
+ if def.RemovalItem != "" {
+ bad = append(bad, "removal_item")
+ }
+ if len(bad) > 0 {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: prefix + fmt.Sprintf(": inline objects may not define interactable behavior (%s) — define it as an object file instead",
+ strings.Join(bad, ", ")),
+ })
+ }
+
+ if def.Color != "" && color.Parse(def.Color).Empty() {
+ issues = append(issues, Issue{
+ Level: "WARN",
+ Type: "reference",
+ Message: prefix + fmt.Sprintf(": invalid color %q", def.Color),
+ })
+ }
+
+ if def.OnLook != nil {
+ issues = append(issues, validateNodeAction(prefix+": on_look", def.OnLook, itemIDs, roomIndex)...)
+ }
+
+ return issues
+}
+
+// roomObjEntry is a single object slot in a room, reduced to what the per-room
+// collision check needs. defKey identifies the distinct definition behind the
+// slot ("file:<id>" for references, "inline:#<index>" for inline defs), so
+// repeated references to the same file object collapse to one definition.
+type roomObjEntry struct {
+ defKey string
+ displayName string // normalized display name ("" when unknown)
+ effDefID string // effective ObjState DefID
+}
+
+// validateRoomObjectCollisions reports two per-room problems: distinct object
+// definitions that share a display name (a player typing the exact name could
+// not disambiguate) and distinct definitions that resolve to the same ObjState
+// DefID (their runtime instances would be conflated — e.g. an inline object
+// whose name matches a referenced file object's id). Repeated references to the
+// same file object share a defKey and are allowed (e.g. multiple copper_rock).
+func validateRoomObjectCollisions(roomID int, entries []roomObjEntry) []Issue {
+ var issues []Issue
+
+ byName := map[string]map[string]bool{}
+ byDefID := map[string]map[string]bool{}
+ for _, e := range entries {
+ if e.displayName != "" {
+ if byName[e.displayName] == nil {
+ byName[e.displayName] = map[string]bool{}
+ }
+ byName[e.displayName][e.defKey] = true
+ }
+ if e.effDefID != "" {
+ if byDefID[e.effDefID] == nil {
+ byDefID[e.effDefID] = map[string]bool{}
+ }
+ byDefID[e.effDefID][e.defKey] = true
+ }
+ }
+
+ reported := map[string]bool{}
+ for name, defKeys := range byName {
+ if len(defKeys) > 1 {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "duplicate",
+ Message: fmt.Sprintf("Room %d: multiple distinct objects share the name %q (each object in a room must have a unique name)",
+ roomID, name),
+ })
+ reported[name] = true
+ }
+ }
+ for defID, defKeys := range byDefID {
+ if len(defKeys) > 1 && !reported[defID] {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "duplicate",
+ Message: fmt.Sprintf("Room %d: object id collision %q (an inline object's name matches another object's id)",
+ roomID, defID),
+ })
+ }
+ }
+
+ return issues
+}
+
func validateMobs(s Source) []Issue {
var issues []Issue
itemIDs := s.Items.IDSet()
diff --git a/internal/validate/inline_test.go b/internal/validate/inline_test.go
new file mode 100644
index 0000000..5816c26
--- /dev/null
+++ b/internal/validate/inline_test.go
@@ -0,0 +1,134 @@
+package validate
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "thehouseoficarus/internal/behavior"
+ "thehouseoficarus/internal/item"
+ "thehouseoficarus/internal/object"
+ "thehouseoficarus/internal/world"
+)
+
+func TestValidateInlineObjectPassiveOK(t *testing.T) {
+ robj := world.RoomObject{ID: "window", Inline: &object.ObjectDef{
+ Name: "window",
+ Hidden: true,
+ Description: behavior.DescList{{Text: "A small window."}},
+ }}
+ issues := validateInlineObject(1001, robj, nil, nil)
+ if len(issues) != 0 {
+ t.Errorf("expected no issues for passive inline object, got: %+v", issues)
+ }
+}
+
+func TestValidateInlineObjectRejectsInteractable(t *testing.T) {
+ robj := world.RoomObject{ID: "rock", Inline: &object.ObjectDef{
+ Name: "rock",
+ Gather: &behavior.GatherConfig{},
+ }}
+ issues := validateInlineObject(1001, robj, nil, nil)
+ if !containsMsg(issues, "interactable behavior") {
+ t.Errorf("expected interactable-behavior error, got: %+v", issues)
+ }
+}
+
+func TestValidateInlineObjectRequiresName(t *testing.T) {
+ robj := world.RoomObject{ID: "x", Inline: &object.ObjectDef{
+ Description: behavior.DescList{{Text: "no name"}},
+ }}
+ issues := validateInlineObject(1001, robj, nil, nil)
+ if !containsMsg(issues, "has no name") {
+ t.Errorf("expected has-no-name error, got: %+v", issues)
+ }
+}
+
+func TestValidateInlineObjectStrayIDWarns(t *testing.T) {
+ robj := world.RoomObject{ID: "anvil", Inline: &object.ObjectDef{Name: "anvil", ID: "anvil"}}
+ issues := validateInlineObject(1001, robj, nil, nil)
+ if !containsMsg(issues, "ignored on inline objects") {
+ t.Errorf("expected stray-id warning, got: %+v", issues)
+ }
+}
+
+func newSource(dir string) Source {
+ return Source{
+ DataDir: dir,
+ Items: item.NewItemStore(dir),
+ Objects: object.NewObjectStore(dir),
+ Mobs: world.NewMobStore(dir),
+ World: world.New(dir),
+ }
+}
+
+func writeFile(t *testing.T, path, body string) {
+ t.Helper()
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestValidateRoomsSameNameCollision(t *testing.T) {
+ dir := t.TempDir()
+ writeFile(t, filepath.Join(dir, "rooms", "1.yaml"), `name: Test Room
+objects:
+ - id: ghost_object
+ - name: sign
+ description: "one"
+ - name: sign
+ description: "two"
+`)
+ issues := validateRooms(newSource(dir))
+
+ if !containsMsg(issues, "nonexistent object \"ghost_object\"") {
+ t.Errorf("expected missing-reference error, got: %+v", issues)
+ }
+ if !containsMsg(issues, "objects share the name \"sign\"") {
+ t.Errorf("expected same-name collision error, got: %+v", issues)
+ }
+}
+
+func TestValidateRoomsDefIDCollisionAndDuplicateRefs(t *testing.T) {
+ dir := t.TempDir()
+ writeFile(t, filepath.Join(dir, "objects", "anvil.yaml"), "name: heavy anvil\n")
+ writeFile(t, filepath.Join(dir, "objects", "copper_rock.yaml"), "name: copper rock\n")
+ writeFile(t, filepath.Join(dir, "rooms", "1.yaml"), `name: Test Room
+objects:
+ - id: copper_rock
+ - id: copper_rock
+ - id: anvil
+ - name: anvil
+ description: "an inline thing that collides with the file id"
+`)
+ issues := validateRooms(newSource(dir))
+
+ // Inline name "anvil" normalizes to the referenced file id "anvil".
+ if !containsMsg(issues, "object id collision \"anvil\"") {
+ t.Errorf("expected defID collision error, got: %+v", issues)
+ }
+ // Two references to the same copper_rock file are allowed.
+ if containsMsg(issues, "name \"copper rock\"") || containsMsg(issues, "collision \"copper_rock\"") {
+ t.Errorf("repeated references to one file object should not collide, got: %+v", issues)
+ }
+}
+
+func TestValidateRoomsPartialNameSiblingsOK(t *testing.T) {
+ dir := t.TempDir()
+ writeFile(t, filepath.Join(dir, "rooms", "1.yaml"), `name: Test Room
+objects:
+ - name: rusty sign
+ description: "rusty"
+ - name: shiny sign
+ description: "shiny"
+`)
+ issues := validateRooms(newSource(dir))
+ for _, iss := range issues {
+ if iss.Type == "duplicate" {
+ t.Errorf("partial-name siblings should not collide, got: %+v", iss)
+ }
+ }
+}
diff --git a/internal/world/room.go b/internal/world/room.go
index 8f13b82..8b8830e 100644
--- a/internal/world/room.go
+++ b/internal/world/room.go
@@ -1,8 +1,11 @@
package world
import (
+ "strings"
+
"gopkg.in/yaml.v3"
"thehouseoficarus/internal/behavior"
+ "thehouseoficarus/internal/object"
)
type ExitDir string
@@ -125,8 +128,54 @@ func (e EnterStep) IsTimed() bool {
e.TakeItem != "" || e.Teleport != 0 || e.Heal != 0
}
+// RoomObject is either a reference to a file-backed object definition (only
+// `id`/wander fields set) or a fully inline object definition (Inline != nil).
+// An entry is treated as inline when it carries any passive content field
+// (name, description, aliases, inroom_description, color, hidden, on_look).
+// Inline objects are room-scoped: their identity (ID, used as the ObjState
+// DefID) is derived from the normalized name; `id:` is reserved for references.
type RoomObject struct {
- ID string `yaml:"id"`
- WanderRooms []int `yaml:"wander_rooms"`
- WanderInterval float64 `yaml:"wander_interval"`
+ ID string
+ WanderRooms []int
+ WanderInterval float64
+ Inline *object.ObjectDef
+}
+
+func (ro *RoomObject) UnmarshalYAML(value *yaml.Node) error {
+ type rawRef struct {
+ ID string `yaml:"id"`
+ WanderRooms []int `yaml:"wander_rooms"`
+ WanderInterval float64 `yaml:"wander_interval"`
+ }
+ var ref rawRef
+ if err := value.Decode(&ref); err != nil {
+ return err
+ }
+ ro.WanderRooms = ref.WanderRooms
+ ro.WanderInterval = ref.WanderInterval
+
+ var def object.ObjectDef
+ if err := value.Decode(&def); err != nil {
+ return err
+ }
+ inline := def.Name != "" || len(def.Description) > 0 || def.InRoomDescription != "" ||
+ len(def.Aliases) > 0 || def.Color != "" || def.Hidden || def.OnLook != nil
+ if inline {
+ // Identity derives from the name. def.ID still holds any explicit `id:`
+ // the builder wrote; it is left in place only so validation can flag it
+ // as a stray field (runtime resolution overrides the copy's ID anyway).
+ ro.ID = NormalizeObjectName(def.Name)
+ ro.Inline = &def
+ return nil
+ }
+ ro.ID = ref.ID
+ return nil
+}
+
+// NormalizeObjectName lowercases, trims, and collapses internal whitespace,
+// keeping spaces (not underscores) so the word-prefix matcher treats the name
+// as a single token in its `_`-split fallback. It is the canonical derivation
+// of an inline object's ObjState DefID from its name.
+func NormalizeObjectName(name string) string {
+ return strings.Join(strings.Fields(strings.ToLower(name)), " ")
}
diff --git a/internal/world/room_test.go b/internal/world/room_test.go
new file mode 100644
index 0000000..472fe4f
--- /dev/null
+++ b/internal/world/room_test.go
@@ -0,0 +1,92 @@
+package world
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func writeRoom(t *testing.T, dir string, body string) {
+ t.Helper()
+ rooms := filepath.Join(dir, "rooms")
+ if err := os.MkdirAll(rooms, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ path := filepath.Join(rooms, "1001.yaml")
+ if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestRoomObjectReferenceVsInline(t *testing.T) {
+ dir := t.TempDir()
+ body := `name: Test Room
+objects:
+ - id: workbench
+ - name: window
+ hidden: true
+ description: "A small window."
+ - id: control
+ name: instrument panel
+ aliases: [cockpit]
+ description: "A panel."
+`
+ // rebuild the path index so LoadRoom can find the room file
+ writeRoom(t, dir, body)
+ w := New(dir)
+
+ room, err := w.LoadRoom(1001)
+ if err != nil {
+ t.Fatalf("LoadRoom: %v", err)
+ }
+ if len(room.Objects) != 3 {
+ t.Fatalf("expected 3 objects, got %d", len(room.Objects))
+ }
+
+ // reference
+ if ref := room.Objects[0]; ref.Inline != nil || ref.ID != "workbench" {
+ t.Errorf("object 0 should be a reference to workbench, got %+v (inline=%v)", ref, ref.Inline)
+ }
+
+ // inline, id derived from the normalized name
+ in := room.Objects[1]
+ if in.Inline == nil {
+ t.Fatalf("object 1 should be inline")
+ }
+ if in.ID != "window" {
+ t.Errorf("inline id should derive from name to %q, got %q", "window", in.ID)
+ }
+ if !in.Inline.Hidden || in.Inline.Name != "window" {
+ t.Errorf("inline fields not parsed: %+v", in.Inline)
+ }
+
+ // inline with a multi-word name: id is the normalized name (spaces kept),
+ // and an explicit `id:` is NOT used as identity (it is preserved on the
+ // def only so validation can flag it).
+ ex := room.Objects[2]
+ if ex.Inline == nil {
+ t.Fatalf("object 2 should be inline")
+ }
+ if ex.ID != "instrument panel" {
+ t.Errorf("inline id should be normalized name %q, got %q", "instrument panel", ex.ID)
+ }
+ if ex.Inline.ID != "control" {
+ t.Errorf("explicit id should be preserved on def for validation, got %q", ex.Inline.ID)
+ }
+ if len(ex.Inline.Aliases) != 1 || ex.Inline.Aliases[0] != "cockpit" {
+ t.Errorf("inline aliases not parsed: %+v", ex.Inline.Aliases)
+ }
+}
+
+func TestNormalizeObjectName(t *testing.T) {
+ cases := map[string]string{
+ "window": "window",
+ "Instrument Panel": "instrument panel",
+ " Safety Card ": "safety card",
+ }
+ for in, want := range cases {
+ if got := NormalizeObjectName(in); got != want {
+ t.Errorf("NormalizeObjectName(%q) = %q, want %q", in, got, want)
+ }
+ }
+}