package game import ( "path/filepath" "strings" "testing" "thehouseoficarus/internal/item" "thehouseoficarus/internal/object" "thehouseoficarus/internal/validate" "thehouseoficarus/internal/world" ) // Load authored content without starting a server, migrating characters or // recovering the live economy journal. World-building tests must be read-only. func loadVesta(t *testing.T) (*Game, map[int]*world.Room, map[int]string) { t.Helper() dir := filepath.Join("..", "..", "data") g := &Game{Deps: Deps{ DataDir: dir, World: world.New(dir), MobStore: world.NewMobStore(dir), ObjectStore: object.NewObjectStore(dir), ItemStore: item.NewItemStore(dir), CourseStore: NewCourseStore(dir), }} rooms := map[int]*world.Room{} zones := map[int]string{} for id := range g.World.RoomIndex() { r, err := g.World.LoadRoom(id) if err != nil { t.Fatalf("room %d: %v", id, err) } rooms[id] = r path, _ := g.World.GetRoomPath(id) zones[id] = filepath.Base(filepath.Dir(path)) } return g, rooms, zones } func vestaCity(zone string) bool { switch zone { case "numisia", "brumalia", "tuccia", "domita", "casino": return true } return false } func safeVestaRooms(t *testing.T, g *Game, rooms map[int]*world.Room) map[int]bool { t.Helper() safe := map[int]bool{} for id, r := range rooms { safe[id] = r.Hazard == "" for _, spawn := range r.Mobs { mob, err := g.MobStore.LoadDef(spawn.ID) if err != nil { t.Fatal(err) } if mob.Combat != nil && mob.Combat.Aggressive { safe[id] = false if spawn.WanderInterval != 0 || len(spawn.WanderRooms) != 0 { t.Errorf("room %d: hostile %s must not wander onto safe routes", id, spawn.ID) } } } } return safe } func vestaWalk(rooms map[int]*world.Room, safe map[int]bool, start int) map[int]bool { seen := map[int]bool{start: true} queue := []int{start} for len(queue) > 0 { id := queue[0] queue = queue[1:] for _, exit := range rooms[id].Exits { if exit.Condition != nil || exit.Hidden || exit.AlwaysBlocked || !safe[exit.Room] || seen[exit.Room] { continue } seen[exit.Room] = true queue = append(queue, exit.Room) } } return seen } func TestVestaGeographyAndSafeTravel(t *testing.T) { g, rooms, zones := loadVesta(t) grid := world.BuildGrid(1001, func(id int) (*world.Room, bool) { r, ok := rooms[id] return r, ok }, nil, nil, func(conflict world.GridConflict) { t.Errorf("inconsistent world grid: %+v", conflict) }) if len(grid.Coord) != len(rooms) { t.Errorf("only %d/%d rooms connected to the retained introduction", len(grid.Coord), len(rooms)) } for _, id := range []int{1001, 1002, 1003} { if rooms[id] == nil { t.Fatalf("retained intro room %d missing", id) } } for id, zone := range zones { if zone == "intro" && (id < 1001 || id > 1003) || id >= 999991 { t.Errorf("placeholder room %d remains", id) } } // Map Y decreases northward. These relative positions are part of the brief. num, fields := grid.Coord[1104], grid.Coord[1306] brum, forest := grid.Coord[2104], grid.Coord[4100] tuccia, domita := grid.Coord[3104], grid.Coord[5104] if fields[1] >= num[1] || brum[0] <= num[0] || forest[0] >= num[0] || tuccia[1] <= forest[1] || domita[0] >= forest[0] || domita[1] >= forest[1] { t.Fatal("region no longer follows the requested city geography") } safe := safeVestaRooms(t, g, rooms) walkable := vestaWalk(rooms, safe, 1104) counts := map[string]int{} for id, zone := range zones { counts[zone]++ if vestaCity(zone) || zone == "roads" { if !safe[id] { t.Errorf("%s room %d is unsafe", zone, id) } // The house is the only ownership-gated city interior. if id != estateNeighborhoodID && rooms[id].Name != "Tuccia — Home Workshop" && !walkable[id] { t.Errorf("%s room %d requires a gate or dangerous detour", zone, id) } } } for _, zone := range []string{"numisia", "brumalia", "tuccia", "domita"} { if counts[zone] < 30 { t.Errorf("%s needs dozens of distinct rooms, got %d", zone, counts[zone]) } } for _, id := range []int{1306, 4100, 1201, 1207, 1212, 5207} { if !walkable[id] { t.Errorf("rural hub/resource destination %d has no safe all-level approach", id) } } for _, course := range g.CourseStore.AllCourses() { if !walkable[course.StartRoom] { t.Errorf("course %s has an unsafe or gated approach", course.ID) } } } func TestVestaEarlySkillsAndSeparateServices(t *testing.T) { g, rooms, zones := loadVesta(t) gathers := map[string]map[int]bool{} placed := map[string]bool{} stock := map[string]bool{} spawns := map[string]bool{} for id, r := range rooms { shops := 0 for _, spawn := range r.Mobs { spawns[spawn.ID] = true mob, err := g.MobStore.LoadDef(spawn.ID) if err != nil { t.Fatal(err) } if mob.Shop != nil { shops++ for _, entry := range mob.Shop.Items { stock[entry.ItemID] = true } } if c := mob.Combat; c != nil { s := c.Stats if c.AssassinLevel > 40 || s.Attack > 40 || s.Strength > 40 || s.Defense > 40 || s.Ranged > 40 || s.Science > 40 { t.Errorf("room %d: %s is beyond this starting region's combat scope", id, spawn.ID) } } if mob.Steal != nil && mob.Steal.Level > 40 { t.Errorf("room %d: %s is beyond early Thieving progression", id, spawn.ID) } } if shops > 1 { t.Errorf("room %d crams %d vendors into one shop", id, shops) } for _, ref := range r.Objects { if ref.Local != nil { continue } placed[ref.ID] = true obj, err := g.ObjectStore.Load(ref.ID) if err != nil { t.Fatal(err) } if obj.Gather == nil { continue } if vestaCity(zones[id]) { t.Errorf("resource %s belongs outside city room %d", ref.ID, id) } if gathers[obj.Gather.Skill] == nil { gathers[obj.Gather.Skill] = map[int]bool{} } for _, drop := range obj.Gather.Drops { gathers[obj.Gather.Skill][drop.Level] = true if drop.Level > 40 { t.Errorf("room %d: %s yields level %d resources in the starting region", id, ref.ID, drop.Level) } } } } for skill, levels := range map[string][]int{ "woodcutting": {1, 15, 30, 35}, "mining": {1, 15, 20, 30, 40}, "fishing": {1, 5, 20, 28, 35, 40}, "technology": {1, 10, 20, 30, 40}, } { for _, level := range levels { if !gathers[skill][level] { t.Errorf("missing %s training tier %d", skill, level) } } } for _, id := range []string{ "furnace", "anvil", "workbench", "spinning_wheel", "pottery_wheel", "pottery_oven", "cooking_range", "allotment_patch", "herb_patch", "flower_patch", "flax_bed", "sheep", "charging_station", "terminal_basic", "terminal_advanced", "terminal_secure", "scrap_pile", "solar_altar", "hydro_altar", "cosmic_altar", "eco_altar", "chaos_altar", "estate_directory", } { if !placed[id] { t.Errorf("missing reachable skill station %s", id) } } for _, id := range []string{ "bronze_axe", "bronze_pickaxe", "hammer", "knife", "matches", "small_fishing_net", "fishing_rod", "fly_fishing_rod", "feather", "fishing_bait", "lobster_pot", "harpoon", "saw", "shears", "needle", "thread", "chisel", "rake", "spade", "watering_can", "plant_cure", "vial", "eye_of_newt", "solar_identifier", "hydro_identifier", "eco_identifier", "cosmic_identifier", "chaos_identifier", "biojunk", "ecojunk", "lawjunk", "naturejunk", "salt", "acid_vial", "insulated_gloves", } { if !stock[id] { t.Errorf("no vendor supplies required skill input %s", id) } } for _, id := range []string{"apprentice_frame", "irrigation_frame", "harbor_winch", "survey_frame", "numisia_contracts", "tuccia_contracts", "domita_contracts"} { if !spawns[id] { t.Errorf("missing combat/Assassin activity %s", id) } } levels := map[int]bool{} for _, course := range g.CourseStore.AllCourses() { levels[course.RequiredLevel] = true if course.RequiredLevel > 40 { t.Errorf("%s exceeds the starting-region scope", course.ID) } for _, obstacle := range course.Obstacles { foundExit := false for _, ref := range rooms[obstacle.RoomID].Objects { if ref.Local != nil { continue } obj, _ := g.ObjectStore.Load(ref.ID) for _, trigger := range obj.OnUse { for _, step := range trigger.Steps { foundExit = foundExit || step.Teleport == course.StartRoom } } } if !foundExit { t.Errorf("%s obstacle %d has no level-independent escape", course.ID, obstacle.RoomID) } } } for _, level := range []int{1, 10, 20, 30, 40} { if !levels[level] { t.Errorf("missing Agility tier %d", level) } } } func TestVestaCasinoFloorsAndVariants(t *testing.T) { _, rooms, zones := loadVesta(t) grid := world.BuildGrid(6101, func(id int) (*world.Room, bool) { r, ok := rooms[id] return r, ok }, nil, nil, nil) games := map[string]int{} future := 0 soft17, surrender, singleDeck, huff, classic := false, false, false, false, false for id, r := range rooms { if zones[id] != "casino" { if len(r.CasinoTables)+len(r.CasinoMachines) > 0 { t.Errorf("unexpected casino game outside Domita's casino: %d", id) } continue } // The vestibule stays at street level (below the gaming floor); // every gaming room shares 6101's floor. if id != 6100 && grid.Coord[id][2] != grid.Coord[6101][2] { t.Errorf("casino room %d drifted off the gaming floor", id) } if strings.Contains(r.Name, "Future ") { future++ } count := len(r.CasinoTables) + len(r.CasinoMachines) if count > 1 { t.Errorf("room %d: games must have individual salons", id) } for _, table := range r.CasinoTables { games[string(table.Game)]++ if table.Game == "blackjack" { soft17 = soft17 || table.Blackjack.DealerHitSoft17 != nil && *table.Blackjack.DealerHitSoft17 surrender = surrender || table.Blackjack.Surrender != nil && *table.Blackjack.Surrender singleDeck = singleDeck || table.Blackjack.Decks == 1 } } for _, machine := range r.CasinoMachines { games[string(machine.Game)]++ huff = huff || machine.Variant == "huff_and_even_more_puff" classic = classic || machine.Variant == "" } } if games["blackjack"] != 5 || games["baccarat"] != 3 || games["roulette"] != 3 || games["craps"] != 7 || games["slots"] != 5 || !soft17 || !surrender || !singleDeck || !huff || !classic { t.Errorf("casino variant coverage lost: %v", games) } if future != 0 { t.Errorf("placeholder galleries remain: %d", future) } // One salon per game sits on the gaming street; each keeps its editions // within a couple of steps north or south, so no variant is ever far away. street := map[int]string{6101: "blackjack", 6127: "baccarat", 6182: "roulette", 6185: "craps", 6118: "slots"} for id, game := range street { r := rooms[id] found := false for _, table := range r.CasinoTables { found = found || string(table.Game) == game } for _, machine := range r.CasinoMachines { found = found || string(machine.Game) == game } if !found { t.Errorf("street room %d lost its %s salon", id, game) } } // Street links: the blackjack salon joins the vestibule by stairs and // the street eastward; the middle salons connect west and east; the // slots salon ends the street. for id, want := range map[int][]world.ExitDir{ 6101: {world.Down, world.East}, 6127: {world.West, world.East}, 6182: {world.West, world.East}, 6185: {world.West, world.East}, 6118: {world.West}, } { r := rooms[id] for _, dir := range want { exit, ok := r.Exits[dir] if !ok || rooms[exit.Room] == nil { t.Errorf("street room %d lost its %s link", id, dir) } } } if rooms[6100].Exits[world.Up].Room != 6101 || rooms[5111].Exits[world.North].Room != 6100 { t.Fatal("vestibule disconnected") } foundBank := false for _, ref := range rooms[6100].Objects { if ref.ID == "bank_booth" { foundBank = true } } if !foundBank { t.Fatal("casino lost its bank booth") } } func TestVestaTransportAndContractReferences(t *testing.T) { g, rooms, _ := loadVesta(t) // The older shipped-data test does not load modules. Explicitly include // them here, otherwise deleted transport destinations can silently escape CI. oldMods, oldByID := AllMods, modByID oldTechs, oldTechByID := AllTechs, techByID t.Cleanup(func() { AllMods, modByID = oldMods, oldByID AllTechs, techByID = oldTechs, oldTechByID }) if err := g.LoadMods(); err != nil { t.Fatal(err) } if err := g.LoadTechs(); err != nil { t.Fatal(err) } g.ValidationConfig.RootRooms = []int{1001} g.CourseStore.SetWorld(g.World) for _, issue := range validate.Run(g.validationSource()) { if issue.Level == "ERROR" { t.Errorf("%s", issue.Message) } } walkable := vestaWalk(rooms, safeVestaRooms(t, g, rooms), 1104) for _, mod := range AllMods { if mod.Category == ModTransport && !walkable[mod.Destination] { t.Errorf("transport %s points to an unsafe/unreachable destination %d", mod.ID, mod.Destination) } } spawns := map[string]bool{} for _, r := range rooms { for _, mob := range r.Mobs { spawns[mob.ID] = true } } for _, id := range []string{"numisia_contracts", "tuccia_contracts", "domita_contracts"} { mob, _ := g.MobStore.LoadDef(id) assignments := 0 for _, node := range mob.Talk.Nodes { for _, option := range node.Options { if option.Action == nil { continue } flags := option.Action.SetPlayerFlags target, _ := flags["assassin_task_mob"].(string) if target == "" { continue } assignments++ if !spawns[target] { t.Errorf("%s assigns unplaced target %s", id, target) } if flags["assassin_task_remaining"] != flags["assassin_task_total"] || flags["assassin_task_total"] == nil { t.Errorf("%s has an incomplete contract for %s", id, target) } } } if assignments == 0 { t.Errorf("%s offers no functioning contracts", id) } } }