// 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 ( "bytes" "fmt" "os" "path/filepath" "sort" "strconv" "strings" "gopkg.in/yaml.v3" "thehouseoficarus/internal/behavior" "thehouseoficarus/internal/cardart" "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 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 RootRooms []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"}, {"triggers", "trigger"}, } { issues = append(issues, validateIDs(s.DataDir, dir.sub, dir.label)...) } issues = append(issues, validateKnownFields(s.DataDir)...) issues = append(issues, validateRoomFilenames(s.DataDir)...) 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, validateRoomTriggers(s)...) issues = append(issues, validateTechs(s)...) issues = append(issues, validateRoomWiring(s)...) issues = append(issues, validateRoomGrid(s)...) issues = append(issues, validateExitReciprocity(s)...) return issues } // validateRoomFilenames flags room files whose name is not a plain integer: // the room index only accepts numeric stems, so a file like // "1001_copy.yaml" would otherwise be silently ignored (and, before the // strconv fix, could shadow the real room 1001). func validateRoomFilenames(dataDir string) []Issue { dir := filepath.Join(dataDir, "rooms") var issues []Issue _ = behavior.WalkYAMLDir(dir, func(path, id string, data []byte) error { if _, err := strconv.Atoi(id); err != nil { rel := strings.TrimPrefix(path, dataDir+"/") issues = append(issues, Issue{ Level: "ERROR", Type: "integrity", Message: fmt.Sprintf("room %s: filename is not a numeric room ID and the file is ignored", rel), }) } return nil }) return issues } // validateKnownFields decodes every authored data file with KnownFields(true) // so a misspelled or stale YAML key is a startup ERROR instead of being // silently discarded by yaml.Unmarshal. Only package-owned content types are // covered (world/object/item/behavior); game-owned definitions are checked by // their own loaders. func validateKnownFields(dataDir string) []Issue { targets := []struct { subDir string label string newDef func() any }{ {"rooms", "room", func() any { return new(world.Room) }}, {"mobs", "mob", func() any { return new(world.MobDef) }}, {"objects", "object", func() any { return new(object.ObjectDef) }}, {"items", "item", func() any { return new(item.ItemDef) }}, {"drops", "drop table", func() any { return new(behavior.DropTableDef) }}, {"hazards", "hazard", func() any { return new(world.HazardDef) }}, } var issues []Issue for _, t := range targets { dir := filepath.Join(dataDir, t.subDir) _ = behavior.WalkYAMLDir(dir, func(path, id string, data []byte) error { dec := yaml.NewDecoder(bytes.NewReader(data)) dec.KnownFields(true) if err := dec.Decode(t.newDef()); err != nil { rel := strings.TrimPrefix(path, dataDir+"/") issues = append(issues, Issue{ Level: "ERROR", Type: "unknown-field", Message: fmt.Sprintf("%s %s: %v", t.label, rel, err), }) } return nil }) } // cards.yaml lives at the data root rather than a subdirectory. if data, err := os.ReadFile(filepath.Join(dataDir, "cards.yaml")); err == nil { dec := yaml.NewDecoder(bytes.NewReader(data)) dec.KnownFields(true) if err := dec.Decode(new(cardart.Store)); err != nil { issues = append(issues, Issue{ Level: "ERROR", Type: "unknown-field", Message: fmt.Sprintf("cards.yaml: %v", err), }) } } 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) } } // 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, }