aboutsummaryrefslogtreecommitdiff
path: root/internal/validate
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-18 15:16:44 -0400
committerhistoria <[not public]>2026-09-18 15:16:44 -0400
commit7eab582d8a8ee2af1a6baae7baf0696c7005de2e (patch)
tree902bbc8c6e39c9f62f779ca23267714ec420b9f5 /internal/validate
parent153acb7f644d755c45533b7c5311e3ffa47e8973 (diff)
downloadthehouseoficarus-7eab582d8a8ee2af1a6baae7baf0696c7005de2e.tar.gz
fix: harden triggers, economy, casino, world data, and admin/net paths
Diffstat (limited to 'internal/validate')
-rw-r--r--internal/validate/checks.go106
-rw-r--r--internal/validate/hardening_fixes_batch2_test.go89
-rw-r--r--internal/validate/validate.go10
3 files changed, 204 insertions, 1 deletions
diff --git a/internal/validate/checks.go b/internal/validate/checks.go
index eb24721..a6eeee8 100644
--- a/internal/validate/checks.go
+++ b/internal/validate/checks.go
@@ -427,6 +427,16 @@ func validateMobs(s Source) []Issue {
})
}
+ // A weakness that matches no module element is dead data: the +weakness
+ // bonus can never trigger against any science module.
+ if s.ModuleElements != nil && def.Combat != nil && def.Combat.Stats.Defenses.Weakness != "" && !s.ModuleElements[def.Combat.Stats.Defenses.Weakness] {
+ issues = append(issues, Issue{
+ Level: "WARN",
+ Type: "reference",
+ Message: fmt.Sprintf("Mob %q: weakness %q matches no science module element (the weakness can never trigger)", id, def.Combat.Stats.Defenses.Weakness),
+ })
+ }
+
if !def.Protected && (def.Combat == nil || def.Combat.Stats.HP <= 0) {
hp := 0
if def.Combat != nil {
@@ -761,6 +771,15 @@ func validateCraftBlocks(s Source) []Issue {
}
for _, e := range c.Ingredients {
+ if len(e.Items) == 0 {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Item %q (craft: %s): ingredient entry has no items; the recipe can never be satisfied",
+ item.ID, craftLabel),
+ })
+ continue
+ }
// Byproducts pair with the alternatives by index
// (collectByproducts reads Byproducts[j] for Items[j]), so a
// short or reordered list silently returns the wrong
@@ -1165,6 +1184,93 @@ func validateRoomWiring(s Source) []Issue {
})
}
+ // Reachability: an orphan check only catches rooms with zero inbound
+ // edges, so an entire disconnected component (every member linked only
+ // within the component) passes silently. BFS the directed exits from the
+ // configured root rooms and warn about anything with no path in, honouring
+ // ignore_unreachable.
+ reachable := make(map[int]bool)
+ var queue []int
+ for _, src := range s.RootRooms {
+ if roomIndex[src] && !reachable[src] {
+ reachable[src] = true
+ queue = append(queue, src)
+ }
+ }
+ // Transport module destinations are also legitimate entry points: a
+ // player can reach them with a module even when no exit path leads there,
+ // so they must not be reported as unreachable.
+ for _, dest := range s.ModuleDestinations {
+ if dest > 0 && roomIndex[dest] && !reachable[dest] {
+ reachable[dest] = true
+ queue = append(queue, dest)
+ }
+ }
+ for len(queue) > 0 {
+ id := queue[0]
+ queue = queue[1:]
+ room, err := s.World.LoadRoom(id)
+ if err != nil {
+ continue
+ }
+ for _, exit := range room.Exits {
+ next := exit.Room
+ if next <= 0 || reachable[next] || !roomIndex[next] {
+ continue
+ }
+ reachable[next] = true
+ queue = append(queue, next)
+ }
+ }
+ var unreachable []int
+ for id := range roomIndex {
+ if reachable[id] || ignoreTo[id] || sourceSet[id] {
+ continue
+ }
+ unreachable = append(unreachable, id)
+ }
+ if len(unreachable) > 0 {
+ sort.Ints(unreachable)
+ var strs []string
+ for _, id := range unreachable {
+ strs = append(strs, fmt.Sprintf("%d", id))
+ }
+ issues = append(issues, Issue{
+ Level: "WARN",
+ Type: "integrity",
+ Message: fmt.Sprintf("Unreachable rooms (no exit path from a configured root room): %s",
+ strings.Join(strs, ", ")),
+ })
+ }
+
+ return issues
+}
+
+// validateModuleDestinations flags a transport module whose destination room
+// does not exist: the game assigns the id unconditionally, so the player is
+// saved into a void with no exits.
+func validateModuleDestinations(s Source) []Issue {
+ if len(s.ModuleDestinations) == 0 {
+ return nil
+ }
+ roomIndex := s.World.RoomIndex()
+ ids := make([]string, 0, len(s.ModuleDestinations))
+ for id := range s.ModuleDestinations {
+ ids = append(ids, id)
+ }
+ sort.Strings(ids)
+ var issues []Issue
+ for _, id := range ids {
+ dest := s.ModuleDestinations[id]
+ if dest <= 0 || roomIndex[dest] {
+ continue
+ }
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Module %q: destination room %d does not exist", id, dest),
+ })
+ }
return issues
}
diff --git a/internal/validate/hardening_fixes_batch2_test.go b/internal/validate/hardening_fixes_batch2_test.go
new file mode 100644
index 0000000..f88c924
--- /dev/null
+++ b/internal/validate/hardening_fixes_batch2_test.go
@@ -0,0 +1,89 @@
+package validate
+
+import (
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "thehouseoficarus/internal/item"
+ "thehouseoficarus/internal/object"
+ "thehouseoficarus/internal/world"
+)
+
+// TestValidateModuleDestinationMissingRoom: a transport destination that is
+// not a real room soft-locks the player, so it must be a startup ERROR.
+func TestValidateModuleDestinationMissingRoom(t *testing.T) {
+ dir := t.TempDir()
+ writeFile(t, filepath.Join(dir, "rooms", "1.yaml"), "id: 1\nname: Root\n")
+
+ src := Source{
+ DataDir: dir,
+ World: world.New(dir),
+ ModuleDestinations: map[string]int{"transport_bad": 999},
+ }
+ issues := validateModuleDestinations(src)
+ if len(issues) != 1 {
+ t.Fatalf("issues = %+v, want exactly one", issues)
+ }
+ if issues[0].Level != "ERROR" || !strings.Contains(issues[0].Message, "999") {
+ t.Fatalf("unexpected issue: %+v", issues[0])
+ }
+}
+
+// TestValidateReachabilityFlagsDisconnectedComponent: a component whose rooms
+// only link to each other has no inbound edge from outside, so the orphan
+// check misses it; the reachability BFS must warn.
+func TestValidateReachabilityFlagsDisconnectedComponent(t *testing.T) {
+ dir := t.TempDir()
+ writeFile(t, filepath.Join(dir, "rooms", "1.yaml"), "id: 1\nname: Root\n")
+ writeFile(t, filepath.Join(dir, "rooms", "2.yaml"), "id: 2\nname: A\nexits:\n north:\n room: 3\n")
+ writeFile(t, filepath.Join(dir, "rooms", "3.yaml"), "id: 3\nname: B\nexits:\n south:\n room: 2\n")
+
+ src := Source{DataDir: dir, World: world.New(dir), RootRooms: []int{1}}
+ issues := validateRoomWiring(src)
+
+ found := false
+ for _, issue := range issues {
+ if strings.Contains(issue.Message, "Unreachable rooms") &&
+ strings.Contains(issue.Message, "2") && strings.Contains(issue.Message, "3") {
+ found = true
+ if issue.Level != "WARN" {
+ t.Fatalf("reachability issue must be a WARN: %+v", issue)
+ }
+ }
+ if strings.Contains(issue.Message, "Orphan") {
+ t.Fatalf("disconnected component must not be reported as an orphan: %+v", issue)
+ }
+ }
+ if !found {
+ t.Fatalf("expected an unreachable-rooms warning, got %+v", issues)
+ }
+}
+
+// TestValidateCraftEmptyIngredientIsError: an ingredient entry with no items
+// makes formatMaterials index out of range at runtime, so it must be an ERROR.
+func TestValidateCraftEmptyIngredientIsError(t *testing.T) {
+ dir := t.TempDir()
+ writeFile(t, filepath.Join(dir, "items", "thing.yaml"),
+ "name: thing\ncraft:\n - type: cooking\n ingredients:\n - quantity: 2\n")
+
+ src := Source{
+ DataDir: dir,
+ Items: item.NewItemStore(dir),
+ Objects: object.NewObjectStore(dir),
+ Mobs: world.NewMobStore(dir),
+ World: world.New(dir),
+ }
+ found := false
+ for _, issue := range validateCraftBlocks(src) {
+ if strings.Contains(issue.Message, "has no items") {
+ found = true
+ if issue.Level != "ERROR" {
+ t.Fatalf("empty ingredient must be an ERROR: %+v", issue)
+ }
+ }
+ }
+ if !found {
+ t.Fatal("expected an ERROR for an ingredient entry with no items")
+ }
+}
diff --git a/internal/validate/validate.go b/internal/validate/validate.go
index 8ca4555..6a05eb6 100644
--- a/internal/validate/validate.go
+++ b/internal/validate/validate.go
@@ -57,6 +57,13 @@ type Source struct {
TechIDs map[string]bool
RootRooms []int
IgnoreUnreachable []int
+ // ModuleElements is the set of science module elements present in the data
+ // (solar/hydro/eco/bio/chaos). A mob weakness matching none can never
+ // trigger. Nil skips the check (synthetic test sources).
+ ModuleElements map[string]bool
+ // ModuleDestinations maps module id to destination room for transport
+ // modules. A destination that is not a real room soft-locks the player.
+ ModuleDestinations map[string]int
}
// Run executes all checks and returns the collected issues.
@@ -67,7 +74,7 @@ func Run(s Source) []Issue {
{"items", "item"}, {"rooms", "room"}, {"mobs", "mob"},
{"objects", "object"}, {"drops", "drop table"}, {"courses", "course"},
{"modules", "module"}, {"techs", "tech"}, {"help", "help topic"},
- {"triggers", "trigger"},
+ {"triggers", "trigger"}, {"hazards", "hazard"},
} {
issues = append(issues, validateIDs(s.DataDir, dir.sub, dir.label)...)
}
@@ -86,6 +93,7 @@ func Run(s Source) []Issue {
issues = append(issues, validateRoomTriggers(s)...)
issues = append(issues, validateTechs(s)...)
issues = append(issues, validateRoomWiring(s)...)
+ issues = append(issues, validateModuleDestinations(s)...)
issues = append(issues, validateRoomGrid(s)...)
issues = append(issues, validateExitReciprocity(s)...)