diff options
| -rw-r--r-- | AGENTS.md | 2 | ||||
| -rw-r--r-- | building_guide/objects.md | 59 | ||||
| -rw-r--r-- | building_guide/triggers.md | 12 | ||||
| -rw-r--r-- | data/objects/unique/1001_door.yaml | 3 | ||||
| -rw-r--r-- | data/objects/unique/1001_instruments.yaml | 4 | ||||
| -rw-r--r-- | data/objects/unique/1001_pilot.yaml | 3 | ||||
| -rw-r--r-- | data/objects/unique/1001_sign.yaml | 28 | ||||
| -rw-r--r-- | data/objects/unique/1001_window.yaml | 3 | ||||
| -rw-r--r-- | data/rooms/intro/1001.yaml | 46 | ||||
| -rw-r--r-- | internal/game/act.go | 8 | ||||
| -rw-r--r-- | internal/game/cmd_move.go | 13 | ||||
| -rw-r--r-- | internal/game/cmd_verbs.go | 2 | ||||
| -rw-r--r-- | internal/game/look_entities.go | 2 | ||||
| -rw-r--r-- | internal/game/look_target.go | 6 | ||||
| -rw-r--r-- | internal/game/object_def.go | 33 | ||||
| -rw-r--r-- | internal/validate/checks.go | 158 | ||||
| -rw-r--r-- | internal/validate/inline_test.go | 134 | ||||
| -rw-r--r-- | internal/world/room.go | 55 | ||||
| -rw-r--r-- | internal/world/room_test.go | 92 |
19 files changed, 593 insertions, 70 deletions
@@ -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) + } + } +} |
