aboutsummaryrefslogtreecommitdiff
path: root/internal/validate/checks.go
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-06-25 15:40:48 -0400
committerhistoria <[not public]>2026-06-25 15:40:48 -0400
commitabd612c15799f604e671e83dc7c410ed2b44185f (patch)
tree0597ca92350de73aac2a7cf26b2f2d6595dac0cc /internal/validate/checks.go
parent2725e2927a1595c7b100d942d1f14146252adeb7 (diff)
downloadthehouseoficarus-abd612c15799f604e671e83dc7c410ed2b44185f.tar.gz
slop refactor
Diffstat (limited to 'internal/validate/checks.go')
-rw-r--r--internal/validate/checks.go751
1 files changed, 751 insertions, 0 deletions
diff --git a/internal/validate/checks.go b/internal/validate/checks.go
new file mode 100644
index 0000000..bb7cbd9
--- /dev/null
+++ b/internal/validate/checks.go
@@ -0,0 +1,751 @@
+package validate
+
+import (
+ "fmt"
+ "sort"
+ "strings"
+
+ "thehouseoficarus/internal/behavior"
+ "thehouseoficarus/internal/world"
+)
+
+func validateRooms(s Source) []Issue {
+ var issues []Issue
+ roomIndex := s.World.RoomIndex()
+ itemIDs := s.Items.IDSet()
+ mobIDs := s.Mobs.AllDefIDs()
+ objIDs := s.Objects.IDSet()
+ hazardIDs := s.World.HazardIndex()
+
+ for id := range roomIndex {
+ room, err := s.World.LoadRoom(id)
+ if err != nil {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Room %d: failed to load: %v", id, err),
+ })
+ continue
+ }
+ if room.Name == "" {
+ issues = append(issues, Issue{
+ Level: "WARN",
+ Type: "reference",
+ Message: fmt.Sprintf("Room %d: has no name", id),
+ })
+ }
+
+ for dir, exit := range room.Exits {
+ switch dir {
+ case world.North, world.South, world.East, world.West, world.Up, world.Down:
+ default:
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Room %d: invalid exit direction %q (only north/south/east/west/up/down supported)", id, dir),
+ })
+ continue
+ }
+ if exit.Room <= 0 {
+ continue
+ }
+ if _, ok := roomIndex[exit.Room]; !ok {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Room %d: exit %q targets nonexistent room %d",
+ id, dir, exit.Room),
+ })
+ }
+ }
+
+ for _, rm := range room.Mobs {
+ if rm.ID == "" {
+ continue
+ }
+ if _, ok := mobIDs[rm.ID]; !ok {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Room %d: references nonexistent mob %q",
+ id, rm.ID),
+ })
+ }
+ for _, wr := range rm.WanderRooms {
+ if _, ok := roomIndex[wr]; !ok {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Room %d: mob %q wander_rooms references nonexistent room %d",
+ id, rm.ID, wr),
+ })
+ }
+ }
+ }
+
+ for _, sp := range room.ItemSpawns {
+ if sp.ID != "" && !itemIDs[sp.ID] {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Room %d: spawns nonexistent item %q",
+ id, sp.ID),
+ })
+ }
+ }
+
+ for _, robj := range room.Objects {
+ 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),
+ })
+ }
+ for _, wr := range robj.WanderRooms {
+ if _, ok := roomIndex[wr]; !ok {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Room %d: object %q wander_rooms references nonexistent room %d",
+ id, robj.ID, wr),
+ })
+ }
+ }
+ }
+
+ if room.Hazard != "" && !hazardIDs[room.Hazard] {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Room %d: references nonexistent hazard %q",
+ id, room.Hazard),
+ })
+ }
+ }
+
+ return issues
+}
+
+func validateMobs(s Source) []Issue {
+ var issues []Issue
+ itemIDs := s.Items.IDSet()
+ dropIDs := dropTableIDSet(s.DataDir)
+ mobIDs := s.Mobs.AllDefIDs()
+
+ for id := range mobIDs {
+ def, err := s.Mobs.LoadDef(id)
+ if err != nil {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Mob %q: failed to load: %v", id, err),
+ })
+ continue
+ }
+ if def.Name == "" {
+ issues = append(issues, Issue{
+ Level: "WARN",
+ Type: "reference",
+ Message: fmt.Sprintf("Mob %q: has no name", id),
+ })
+ }
+
+ if def.Kind != "" && def.Kind != "combat" && def.Kind != "task" {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Mob %q: invalid kind %q (must be \"combat\" or \"task\")", id, def.Kind),
+ })
+ }
+
+ if def.Drops.Remains != "" && !itemIDs[def.Drops.Remains] {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Mob %q: drops remains (item %q) does not exist",
+ id, def.Drops.Remains),
+ })
+ }
+
+ for _, d := range def.Drops.Loot {
+ if d.ItemID != "" && !itemIDs[d.ItemID] {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Mob %q: loot item %q does not exist",
+ id, d.ItemID),
+ })
+ }
+ if d.Table != "" && !dropIDs[d.Table] {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Mob %q: loot table %q does not exist",
+ id, d.Table),
+ })
+ }
+ }
+
+ if def.StealTable != "" && !dropIDs[def.StealTable] {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Mob %q: steal_table %q does not exist",
+ id, def.StealTable),
+ })
+ }
+
+ if def.FinishingBlow != "" && !itemIDs[def.FinishingBlow] {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Mob %q: finishing_blow item %q does not exist",
+ id, def.FinishingBlow),
+ })
+ }
+ }
+
+ return issues
+}
+
+func validateHazards(s Source) []Issue {
+ var issues []Issue
+ itemIDs := s.Items.IDSet()
+ validTypes := map[string]bool{"stab": true, "slash": true, "crush": true, "ranged": true, "science": true}
+
+ for id := range s.World.HazardIndex() {
+ def, err := s.World.LoadHazard(id)
+ if err != nil {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Hazard %q: failed to load: %v", id, err),
+ })
+ continue
+ }
+ if def.AttackType != "" && !validTypes[def.AttackType] {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Hazard %q: invalid attack_type %q (must be stab/slash/crush/ranged/science)", id, def.AttackType),
+ })
+ }
+ if def.RequiredItem != "" && !itemIDs[def.RequiredItem] {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Hazard %q: required_item %q does not exist", id, def.RequiredItem),
+ })
+ }
+ }
+
+ return issues
+}
+
+func validateObjects(s Source) []Issue {
+ var issues []Issue
+ objIDs := s.Objects.IDSet()
+ itemIDs := s.Items.IDSet()
+ mobIDs := s.Mobs.AllDefIDs()
+ dropIDs := dropTableIDSet(s.DataDir)
+ roomIndex := s.World.RoomIndex()
+
+ for id := range objIDs {
+ obj, err := s.Objects.Load(id)
+ if err != nil {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Object %q: failed to load: %v", id, err),
+ })
+ continue
+ }
+ if obj.Name == "" {
+ issues = append(issues, Issue{
+ Level: "WARN",
+ Type: "reference",
+ Message: fmt.Sprintf("Object %q: has no name", id),
+ })
+ }
+
+ if obj.RemovalItem != "" && !itemIDs[obj.RemovalItem] {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Object %q: removal_item %q does not exist",
+ id, obj.RemovalItem),
+ })
+ }
+
+ if obj.StealTable != "" && !dropIDs[obj.StealTable] {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Object %q: steal_table %q does not exist",
+ id, obj.StealTable),
+ })
+ }
+
+ if obj.GuardMob != "" && !mobIDs[obj.GuardMob] {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Object %q: guard_mob %q does not exist",
+ id, obj.GuardMob),
+ })
+ }
+
+ for _, ui := range obj.UseInteractions {
+ if ui.Item != "" && !itemIDs[ui.Item] {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Object %q: use_interaction item %q does not exist",
+ id, ui.Item),
+ })
+ }
+ if ui.Action != nil {
+ issues = append(issues, validateNodeAction(fmt.Sprintf("Object %q: use_interaction", id), ui.Action, itemIDs, roomIndex)...)
+ }
+ }
+
+ if obj.Gather != nil {
+ issues = append(issues, validateGather(fmt.Sprintf("Object %q: gather", id), obj.Gather, itemIDs, dropIDs)...)
+ }
+
+ if obj.Talk != nil {
+ issues = append(issues, validateTalkConfig(fmt.Sprintf("Object %q: talk", id), obj.Talk, itemIDs, roomIndex)...)
+ }
+
+ if obj.Use != nil {
+ issues = append(issues, validateUseConfig(fmt.Sprintf("Object %q: use", id), obj.Use, itemIDs)...)
+ }
+ }
+
+ return issues
+}
+
+func validateItems(s Source) []Issue {
+ var issues []Issue
+ itemIDs := s.Items.IDSet()
+ dropIDs := dropTableIDSet(s.DataDir)
+
+ for id := range itemIDs {
+ def, err := s.Items.Load(id)
+ if err != nil {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Item %q: failed to load: %v", id, err),
+ })
+ continue
+ }
+ if def.Name == "" {
+ issues = append(issues, Issue{
+ Level: "WARN",
+ Type: "reference",
+ Message: fmt.Sprintf("Item %q: has no name", id),
+ })
+ }
+
+ if def.SearchTable != "" && !dropIDs[def.SearchTable] {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Item %q: search_table %q does not exist",
+ id, def.SearchTable),
+ })
+ }
+
+ if def.SearchMiscTable != "" && !dropIDs[def.SearchMiscTable] {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Item %q: search_misc_table %q does not exist",
+ id, def.SearchMiscTable),
+ })
+ }
+
+ if def.FarmProduct != "" && !itemIDs[def.FarmProduct] {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Item %q (seed): farm_product %q does not exist",
+ id, def.FarmProduct),
+ })
+ }
+ }
+
+ return issues
+}
+
+func validateCraftBlocks(s Source) []Issue {
+ var issues []Issue
+ itemIDs := s.Items.IDSet()
+ objIDs := s.Objects.IDSet()
+
+ allItems, err := s.Items.LoadAll()
+ if err != nil {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Failed to load items: %v", err),
+ })
+ return issues
+ }
+
+ for _, item := range allItems {
+ if len(item.Craft) == 0 {
+ continue
+ }
+ for ci := range item.Craft {
+ c := &item.Craft[ci]
+
+ for _, e := range c.Consume {
+ for _, consumeItem := range e.Items {
+ if consumeItem != "" && !itemIDs[consumeItem] {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Item %q (craft: %s): consumes nonexistent item %q",
+ item.ID, c.Type, consumeItem),
+ })
+ }
+ }
+ }
+
+ if c.Fail != "" && !itemIDs[c.Fail] {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Item %q (craft: %s): fail product %q does not exist",
+ item.ID, c.Type, c.Fail),
+ })
+ }
+
+ for _, sid := range c.Station {
+ if sid != "" && !objIDs[sid] {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Item %q (craft: %s): station object %q does not exist",
+ item.ID, c.Type, sid),
+ })
+ }
+ }
+ }
+ }
+
+ return issues
+}
+
+func validateDropTables(s Source) []Issue {
+ var issues []Issue
+ dropIDs := dropTableIDSet(s.DataDir)
+ itemIDs := s.Items.IDSet()
+
+ for id := range dropIDs {
+ dt, err := behavior.LoadDropTable(s.DataDir, id)
+ if err != nil {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Drop table %q: failed to load: %v", id, err),
+ })
+ continue
+ }
+ for _, d := range dt.Drops {
+ if d.ItemID != "" && !itemIDs[d.ItemID] {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Drop table %q: drops nonexistent item %q",
+ id, d.ItemID),
+ })
+ }
+ if d.Table != "" && !dropIDs[d.Table] && d.Table != id {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Drop table %q: references nonexistent sub-table %q",
+ id, d.Table),
+ })
+ }
+ }
+ }
+
+ return issues
+}
+
+func validateCourses(s Source) []Issue {
+ var issues []Issue
+ roomIndex := s.World.RoomIndex()
+
+ for _, cv := range s.Courses {
+ if cv.StartRoom > 0 {
+ if _, ok := roomIndex[cv.StartRoom]; !ok {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Course %q: start_room %d does not exist",
+ cv.ID, cv.StartRoom),
+ })
+ }
+ }
+ for _, rid := range cv.ObstacleRooms {
+ if _, ok := roomIndex[rid]; !ok {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Course %q: obstacle room %d does not exist",
+ cv.ID, rid),
+ })
+ }
+ }
+ }
+
+ return issues
+}
+
+func validateRoomWiring(s Source) []Issue {
+ var issues []Issue
+ roomIndex := s.World.RoomIndex()
+
+ sourceSet := make(map[int]bool)
+ for _, src := range s.CheckSources {
+ sourceSet[src] = true
+ }
+
+ ignoreTo := make(map[int]bool)
+ for _, r := range s.IgnoreUnreachable {
+ ignoreTo[r] = true
+ }
+
+ hasExitTo := make(map[int]bool)
+ for id := range roomIndex {
+ room, err := s.World.LoadRoom(id)
+ if err != nil {
+ continue
+ }
+ for _, exit := range room.Exits {
+ if exit.Room > 0 {
+ hasExitTo[exit.Room] = true
+ }
+ }
+ }
+
+ var orphans []int
+ for id := range roomIndex {
+ if ignoreTo[id] {
+ continue
+ }
+ if sourceSet[id] {
+ continue
+ }
+ if !hasExitTo[id] {
+ orphans = append(orphans, id)
+ }
+ }
+ if len(orphans) > 0 {
+ sort.Ints(orphans)
+ var strs []string
+ for _, id := range orphans {
+ strs = append(strs, fmt.Sprintf("%d", id))
+ }
+ issues = append(issues, Issue{
+ Level: "WARN",
+ Type: "integrity",
+ Message: fmt.Sprintf("Orphan rooms (no exit leads to them): %s",
+ strings.Join(strs, ", ")),
+ })
+ }
+
+ return issues
+}
+
+func validateTechs(s Source) []Issue {
+ var issues []Issue
+ seen := make(map[string]bool)
+ for _, def := range s.Techs {
+ if def.ID == "" {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: "Tech has empty id",
+ })
+ continue
+ }
+ if seen[def.ID] {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "duplicate",
+ Message: fmt.Sprintf("Duplicate tech %q", def.ID),
+ })
+ }
+ seen[def.ID] = true
+
+ if def.Name == "" {
+ issues = append(issues, Issue{
+ Level: "WARN",
+ Type: "reference",
+ Message: fmt.Sprintf("Tech %q: has no name", def.ID),
+ })
+ }
+ if def.Category != "" && !knownTechCategories[def.Category] {
+ issues = append(issues, Issue{
+ Level: "WARN",
+ Type: "reference",
+ Message: fmt.Sprintf("Tech %q: unknown category %q", def.ID, def.Category),
+ })
+ }
+ if def.DrainRate < 0 {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Tech %q: negative drain_rate %.2f", def.ID, def.DrainRate),
+ })
+ }
+ }
+
+ for id := range reservedTechIDs {
+ if !s.TechIDs[id] {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Reserved tech %q does not exist — required by code (damage protection / retribution)", id),
+ })
+ }
+ }
+
+ return issues
+}
+
+func validateGather(prefix string, cfg *behavior.GatherConfig, itemIDs map[string]bool, dropIDs map[string]bool) []Issue {
+ var issues []Issue
+
+ if cfg.Bait != "" && !itemIDs[cfg.Bait] {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("%s: bait item %q does not exist",
+ prefix, cfg.Bait),
+ })
+ }
+
+ for _, d := range cfg.Drops {
+ if d.ItemID != "" && !itemIDs[d.ItemID] {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("%s: drop item %q does not exist",
+ prefix, d.ItemID),
+ })
+ }
+ if d.Table != "" && !dropIDs[d.Table] {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("%s: drop table %q does not exist",
+ prefix, d.Table),
+ })
+ }
+ }
+
+ return issues
+}
+
+func validateTalkConfig(prefix string, cfg *behavior.TalkConfig, itemIDs map[string]bool, roomIndex map[int]bool) []Issue {
+ var issues []Issue
+ if cfg == nil {
+ return issues
+ }
+ for nodeID, node := range cfg.Nodes {
+ nodePrefix := fmt.Sprintf("%s: node %q", prefix, nodeID)
+ if node.Action != nil {
+ issues = append(issues, validateNodeAction(nodePrefix, node.Action, itemIDs, roomIndex)...)
+ }
+ }
+ return issues
+}
+
+func validateUseConfig(prefix string, cfg *behavior.UseConfig, itemIDs map[string]bool) []Issue {
+ var issues []Issue
+ if cfg == nil {
+ return issues
+ }
+
+ for itemID := range cfg.Consume {
+ if itemID != "" && !itemIDs[itemID] {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("%s: consumes nonexistent item %q",
+ prefix, itemID),
+ })
+ }
+ }
+
+ if cfg.Reward.ItemID != "" && !itemIDs[cfg.Reward.ItemID] {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("%s: reward item %q does not exist",
+ prefix, cfg.Reward.ItemID),
+ })
+ }
+
+ return issues
+}
+
+func validateNodeAction(prefix string, na *behavior.NodeAction, itemIDs map[string]bool, roomIndex map[int]bool) []Issue {
+ var issues []Issue
+ if na == nil {
+ return issues
+ }
+
+ if na.GiveItem != "" && !itemIDs[na.GiveItem] {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("%s: give_item %q does not exist",
+ prefix, na.GiveItem),
+ })
+ }
+
+ if na.TakeItem != "" && !itemIDs[na.TakeItem] {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("%s: take_item %q does not exist",
+ prefix, na.TakeItem),
+ })
+ }
+
+ if na.Teleport > 0 {
+ if _, ok := roomIndex[na.Teleport]; !ok {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("%s: teleport to nonexistent room %d",
+ prefix, na.Teleport),
+ })
+ }
+ }
+
+ if na.Shop != nil {
+ for _, si := range na.Shop.Items {
+ if si.ItemID != "" && !itemIDs[si.ItemID] {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("%s: shop item %q does not exist",
+ prefix, si.ItemID),
+ })
+ }
+ }
+ }
+
+ return issues
+}