package validate import ( "fmt" "sort" "strings" "thehouseoficarus/internal/behavior" "thehouseoficarus/internal/color" "thehouseoficarus/internal/combat" "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.Northeast, world.Northwest, world.Southeast, world.Southwest, world.Up, world.Down: default: issues = append(issues, Issue{ Level: "ERROR", Type: "reference", Message: fmt.Sprintf("Room %d: invalid exit direction %q (only n/s/e/w/ne/nw/se/sw/up/down supported)", id, dir), }) continue } if exit.Room > 0 { 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 i, it := range exit.OnTraverse { issues = append(issues, validateStepAction( fmt.Sprintf("Room %d: exit %q on_traverse[%d]", id, dir, i), it.Action, itemIDs, roomIndex, mobIDs)...) } } 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.Local != nil { collEntries = append(collEntries, roomObjEntry{ defKey: fmt.Sprintf("local:#%d", idx), displayName: world.NormalizeObjectName(robj.Local.Name), effDefID: robj.ID, }) issues = append(issues, validateLocalObject(id, robj, itemIDs, roomIndex, mobIDs)...) } 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 } // validateLocalObject checks a room-local object definition. Local 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 validateLocalObject(roomID int, robj world.RoomObject, itemIDs map[string]bool, roomIndex map[int]bool, mobIDs map[string]bool) []Issue { var issues []Issue def := robj.Local prefix := fmt.Sprintf("Room %d: local 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 local 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.Safespot != nil { bad = append(bad, "safespot") } if len(def.OnUse) > 0 { bad = append(bad, "on_use") } if def.Steal != nil { bad = append(bad, "steal") } if def.RemovalItem != "" { bad = append(bad, "removal_item") } if len(bad) > 0 { issues = append(issues, Issue{ Level: "ERROR", Type: "reference", Message: prefix + fmt.Sprintf(": local 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 len(def.OnLook) > 0 { issues = append(issues, validateOnLook(prefix+": on_look", def.OnLook, itemIDs, roomIndex, mobIDs)...) } 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, "local:#" for local 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. a local 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 (a local object's name matches another object's id)", roomID, defID), }) } } return issues } // validateMobAttackTypes enforces the mob attack_types spec: a non-empty list // containing exactly one melee type (stab/slash/crush) plus at most one each of // the optional ranged/science types, with no invalid or duplicate entries. func validateMobAttackTypes(id string, types []string) []Issue { var issues []Issue if len(types) == 0 { issues = append(issues, Issue{ Level: "ERROR", Type: "reference", Message: fmt.Sprintf("Mob %q: missing attack_types (must be a list with exactly one of stab/slash/crush, optionally plus ranged/science)", id), }) return issues } meleeCount := 0 seen := map[string]bool{} for _, t := range types { if !combat.IsValidAttackType(t) { issues = append(issues, Issue{ Level: "ERROR", Type: "reference", Message: fmt.Sprintf("Mob %q: invalid attack_types entry %q (must be stab/slash/crush/ranged/science)", id, t), }) continue } if seen[t] { issues = append(issues, Issue{ Level: "ERROR", Type: "reference", Message: fmt.Sprintf("Mob %q: duplicate attack_types entry %q", id, t), }) continue } seen[t] = true if combat.IsMeleeType(t) { meleeCount++ } } if meleeCount != 1 { issues = append(issues, Issue{ Level: "ERROR", Type: "reference", Message: fmt.Sprintf("Mob %q: attack_types must contain exactly one melee type (stab/slash/crush), found %d", id, meleeCount), }) } return issues } func validateMobs(s Source) []Issue { var issues []Issue itemIDs := s.Items.IDSet() dropIDs := dropTableIDSet(s.DataDir) mobIDs := s.Mobs.AllDefIDs() roomIndex := s.World.RoomIndex() 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.Combat == nil || def.Combat.Stats.HP <= 0) { hp := 0 if def.Combat != nil { hp = def.Combat.Stats.HP } 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, hp), }) } if def.Combat != nil && def.Combat.Kind != "" && def.Combat.Kind != "combat" && def.Combat.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.Combat.Kind), }) } if def.Combat != nil && def.Combat.Kind != "task" { issues = append(issues, validateMobAttackTypes(id, def.Combat.AttackTypes)...) } 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.Steal != nil { for _, d := range def.Steal.Drops { if d.ItemID != "" && !itemIDs[d.ItemID] { issues = append(issues, Issue{ Level: "ERROR", Type: "reference", Message: fmt.Sprintf("Mob %q: steal drop 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: steal drop table %q does not exist", id, d.Table), }) } } } if def.Combat != nil && def.Combat.FinishingBlow != "" && !itemIDs[def.Combat.FinishingBlow] { issues = append(issues, Issue{ Level: "ERROR", Type: "reference", Message: fmt.Sprintf("Mob %q: finishing_blow item %q does not exist", id, def.Combat.FinishingBlow), }) } if def.Shop != nil { if def.Shop.BuyPercentage < 0 || def.Shop.ChangePercentage < 0 { issues = append(issues, Issue{ Level: "ERROR", Type: "reference", Message: fmt.Sprintf("Mob %q: shop buy_percentage/change_percentage must not be negative", id), }) } seenShop := map[string]bool{} for _, si := range def.Shop.Items { if si.ItemID == "" { continue } if !itemIDs[si.ItemID] { issues = append(issues, Issue{ Level: "ERROR", Type: "reference", Message: fmt.Sprintf("Mob %q: shop item %q does not exist", id, si.ItemID), }) } if seenShop[si.ItemID] { issues = append(issues, Issue{ Level: "WARN", Type: "reference", Message: fmt.Sprintf("Mob %q: shop lists item %q more than once", id, si.ItemID), }) } seenShop[si.ItemID] = true if si.Stock < 0 { issues = append(issues, Issue{ Level: "ERROR", Type: "reference", Message: fmt.Sprintf("Mob %q: shop item %q has negative stock", id, si.ItemID), }) } } } for i, it := range def.OnKill { issues = append(issues, validateStepAction( fmt.Sprintf("Mob %q: on_kill[%d]", id, i), it.Action, itemIDs, roomIndex, mobIDs)...) } } return issues } func validateHazards(s Source) []Issue { var issues []Issue itemIDs := s.Items.IDSet() 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 != "" && !combat.IsValidAttackType(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.Steal != nil { for _, d := range obj.Steal.Drops { if d.ItemID != "" && !itemIDs[d.ItemID] { issues = append(issues, Issue{ Level: "ERROR", Type: "reference", Message: fmt.Sprintf("Object %q: steal drop 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("Object %q: steal drop table %q does not exist", id, d.Table), }) } } if obj.Steal.GuardMob != "" && !mobIDs[obj.Steal.GuardMob] { issues = append(issues, Issue{ Level: "ERROR", Type: "reference", Message: fmt.Sprintf("Object %q: steal.guard_mob %q does not exist", id, obj.Steal.GuardMob), }) } } for _, ui := range obj.OnUse { if ui.Item != "" && !itemIDs[ui.Item] { issues = append(issues, Issue{ Level: "ERROR", Type: "reference", Message: fmt.Sprintf("Object %q: on_use item %q does not exist", id, ui.Item), }) } if ui.Action != nil { issues = append(issues, validateStepAction(fmt.Sprintf("Object %q: on_use", id), ui.Action, itemIDs, roomIndex, mobIDs)...) } } 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 len(obj.OnLook) > 0 { issues = append(issues, validateOnLook(fmt.Sprintf("Object %q: on_look", id), obj.OnLook, itemIDs, roomIndex, mobIDs)...) } } 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.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] craftLabel := c.Type if c.Subtype != "" { craftLabel = c.Type + "/" + c.Subtype } for _, e := range c.Ingredients { for _, ingredientID := range e.Items { if ingredientID != "" && !itemIDs[ingredientID] { issues = append(issues, Issue{ Level: "ERROR", Type: "reference", Message: fmt.Sprintf("Item %q (craft: %s): consumes nonexistent item %q", item.ID, craftLabel, ingredientID), }) } } } 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, craftLabel, 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, craftLabel, 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 := range room.OnEnter { stepPrefix := fmt.Sprintf("Room %d: on_enter[%d]", id, si) issues = append(issues, validateStepAction(stepPrefix, &room.OnEnter[si], itemIDs, roomIndex, mobIDs)...) } } 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 := range trigger.Steps { stepPrefix := fmt.Sprintf("%s: step[%d]", prefix, si) issues = append(issues, validateStepAction(stepPrefix, &trigger.Steps[si], itemIDs, roomIndex, mobIDs)...) } } } 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 } // validateRoomGrid embeds the entire reachable world on a single 3D 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. // // All 10 exits (including Up/Down) participate in coordinate assignment: // horizontal exits shift (x,y); Up/Down shift z at the same (x,y). // 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() load := func(id int) (*world.Room, bool) { r, err := s.World.LoadRoom(id) if err != nil { return nil, false } return r, true } include := func(id int) bool { return roomIndex[id] } placed := make(map[int]bool) var seeds []int for _, r := range s.RootRooms { if roomIndex[r] { seeds = append(seeds, r) } } for _, origin := range seeds { if placed[origin] { continue } grid := world.BuildGrid(origin, load, include, nil, func(c world.GridConflict) { switch c.Kind { case "twist": issues = append(issues, Issue{ Level: "ERROR", Type: "integrity", Message: fmt.Sprintf( "Grid twist: room %d (via %d %s) maps to (%d,%d,%d) but was already placed at (%d,%d,%d) [origin %d]", c.Target, c.From, c.Dir, c.Want[0], c.Want[1], c.Want[2], c.Existing[0], c.Existing[1], c.Existing[2], origin), }) case "overlap": issues = append(issues, Issue{ Level: "ERROR", Type: "integrity", Message: fmt.Sprintf( "Grid overlap: room %d (via %d %s) wants (%d,%d,%d), already used by room %d [origin %d]", c.Target, c.From, c.Dir, c.Want[0], c.Want[1], c.Want[2], c.Occupier, origin), }) } }) for rid := range grid.Coord { placed[rid] = true } } onConf := func(origin int) func(c world.GridConflict) { return func(c world.GridConflict) { switch c.Kind { case "twist": issues = append(issues, Issue{ Level: "ERROR", Type: "integrity", Message: fmt.Sprintf( "Grid twist: room %d (via %d %s) maps to (%d,%d,%d) but was already placed at (%d,%d,%d) [origin %d]", c.Target, c.From, c.Dir, c.Want[0], c.Want[1], c.Want[2], c.Existing[0], c.Existing[1], c.Existing[2], origin), }) case "overlap": issues = append(issues, Issue{ Level: "ERROR", Type: "integrity", Message: fmt.Sprintf( "Grid overlap: room %d (via %d %s) wants (%d,%d,%d), already used by room %d [origin %d]", c.Target, c.From, c.Dir, c.Want[0], c.Want[1], c.Want[2], c.Occupier, origin), }) } } } for id := range roomIndex { if placed[id] { continue } grid := world.BuildGrid(id, load, include, nil, onConf(id)) for rid := range grid.Coord { placed[rid] = true } } 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), }) } if d.Tool != "" { if !toolInList(cfg.Tools, d.Tool) { issues = append(issues, Issue{ Level: "ERROR", Type: "reference", Message: fmt.Sprintf("%s: drop tool %q is not in the object's tools list", prefix, d.Tool), }) } } else if len(cfg.Tools) >= 2 { label := d.ItemID if label == "" { label = "" } issues = append(issues, Issue{ Level: "ERROR", Type: "config", Message: fmt.Sprintf("%s: drop %q has no tool; required because object has %d tools", prefix, label, len(cfg.Tools)), }) } } return issues } func toolInList(list []string, target string) bool { for _, t := range list { if t == target { return true } } return false } 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 validateExitReciprocity(s Source) []Issue { var issues []Issue roomIndex := s.World.RoomIndex() for srcID := range roomIndex { src, err := s.World.LoadRoom(srcID) if err != nil { continue } for dir, exit := range src.Exits { if exit.Room <= 0 || !roomIndex[exit.Room] { continue } if srcID >= exit.Room { continue } dst, err := s.World.LoadRoom(exit.Room) if err != nil { continue } expected := world.OppositeExit[dir] hasCorrect := false var returnDirs []string for retDir, retExit := range dst.Exits { if retExit.Room == srcID { if retDir == expected { hasCorrect = true break } returnDirs = append(returnDirs, string(retDir)) } } if !hasCorrect && len(returnDirs) > 0 { issues = append(issues, Issue{ Level: "WARN", Type: "integrity", Message: fmt.Sprintf( "Room %d: exit %q to room %d, but return exit(s) %s are not the expected opposite direction %q", srcID, dir, exit.Room, returnDirs, expected), }) } } } 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), }) } } return issues } // validateStepAction validates the universal effect superset used by on_use, // on_look, on_kill, on_enter steps, trigger steps, and exit // traversal. It covers the inline NodeAction fields plus spawn_mob/despawn_mob // (when mobIDs is non-nil). Returns the list of issues found. func validateStepAction(prefix string, step *behavior.StepAction, itemIDs map[string]bool, roomIndex map[int]bool, mobIDs map[string]bool) []Issue { var issues []Issue if step == nil { return issues } // Validate the embedded NodeAction subset. if step.GiveItem != "" && !itemIDs[step.GiveItem] { issues = append(issues, Issue{ Level: "ERROR", Type: "reference", Message: fmt.Sprintf("%s: give_item %q does not exist", prefix, 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", prefix, 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", prefix, step.Teleport), }) } } // Spawn mob: validate mob def + despawn_rooms (only if mobIDs populated — // a nil map means the caller doesn't track mob ids, e.g. local objects). if step.SpawnMob != nil && step.SpawnMob.ID != "" { if mobIDs != nil && !mobIDs[step.SpawnMob.ID] { issues = append(issues, Issue{ Level: "ERROR", Type: "reference", Message: fmt.Sprintf("%s: spawn_mob %q does not exist", prefix, 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", prefix, wr), }) } } } // Despawn mob. if step.DespawnMob != "" && mobIDs != nil && !mobIDs[step.DespawnMob] { issues = append(issues, Issue{ Level: "ERROR", Type: "reference", Message: fmt.Sprintf("%s: despawn_mob %q does not exist", prefix, step.DespawnMob), }) } return issues } // validateOnLook validates a list of on_look interactions (now a list, not a // single NodeAction). For each entry, validates the optional item_id (unused // on on_look but permitted), the action's effects, and skips (the entry's // condition is a runtime gate, not a static referential ref). func validateOnLook(prefix string, list []behavior.Interaction, itemIDs map[string]bool, roomIndex map[int]bool, mobIDs map[string]bool) []Issue { var issues []Issue for i, it := range list { entryPrefix := fmt.Sprintf("%s[%d]", prefix, i) if it.Item != "" && !itemIDs[it.Item] { issues = append(issues, Issue{ Level: "ERROR", Type: "reference", Message: fmt.Sprintf("%s: item_id %q does not exist", entryPrefix, it.Item), }) } issues = append(issues, validateStepAction(entryPrefix, it.Action, itemIDs, roomIndex, mobIDs)...) } return issues }