diff options
| author | historia <[not public]> | 2026-09-18 15:16:44 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-09-18 15:16:44 -0400 |
| commit | 7eab582d8a8ee2af1a6baae7baf0696c7005de2e (patch) | |
| tree | 902bbc8c6e39c9f62f779ca23267714ec420b9f5 /internal/validate | |
| parent | 153acb7f644d755c45533b7c5311e3ffa47e8973 (diff) | |
| download | thehouseoficarus-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.go | 106 | ||||
| -rw-r--r-- | internal/validate/hardening_fixes_batch2_test.go | 89 | ||||
| -rw-r--r-- | internal/validate/validate.go | 10 |
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)...) |
