aboutsummaryrefslogtreecommitdiff
path: root/internal/validate
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-18 16:13:49 -0400
committerhistoria <[not public]>2026-09-18 16:13:49 -0400
commit194164a77eb4f2479b94c19e6f0e1278ab608781 (patch)
tree53413add8ed6f32a34d1733397e380cc98ef70ef /internal/validate
parent67e01aab85607b9e601185c7d62dff576af3bb43 (diff)
downloadthehouseoficarus-194164a77eb4f2479b94c19e6f0e1278ab608781.tar.gz
fix: more casino, economy, gameplay, security, and world data hardening gaps
Diffstat (limited to 'internal/validate')
-rw-r--r--internal/validate/checks.go44
-rw-r--r--internal/validate/local_test.go8
-rw-r--r--internal/validate/p2_hardening_test.go50
3 files changed, 85 insertions, 17 deletions
diff --git a/internal/validate/checks.go b/internal/validate/checks.go
index a6eeee8..bfa8342 100644
--- a/internal/validate/checks.go
+++ b/internal/validate/checks.go
@@ -24,6 +24,7 @@ func validateRooms(s Source) []Issue {
mobIDs := s.Mobs.AllDefIDs()
objIDs := s.Objects.IDSet()
hazardIDs := s.World.HazardIndex()
+ dropIDs := dropTableIDSet(s.DataDir)
for id := range roomIndex {
room, err := s.World.LoadRoom(id)
@@ -69,7 +70,14 @@ func validateRooms(s Source) []Issue {
})
continue
}
- if exit.Room > 0 {
+ if exit.Room <= 0 && !exit.AlwaysBlocked {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Room %d: exit %q has room %d; a non-positive target is a permanently blocked exit (set always_blocked: true to express that intent)",
+ id, dir, exit.Room),
+ })
+ } else if exit.Room > 0 {
if _, ok := roomIndex[exit.Room]; !ok {
issues = append(issues, Issue{
Level: "ERROR",
@@ -81,7 +89,7 @@ func validateRooms(s Source) []Issue {
}
issues = append(issues, validateTriggerBlock(
fmt.Sprintf("Room %d: exit %q on_traverse", id, dir),
- exit.OnTraverse, itemIDs, roomIndex, mobIDs, nil)...)
+ exit.OnTraverse, itemIDs, roomIndex, mobIDs, dropIDs)...)
}
for _, rm := range room.Mobs {
@@ -128,7 +136,7 @@ func validateRooms(s Source) []Issue {
displayName: world.NormalizeObjectName(robj.Local.Name),
effDefID: robj.ID,
})
- issues = append(issues, validateLocalObject(id, robj, itemIDs, roomIndex, mobIDs)...)
+ issues = append(issues, validateLocalObject(id, robj, itemIDs, roomIndex, mobIDs, dropIDs)...)
} else if robj.ID != "" && !objIDs[robj.ID] {
issues = append(issues, Issue{
Level: "ERROR",
@@ -144,6 +152,15 @@ func validateRooms(s Source) []Issue {
effDefID: robj.ID,
})
}
+ } else {
+ // Neither a local definition nor a file reference: the entry
+ // silently disappears at runtime, so surface it as an error.
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Room %d: object entry %d resolves to nothing (no local definition and no id)",
+ id, idx),
+ })
}
for _, wr := range robj.WanderRooms {
if _, ok := roomIndex[wr]; !ok {
@@ -176,7 +193,7 @@ func validateRooms(s Source) []Issue {
// 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 validateLocalObject(roomID int, robj world.RoomObject, itemIDs map[string]bool, roomIndex map[int]bool, mobIDs map[string]bool) []Issue {
+func validateLocalObject(roomID int, robj world.RoomObject, itemIDs map[string]bool, roomIndex map[int]bool, mobIDs map[string]bool, dropIDs map[string]bool) []Issue {
var issues []Issue
def := robj.Local
prefix := fmt.Sprintf("Room %d: local object %q", roomID, robj.ID)
@@ -233,7 +250,7 @@ func validateLocalObject(roomID int, robj world.RoomObject, itemIDs map[string]b
}
if len(def.OnLook) > 0 {
- issues = append(issues, validateTriggerBlock(prefix+": on_look", def.OnLook, itemIDs, roomIndex, mobIDs, nil)...)
+ issues = append(issues, validateTriggerBlock(prefix+": on_look", def.OnLook, itemIDs, roomIndex, mobIDs, dropIDs)...)
}
return issues
@@ -678,7 +695,7 @@ func validateObjects(s Source) []Issue {
}
if obj.Talk != nil {
- issues = append(issues, validateTalkConfig(fmt.Sprintf("Object %q: talk", id), obj.Talk, itemIDs, roomIndex)...)
+ issues = append(issues, validateTalkConfig(fmt.Sprintf("Object %q: talk", id), obj.Talk, itemIDs, roomIndex, mobIDs, dropIDs)...)
}
if len(obj.OnLook) > 0 {
@@ -1112,6 +1129,7 @@ func validateRoomTriggers(s Source) []Issue {
roomIndex := s.World.RoomIndex()
itemIDs := s.Items.IDSet()
mobIDs := s.Mobs.AllDefIDs()
+ dropIDs := dropTableIDSet(s.DataDir)
for id := range roomIndex {
room, err := s.World.LoadRoom(id)
@@ -1120,11 +1138,11 @@ func validateRoomTriggers(s Source) []Issue {
}
if len(room.OnEnter) > 0 {
issues = append(issues, validateTriggerBlock(
- fmt.Sprintf("Room %d: on_enter", id), room.OnEnter, itemIDs, roomIndex, mobIDs, nil)...)
+ fmt.Sprintf("Room %d: on_enter", id), room.OnEnter, itemIDs, roomIndex, mobIDs, dropIDs)...)
}
if len(room.OnExit) > 0 {
issues = append(issues, validateTriggerBlock(
- fmt.Sprintf("Room %d: on_exit", id), room.OnExit, itemIDs, roomIndex, mobIDs, nil)...)
+ fmt.Sprintf("Room %d: on_exit", id), room.OnExit, itemIDs, roomIndex, mobIDs, dropIDs)...)
}
}
@@ -1486,7 +1504,7 @@ func toolInList(list []string, target string) bool {
return false
}
-func validateTalkConfig(prefix string, cfg *behavior.TalkConfig, itemIDs map[string]bool, roomIndex map[int]bool) []Issue {
+func validateTalkConfig(prefix string, cfg *behavior.TalkConfig, itemIDs map[string]bool, roomIndex map[int]bool, mobIDs map[string]bool, dropIDs map[string]bool) []Issue {
var issues []Issue
if cfg == nil {
return issues
@@ -1494,7 +1512,7 @@ func validateTalkConfig(prefix string, cfg *behavior.TalkConfig, itemIDs map[str
for nodeID, node := range cfg.Nodes {
nodePrefix := fmt.Sprintf("%s: node %q", prefix, nodeID)
if node.Action != nil {
- issues = append(issues, validateTalkStep(nodePrefix, node.Action, itemIDs, roomIndex)...)
+ issues = append(issues, validateTalkStep(nodePrefix, node.Action, itemIDs, roomIndex, mobIDs, dropIDs)...)
}
if node.Goto != "" {
if _, ok := cfg.Nodes[node.Goto]; !ok {
@@ -1508,7 +1526,7 @@ func validateTalkConfig(prefix string, cfg *behavior.TalkConfig, itemIDs map[str
for i, opt := range node.Options {
optPrefix := fmt.Sprintf("%s: option %d", nodePrefix, i+1)
if opt.Action != nil {
- issues = append(issues, validateTalkStep(optPrefix, opt.Action, itemIDs, roomIndex)...)
+ issues = append(issues, validateTalkStep(optPrefix, opt.Action, itemIDs, roomIndex, mobIDs, dropIDs)...)
}
}
}
@@ -1750,9 +1768,9 @@ func validateTriggerBlock(prefix string, block []behavior.Trigger, itemIDs map[s
// validateTalkStep validates a talk node/option Action (a single Step, no mob
// spawn/despawn expected — mobIDs nil so those are skipped).
-func validateTalkStep(prefix string, step *behavior.Step, itemIDs map[string]bool, roomIndex map[int]bool) []Issue {
+func validateTalkStep(prefix string, step *behavior.Step, itemIDs map[string]bool, roomIndex map[int]bool, mobIDs map[string]bool, dropIDs map[string]bool) []Issue {
if step == nil {
return nil
}
- return validateStep(prefix, step, itemIDs, roomIndex, nil, nil)
+ return validateStep(prefix, step, itemIDs, roomIndex, mobIDs, dropIDs)
}
diff --git a/internal/validate/local_test.go b/internal/validate/local_test.go
index f0bc0a1..f696b7b 100644
--- a/internal/validate/local_test.go
+++ b/internal/validate/local_test.go
@@ -17,7 +17,7 @@ func TestValidateLocalObjectPassiveOK(t *testing.T) {
Hidden: true,
Description: behavior.DescList{{Text: "A small window."}},
}}
- issues := validateLocalObject(1001, robj, nil, nil, nil)
+ issues := validateLocalObject(1001, robj, nil, nil, nil, nil)
if len(issues) != 0 {
t.Errorf("expected no issues for passive local object, got: %+v", issues)
}
@@ -28,7 +28,7 @@ func TestValidateLocalObjectRejectsInteractable(t *testing.T) {
Name: "rock",
Gather: &behavior.GatherConfig{},
}}
- issues := validateLocalObject(1001, robj, nil, nil, nil)
+ issues := validateLocalObject(1001, robj, nil, nil, nil, nil)
if !containsMsg(issues, "interactable behavior") {
t.Errorf("expected interactable-behavior error, got: %+v", issues)
}
@@ -38,7 +38,7 @@ func TestValidateLocalObjectRequiresName(t *testing.T) {
robj := world.RoomObject{ID: "x", Local: &object.ObjectDef{
Description: behavior.DescList{{Text: "no name"}},
}}
- issues := validateLocalObject(1001, robj, nil, nil, nil)
+ issues := validateLocalObject(1001, robj, nil, nil, nil, nil)
if !containsMsg(issues, "has no name") {
t.Errorf("expected has-no-name error, got: %+v", issues)
}
@@ -46,7 +46,7 @@ func TestValidateLocalObjectRequiresName(t *testing.T) {
func TestValidateLocalObjectStrayIDWarns(t *testing.T) {
robj := world.RoomObject{ID: "anvil", Local: &object.ObjectDef{Name: "anvil", ID: "anvil"}}
- issues := validateLocalObject(1001, robj, nil, nil, nil)
+ issues := validateLocalObject(1001, robj, nil, nil, nil, nil)
if !containsMsg(issues, "ignored on local objects") {
t.Errorf("expected stray-id warning, got: %+v", issues)
}
diff --git a/internal/validate/p2_hardening_test.go b/internal/validate/p2_hardening_test.go
new file mode 100644
index 0000000..68ae48f
--- /dev/null
+++ b/internal/validate/p2_hardening_test.go
@@ -0,0 +1,50 @@
+package validate
+
+import (
+ "path/filepath"
+ "testing"
+)
+
+// TestValidateRejectsNonPositiveExit: an exit with room <= 0 renders as a
+// blocked direction but passes every other check, so it must be reported
+// unless always_blocked: true expresses the intent.
+func TestValidateRejectsNonPositiveExit(t *testing.T) {
+ dir := t.TempDir()
+ writeFile(t, filepath.Join(dir, "rooms", "1.yaml"), "name: Blocked\nexits:\n north:\n room: 0\n")
+ issues := validateRooms(newSource(dir))
+ if !containsMsg(issues, "non-positive") {
+ t.Fatalf("non-positive exit not reported: %+v", issues)
+ }
+}
+
+// TestValidateRejectsEmptyObjectEntry: an objects: entry that is neither a
+// local definition nor a file reference disappears at runtime, so it must be
+// an error instead of a silent no-op.
+func TestValidateRejectsEmptyObjectEntry(t *testing.T) {
+ dir := t.TempDir()
+ writeFile(t, filepath.Join(dir, "rooms", "1.yaml"),
+ "name: Empty\nobjects:\n - aliases: []\n description: []\n hidden: false\n name: \"\"\n")
+ issues := validateRooms(newSource(dir))
+ if !containsMsg(issues, "resolves to nothing") {
+ t.Fatalf("empty object entry not reported: %+v", issues)
+ }
+}
+
+// TestValidateRejectsDropTableInRoomTrigger: room on_enter/on_exit and
+// on_traverse steps consume drop_table like every other trigger block, so a
+// typo'd table id must be reported rather than silently awarding nothing.
+func TestValidateRejectsDropTableInRoomTrigger(t *testing.T) {
+ dir := t.TempDir()
+ writeFile(t, filepath.Join(dir, "rooms", "1.yaml"),
+ "name: Loot\nexits:\n north:\n room: 1\n on_traverse:\n - steps:\n - drop_table:\n - table: no_such_table\n weight: 100\n")
+ if issues := validateRooms(newSource(dir)); !containsMsg(issues, "no_such_table") {
+ t.Fatalf("unknown drop table in an exit trigger not reported: %+v", issues)
+ }
+
+ // on_enter is validated by validateRoomTriggers.
+ writeFile(t, filepath.Join(dir, "rooms", "2.yaml"),
+ "name: Loot2\non_enter:\n - steps:\n - drop_table:\n - table: no_such_table\n weight: 100\n")
+ if issues := validateRoomTriggers(newSource(dir)); !containsMsg(issues, "no_such_table") {
+ t.Fatalf("unknown drop table in on_enter not reported: %+v", issues)
+ }
+}