diff options
| author | historia <[not public]> | 2026-06-27 21:25:20 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-06-27 21:25:20 -0400 |
| commit | 87dacfbb3dd16e7f55a82361eace98f9a39d75c8 (patch) | |
| tree | d548f75a37b5b8e960c5e5d111237e172650bb59 /internal/validate | |
| parent | 83f8f5447ec3baf85314ce7343f0e339d28bcc15 (diff) | |
| download | thehouseoficarus-87dacfbb3dd16e7f55a82361eace98f9a39d75c8.tar.gz | |
feat: room-specific objects can be defined inline in room yaml
Diffstat (limited to 'internal/validate')
| -rw-r--r-- | internal/validate/checks.go | 158 | ||||
| -rw-r--r-- | internal/validate/inline_test.go | 134 |
2 files changed, 290 insertions, 2 deletions
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) + } + } +} |
