package validate import ( "fmt" "sort" "strings" "thehouseoficarus/internal/behavior" "thehouseoficarus/internal/color" "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), }) } if room.Color != "" && color.Parse(room.Color).Empty() { issues = append(issues, Issue{ Level: "WARN", Type: "reference", Message: fmt.Sprintf("Room %d: invalid color %q", id, room.Color), }) } 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), }) } } var collEntries []roomObjEntry for idx, robj := range room.Objects { if robj.Inline != nil { collEntries = append(collEntries, roomObjEntry{ defKey: fmt.Sprintf("inline:#%d", idx), displayName: world.NormalizeObjectName(robj.Inline.Name), effDefID: robj.ID, }) issues = append(issues, validateInlineObject(id, robj, itemIDs, roomIndex)...) } else 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), }) } else if robj.ID != "" { if def, err := s.Objects.Load(robj.ID); err == nil { collEntries = append(collEntries, roomObjEntry{ defKey: "file:" + robj.ID, displayName: world.NormalizeObjectName(def.Name), effDefID: 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), }) } } } issues = append(issues, validateRoomObjectCollisions(id, collEntries)...) 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 } // validateInlineObject checks a room-inline object definition. Inline objects // are restricted to the passive subset (name/aliases/color/hidden/ // inroom_description/description/on_look); interactable or stateful behavior // must be defined as a standalone object file instead. func validateInlineObject(roomID int, robj world.RoomObject, itemIDs map[string]bool, roomIndex map[int]bool) []Issue { var issues []Issue def := robj.Inline prefix := fmt.Sprintf("Room %d: inline object %q", roomID, robj.ID) if def.Name == "" { issues = append(issues, Issue{ Level: "ERROR", Type: "reference", Message: prefix + ": has no name", }) } if def.ID != "" { issues = append(issues, Issue{ Level: "WARN", Type: "reference", Message: prefix + ": `id:` is ignored on inline objects — identity derives from `name`", }) } var bad []string if def.Gather != nil { bad = append(bad, "gather") } if def.Talk != nil { bad = append(bad, "talk") } if def.Use != nil { bad = append(bad, "use") } if def.Safespot != nil { bad = append(bad, "safespot") } if len(def.UseInteractions) > 0 { bad = append(bad, "use_interactions") } if def.StealTable != "" || def.StealLevel != 0 || def.StealXP != 0 || def.StealSpeed != 0 { bad = append(bad, "steal") } if def.GuardMob != "" { bad = append(bad, "guard_mob") } if def.RemovalItem != "" { bad = append(bad, "removal_item") } if len(bad) > 0 { issues = append(issues, Issue{ Level: "ERROR", Type: "reference", Message: prefix + fmt.Sprintf(": inline objects may not define interactable behavior (%s) — define it as an object file instead", strings.Join(bad, ", ")), }) } if def.Color != "" && color.Parse(def.Color).Empty() { issues = append(issues, Issue{ Level: "WARN", Type: "reference", Message: prefix + fmt.Sprintf(": invalid color %q", def.Color), }) } if def.OnLook != nil { issues = append(issues, validateNodeAction(prefix+": on_look", def.OnLook, itemIDs, roomIndex)...) } return issues } // roomObjEntry is a single object slot in a room, reduced to what the per-room // collision check needs. defKey identifies the distinct definition behind the // slot ("file:" for references, "inline:#" for inline defs), so // repeated references to the same file object collapse to one definition. type roomObjEntry struct { defKey string displayName string // normalized display name ("" when unknown) effDefID string // effective ObjState DefID } // validateRoomObjectCollisions reports two per-room problems: distinct object // definitions that share a display name (a player typing the exact name could // not disambiguate) and distinct definitions that resolve to the same ObjState // DefID (their runtime instances would be conflated — e.g. an inline object // whose name matches a referenced file object's id). Repeated references to the // same file object share a defKey and are allowed (e.g. multiple copper_rock). func validateRoomObjectCollisions(roomID int, entries []roomObjEntry) []Issue { var issues []Issue byName := map[string]map[string]bool{} byDefID := map[string]map[string]bool{} for _, e := range entries { if e.displayName != "" { if byName[e.displayName] == nil { byName[e.displayName] = map[string]bool{} } byName[e.displayName][e.defKey] = true } if e.effDefID != "" { if byDefID[e.effDefID] == nil { byDefID[e.effDefID] = map[string]bool{} } byDefID[e.effDefID][e.defKey] = true } } reported := map[string]bool{} for name, defKeys := range byName { if len(defKeys) > 1 { issues = append(issues, Issue{ Level: "ERROR", Type: "duplicate", Message: fmt.Sprintf("Room %d: multiple distinct objects share the name %q (each object in a room must have a unique name)", roomID, name), }) reported[name] = true } } for defID, defKeys := range byDefID { if len(defKeys) > 1 && !reported[defID] { issues = append(issues, Issue{ Level: "ERROR", Type: "duplicate", Message: fmt.Sprintf("Room %d: object id collision %q (an inline object's name matches another object's id)", roomID, defID), }) } } 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 validateRoomEnterSteps(s Source) []Issue { var issues []Issue roomIndex := s.World.RoomIndex() itemIDs := s.Items.IDSet() mobIDs := s.Mobs.AllDefIDs() for id := range roomIndex { room, err := s.World.LoadRoom(id) if err != nil || len(room.OnEnter) == 0 { continue } for si, step := range room.OnEnter { stepPrefix := fmt.Sprintf("Room %d: on_enter[%d]", id, si) if step.SpawnMob != nil && step.SpawnMob.ID != "" { if _, ok := mobIDs[step.SpawnMob.ID]; !ok { issues = append(issues, Issue{ Level: "ERROR", Type: "reference", Message: fmt.Sprintf("%s: spawn_mob %q does not exist", stepPrefix, step.SpawnMob.ID), }) } for _, wr := range step.SpawnMob.DespawnRooms { if _, ok := roomIndex[wr]; !ok { issues = append(issues, Issue{ Level: "ERROR", Type: "reference", Message: fmt.Sprintf("%s: spawn_mob despawn_rooms references nonexistent room %d", stepPrefix, wr), }) } } } if step.GiveItem != "" && !itemIDs[step.GiveItem] { issues = append(issues, Issue{ Level: "ERROR", Type: "reference", Message: fmt.Sprintf("%s: give_item %q does not exist", stepPrefix, step.GiveItem), }) } if step.TakeItem != "" && !itemIDs[step.TakeItem] { issues = append(issues, Issue{ Level: "ERROR", Type: "reference", Message: fmt.Sprintf("%s: take_item %q does not exist", stepPrefix, step.TakeItem), }) } if step.Teleport > 0 { if _, ok := roomIndex[step.Teleport]; !ok { issues = append(issues, Issue{ Level: "ERROR", Type: "reference", Message: fmt.Sprintf("%s: teleport to nonexistent room %d", stepPrefix, step.Teleport), }) } } } } return issues } func validateRoomTriggers(s Source) []Issue { var issues []Issue roomIndex := s.World.RoomIndex() itemIDs := s.Items.IDSet() mobIDs := s.Mobs.AllDefIDs() for id := range roomIndex { room, err := s.World.LoadRoom(id) if err != nil || len(room.Triggers) == 0 { continue } for ti, trigger := range room.Triggers { prefix := fmt.Sprintf("Room %d: trigger[%d]", id, ti) for si, step := range trigger.Steps { stepPrefix := fmt.Sprintf("%s: step[%d]", prefix, si) if step.SpawnMob != nil && step.SpawnMob.ID != "" { if _, ok := mobIDs[step.SpawnMob.ID]; !ok { issues = append(issues, Issue{ Level: "ERROR", Type: "reference", Message: fmt.Sprintf("%s: spawn_mob %q does not exist", stepPrefix, step.SpawnMob.ID), }) } for _, wr := range step.SpawnMob.DespawnRooms { if _, ok := roomIndex[wr]; !ok { issues = append(issues, Issue{ Level: "ERROR", Type: "reference", Message: fmt.Sprintf("%s: spawn_mob despawn_rooms references nonexistent room %d", stepPrefix, wr), }) } } } if step.GiveItem != "" && !itemIDs[step.GiveItem] { issues = append(issues, Issue{ Level: "ERROR", Type: "reference", Message: fmt.Sprintf("%s: give_item %q does not exist", stepPrefix, step.GiveItem), }) } if step.TakeItem != "" && !itemIDs[step.TakeItem] { issues = append(issues, Issue{ Level: "ERROR", Type: "reference", Message: fmt.Sprintf("%s: take_item %q does not exist", stepPrefix, step.TakeItem), }) } if step.Teleport > 0 { if _, ok := roomIndex[step.Teleport]; !ok { issues = append(issues, Issue{ Level: "ERROR", Type: "reference", Message: fmt.Sprintf("%s: teleport to nonexistent room %d", stepPrefix, step.Teleport), }) } } } } } return issues } func validateRoomWiring(s Source) []Issue { var issues []Issue roomIndex := s.World.RoomIndex() sourceSet := make(map[int]bool) for _, src := range s.RootRooms { 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 } // gridDeltas maps the horizontal exits to their grid movement. Up/Down are // intentionally excluded: they connect separate horizontal planes rather than // moving within one. var gridDeltas = map[world.ExitDir][2]int{ world.North: {0, -1}, world.South: {0, 1}, world.East: {1, 0}, world.West: {-1, 0}, } // validateRoomGrid lays each reachable horizontal plane on a 2D grid starting // from the configured root rooms and reports when the exit layout cannot be // embedded without conflict: // - overlap: two distinct rooms land on the same grid cell. // - twist: one room is forced onto two different grid cells. // // Up/Down exits don't move on the grid; each leads to a new plane that is laid // out independently (fresh origin), so one root validates every reachable // floor. Exit conditions are ignored (geometry is independent of gating), and // exits to nonexistent rooms are skipped (covered by referential checks). func validateRoomGrid(s Source) []Issue { var issues []Issue roomIndex := s.World.RoomIndex() placed := make(map[int]bool) var seeds []int for _, r := range s.RootRooms { if roomIndex[r] { seeds = append(seeds, r) } } for len(seeds) > 0 { origin := seeds[0] seeds = seeds[1:] if placed[origin] { continue } coordOf := map[int][2]int{origin: {0, 0}} roomAt := map[[2]int]int{{0, 0}: origin} placed[origin] = true queue := []int{origin} for len(queue) > 0 { rid := queue[0] queue = queue[1:] room, err := s.World.LoadRoom(rid) if err != nil { continue } c := coordOf[rid] for _, dir := range world.ExitOrder { exit, ok := room.Exits[dir] if !ok || exit.Room <= 0 || !roomIndex[exit.Room] { continue } target := exit.Room if dir == world.Up || dir == world.Down { if !placed[target] { seeds = append(seeds, target) } continue } d := gridDeltas[dir] want := [2]int{c[0] + d[0], c[1] + d[1]} if existing, ok := coordOf[target]; ok { if existing != want { issues = append(issues, Issue{ Level: "ERROR", Type: "integrity", Message: fmt.Sprintf( "Grid twist: room %d (via %d %s) maps to grid (%d,%d) but was already placed at (%d,%d) [plane origin %d]", target, rid, dir, want[0], want[1], existing[0], existing[1], origin), }) } continue } if occupier, ok := roomAt[want]; ok && occupier != target { issues = append(issues, Issue{ Level: "ERROR", Type: "integrity", Message: fmt.Sprintf( "Grid overlap: room %d (via %d %s) wants grid (%d,%d), already used by room %d [plane origin %d]", target, rid, dir, want[0], want[1], occupier, origin), }) continue } coordOf[target] = want roomAt[want] = target placed[target] = true queue = append(queue, target) } } } 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.Goto != "" { if _, ok := cfg.Nodes[node.Goto]; !ok { issues = append(issues, Issue{ Level: "ERROR", Type: "reference", Message: fmt.Sprintf("%s: goto points to nonexistent node %q", nodePrefix, node.Goto), }) } } 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 }