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.Protected && def.HP <= 0 { issues = append(issues, Issue{ Level: "ERROR", Type: "reference", Message: fmt.Sprintf("Mob %q: is not protected but has HP=%d — mobs must have hp > 0 (or set protected: true)", id, def.HP), }) } 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)...) } if obj.OnLook != nil { issues = append(issues, validateNodeAction(fmt.Sprintf("Object %q: on_look", id), obj.OnLook, itemIDs, roomIndex)...) } } 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)...) } if node.Next != "" { if _, ok := cfg.Nodes[node.Next]; !ok { issues = append(issues, Issue{ Level: "ERROR", Type: "reference", Message: fmt.Sprintf("%s: next points to nonexistent node %q", nodePrefix, node.Next), }) } } for i, opt := range node.Options { optPrefix := fmt.Sprintf("%s: option %d", nodePrefix, i+1) if opt.Action != nil { issues = append(issues, validateNodeAction(optPrefix, opt.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 }