package game import ( "fmt" "os" "path/filepath" "sort" "strings" "thehouseoficarus/internal/action" "thehouseoficarus/internal/world" ) type StartupIssue struct { Level string Type string Message string } func (g *Game) ValidateStartup() []StartupIssue { var issues []StartupIssue issues = append(issues, validateIDs(g.DataDir, "items", "item")...) issues = append(issues, validateIDs(g.DataDir, "rooms", "room")...) issues = append(issues, validateIDs(g.DataDir, "mobs", "mob")...) issues = append(issues, validateIDs(g.DataDir, "objects", "object")...) issues = append(issues, validateIDs(g.DataDir, "drops", "drop table")...) issues = append(issues, validateIDs(g.DataDir, "courses", "course")...) issues = append(issues, validateIDs(g.DataDir, "modules", "module")...) issues = append(issues, validateIDs(g.DataDir, "techs", "tech")...) issues = append(issues, validateIDs(g.DataDir, "help", "help topic")...) issues = append(issues, validateRooms(g)...) issues = append(issues, validateMobs(g)...) issues = append(issues, validateHazards(g)...) issues = append(issues, validateObjects(g)...) issues = append(issues, validateItems(g)...) issues = append(issues, validateCraftBlocks(g)...) issues = append(issues, validateDropTables(g)...) issues = append(issues, validateCourses(g)...) issues = append(issues, validateTechs(g)...) issues = append(issues, validateRoomWiring(g)...) return issues } func validateIDs(dataDir, subDir, label string) []StartupIssue { dir := filepath.Join(dataDir, subDir) dups := action.CheckDuplicateIDs(dir) var issues []StartupIssue 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, StartupIssue{ Level: "ERROR", Type: "duplicate", Message: fmt.Sprintf("Duplicate %s ID %q found in: %s", label, d.ID, strings.Join(short, ", ")), }) } return issues } func validateRooms(g *Game) []StartupIssue { var issues []StartupIssue roomIndex := g.World.RoomIndex() itemIDs := g.ItemStore.IDSet() mobIDs := g.MobStore.AllDefIDs() objIDs := g.ObjectStore.IDSet() hazardIDs := g.World.HazardIndex() for id := range roomIndex { room, err := g.World.LoadRoom(id) if err != nil { issues = append(issues, StartupIssue{ Level: "ERROR", Type: "reference", Message: fmt.Sprintf("Room %d: failed to load: %v", id, err), }) continue } if room.Name == "" { issues = append(issues, StartupIssue{ 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, StartupIssue{ 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, StartupIssue{ 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, StartupIssue{ 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, StartupIssue{ Level: "ERROR", Type: "reference", Message: fmt.Sprintf("Room %d: mob %q wander_rooms references nonexistent room %d", id, rm.ID, wr), }) } } } for _, s := range room.ItemSpawns { if s.ID != "" && !itemIDs[s.ID] { issues = append(issues, StartupIssue{ Level: "ERROR", Type: "reference", Message: fmt.Sprintf("Room %d: spawns nonexistent item %q", id, s.ID), }) } } for _, robj := range room.Objects { if robj.ID != "" && !objIDs[robj.ID] { issues = append(issues, StartupIssue{ 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, StartupIssue{ 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, StartupIssue{ Level: "ERROR", Type: "reference", Message: fmt.Sprintf("Room %d: references nonexistent hazard %q", id, room.Hazard), }) } } return issues } func validateMobs(g *Game) []StartupIssue { var issues []StartupIssue itemIDs := g.ItemStore.IDSet() dropIDs := dropTableIDSet(g.DataDir) mobIDs := g.MobStore.AllDefIDs() for id := range mobIDs { def, err := g.MobStore.LoadDef(id) if err != nil { issues = append(issues, StartupIssue{ Level: "ERROR", Type: "reference", Message: fmt.Sprintf("Mob %q: failed to load: %v", id, err), }) continue } if def.Name == "" { issues = append(issues, StartupIssue{ 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, StartupIssue{ 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, StartupIssue{ 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, StartupIssue{ 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, StartupIssue{ 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, StartupIssue{ 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, StartupIssue{ Level: "ERROR", Type: "reference", Message: fmt.Sprintf("Mob %q: finishing_blow item %q does not exist", id, def.FinishingBlow), }) } } return issues } func validateHazards(g *Game) []StartupIssue { var issues []StartupIssue itemIDs := g.ItemStore.IDSet() validTypes := map[string]bool{"stab": true, "slash": true, "crush": true, "ranged": true, "science": true} for id := range g.World.HazardIndex() { def, err := g.World.LoadHazard(id) if err != nil { issues = append(issues, StartupIssue{ 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, StartupIssue{ 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, StartupIssue{ Level: "ERROR", Type: "reference", Message: fmt.Sprintf("Hazard %q: required_item %q does not exist", id, def.RequiredItem), }) } } return issues } func validateObjects(g *Game) []StartupIssue { var issues []StartupIssue objIDs := g.ObjectStore.IDSet() itemIDs := g.ItemStore.IDSet() mobIDs := g.MobStore.AllDefIDs() dropIDs := dropTableIDSet(g.DataDir) roomIndex := g.World.RoomIndex() for id := range objIDs { obj, err := g.ObjectStore.Load(id) if err != nil { issues = append(issues, StartupIssue{ Level: "ERROR", Type: "reference", Message: fmt.Sprintf("Object %q: failed to load: %v", id, err), }) continue } if obj.Name == "" { issues = append(issues, StartupIssue{ Level: "WARN", Type: "reference", Message: fmt.Sprintf("Object %q: has no name", id), }) } if obj.RemovalItem != "" && !itemIDs[obj.RemovalItem] { issues = append(issues, StartupIssue{ 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, StartupIssue{ 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, StartupIssue{ 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, StartupIssue{ 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(g *Game) []StartupIssue { var issues []StartupIssue itemIDs := g.ItemStore.IDSet() dropIDs := dropTableIDSet(g.DataDir) for id := range itemIDs { def, err := g.ItemStore.Load(id) if err != nil { issues = append(issues, StartupIssue{ Level: "ERROR", Type: "reference", Message: fmt.Sprintf("Item %q: failed to load: %v", id, err), }) continue } if def.Name == "" { issues = append(issues, StartupIssue{ Level: "WARN", Type: "reference", Message: fmt.Sprintf("Item %q: has no name", id), }) } if def.SearchTable != "" && !dropIDs[def.SearchTable] { issues = append(issues, StartupIssue{ 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, StartupIssue{ 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, StartupIssue{ Level: "ERROR", Type: "reference", Message: fmt.Sprintf("Item %q (seed): farm_product %q does not exist", id, def.FarmProduct), }) } } return issues } func validateCraftBlocks(g *Game) []StartupIssue { var issues []StartupIssue itemIDs := g.ItemStore.IDSet() objIDs := g.ObjectStore.IDSet() allItems, err := g.ItemStore.LoadAll() if err != nil { issues = append(issues, StartupIssue{ 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, StartupIssue{ 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, StartupIssue{ 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, StartupIssue{ 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(g *Game) []StartupIssue { var issues []StartupIssue dropIDs := dropTableIDSet(g.DataDir) itemIDs := g.ItemStore.IDSet() for id := range dropIDs { dt, err := action.LoadDropTable(g.DataDir, id) if err != nil { issues = append(issues, StartupIssue{ 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, StartupIssue{ 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, StartupIssue{ Level: "ERROR", Type: "reference", Message: fmt.Sprintf("Drop table %q: references nonexistent sub-table %q", id, d.Table), }) } } } return issues } func validateCourses(g *Game) []StartupIssue { var issues []StartupIssue roomIndex := g.World.RoomIndex() courses := g.CourseStore.AllCourses() for id, cfg := range courses { if cfg.StartRoom > 0 { if _, ok := roomIndex[cfg.StartRoom]; !ok { issues = append(issues, StartupIssue{ Level: "ERROR", Type: "reference", Message: fmt.Sprintf("Course %q: start_room %d does not exist", id, cfg.StartRoom), }) } } for _, obs := range cfg.Obstacles { if _, ok := roomIndex[obs.RoomID]; !ok { issues = append(issues, StartupIssue{ Level: "ERROR", Type: "reference", Message: fmt.Sprintf("Course %q: obstacle room %d does not exist", id, obs.RoomID), }) } } } return issues } func validateRoomWiring(g *Game) []StartupIssue { var issues []StartupIssue roomIndex := g.World.RoomIndex() cfg := g.ValidationConfig sourceSet := make(map[int]bool) for _, s := range cfg.CheckSources { sourceSet[s] = true } ignoreTo := make(map[int]bool) for _, r := range cfg.IgnoreUnreachable { ignoreTo[r] = true } hasExitTo := make(map[int]bool) for id := range roomIndex { room, err := g.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, StartupIssue{ Level: "WARN", Type: "integrity", Message: fmt.Sprintf("Orphan rooms (no exit leads to them): %s", strings.Join(strs, ", ")), }) } return issues } func validateGather(prefix string, cfg *action.GatherConfig, itemIDs map[string]bool, dropIDs map[string]bool) []StartupIssue { var issues []StartupIssue if cfg.Bait != "" && !itemIDs[cfg.Bait] { issues = append(issues, StartupIssue{ 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, StartupIssue{ 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, StartupIssue{ Level: "ERROR", Type: "reference", Message: fmt.Sprintf("%s: drop table %q does not exist", prefix, d.Table), }) } } return issues } func validateTalkConfig(prefix string, cfg *action.TalkConfig, itemIDs map[string]bool, roomIndex map[int]bool) []StartupIssue { var issues []StartupIssue 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 *action.UseConfig, itemIDs map[string]bool) []StartupIssue { var issues []StartupIssue if cfg == nil { return issues } for itemID := range cfg.Consume { if itemID != "" && !itemIDs[itemID] { issues = append(issues, StartupIssue{ 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, StartupIssue{ 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 *action.NodeAction, itemIDs map[string]bool, roomIndex map[int]bool) []StartupIssue { var issues []StartupIssue if na == nil { return issues } if na.GiveItem != "" && !itemIDs[na.GiveItem] { issues = append(issues, StartupIssue{ 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, StartupIssue{ 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, StartupIssue{ 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, StartupIssue{ Level: "ERROR", Type: "reference", Message: fmt.Sprintf("%s: shop item %q does not exist", prefix, si.ItemID), }) } } } return issues } var knownTechCategories = map[string]bool{ "accuracy": true, "strength": true, "defense": true, "ranged": true, "science": true, "protection": true, "utility": true, "combo": true, } // Reserved tech IDs whose semantics are coupled to code logic — // damageAfterTechProtection maps attack types to protect_melee/ranged/science // and killPlayer checks for retribution. These IDs must exist in YAML. var reservedTechIDs = map[string]bool{ "protect_melee": true, "protect_ranged": true, "protect_science": true, "retribution": true, } func validateTechs(g *Game) []StartupIssue { var issues []StartupIssue seen := make(map[string]bool) for _, def := range AllTechs { if def.ID == "" { issues = append(issues, StartupIssue{ Level: "ERROR", Type: "reference", Message: "Tech has empty id", }) continue } if seen[def.ID] { issues = append(issues, StartupIssue{ Level: "ERROR", Type: "duplicate", Message: fmt.Sprintf("Duplicate tech %q", def.ID), }) } seen[def.ID] = true if def.Name == "" { issues = append(issues, StartupIssue{ Level: "WARN", Type: "reference", Message: fmt.Sprintf("Tech %q: has no name", def.ID), }) } if def.Category != "" && !knownTechCategories[def.Category] { issues = append(issues, StartupIssue{ Level: "WARN", Type: "reference", Message: fmt.Sprintf("Tech %q: unknown category %q", def.ID, def.Category), }) } if def.DrainRate < 0 { issues = append(issues, StartupIssue{ Level: "ERROR", Type: "reference", Message: fmt.Sprintf("Tech %q: negative drain_rate %.2f", def.ID, def.DrainRate), }) } } for id := range reservedTechIDs { if _, ok := techByID[id]; !ok { issues = append(issues, StartupIssue{ Level: "ERROR", Type: "reference", Message: fmt.Sprintf("Reserved tech %q does not exist — required by code (damage protection / retribution)", id), }) } } return issues } func dropTableIDSet(dataDir string) map[string]bool { dir := filepath.Join(dataDir, "drops") ids := make(map[string]bool) action.WalkYAMLDir(dir, func(path, id string, data []byte) error { ids[id] = true return nil }) return ids } func (cs *CourseStore) AllCourses() map[string]*CourseConfig { cs.mu.Lock() defer cs.mu.Unlock() if !cs.loaded { cs.loadAllLocked() } return cs.courses } func (g *Game) ValidateAndLog() { LogStartupIssues(g.ValidateStartup()) } func LogStartupIssues(issues []StartupIssue) { 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) } }