aboutsummaryrefslogtreecommitdiff
path: root/internal/validate
diff options
context:
space:
mode:
Diffstat (limited to 'internal/validate')
-rw-r--r--internal/validate/checks.go158
-rw-r--r--internal/validate/inline_test.go134
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)
+ }
+ }
+}