From abd612c15799f604e671e83dc7c410ed2b44185f Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Thu, 25 Jun 2026 15:40:48 -0400 Subject: slop refactor --- internal/validate/checks.go | 751 ++++++++++++++++++++++++++++++++++++++++++ internal/validate/doc.go | 6 + internal/validate/validate.go | 162 +++++++++ 3 files changed, 919 insertions(+) create mode 100644 internal/validate/checks.go create mode 100644 internal/validate/doc.go create mode 100644 internal/validate/validate.go (limited to 'internal/validate') 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 +} diff --git a/internal/validate/doc.go b/internal/validate/doc.go new file mode 100644 index 0000000..bbdf211 --- /dev/null +++ b/internal/validate/doc.go @@ -0,0 +1,6 @@ +// Package validate runs startup integrity checks across all YAML data files: +// duplicate IDs, referential integrity (rooms, mobs, objects, items, drops, +// courses, techs), and world wiring (orphan rooms). It imports only the data +// packages (world, object, item, behavior) and may be called directly in +// tests. +package validate diff --git a/internal/validate/validate.go b/internal/validate/validate.go new file mode 100644 index 0000000..5557f72 --- /dev/null +++ b/internal/validate/validate.go @@ -0,0 +1,162 @@ +// Package validate runs startup integrity checks across all data files: +// duplicate IDs, referential integrity (rooms, mobs, objects, items, drops, +// courses, techs), and world wiring (orphan rooms). It depends only on the +// data packages (world, object, item, behavior), never on the game +// orchestrator, so it can be reused and tested in isolation. +package validate + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "thehouseoficarus/internal/behavior" + "thehouseoficarus/internal/item" + "thehouseoficarus/internal/object" + "thehouseoficarus/internal/world" +) + +// Issue is a single validation finding. +type Issue struct { + Level string // "ERROR" or "WARN" + Type string // "duplicate", "reference", "integrity" + Message string +} + +// CourseView is the subset of a course definition needed for validation. +type CourseView struct { + ID string + StartRoom int + ObstacleRooms []int +} + +// TechView is the subset of a tech definition needed for validation. +type TechView struct { + ID string + Name string + Category string + DrainRate float64 +} + +// Source bundles everything the validator reads. The game package builds this +// from its stores and passes it to Run. +type Source struct { + DataDir string + Items *item.ItemStore + Objects *object.ObjectStore + Mobs *world.MobStore + World *world.World + Courses []CourseView + Techs []TechView + TechIDs map[string]bool + CheckSources []int + IgnoreUnreachable []int +} + +// Run executes all checks and returns the collected issues. +func Run(s Source) []Issue { + var issues []Issue + + for _, dir := range []struct{ sub, label string }{ + {"items", "item"}, {"rooms", "room"}, {"mobs", "mob"}, + {"objects", "object"}, {"drops", "drop table"}, {"courses", "course"}, + {"modules", "module"}, {"techs", "tech"}, {"help", "help topic"}, + } { + issues = append(issues, validateIDs(s.DataDir, dir.sub, dir.label)...) + } + + issues = append(issues, validateRooms(s)...) + issues = append(issues, validateMobs(s)...) + issues = append(issues, validateHazards(s)...) + issues = append(issues, validateObjects(s)...) + issues = append(issues, validateItems(s)...) + issues = append(issues, validateCraftBlocks(s)...) + issues = append(issues, validateDropTables(s)...) + issues = append(issues, validateCourses(s)...) + issues = append(issues, validateTechs(s)...) + issues = append(issues, validateRoomWiring(s)...) + + return issues +} + +func validateIDs(dataDir, subDir, label string) []Issue { + dir := filepath.Join(dataDir, subDir) + dups := behavior.CheckDuplicateIDs(dir) + var issues []Issue + for _, d := range dups { + short := make([]string, len(d.Paths)) + for i, p := range d.Paths { + short[i] = strings.TrimPrefix(p, dir+"/") + } + issues = append(issues, Issue{ + Level: "ERROR", + Type: "duplicate", + Message: fmt.Sprintf("Duplicate %s ID %q found in: %s", + label, d.ID, strings.Join(short, ", ")), + }) + } + return issues +} + +func dropTableIDSet(dataDir string) map[string]bool { + dir := filepath.Join(dataDir, "drops") + ids := make(map[string]bool) + behavior.WalkYAMLDir(dir, func(path, id string, data []byte) error { + ids[id] = true + return nil + }) + return ids +} + +// LogIssues prints validation results to stderr, sorted with errors first. +func LogIssues(issues []Issue) { + if len(issues) == 0 { + fmt.Fprintf(os.Stderr, "Startup validation: OK\n") + return + } + + errors := 0 + warns := 0 + for _, issue := range issues { + if issue.Level == "ERROR" { + errors++ + } else { + warns++ + } + } + + fmt.Fprintf(os.Stderr, "\n=== Startup Validation: %d error(s), %d warning(s) ===\n\n", errors, warns) + + sort.Slice(issues, func(i, j int) bool { + if issues[i].Level != issues[j].Level { + return issues[i].Level == "ERROR" + } + return issues[i].Message < issues[j].Message + }) + + for _, issue := range issues { + tag := fmt.Sprintf("[%s]", issue.Level) + fmt.Fprintf(os.Stderr, " %-7s %s\n", tag, issue.Message) + } + fmt.Fprintln(os.Stderr) + + if errors > 0 { + fmt.Fprintf(os.Stderr, "*** %d startup errors detected. The server may behave unexpectedly. ***\n", errors) + } +} + +var knownTechCategories = map[string]bool{ + "accuracy": true, "strength": true, "defense": true, "ranged": true, + "science": true, "protection": true, "utility": true, "combo": true, +} + +// reservedTechIDs are tech IDs whose semantics are coupled to code logic and +// must exist in YAML (damage protection mapping + death retribution). +var reservedTechIDs = map[string]bool{ + "protect_melee": true, + "protect_ranged": true, + "protect_science": true, + "retribution": true, +} -- cgit v1.2.3