From 8b656d1a218d9c759b81fe85fe3e4f7ed16112a4 Mon Sep 17 00:00:00 2001 From: dayfine Date: Mon, 9 Nov 2026 10:20:41 -0800 Subject: [PATCH 01/10] FRLG story drafts 00.29-00.31: Lavender to the Silph Scope Unverified authored-checkpoint drafts, outside the embedded StoryData set: the Lavender Mart, Route 8, the Underground Path, a Pidgey for Fly, and the Rocket Hideout to Giovanni. The README lists the whole 00.29-00.51 draft, its conventions, the user's open decisions and the leg check. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013VUubhHeYSMGSSDXZkKZss --- .../AutoStory/StoryData/Draft/README.md | 135 ++++++++++++++++++ .../StoryData/Draft/frlg-00.29.plan.json | 91 ++++++++++++ .../StoryData/Draft/frlg-00.30.plan.json | 86 +++++++++++ .../StoryData/Draft/frlg-00.31.plan.json | 105 ++++++++++++++ 4 files changed, 417 insertions(+) create mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/Draft/README.md create mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/Draft/frlg-00.29.plan.json create mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/Draft/frlg-00.30.plan.json create mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/Draft/frlg-00.31.plan.json diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/Draft/README.md b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/Draft/README.md new file mode 100644 index 0000000000..8046824952 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/Draft/README.md @@ -0,0 +1,135 @@ +# FRLG story drafts, 00.29 to the Hall of Fame + +**Every file here is an unverified draft.** None has run on a console, and the +story table doesn't load them: `pa_embed_text_files()` embeds only +`StoryData/*.json`, not this folder. The authored format has no status field, +so this README is where they are marked unverified. + +Each file is a `pokesim-authored-checkpoint/1` plan in the same format as +`../frlg-parcel-00.05.plan.json`. The order and the item stops follow PokeGuy's +LeafGreen run (PokemonSimulation `data/runs/pokeguy-leafgreen-15934/events.json` +and `external/pokeguy-notes.md`). They start where 00.28 (Rock Tunnel to +Lavender, on `frlg/stage8-rocktunnel`) is expected to end: at Lavender's nurse. + +| Id | Title | Events | +|---|---|---:| +| 00.29 | Lavender Mart, then west over Route 8: Gamer Rich, a Pidgey for Fly, the Underground Path to Celadon | 12 | +| 00.30 | Celadon Game Corner and the Rocket Hideout down to B4F: the poster guard, the Lift Key Grunt, the LIFT KEY and B3F's Rare Candy | 12 | +| 00.31 | Rocket Hideout: the lift to B4F, the two door guards, two Rare Candies, Blastoise, Giovanni and the SILPH SCOPE | 16 | +| 00.32 | Celadon: Department Store 5F battle items, HM02 Fly on Route 16 for the Pidgey, the Tea, then the Center | 20 | +| 00.32b | Celadon Mansion roof room: the Eevee (Gift RNG placeholder) | 4 | +| 00.33 | Fly to Lavender, the Center, Pokemon Tower 2F: the rival | 8 | +| 00.34 | Pokemon Tower 3F to 7F: Tammy, Jennifer, Emilia, the Marowak, three Grunts, Mr. Fuji | 21 | +| 00.35 | The POKE FLUTE, Fly to Celadon, Route 16's Snorlax, Cycling Road's Rare Candy and Max Elixir, Fuchsia | 16 | +| 00.36 | Safari Zone: a Full Restore, the GOLD TEETH, HM03 Surf from the Secret House | 12 | +| 00.37 | Fuchsia: Surf to the lead, Juggler Kirk, Koga, then HM04 Strength from the Warden | 14 | +| 00.38 | Fly to Pallet, Surf down Route 21 to Cinnabar, the Mansion: Elixir, TM14 Blizzard and the SECRET KEY | 15 | +| 00.38b | Cinnabar Lab: the Helix Fossil revived (Gift RNG placeholder) | 6 | +| 00.39 | Cinnabar Gym: the quiz doors, Blaine | 9 | +| 00.40 | Fly to Celadon: two Rare Candies, Blizzard, Celadon Gym: Tamia, Lori, Erika | 21 | +| 00.41 | East to Saffron: the TEA at the gate, the Fighting Dojo's Hitmonchan (Gift RNG placeholder), the Center | 13 | +| 00.42 | Silph Co.: the CARD KEY, the rival on 7F, the Lapras (Gift RNG placeholder) | 10 | +| 00.43 | Silph Co. 11F: the boardroom Grunt, Giovanni; then Sabrina | 13 | +| 00.44 | Fly to Viridian: Viridian Gym: Takashi, Warren, Giovanni | 14 | +| 00.45 | Route 22: the rival; Route 23's badge gates; Strength | 7 | +| 00.46 | Victory Road's boulders, Indigo Plateau: Full Restores, the nurse, the save | 8 | +| 00.47 | Lorelei | 4 | +| 00.48 | Bruno | 3 | +| 00.49 | Agatha | 3 | +| 00.50 | Lance | 3 | +| 00.51 | The Champion and the Hall of Fame | 4 | + +## Conventions + +- A `battle` event's `id` is the battle's id in PokemonSimulation + `docs/battles/pokeguy-trainers.json`, e.g. `route8-rich`, `koga`. Its strategy + belongs to the battle agent, not to these files. Two battles have no entry + there: `tower-marowak` (an `events.json` scripted fight) and + `dojo-karate-master` (not in PokeGuy's run). +- Shopping is a `mart ...` `game_action` with the quantities from + PokemonSimulation `docs/battles/pokeguy-conditions.md` bags and PokeGuy's + receipts: Lavender (4 Escape Ropes, 7 Super Repels), Celadon 5F (6 X Speed, + 21 X Special, 5 X Accuracy, 2 Guard Spec), Indigo Plateau (12 Full Restores). +- A pickup faces the ball and talks (A). Hidden items (Cycling Road's Rare Candy + and Max Elixir, the Mansion's Elixir) have no tile in the candidate maps. +- The story's shiny gifts are placeholders: a `party_room free=1` check, then a + `gift_rng ` handoff, for the Eevee (00.32b), the Omanyte from the Helix + Fossil (00.38b), the Hitmonchan (00.41) and the Lapras (00.42). The authored + format can't yet say `handoff`/`live_start` as 00.12/00.12b do in C++. +- New `game_action` variants that no executor has yet: `party_room`, + `gift_rng`, `use_item`, `fly`, `flee_wild`, `surf`, `strength_puzzle`, + `reenter_map`. The `mart` variants name Marts the Mart table doesn't list + yet (Lavender, Celadon 5F, Indigo Plateau). +- HMs and key items come in story order: LIFT KEY, SILPH SCOPE, HM02 Fly, TEA, + POKE FLUTE, HM03 Surf, GOLD TEETH, HM04 Strength, SECRET KEY, CARD KEY. + +## Decisions for the user + +- **A Pidgey for Fly.** PokeGuy flies everywhere from 1:03; the story party has + no flier. 00.29 catches a Pidgey in Route 7's grass (Route 8's grass is behind + two Cut trees). Without Fly, Cinnabar needs a long walk round to Pallet. +- **TM14 Blizzard over Mega Kick** (00.40) is a one-shot TM: it needs the team + plan first. +- **Strength over Water Pulse** (00.45) and **Surf over Water Gun** (00.37) + follow PokeGuy. +- **The Hitmonchan** needs the Dojo's Karate Master beaten, a fight PokeGuy + skipped. + +## Leg check + +Every `route` event was planned on PokemonSimulation `6c2f5c8` +(`data/games/frlg/maps/source-candidate`, plus `runtime` for the maps the +candidate set lacks) with `RuntimeWorld` and the shared `shortest_path()`, from +the previous event's pose. 74 legs plan; 36 are skipped, none fails. The +planner ignores trainer sight, Surf, Strength, spinner and quiz tiles, and holds +NPC wander boxes as walls. No map was edited. Where a leg crosses a map edge on +an open tile (Lavender to Route 8, Route 7 to Celadon), the executor should hold +it to a wall, because of the known off-by-one on unheld crossings. + +Two candidate maps look wrong; these are map questions for the user, not edits: +the Elite Four rooms' arrival points (e.g. Lorelei's `warp_0` (6, 12)) are +wall tiles, and Indigo Plateau's door glyph is at (11, 5) with its warp at +(11, 6). + +| Skipped leg | Why | +|---|---| +| 00.29/celadon_nurse | map not in source-candidate | +| 00.31/b2f_lift | B2F's lift corner is reached over arrow tiles the planner does not solve | +| 00.31/b4f_below_guards | RocketHideout_Elevator map not in source-candidate | +| 00.32/x_item_clerk | CeladonCity_DepartmentStore_Elevator map not in source-candidate | +| 00.32/route16_below_tree | CeladonCity_DepartmentStore_Elevator map not in source-candidate | +| 00.32/celadon_nurse | map not in source-candidate | +| 00.32b/eevee_ball | Condominiums 2F/3F/RoofRoom map not in source-candidate | +| 00.35/cycling_road_rare_candy | Route17 map not in source-candidate | +| 00.35/cycling_road_max_elixir | Route17 map not in source-candidate | +| 00.35/fuchsia_below_center | Route17 map not in source-candidate | +| 00.35/fuchsia_nurse | map not in source-candidate | +| 00.37/fuchsia_nurse | map not in source-candidate | +| 00.38/mansion_b1f | the statue-switch gates and the floor drop are not modeled | +| 00.38/hidden_elixir_spot | hidden item tiles are not in the candidate maps | +| 00.38/below_tm14 | the statue-switch gates are not modeled | +| 00.38/below_secret_key | the statue-switch gates are not modeled | +| 00.38/cinnabar_nurse | map not in source-candidate | +| 00.38b/fossil_scientist | CinnabarIsland_PokemonLab_* map not in source-candidate | +| 00.39/below_blaine | the quiz doors are not modeled | +| 00.40/celadon_nurse | map not in source-candidate | +| 00.41/karate_master | SaffronCity_Dojo map not in source-candidate | +| 00.41/hitmonchan_ball | SaffronCity_Dojo map not in source-candidate | +| 00.41/saffron_center_door | SaffronCity_Dojo map not in source-candidate | +| 00.41/saffron_nurse | map not in source-candidate | +| 00.43/below_sabrina | warp-tile room order not planned: check SaffronCity_Gym warps | +| 00.44/takashi_line | arrow tiles and trainer lines not planned | +| 00.44/warren_line | arrow tiles and trainer lines not planned | +| 00.44/below_gym_giovanni | start unknown | +| 00.45/victory_road_door | Surf legs between the gates are not planned | +| 00.46/indigo_door | Victory Road boulder states are not modeled | +| 00.46/indigo_clerk | the Plateau door (11, 6) does not lead in on the candidate maps (no door glyph under the credits NPCs) | +| 00.47/below_lorelei | E4 arrival: the room's arrival point is a wall tile in the candidate map (map question, not edited) | +| 00.48/below_bruno | E4 arrival: the room's arrival point is a wall tile in the candidate map (map question, not edited) | +| 00.49/below_agatha | E4 arrival: the room's arrival point is a wall tile in the candidate map (map question, not edited) | +| 00.50/below_lance | E4 arrival: the room's arrival point is a wall tile in the candidate map (map question, not edited) | +| 00.51/below_champion | E4 arrival: the room's arrival point is a wall tile in the candidate map (map question, not edited) | + +The native codec (`Pokemon::read_authored_checkpoint()`, built alone from the +story-data-tests sources) and PokemonSimulation's `read_authored_checkpoint()` +both read all 25 files. diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/Draft/frlg-00.29.plan.json b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/Draft/frlg-00.29.plan.json new file mode 100644 index 0000000000..135608d5d2 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/Draft/frlg-00.29.plan.json @@ -0,0 +1,91 @@ +{ + "format": "pokesim-authored-checkpoint/1", + "game": "frlg", + "checkpoint": { + "id": "00.29", + "title": "Lavender Mart, then west over Route 8: Gamer Rich, a Pidgey for Fly, the Underground Path to Celadon", + "start": {"certainty": "tile", "map": "LavenderTown_PokemonCenter_1F", "where": {"facing": "north", "kind": "tiles", "tiles": [[7, 4]]}}, + "events": [ + { + "id": "lavender_mart_clerk", + "kind": "route", + "target": {"certainty": "tile", "map": "LavenderTown_Mart", "where": {"facing": "west", "kind": "tiles", "tiles": [[4, 3]]}}, + "variant": "run", + "why": "out of the Center, east along row 6 and down to the Mart (20, 15), to its clerk" + }, + { + "id": "buy_ropes_and_super_repels", + "kind": "game_action", + "variant": "mart LavenderTown_Mart budget=5700 floor=9000 escape-rope=4 super-repel=7", + "why": "pokeguy 54:18: 4 Escape Ropes and 7 Super Repels" + }, + { + "id": "east_of_rich_line", + "kind": "route", + "target": {"certainty": "tile", "map": "Route8", "where": {"facing": "west", "kind": "tiles", "tiles": [[56, 18]]}}, + "variant": "run", + "why": "west out of Lavender onto Route 8, down column 64 east of Julia (62, 14) and west along row 18" + }, + { + "id": "rich_line", + "kind": "step", + "direction": "west", + "variant": "trainer_line", + "why": "onto (55, 18), the end of Rich's line (55, 16..18): he walks down to (55, 17)" + }, + { + "id": "route8-rich", + "kind": "battle", + "declared_after": {"certainty": "tile", "map": "Route8", "where": {"facing": "north", "kind": "tiles", "tiles": [[55, 18]]}}, + "why": "Gamer Rich (TRAINER_GAMER_RICH): Growlithe, Vulpix Lv24" + }, + { + "id": "below_underground_door", + "kind": "route", + "target": {"certainty": "tile", "map": "Route8", "where": {"facing": "north", "kind": "tiles", "tiles": [[13, 5]]}}, + "variant": "run", + "why": "west along rows 14..18, up column 14 east of Aidan's line (10..13, 6), to below the Underground Path door (13, 4)" + }, + { + "id": "route7_underground_exit", + "kind": "route", + "target": {"certainty": "tile", "map": "Route7", "where": {"facing": "south", "kind": "tiles", "tiles": [[7, 15]]}}, + "variant": "run", + "why": "through the door, the east entrance, the east-west tunnel and the west entrance, out onto Route 7" + }, + { + "id": "pidgey_grass", + "kind": "route", + "target": {"certainty": "tile", "map": "Route7", "where": {"facing": "east", "kind": "tiles", "tiles": [[14, 5]]}}, + "variant": "run", + "why": "north round the ledges into Route 7's grass (11..16, 2..5), a grass tile east of it free; Route 8's grass is behind two Cut trees" + }, + { + "id": "catch_pidgey", + "kind": "game_action", + "variant": "capture pidgey catches=1 encounters=30 balls=5 pace=east", + "why": "a Pidgey to carry HM02 Fly (pokeguy flies with a Pidgey from 1:03 on); the party has no other flier" + }, + { + "id": "celadon_below_center", + "kind": "route", + "target": {"certainty": "tile", "map": "CeladonCity", "where": {"facing": "north", "kind": "tiles", "tiles": [[48, 12]]}}, + "variant": "run", + "why": "north round the grass, west onto Celadon (row 12..15) and along row 12 to below the Center door (48, 11)" + }, + { + "id": "celadon_nurse", + "kind": "route", + "target": {"certainty": "tile", "map": "CeladonCity_PokemonCenter_1F", "where": {"facing": "north", "kind": "tiles", "tiles": [[7, 4]]}}, + "variant": "run", + "why": "into the Center, to the nurse" + }, + { + "id": "heal_celadon", + "kind": "heal", + "why": "the nurse heals the party" + } + ] + }, + "execution": {"menu": "full"} +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/Draft/frlg-00.30.plan.json b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/Draft/frlg-00.30.plan.json new file mode 100644 index 0000000000..0b256ee6be --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/Draft/frlg-00.30.plan.json @@ -0,0 +1,86 @@ +{ + "format": "pokesim-authored-checkpoint/1", + "game": "frlg", + "checkpoint": { + "id": "00.30", + "title": "Celadon Game Corner and the Rocket Hideout down to B4F: the poster guard, the Lift Key Grunt, the LIFT KEY and B3F's Rare Candy", + "start": {"certainty": "tile", "map": "CeladonCity_PokemonCenter_1F", "where": {"facing": "north", "kind": "tiles", "tiles": [[7, 4]]}}, + "events": [ + { + "id": "celadon_below_game_corner", + "kind": "route", + "target": {"certainty": "tile", "map": "CeladonCity", "where": {"facing": "north", "kind": "tiles", "tiles": [[34, 22]]}}, + "variant": "run", + "why": "out of the Center, west along row 12, down column 41..43 to row 22, west to below the Game Corner (34, 21)" + }, + { + "id": "below_poster_guard", + "kind": "route", + "target": {"certainty": "tile", "map": "CeladonCity_GameCorner", "where": {"facing": "north", "kind": "tiles", "tiles": [[11, 3]]}}, + "variant": "run", + "why": "into the Game Corner, up the slot rows to below the Grunt (11, 2) in front of the poster" + }, + { + "id": "talk_poster_guard", + "kind": "talk", + "why": "the Grunt guarding the poster" + }, + { + "id": "hideout-poster-guard", + "kind": "battle", + "declared_after": {"certainty": "tile", "map": "CeladonCity_GameCorner", "where": {"facing": "north", "kind": "tiles", "tiles": [[11, 3]]}}, + "why": "Rocket Grunt (TRAINER_TEAM_ROCKET_GRUNT_7): Raticate, Zubat Lv20; he runs off" + }, + { + "id": "poster_switch", + "kind": "talk", + "why": "the poster (11, 1): the switch opens the stairs (15, 2)" + }, + { + "id": "hideout_b4f_west", + "kind": "route", + "target": {"certainty": "tile", "map": "RocketHideout_B4F", "where": {"facing": "west", "kind": "tiles", "tiles": [[5, 3]]}}, + "variant": "run", + "why": "the stairs (15, 2) down to B1F, B1F to B2F (17, 2), B2F's arrow tiles to B3F (21, 2), B3F to B4F (15, 18), round to (5, 3), east of the tile below the Grunt (4, 2)" + }, + { + "id": "key_grunt_line", + "kind": "step", + "direction": "west", + "variant": "trainer_line", + "why": "onto (4, 3), in the Grunt's line (sight 1)" + }, + { + "id": "hideout-key-grunt", + "kind": "battle", + "declared_after": {"certainty": "tile", "map": "RocketHideout_B4F", "where": {"facing": "west", "kind": "tiles", "tiles": [[4, 3]]}}, + "why": "Rocket Grunt (TRAINER_TEAM_ROCKET_GRUNT_18): Koffing, Zubat Lv21; he drops the LIFT KEY" + }, + { + "id": "below_lift_key", + "kind": "route", + "target": {"certainty": "tile", "map": "RocketHideout_B4F", "where": {"facing": "north", "kind": "tiles", "tiles": [[3, 3]]}}, + "variant": "run", + "why": "a tile west, below the LIFT KEY (3, 2)" + }, + { + "id": "lift_key", + "kind": "talk", + "why": "pick up the LIFT KEY (pokeguy 57:33)" + }, + { + "id": "beside_b3f_rare_candy", + "kind": "route", + "target": {"certainty": "tile", "map": "RocketHideout_B3F", "where": {"facing": "north", "kind": "tiles", "tiles": [[12, 13]]}}, + "variant": "run", + "why": "back up the stairs to B3F, below the Rare Candy (12, 12) beside the arrow tiles" + }, + { + "id": "b3f_rare_candy", + "kind": "talk", + "why": "pick up the Rare Candy: two, with Route 9's" + } + ] + }, + "execution": {"menu": "full"} +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/Draft/frlg-00.31.plan.json b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/Draft/frlg-00.31.plan.json new file mode 100644 index 0000000000..e040f84d88 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/Draft/frlg-00.31.plan.json @@ -0,0 +1,105 @@ +{ + "format": "pokesim-authored-checkpoint/1", + "game": "frlg", + "checkpoint": { + "id": "00.31", + "title": "Rocket Hideout: the lift to B4F, the two door guards, two Rare Candies, Blastoise, Giovanni and the SILPH SCOPE", + "start": {"certainty": "tile", "map": "RocketHideout_B3F", "where": {"facing": "north", "kind": "tiles", "tiles": [[12, 13]]}}, + "events": [ + { + "id": "b2f_lift", + "kind": "route", + "target": {"certainty": "tile", "map": "RocketHideout_B2F", "where": {"facing": "north", "kind": "tiles", "tiles": [[28, 17]]}}, + "variant": "run", + "why": "up to B2F, along the arrow tiles to the lift (28..29, 16)" + }, + { + "id": "lift_to_b4f", + "kind": "scene", + "why": "the LIFT KEY works the lift: B4F" + }, + { + "id": "b4f_below_guards", + "kind": "route", + "target": {"certainty": "tile", "map": "RocketHideout_B4F", "where": {"facing": "north", "kind": "tiles", "tiles": [[17, 16]]}}, + "variant": "run", + "why": "out of the lift to below the two Grunts (16, 14) and (19, 14)" + }, + { + "id": "talk_left_guard", + "kind": "talk", + "why": "the left door guard" + }, + { + "id": "hideout-left-guard", + "kind": "battle", + "declared_after": {"certainty": "tile", "map": "RocketHideout_B4F", "where": {"facing": "north", "kind": "tiles", "tiles": [[17, 16]]}}, + "why": "Rocket Grunt (TRAINER_TEAM_ROCKET_GRUNT_16): Sandshrew, Sandslash, Ekans Lv23" + }, + { + "id": "talk_right_guard", + "kind": "talk", + "why": "the right door guard" + }, + { + "id": "hideout-right-guard", + "kind": "battle", + "declared_after": {"certainty": "tile", "map": "RocketHideout_B4F", "where": {"facing": "north", "kind": "tiles", "tiles": [[17, 16]]}}, + "why": "Rocket Grunt (TRAINER_TEAM_ROCKET_GRUNT_17): Ekans, Sandshrew, Arbok Lv23; the door opens" + }, + { + "id": "rare_candy_1", + "kind": "game_action", + "variant": "use_item rare-candy slot=1", + "why": "Rare Candy: Lv35 (pokeguy 1:00:25)" + }, + { + "id": "rare_candy_2", + "kind": "game_action", + "variant": "use_item rare-candy slot=1", + "why": "Rare Candy: Lv36, Wartortle evolves" + }, + { + "id": "evolve_blastoise", + "kind": "scene", + "why": "Wartortle evolves into Blastoise; do not press B" + }, + { + "id": "below_giovanni", + "kind": "route", + "target": {"certainty": "tile", "map": "RocketHideout_B4F", "where": {"facing": "north", "kind": "tiles", "tiles": [[19, 5]]}}, + "why": "through the door to below Giovanni (19, 4)" + }, + { + "id": "talk_giovanni", + "kind": "talk", + "why": "Giovanni" + }, + { + "id": "hideout-giovanni", + "kind": "battle", + "declared_after": {"certainty": "tile", "map": "RocketHideout_B4F", "where": {"facing": "north", "kind": "tiles", "tiles": [[19, 5]]}}, + "why": "Giovanni (TRAINER_BOSS_GIOVANNI): Onix, Rhyhorn, Kangaskhan" + }, + { + "id": "beside_silph_scope", + "kind": "route", + "target": {"certainty": "tile", "map": "RocketHideout_B4F", "where": {"facing": "west", "kind": "tiles", "tiles": [[21, 5]]}}, + "why": "round to east of the SILPH SCOPE (20, 5)" + }, + { + "id": "silph_scope", + "kind": "talk", + "why": "pick up the SILPH SCOPE" + }, + { + "id": "escape_rope_hideout", + "kind": "game_action", + "declared_after": {"certainty": "tile", "map": "CeladonCity", "where": {"facing": "south", "kind": "tiles", "tiles": [[34, 22]]}}, + "variant": "use_item escape-rope", + "why": "out to the Game Corner's door (pokeguy 1:02:10)" + } + ] + }, + "execution": {"menu": "full"} +} From 5a1c77026f5565a1943fee4a496db55199207acf Mon Sep 17 00:00:00 2001 From: dayfine Date: Mon, 9 Nov 2026 17:00:04 -0800 Subject: [PATCH 02/10] FRLG drafts: drop the 00.29 draft for #293's row; say why 00.30-00.31 aren't rows #293's 00.29 (Route 10 to Lavender's nurse) passed on the console, so the draft that reused the id is dropped. The README says what blocks 00.30 and 00.31 from becoming table rows: no Celadon or Rocket Hideout maps in the locked runtime pack, no row to Celadon's Center, no spinner or lift step. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013VUubhHeYSMGSSDXZkKZss --- .../AutoStory/StoryData/Draft/README.md | 29 ++++-- .../StoryData/Draft/frlg-00.29.plan.json | 91 ------------------- 2 files changed, 23 insertions(+), 97 deletions(-) delete mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/Draft/frlg-00.29.plan.json diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/Draft/README.md b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/Draft/README.md index 8046824952..bff9a09871 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/Draft/README.md +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/Draft/README.md @@ -1,4 +1,4 @@ -# FRLG story drafts, 00.29 to the Hall of Fame +# FRLG story drafts, 00.30 to the Hall of Fame **Every file here is an unverified draft.** None has run on a console, and the story table doesn't load them: `pa_embed_text_files()` embeds only @@ -8,12 +8,14 @@ so this README is where they are marked unverified. Each file is a `pokesim-authored-checkpoint/1` plan in the same format as `../frlg-parcel-00.05.plan.json`. The order and the item stops follow PokeGuy's LeafGreen run (PokemonSimulation `data/runs/pokeguy-leafgreen-15934/events.json` -and `external/pokeguy-notes.md`). They start where 00.28 (Rock Tunnel to -Lavender, on `frlg/stage8-rocktunnel`) is expected to end: at Lavender's nurse. +and `external/pokeguy-notes.md`). 00.28 and 00.29 are table rows on +`frlg/stage8-rocktunnel` (#293), not drafts: 00.29 ends at Lavender's nurse. +The draft that also used the id 00.29 (Lavender Mart, Route 8, the Underground +Path to Celadon's Center) was dropped in favour of #293's row, so nothing here +covers Lavender's nurse to Celadon's yet; its events are in commit 8b656d1a2. | Id | Title | Events | |---|---|---:| -| 00.29 | Lavender Mart, then west over Route 8: Gamer Rich, a Pidgey for Fly, the Underground Path to Celadon | 12 | | 00.30 | Celadon Game Corner and the Rocket Hideout down to B4F: the poster guard, the Lift Key Grunt, the LIFT KEY and B3F's Rare Candy | 12 | | 00.31 | Rocket Hideout: the lift to B4F, the two door guards, two Rare Candies, Blastoise, Giovanni and the SILPH SCOPE | 16 | | 00.32 | Celadon: Department Store 5F battle items, HM02 Fly on Route 16 for the Pidgey, the Tea, then the Center | 20 | @@ -39,6 +41,22 @@ Lavender, on `frlg/stage8-rocktunnel`) is expected to end: at Lavender's nurse. | 00.50 | Lance | 3 | | 00.51 | The Champion and the Hall of Fame | 4 | +## Why 00.30 and 00.31 are not table rows yet + +They can't run or be simulated on this branch's data: + +- The map resource lock (PokemonSimulation `6c2f5c8`, `runtime-manifest.json`) + has none of CeladonCity, CeladonCity_GameCorner or RocketHideout_B1F..B4F; + they exist only in `source-candidate`. CeladonCity_PokemonCenter_1F (00.30's + start) and RocketHideout_Elevator (00.31's lift) are in neither. Adding them + to the runtime pack is a PokemonSimulation publish and a lock bump. +- 00.30's start, Celadon's nurse, isn't reached by any row: 00.29 ends in + Lavender. +- The walker has no spinner (arrow) tiles, which B2F and B3F need, and no + step for the lift's floor menu or the poster switch. +- `STORY_TRAINERS` names none of the hideout's Grunts or Giovanni yet; + their parties are in `BattleData/`. + ## Conventions - A `battle` event's `id` is the battle's id in PokemonSimulation @@ -66,7 +84,7 @@ Lavender, on `frlg/stage8-rocktunnel`) is expected to end: at Lavender's nurse. ## Decisions for the user - **A Pidgey for Fly.** PokeGuy flies everywhere from 1:03; the story party has - no flier. 00.29 catches a Pidgey in Route 7's grass (Route 8's grass is behind + no flier. The dropped Lavender-to-Celadon draft caught a Pidgey in Route 7's grass (Route 8's grass is behind two Cut trees). Without Fly, Cinnabar needs a long walk round to Pallet. - **TM14 Blizzard over Mega Kick** (00.40) is a one-shot TM: it needs the team plan first. @@ -93,7 +111,6 @@ wall tiles, and Indigo Plateau's door glyph is at (11, 5) with its warp at | Skipped leg | Why | |---|---| -| 00.29/celadon_nurse | map not in source-candidate | | 00.31/b2f_lift | B2F's lift corner is reached over arrow tiles the planner does not solve | | 00.31/b4f_below_guards | RocketHideout_Elevator map not in source-candidate | | 00.32/x_item_clerk | CeladonCity_DepartmentStore_Elevator map not in source-candidate | diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/Draft/frlg-00.29.plan.json b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/Draft/frlg-00.29.plan.json deleted file mode 100644 index 135608d5d2..0000000000 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/Draft/frlg-00.29.plan.json +++ /dev/null @@ -1,91 +0,0 @@ -{ - "format": "pokesim-authored-checkpoint/1", - "game": "frlg", - "checkpoint": { - "id": "00.29", - "title": "Lavender Mart, then west over Route 8: Gamer Rich, a Pidgey for Fly, the Underground Path to Celadon", - "start": {"certainty": "tile", "map": "LavenderTown_PokemonCenter_1F", "where": {"facing": "north", "kind": "tiles", "tiles": [[7, 4]]}}, - "events": [ - { - "id": "lavender_mart_clerk", - "kind": "route", - "target": {"certainty": "tile", "map": "LavenderTown_Mart", "where": {"facing": "west", "kind": "tiles", "tiles": [[4, 3]]}}, - "variant": "run", - "why": "out of the Center, east along row 6 and down to the Mart (20, 15), to its clerk" - }, - { - "id": "buy_ropes_and_super_repels", - "kind": "game_action", - "variant": "mart LavenderTown_Mart budget=5700 floor=9000 escape-rope=4 super-repel=7", - "why": "pokeguy 54:18: 4 Escape Ropes and 7 Super Repels" - }, - { - "id": "east_of_rich_line", - "kind": "route", - "target": {"certainty": "tile", "map": "Route8", "where": {"facing": "west", "kind": "tiles", "tiles": [[56, 18]]}}, - "variant": "run", - "why": "west out of Lavender onto Route 8, down column 64 east of Julia (62, 14) and west along row 18" - }, - { - "id": "rich_line", - "kind": "step", - "direction": "west", - "variant": "trainer_line", - "why": "onto (55, 18), the end of Rich's line (55, 16..18): he walks down to (55, 17)" - }, - { - "id": "route8-rich", - "kind": "battle", - "declared_after": {"certainty": "tile", "map": "Route8", "where": {"facing": "north", "kind": "tiles", "tiles": [[55, 18]]}}, - "why": "Gamer Rich (TRAINER_GAMER_RICH): Growlithe, Vulpix Lv24" - }, - { - "id": "below_underground_door", - "kind": "route", - "target": {"certainty": "tile", "map": "Route8", "where": {"facing": "north", "kind": "tiles", "tiles": [[13, 5]]}}, - "variant": "run", - "why": "west along rows 14..18, up column 14 east of Aidan's line (10..13, 6), to below the Underground Path door (13, 4)" - }, - { - "id": "route7_underground_exit", - "kind": "route", - "target": {"certainty": "tile", "map": "Route7", "where": {"facing": "south", "kind": "tiles", "tiles": [[7, 15]]}}, - "variant": "run", - "why": "through the door, the east entrance, the east-west tunnel and the west entrance, out onto Route 7" - }, - { - "id": "pidgey_grass", - "kind": "route", - "target": {"certainty": "tile", "map": "Route7", "where": {"facing": "east", "kind": "tiles", "tiles": [[14, 5]]}}, - "variant": "run", - "why": "north round the ledges into Route 7's grass (11..16, 2..5), a grass tile east of it free; Route 8's grass is behind two Cut trees" - }, - { - "id": "catch_pidgey", - "kind": "game_action", - "variant": "capture pidgey catches=1 encounters=30 balls=5 pace=east", - "why": "a Pidgey to carry HM02 Fly (pokeguy flies with a Pidgey from 1:03 on); the party has no other flier" - }, - { - "id": "celadon_below_center", - "kind": "route", - "target": {"certainty": "tile", "map": "CeladonCity", "where": {"facing": "north", "kind": "tiles", "tiles": [[48, 12]]}}, - "variant": "run", - "why": "north round the grass, west onto Celadon (row 12..15) and along row 12 to below the Center door (48, 11)" - }, - { - "id": "celadon_nurse", - "kind": "route", - "target": {"certainty": "tile", "map": "CeladonCity_PokemonCenter_1F", "where": {"facing": "north", "kind": "tiles", "tiles": [[7, 4]]}}, - "variant": "run", - "why": "into the Center, to the nurse" - }, - { - "id": "heal_celadon", - "kind": "heal", - "why": "the nurse heals the party" - } - ] - }, - "execution": {"menu": "full"} -} From cf9400fb38c88d4d2b126ac6c4c46da781aebb58 Mon Sep 17 00:00:00 2001 From: dayfine Date: Mon, 9 Nov 2026 17:12:55 -0800 Subject: [PATCH 03/10] FRLG overworld: spinner tiles, the lift's floor and the poster switch on the sim Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013VUubhHeYSMGSSDXZkKZss --- .../Overworld/PokemonFRLG_MovementMechanics.h | 7 + .../Overworld/PokemonFRLG_OverworldMap.cpp | 62 ++++ .../Overworld/PokemonFRLG_OverworldMap.h | 31 ++ .../Overworld/PokemonFRLG_OverworldRoute.cpp | 45 ++- .../Overworld/PokemonFRLG_OverworldRoute.h | 4 + .../PokemonFRLG_OverworldRouteStrings.cpp | 3 + .../Overworld/PokemonFRLG_OverworldSim.cpp | 61 +++- .../Overworld/PokemonFRLG_OverworldSim.h | 24 ++ .../PokemonFRLG_OverworldSim_Tests.cpp | 1 + .../PokemonFRLG_OverworldSpin_Tests.cpp | 279 ++++++++++++++++++ .../Overworld/PokemonFRLG_OverworldWalk.cpp | 4 + SerialPrograms/cmake/SourceFiles.cmake | 1 + 12 files changed, 516 insertions(+), 6 deletions(-) create mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSpin_Tests.cpp diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_MovementMechanics.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_MovementMechanics.h index f7062858bc..0a20f37a7a 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_MovementMechanics.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_MovementMechanics.h @@ -62,6 +62,10 @@ inline constexpr Milliseconds STEP_TAP_SPACING_WORKED(666); // the D-pad this long. Not measured: pokefirered's fade from black is 16 // frames, then the field takes input; a tap sent at once was lost. inline constexpr Milliseconds BATTLE_EXIT_LOCKOUT(500); +// A spinner tile (Rocket Hideout, Viridian Gym) slides the player a tile +// per 8 frames (pokefirered PlayerGoSpin(): MOVE_SPEED_FAST_1). Not +// measured on the console. +inline constexpr Milliseconds SPIN_TILE_TIME(133); // The walker's presses. @@ -87,6 +91,9 @@ inline constexpr Milliseconds STEP_TAP_RELEASE(700); // How long the overworld must show nothing (no box, fade or battle) after // a battle before the next tap: twice the lockout. inline constexpr Milliseconds AFTER_BATTLE_QUIET(1000); +// The wait after a slide's last tile before the next press: the spin's +// last frames and the facing it leaves. Not measured. +inline constexpr Milliseconds SPIN_STOP_SETTLE(300); static_assert(TURN_TAP <= LONGEST_TAP && FACE_HOLD <= LONGEST_TAP && STEP_TAP <= LONGEST_TAP); static_assert(STEP_TAP + STEP_TAP_RELEASE > STEP_TAP_SPACING_WORKED); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMap.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMap.cpp index 03efa4db3f..fb2317c92a 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMap.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMap.cpp @@ -230,6 +230,68 @@ StepKind classify_step_onto( return StepKind::WALK; } +const char SPIN_NORTH_REGION[] = "spin_north"; +const char SPIN_SOUTH_REGION[] = "spin_south"; +const char SPIN_WEST_REGION[] = "spin_west"; +const char SPIN_EAST_REGION[] = "spin_east"; +const char SPIN_STOP_REGION[] = "spin_stop"; + +namespace{ + +bool in_region(const MapData& map, const char* region, const TilePos& point){ + const auto tiles = map.regions.find(region); + return tiles != map.regions.end() && std::find(tiles->second.begin(), tiles->second.end(), point) != tiles->second.end(); +} + +} // namespace + +std::optional spin_direction(const MapData& map, const TilePos& point){ + static const std::pair ARROWS[] = { + {SPIN_NORTH_REGION, Direction::NORTH}, + {SPIN_SOUTH_REGION, Direction::SOUTH}, + {SPIN_WEST_REGION, Direction::WEST}, + {SPIN_EAST_REGION, Direction::EAST}, + }; + for (const auto& [region, direction] : ARROWS){ + if (in_region(map, region, point)){ + return direction; + } + } + return std::nullopt; +} +bool stops_spinning(const MapData& map, const TilePos& point){ + return in_region(map, SPIN_STOP_REGION, point); +} + +std::optional spin_slide(const MapData& map, const PlayerState& state){ + std::optional arrow = spin_direction(map, state.position); + if (!arrow){ + return std::nullopt; + } + SpinSlide slide{state, *arrow, 0}; + // A slide longer than the map has tiles has gone round a loop. + const int max_tiles = map.tiles.width * map.tiles.height; + while (slide.tiles <= max_tiles){ + const StepKind kind = classify_step(map, slide.end, slide.facing); + if (kind != StepKind::WALK){ + return slide; + } + slide.end = after_step(map, slide.end, slide.facing, kind); + slide.tiles++; + if (warp_at(map, slide.end.position) != nullptr){ + return std::nullopt; + } + if (stops_spinning(map, slide.end.position)){ + return slide; + } + arrow = spin_direction(map, slide.end.position); + if (arrow){ + slide.facing = *arrow; + } + } + return std::nullopt; +} + PlayerState after_step(const MapData& map, const PlayerState& state, Direction direction, StepKind kind){ return after_step_onto(map, state, map, state.position.next(direction), kind); } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMap.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMap.h index 7e1935b643..e6d7490370 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMap.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMap.h @@ -160,6 +160,37 @@ PlayerState after_step_onto( ); +// Spinner tiles (Rocket Hideout B2F and B3F, Viridian Gym): pokefirered's +// MB_SPIN_UP, MB_SPIN_DOWN, MB_SPIN_LEFT, MB_SPIN_RIGHT and +// MB_STOP_SPINNING. The tile glyphs can't say so, so a map lists them in +// these regions, as it does WILD_REGION. +extern const char SPIN_NORTH_REGION[]; +extern const char SPIN_SOUTH_REGION[]; +extern const char SPIN_WEST_REGION[]; +extern const char SPIN_EAST_REGION[]; +extern const char SPIN_STOP_REGION[]; +// The way the arrow on this tile slides the player, if it is a spinner. +std::optional spin_direction(const MapData& map, const TilePos& point); +// Whether this tile ends a slide (MB_STOP_SPINNING). +bool stops_spinning(const MapData& map, const TilePos& point); + +// Where a slide leaves the player, the way it last moved them, and how +// many tiles it took. +struct SpinSlide{ + PlayerState end; + Direction facing = Direction::NORTH; + int tiles = 0; +}; +// The slide of a player who has just stepped onto the spinner `state` +// stands on (pokefirered TryUpdatePlayerSpinDirection()): a tile at a +// time the last arrow's way, turned by each arrow on the way, until a stop +// tile, or a step the step rules refuse (a wall, a person: the game's +// collision ends the forced movement there). Nothing if `state` isn't on a +// spinner, if the arrows turn the player round in a loop, or if the slide +// crosses a warp, which isn't modelled. +std::optional spin_slide(const MapData& map, const PlayerState& state); + + } // namespace PokemonFRLG } // namespace NintendoSwitch diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.cpp index 8556442dd0..cdea398515 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.cpp @@ -81,6 +81,9 @@ struct Transition{ bool warped = false; bool walked_out = false; // the warp ended with the game walking the player out bool dismounted = false; // stepped off the water onto land + // The step landed on a spinner, which slid the player on: how far, + // and the way they last moved. + std::optional spin; }; // The player taking `warp`, or nothing if it leads off the loaded maps. @@ -182,6 +185,13 @@ std::optional press(const std::vector& maps, const Located& if (transition.action == RouteAction::WALK && warps_on_step(map, landed) && warp_at(map, landed) != nullptr){ return take_warp(maps, warp_at(map, landed)); } + if (transition.action == RouteAction::WALK && spin_direction(map, landed)){ + transition.spin = spin_slide(map, transition.next.state); + if (!transition.spin){ + return std::nullopt; + } + transition.next.state = transition.spin->end; + } return transition; } @@ -268,9 +278,14 @@ class StepModel{ step.action = transition->action == RouteAction::SURF ? ACTION_SURF : 0; // Leaving the water: the player walks at half the speed from here. step.starts_leg = transition->dismounted; - step.ends_leg = transition->warped || transition->dismounted; + // A slide ends the leg: the hold must let go on the spinner. + step.ends_leg = transition->warped || transition->dismounted || transition->spin; step.warped = transition->warped; step.facing = transition->walked_out ? Direction::SOUTH : direction; + if (transition->spin){ + step.facing = transition->spin->facing; + step.extra_cost += transition->spin->tiles * route_cost(m_costs, RouteCost::TILE); + } if (transition->action == RouteAction::SURF){ step.extra_cost += route_cost(m_costs, RouteCost::SURF); } @@ -306,6 +321,24 @@ Pokemon::LegCosts to_leg_costs(const RouteCosts& costs){ return leg_costs; } +// The tiles the slide at the end of WALK `leg` takes, if it ends on a +// spinner: only a leg's last step can slide (StepModel ends the leg). +std::optional last_spin_tiles(const std::vector& maps, const Pokemon::Leg& leg){ + Located player = leg.start; + std::optional transition; + for (int c = 0; c < leg.tiles; c++){ + transition = press(maps, player, leg.direction); + if (!transition){ + return std::nullopt; + } + player = transition->next; + } + if (!transition || !transition->spin){ + return std::nullopt; + } + return transition->spin->tiles; +} + RouteStep to_route_step(const std::vector& maps, const Pokemon::Leg& leg){ RouteStep step; switch (leg.kind){ @@ -333,6 +366,10 @@ RouteStep to_route_step(const std::vector& maps, const Pokemon::Leg tiles = leg_tiles(maps, MapPosition{maps[leg.start.map].name, leg.start.state.position}, step); for (int c : std::views::iota(1, (int)tiles.size()) | std::views::filter([&](int n){ return tiles[n].map != tiles[n - 1].map; })){ step.connection_steps.push_back(c); @@ -361,7 +398,7 @@ std::optional predict_overworld_press( .position = {maps[next->next.map].name, next->next.state.position}, .elevation = next->next.state.elevation, .surfing = next->next.state.surfing, - .facing = next->walked_out ? Direction::SOUTH : direction, + .facing = next->spin ? next->spin->facing : next->walked_out ? Direction::SOUTH : direction, }, .warped = next->warped, }; @@ -424,7 +461,7 @@ std::optional locate(const std::vector& maps, const MapPositio // A plain step: no warp, no Surf, no getting off the water. std::optional plain_step(const std::vector& maps, const Located& player, Direction direction){ const std::optional transition = press(maps, player, direction); - if (!transition || transition->warped || transition->dismounted || transition->action != RouteAction::WALK){ + if (!transition || transition->warped || transition->dismounted || transition->spin || transition->action != RouteAction::WALK){ return std::nullopt; } return transition->next; @@ -473,7 +510,7 @@ std::vector leg_tiles( ){ std::vector tiles{start}; std::optional player = locate(maps, start, step.surfing); - const int along = step.tiles - (step.warps ? 1 : 0); + const int along = step.tiles - (step.warps || step.spin_tiles ? 1 : 0); for (int c = 0; player && c < along; c++){ player = plain_step(maps, *player, step.direction); if (player){ diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.h index 82a9d65676..2d5f45f8e4 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.h @@ -76,6 +76,10 @@ struct RouteStep{ // WALK: a wall stops the player at the end, so the leg can be held for // longer than it takes and still ends on its tile. bool to_wall = false; + // WALK: the last tile is a spinner, which slides the player this many + // tiles on, to `end` (spin_slide()). The hold must end on the spinner: + // held on, the player walks on after the slide. + std::optional spin_tiles; // WALK, SURF: a wild Pokemon can appear on the way: the step crosses // tall grass or water. Elsewhere (indoors, streets) no wild battle can // start, so the walker doesn't watch for one. diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRouteStrings.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRouteStrings.cpp index 4c082f2c76..8b7175b066 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRouteStrings.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRouteStrings.cpp @@ -21,6 +21,9 @@ std::string to_string(const RouteStep& step){ if (step.to_wall){ text += " (to wall)"; } + if (step.spin_tiles){ + text += std::format(" (spins {})", *step.spin_tiles); + } break; case RouteAction::SURF: text = "surf " + std::string(direction_name(step.direction)); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp index e8625e13b3..0b9483bf2c 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp @@ -243,7 +243,19 @@ std::optional OverworldSim::watch_until(const std::vector& std::optional OverworldSim::take_warp(int map_index, const TilePos& tile) const{ const MapWarp* warp = warp_at(m_maps[map_index], tile); - if (warp == nullptr || !warp->to){ + if (warp == nullptr){ + return std::nullopt; + } + const auto lift = m_script.lift_floors.find(m_maps[map_index].name); + if (lift != m_script.lift_floors.end() && m_lift_floor){ + const MapPosition& out = lift->second[*m_lift_floor]; + const int destination = find_map(m_maps, out.map); + if (destination < 0){ + return std::nullopt; + } + return Step{destination, PlayerState{out.point, elevation_at(m_maps[destination], out.point), false}, true, false, false}; + } + if (!warp->to){ return std::nullopt; } const int destination = find_map(m_maps, warp->to->map); @@ -309,10 +321,17 @@ std::optional OverworldSim::press(Direction direction) const if (kind != StepKind::WALK){ return std::nullopt; } - const Step step{m_map, after_step_onto(map(), m_player, map(), ahead, kind), false, false, false}; + Step step{m_map, after_step_onto(map(), m_player, map(), ahead, kind), false, false, false}; if (warps_on_step(map(), ahead)){ return take_warp(m_map, ahead); } + if (spin_direction(map(), ahead)){ + step.spin = spin_slide(map(), step.player); + if (!step.spin){ + return std::nullopt; + } + step.player = step.spin->end; + } return step; } @@ -491,6 +510,13 @@ void OverworldSim::take_steps(Direction direction, Milliseconds pressed, Millise if (step->walked_out){ m_state.facing = Direction::SOUTH; } + if (step->spin){ + // The game moves the player through the slide; the hold, if + // it goes on, walks on from its end. + m_state.facing = step->spin->facing; + note(std::format("slid {} tile(s) to {}.", step->spin->tiles, to_string(step->spin->end.position))); + pressed += MovementMechanics::SPIN_TILE_TIME * step->spin->tiles; + } if (after_step(*step, pressed)){ return; } @@ -669,6 +695,34 @@ bool OverworldSim::cut_tree(){ return false; } +bool OverworldSim::choose_lift_floor(size_t floor){ + const auto lift = m_script.lift_floors.find(map().name); + if (lift == m_script.lift_floors.end() || floor >= lift->second.size() || m_frozen){ + violation("no lift panel on " + to_string(m_state.position) + " with a floor " + std::to_string(floor) + "."); + return false; + } + m_lift_floor = floor; + advance_to(m_now + m_script.timing.message); + note("the lift goes to " + to_string(lift->second[floor]) + "."); + return true; +} + +bool OverworldSim::push_poster_switch(){ + std::vector& objects = m_maps[m_map].objects; + const TilePos ahead = m_player.position.next(m_state.facing); + const auto poster = std::find_if(objects.begin(), objects.end(), [&](const MapObject& object){ + return object.name == "poster" && object.x == ahead.x && object.y == ahead.y; + }); + if (poster == objects.end() || m_state.screen != SimScreen::OVERWORLD || m_frozen){ + violation("no poster " + std::string(direction_name(m_state.facing)) + " of " + to_string(m_state.position) + "."); + return false; + } + std::erase_if(objects, [](const MapObject& object){ return object.name == "poster_stairs"; }); + advance_to(m_now + m_script.timing.message); + note("pushed the switch behind the poster: the stairs are open."); + return true; +} + // @@ -846,6 +900,9 @@ std::optional OverworldSimWalker::walk_step(const RouteStep& step, if (result.stopped){ return dark_stop(step, walked_near(step, result.tiles, TILE_TIME)); } + if (step.spin_tiles){ + m_sim.watch({}, SPIN_TILE_TIME * *step.spin_tiles + SPIN_STOP_SETTLE); + } const Milliseconds fade_watched_from = m_rules.watch_fade_along_hold ? m_leg_start : m_sim.state().time; if (step.warps && !after_warp(step, warps_before, fade_watched_from)){ if (!m_rules.find_player_after_warp_battle){ diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h index 21038720d4..4eb0884abc 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h @@ -137,6 +137,11 @@ struct SimScript{ // check and screen observer read as a battle on its way (console B, // 2026-11-09, 00.28), unless SimWalkerRules::dark_cave_not_battle. std::set dark_maps; + // A lift's map, and where its door lets the player out for each floor + // on its panel's menu, top first (pokefirered setdynamicwarp in + // RocketHideout_Elevator's scripts): the door's own warp is the + // game's placeholder. + std::map> lift_floors = {}; SimTiming timing; }; @@ -255,6 +260,18 @@ class OverworldSim{ // as its hide flag is a FLAG_TEMP. Returns whether there was one. bool cut_tree(); + // A on the panel of the lift the player stands in (SimScript:: + // lift_floors) and floor `floor` picked: the lift's door now lets out + // there. Returns whether there was such a lift and floor. + bool choose_lift_floor(size_t floor); + + // A on the poster the player faces (an object named "poster"): the + // switch behind it opens the stairs, which takes away the objects + // named "poster_stairs" for good (pokefirered + // CeladonCity_GameCorner_EventScript_Poster's setmetatile). Returns + // whether there was a poster. + bool push_poster_switch(); + private: // A screen that shows at `at`; `unfreeze`: the player can move again. struct Pending{ @@ -269,6 +286,8 @@ class OverworldSim{ bool warped = false; bool walked_out = false; // the warp ended with the game walking the player a tile south bool ledge = false; + // The step landed on a spinner, which slid the player to `player`. + std::optional spin = {}; }; void advance_to(Milliseconds time); @@ -323,6 +342,8 @@ class OverworldSim{ std::optional m_trainer; // The trees cut since the last warp, and their maps. std::vector> m_cut_trees; + // The floor picked on the lift's panel, if any. + std::optional m_lift_floor; int m_grass_steps = 0; // The game's wild encounter state (SimScript::encounter_rates). std::mt19937 m_encounter_rng; @@ -515,6 +536,9 @@ class OverworldSimWalker{ void add_tests_OverworldSim(UnitTestDatabase& database); // Register the tests of a warp's fade over before the hold onto it ends. void add_tests_OverworldWarpFade(UnitTestDatabase& database); +// Register the tests of spinner tiles, the lift's panel and the poster +// switch on made-up maps. +void add_tests_OverworldSpin(UnitTestDatabase& database); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim_Tests.cpp index 7b7fb39c46..ce0f2054ae 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim_Tests.cpp @@ -986,6 +986,7 @@ void add_tests_OverworldSim(UnitTestDatabase& database){ } add_tests_OverworldLateBattle(database); add_tests_OverworldWarpFade(database); + add_tests_OverworldSpin(database); } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSpin_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSpin_Tests.cpp new file mode 100644 index 0000000000..afabcff1dd --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSpin_Tests.cpp @@ -0,0 +1,279 @@ +/* Overworld Spinner, Lift and Poster Tests + * + * From: https://github.com/PokemonAutomation/ + * + * The Rocket Hideout's walking on small made-up maps: spinner tiles that + * slide the player (the map functions, the planner, the sim and its + * walker), the lift's floor panel and the Game Corner's poster switch + * on the sim. + * + */ + +#include +#include +#include +#include +#include "Common/Cpp/Exceptions.h" +#include "Common/Cpp/TestRunners/UnitTest.h" +#include "PokemonFRLG_MovementMechanics.h" +#include "PokemonFRLG_OverworldMaps.h" +#include "PokemonFRLG_OverworldRecovery.h" +#include "PokemonFRLG_OverworldRoute.h" +#include "PokemonFRLG_OverworldSim.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +namespace{ + +class Test_OverworldSpin : public UnitTest{ +public: + Test_OverworldSpin(std::string name, std::function body) + : UnitTest("PokemonFRLG::OverworldSpin - " + std::move(name)) + , m_body(std::move(body)) + {} + virtual UnitTestResult run(Logger&, CancellableScope&) const override{ + try{ + return m_body(); + }catch (Exception& e){ + return std::string(e.name()) + ": " + e.message(); + } + } + +private: + std::function m_body; +}; + +// A room with spinners: +// - (3, 3) north, up a dead end to (3, 1) against the wall; (3, 2) is only +// ever slid over; +// - (5, 4) east onto (6, 4), which turns the slide south to (6, 5); +// - (7, 3) west, onto the stop tile (5, 3). +const MapData& spinner_room(){ + static const MapData MAP = parse_map(R"({ + "name": "Spinners", "source": {"decomp_file": "", "revision": "test", "date": "2026-11-09"}, + "allow_running": false, + "tiles": ["#########", "###.#####", "###.#####", "#.......#", "#.......#", "#.......#", "#########"], + "regions": { + "spin_north": [[3, 3]], "spin_east": [[5, 4]], "spin_south": [[6, 4]], + "spin_west": [[7, 3]], "spin_stop": [[5, 3]] + } + })", "Spinners"); + return MAP; +} + +// Two arrows that face each other. +const MapData& spinner_loop(){ + static const MapData MAP = parse_map(R"({ + "name": "SpinLoop", "source": {"decomp_file": "", "revision": "test", "date": "2026-11-09"}, + "tiles": ["####", "#..#", "####"], + "regions": {"spin_east": [[1, 1]], "spin_west": [[2, 1]]} + })", "SpinLoop"); + return MAP; +} + +std::string describe(const std::vector& route){ + std::string text; + for (const RouteStep& step : route){ + text += "\n " + to_string(step); + } + return text; +} + +UnitTestResult check_slide(const TilePos& from, const TilePos& end, Direction facing, int tiles){ + const std::optional slide = spin_slide(spinner_room(), PlayerState{from, ELEVATION_GROUND, false}); + if (!slide){ + return "No slide from " + to_string(from) + "."; + } + if (!(slide->end.position == end) || slide->facing != facing || slide->tiles != tiles){ + return std::format( + "From {}: slid {} tile(s) to {} facing {}; expected {} to {} facing {}.", + to_string(from), slide->tiles, to_string(slide->end.position), direction_name(slide->facing), + tiles, to_string(end), direction_name(facing) + ); + } + return true; +} + +UnitTestResult test_slide_into_wall(){ + return check_slide({3, 3}, {3, 1}, Direction::NORTH, 2); +} +UnitTestResult test_slide_turned_by_arrow(){ + return check_slide({5, 4}, {6, 5}, Direction::SOUTH, 2); +} +UnitTestResult test_slide_to_stop_tile(){ + return check_slide({7, 3}, {5, 3}, Direction::WEST, 2); +} +UnitTestResult test_slide_loop(){ + if (spin_slide(spinner_loop(), PlayerState{{1, 1}, ELEVATION_GROUND, false})){ + return "A slide round two arrows that face each other stopped."; + } + if (spin_slide(spinner_room(), PlayerState{{1, 3}, ELEVATION_GROUND, false})){ + return "A slide from a tile with no arrow."; + } + return true; +} + +const MapPosition SPIN_START{"Spinners", {1, 5}}; +const MapPosition DEAD_END{"Spinners", {3, 1}}; + +UnitTestResult test_route_ends_on_slide(){ + const std::vector maps{spinner_room()}; + const auto route = plan_overworld_route(maps, SPIN_START, RouteGoal{DEAD_END, std::nullopt}); + if (!route || route->empty()){ + return std::string("No route to the dead end, which only a slide reaches."); + } + const RouteStep& last = route->back(); + if (last.spin_tiles != 2 || last.to_wall || !(last.end == DEAD_END)){ + return "Expected a last leg onto (3, 3) that slides 2 tiles:" + describe(*route); + } + const MapPosition end = follow_overworld_route(maps, SPIN_START, *route); + if (!(end == DEAD_END)){ + return "The route replays to " + to_string(end) + ":" + describe(*route); + } + return true; +} + +UnitTestResult test_no_stop_mid_slide(){ + const std::vector maps{spinner_room()}; + const auto route = plan_overworld_route(maps, SPIN_START, RouteGoal{MapPosition{"Spinners", {3, 2}}, std::nullopt}); + if (route){ + return "Planned a stop on (3, 2), which a slide only passes:" + describe(*route); + } + return true; +} + +UnitTestResult test_sim_slides(){ + OverworldSim sim({spinner_room()}, MapPosition{"Spinners", {3, 4}}, Direction::NORTH); + sim.hold(Direction::NORTH, MovementMechanics::TILE_TIME, MovementMechanics::STEP_RELEASE, MovementMechanics::TILE_TIME, {}); + if (!(sim.state().position == DEAD_END)){ + return "One step north onto (3, 3) ended on " + to_string(sim.state().position) + "."; + } + if (sim.state().facing != Direction::NORTH){ + return std::string("The slide left the player facing ") + direction_name(sim.state().facing) + "."; + } + return true; +} + +UnitTestResult test_sim_walker_walks_slide(){ + const std::vector maps{spinner_room()}; + const auto route = plan_overworld_route(maps, SPIN_START, RouteGoal{DEAD_END, std::nullopt}); + if (!route){ + return std::string("No route."); + } + OverworldSim sim(maps, SPIN_START, Direction::NORTH); + OverworldSimWalker walker(sim); + const RoutePlanning planning{.maps = &maps, .costs = default_route_costs(), .start = SPIN_START, .label = "Sim"}; + const std::string failure = walker.run(*route, planning); + if (!failure.empty() || !(sim.state().position == DEAD_END)){ + std::string log; + for (const std::string& line : walker.log()){ + log += "\n " + line; + } + return "Walked to " + to_string(sim.state().position) + ", failure \"" + failure + "\":" + describe(*route) + log; + } + return true; +} + + +// A lift whose door, the exit mat (1, 2), lets out on the floor picked. +std::vector lift_maps(){ + const char* FLOOR = R"({ + "name": "%s", "source": {"decomp_file": "", "revision": "test", "date": "2026-11-09"}, + "tiles": ["###", "#.#", "###"] + })"; + std::vector maps; + maps.push_back(parse_map(R"({ + "name": "Lift", "source": {"decomp_file": "", "revision": "test", "date": "2026-11-09"}, + "tiles": ["###", "#.#", "#.#", "###"], + "warps": [{"x": 1, "y": 2, "enter": "walk_south"}] + })", "Lift")); + for (const char* name : {"FloorA", "FloorB"}){ + std::string json = FLOOR; + json.replace(json.find("%s"), 2, name); + maps.push_back(parse_map(json, name)); + } + return maps; +} + +UnitTestResult lift_ride(bool pick, const MapPosition& expected){ + SimScript script; + script.lift_floors["Lift"] = {MapPosition{"FloorA", {1, 1}}, MapPosition{"FloorB", {1, 1}}}; + OverworldSim sim(lift_maps(), MapPosition{"Lift", {1, 2}}, Direction::SOUTH, script); + if (pick && !sim.choose_lift_floor(1)){ + return std::string("No floor 1 on the lift's panel."); + } + sim.hold(Direction::SOUTH, MovementMechanics::TILE_TIME, MovementMechanics::STEP_RELEASE, std::chrono::milliseconds(3000), {}); + if (!(sim.state().position == expected)){ + return "Expected " + to_string(expected) + ", ended on " + to_string(sim.state().position) + "."; + } + return true; +} +UnitTestResult test_lift_floor(){ + return lift_ride(true, MapPosition{"FloorB", {1, 1}}); +} +UnitTestResult test_lift_no_floor(){ + return lift_ride(false, MapPosition{"Lift", {1, 2}}); +} + + +// The poster (2, 1) north of the player; the stairs behind it, (3, 1), +// closed until its switch is pushed. +const MapData& poster_room(){ + static const MapData MAP = parse_map(R"({ + "name": "Corner", "source": {"decomp_file": "", "revision": "test", "date": "2026-11-09"}, + "tiles": ["#####", "#...#", "#...#", "#####"], + "objects": [ + {"name": "poster", "kind": "sign", "x": 2, "y": 1}, + {"name": "poster_stairs", "kind": "npc", "x": 3, "y": 1} + ] + })", "Corner"); + return MAP; +} + +MapPosition poster_walk(bool push){ + OverworldSim sim({poster_room()}, MapPosition{"Corner", {2, 2}}, Direction::NORTH); + if (push){ + sim.push_poster_switch(); + } + const Milliseconds tile = MovementMechanics::TILE_TIME; + sim.hold(Direction::EAST, tile, MovementMechanics::STEP_RELEASE, tile, {}); + sim.hold(Direction::NORTH, tile * 2, MovementMechanics::STEP_RELEASE, tile * 2, {}); + return sim.state().position; +} +UnitTestResult test_poster_opens_stairs(){ + const MapPosition shut = poster_walk(false); + if (!(shut == MapPosition{"Corner", {3, 2}})){ + return "Before the switch, walked onto " + to_string(shut) + "."; + } + const MapPosition open = poster_walk(true); + if (!(open == MapPosition{"Corner", {3, 1}})){ + return "After the switch, walked onto " + to_string(open) + ", not the stairs."; + } + return true; +} + +} // namespace + + +void add_tests_OverworldSpin(UnitTestDatabase& database){ + database.add("slide up to a wall", test_slide_into_wall); + database.add("slide turned by an arrow", test_slide_turned_by_arrow); + database.add("slide to a stop tile", test_slide_to_stop_tile); + database.add("arrows in a loop never stop", test_slide_loop); + database.add("route ends with a slide", test_route_ends_on_slide); + database.add("no stop in the middle of a slide", test_no_stop_mid_slide); + database.add("sim slides the player", test_sim_slides); + database.add("sim walker walks a slide", test_sim_walker_walks_slide); + database.add("lift lets out on the floor picked", test_lift_floor); + database.add("lift with no floor picked stays", test_lift_no_floor); + database.add("poster switch opens the stairs", test_poster_opens_stairs); +} + + + +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp index 80cf4de310..80c8d798f6 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp @@ -536,6 +536,10 @@ void walk_step_leg( console, context, leg_mover(step, speed), step.wild ? interrupts.wild : interrupts.other, leg_spec(step, speed), MAX_INTERRUPTIONS_PER_LEG, warp_hold_watchers(step, fade) ); + if (step.spin_tiles){ + // The hold let go on the spinner; the game slides the player on. + pbf_wait(context, SPIN_TILE_TIME * *step.spin_tiles + SPIN_STOP_SETTLE); + } if (!step.warps){ return; } diff --git a/SerialPrograms/cmake/SourceFiles.cmake b/SerialPrograms/cmake/SourceFiles.cmake index 976060be1f..08e57e3244 100644 --- a/SerialPrograms/cmake/SourceFiles.cmake +++ b/SerialPrograms/cmake/SourceFiles.cmake @@ -1869,6 +1869,7 @@ file(GLOB LIBRARY_SOURCES Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim_Tests.cpp + Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSpin_Tests.cpp Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk_Tests.cpp From 5ca3d8f6030591e5b23ae80d85e3b63831eec699 Mon Sep 17 00:00:00 2001 From: dayfine Date: Mon, 9 Nov 2026 17:18:16 -0800 Subject: [PATCH 04/10] FRLG AutoStory: lift floor and poster switch steps; the Rocket Hideout's trainers Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013VUubhHeYSMGSSDXZkKZss --- .../PokemonFRLG_OverworldSpin_Tests.cpp | 13 +- .../PokemonFRLG/PokemonFRLG_Tests.cpp | 2 + .../PokemonFRLG_AutoStoryBattleData.cpp | 10 + .../PokemonFRLG_AutoStoryBattleData_Tests.cpp | 36 +++ .../AutoStory/PokemonFRLG_AutoStoryEvents.cpp | 10 + .../PokemonFRLG_AutoStoryHideout.cpp | 116 ++++++++++ .../AutoStory/PokemonFRLG_AutoStoryHideout.h | 86 ++++++++ .../PokemonFRLG_AutoStoryHideout_Tests.cpp | 207 ++++++++++++++++++ .../AutoStory/PokemonFRLG_AutoStoryRowSim.cpp | 30 +++ .../AutoStory/PokemonFRLG_AutoStoryRowSim.h | 2 + .../PokemonFRLG_AutoStoryTableSteps.cpp | 13 ++ .../PokemonFRLG_AutoStoryTableSteps.h | 7 + SerialPrograms/cmake/SourceFiles.cmake | 3 + 13 files changed, 530 insertions(+), 5 deletions(-) create mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryHideout.cpp create mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryHideout.h create mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryHideout_Tests.cpp diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSpin_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSpin_Tests.cpp index afabcff1dd..298e3cb156 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSpin_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSpin_Tests.cpp @@ -136,11 +136,13 @@ UnitTestResult test_route_ends_on_slide(){ return true; } -UnitTestResult test_no_stop_mid_slide(){ +// Walking back down from the dead end steps onto (3, 3), which slides the +// player north again: there is no way out. +UnitTestResult test_dead_end_traps(){ const std::vector maps{spinner_room()}; - const auto route = plan_overworld_route(maps, SPIN_START, RouteGoal{MapPosition{"Spinners", {3, 2}}, std::nullopt}); + const auto route = plan_overworld_route(maps, DEAD_END, RouteGoal{SPIN_START, std::nullopt}); if (route){ - return "Planned a stop on (3, 2), which a slide only passes:" + describe(*route); + return "Planned a way out past the spinner that slides back in:" + describe(*route); } return true; } @@ -239,7 +241,8 @@ MapPosition poster_walk(bool push){ sim.push_poster_switch(); } const Milliseconds tile = MovementMechanics::TILE_TIME; - sim.hold(Direction::EAST, tile, MovementMechanics::STEP_RELEASE, tile, {}); + // Each hold turns the player first, which costs a tile's time. + sim.hold(Direction::EAST, tile * 2, MovementMechanics::STEP_RELEASE, tile * 2, {}); sim.hold(Direction::NORTH, tile * 2, MovementMechanics::STEP_RELEASE, tile * 2, {}); return sim.state().position; } @@ -264,7 +267,7 @@ void add_tests_OverworldSpin(UnitTestDatabase& database){ database.add("slide to a stop tile", test_slide_to_stop_tile); database.add("arrows in a loop never stop", test_slide_loop); database.add("route ends with a slide", test_route_ends_on_slide); - database.add("no stop in the middle of a slide", test_no_stop_mid_slide); + database.add("a spinner back into a dead end traps", test_dead_end_traps); database.add("sim slides the player", test_sim_slides); database.add("sim walker walks a slide", test_sim_walker_walks_slide); database.add("lift lets out on the floor picked", test_lift_floor); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/PokemonFRLG_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/PokemonFRLG_Tests.cpp index 2bf7183092..916b0837a2 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/PokemonFRLG_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/PokemonFRLG_Tests.cpp @@ -48,6 +48,7 @@ #include "Programs/AutoStory/PokemonFRLG_AutoStoryAbilities.h" #include "Programs/AutoStory/PokemonFRLG_AutoStoryBattle.h" #include "Programs/AutoStory/PokemonFRLG_AutoStoryCapture.h" +#include "Programs/AutoStory/PokemonFRLG_AutoStoryHideout.h" #include "Programs/AutoStory/PokemonFRLG_AutoStoryPaceSim.h" #include "Programs/AutoStory/PokemonFRLG_AutoStoryPcStorage.h" #include "Programs/AutoStory/PokemonFRLG_AutoStoryRngLoop.h" @@ -938,6 +939,7 @@ void add_tests(UnitTestDatabase& database){ add_tests_AutoStorySimulation(database); add_tests_AutoStoryRows(database); add_tests_AutoStoryTrashCans(database); + add_tests_AutoStoryHideout(database); add_tests_AutoStoryPaceSim(database); add_tests_AutoStoryItems(database); add_tests_AutoStoryRngLoop(database); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleData.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleData.cpp index 0deab3ef7c..17281e2e83 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleData.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleData.cpp @@ -86,6 +86,16 @@ constexpr StoryTrainer STORY_TRAINERS[] = { {.map = "RockTunnel_B1F", .battle = "hiker_dudley", .trainer = "TRAINER_HIKER_DUDLEY"}, {.map = "RockTunnel_B1F", .battle = "pokemaniac_cooper", .trainer = "TRAINER_POKEMANIAC_COOPER"}, {.map = "RockTunnel_1F", .battle = "picnicker_dana", .trainer = "TRAINER_PICNICKER_DANA"}, + // The Rocket Hideout, as PokeGuy's run fights it (the 00.31 and 00.32 + // drafts): the Grunt at the Game Corner's poster (11, 2) + // (CeladonCity_GameCorner_EventScript_RocketGrunt); on B4F, Grunt1 at + // (4, 2), who drops the LIFT KEY, the door guards Grunt2 (16, 14) and + // Grunt3 (19, 14), and Giovanni (19, 4) (RocketHideout_B4F scripts.inc). + {.map = "CeladonCity_GameCorner", .battle = "rocket_grunt", .trainer = "TRAINER_TEAM_ROCKET_GRUNT_7"}, + {.map = "RocketHideout_B4F", .battle = "lift_key_grunt", .trainer = "TRAINER_TEAM_ROCKET_GRUNT_18"}, + {.map = "RocketHideout_B4F", .battle = "left_guard", .trainer = "TRAINER_TEAM_ROCKET_GRUNT_16"}, + {.map = "RocketHideout_B4F", .battle = "right_guard", .trainer = "TRAINER_TEAM_ROCKET_GRUNT_17"}, + {.map = "RocketHideout_B4F", .battle = "giovanni", .trainer = "TRAINER_BOSS_GIOVANNI"}, }; using JsonTable = std::map>; diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleData_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleData_Tests.cpp index d2e1cda312..f9e53a5779 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleData_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleData_Tests.cpp @@ -140,6 +140,38 @@ UnitTestResult test_story_trainers(){ return true; } +// The Rocket Hideout's battles, which no table row fights yet (the +// 00.31 and 00.32 drafts): each names its map's trainer, with pret's +// party. +UnitTestResult test_hideout_trainers(){ + struct Expected{ + const char* map; + const char* battle; + std::vector party; + }; + const Expected EXPECTED[] = { + {"CeladonCity_GameCorner", "rocket_grunt", {"raticate", "zubat"}}, + {"RocketHideout_B4F", "lift_key_grunt", {"koffing", "zubat"}}, + {"RocketHideout_B4F", "left_guard", {"sandshrew", "ekans", "sandslash"}}, + {"RocketHideout_B4F", "right_guard", {"ekans", "sandshrew", "arbok"}}, + {"RocketHideout_B4F", "giovanni", {"onix", "rhyhorn", "kangaskhan"}}, + }; + for (const Expected& expected : EXPECTED){ + const std::optional trainer = story_trainer(expected.map, expected.battle); + if (!trainer){ + return std::format("{} on {}: no trainer.", expected.battle, expected.map); + } + const std::vector on_map = map_trainers(expected.map); + if (std::find(on_map.begin(), on_map.end(), *trainer) == on_map.end()){ + return std::format("{} on {}: {} isn't one of the map's trainers.", expected.battle, expected.map, *trainer); + } + if (trainer_party(*trainer) != expected.party){ + return std::format("{} on {}: {} hasn't the expected party.", expected.battle, expected.map, *trainer); + } + } + return true; +} + // Mt. Moon: Super Nerd Miguel's Grimer (Poison) takes Water Gun, with or // without Bite known: Bite (60, Dark) ties Water Gun (40 x 1.5 STAB), both // special, and the tie goes to the plan's Water Gun. @@ -208,6 +240,10 @@ const AutoStoryBattleDataTestCase AUTO_STORY_BATTLE_DATA_CASES[] = { .input = {.name = "every story battle fights a trainer with a party", .body = test_story_trainers}, .expectation = {.description = "ensures all scripted story battles encounter valid teams"}, }, + { + .input = {.name = "the Rocket Hideout's Grunts and Giovanni", .body = test_hideout_trainers}, + .expectation = {.description = "names each hideout battle's trainer and party"}, + }, { .input = {.name = "Miguel's Grimer: Water Gun", .body = test_miguel_grimer}, .expectation = {.description = "verifies move matchup choice against Miguel Grimer"}, diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.cpp index 13a39e6cae..12ace02fe6 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.cpp @@ -22,6 +22,7 @@ #include "PokemonFRLG_AutoStoryCapture.h" #include "PokemonFRLG_AutoStoryEvents.h" #include "PokemonFRLG_AutoStoryGrind.h" +#include "PokemonFRLG_AutoStoryHideout.h" #include "PokemonFRLG_AutoStoryScreenEvents.h" #include "PokemonFRLG_AutoStorySummary.h" #include "PokemonFRLG_AutoStoryTable.h" @@ -684,6 +685,15 @@ Outcome open_gym_door(StoryEnv& env, RunState& state, const StoryStep& step){ // The GAME steps without a row of their own: a capture or a PC deposit or // withdrawal, a MART, TEACH, LEVEL, GRIND or fossil CHOICE. Outcome other_game_step(StoryEnv& env, RunState& state, const StoryStep& step){ + // The Rocket Hideout's steps leave the player where they stand. + if (const std::optional ride = parse_lift_floor_variant(step.variant)){ + ride_lift(env.console, env.context, *ride); + return Pokemon::outcome_ok(); + } + if (step.variant == POSTER_SWITCH_VARIANT){ + push_poster_switch(env.console, env.context); + return Pokemon::outcome_ok(); + } const Pokemon::Pose patch = state.pose; ConsoleCaptureActions capture(env.console, env.context, env.language, [&]{ pace_trainer_battle(env, state, step, patch); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryHideout.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryHideout.cpp new file mode 100644 index 0000000000..f1208ac841 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryHideout.cpp @@ -0,0 +1,116 @@ +/* AutoStory Rocket Hideout Steps + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include "NintendoSwitch/Commands/NintendoSwitch_Commands_PushButtons.h" +#include "PokemonFRLG_AutoStoryActions.h" +#include "PokemonFRLG_AutoStoryHideout.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +using namespace std::chrono_literals; + + +const std::vector& rocket_hideout_lift_menu(){ + static const std::vector MENU{"B1F", "B2F", "B4F", "EXIT"}; + return MENU; +} + +std::string lift_floor_variant(std::string_view from, std::string_view to){ + return std::string(LIFT_FLOOR_PREFIX) + std::string(from) + " " + std::string(to); +} + +namespace{ + +// The row of `floor` on the panel's menu, if it is a floor (not EXIT). +std::optional floor_row(std::string_view floor){ + const std::vector& menu = rocket_hideout_lift_menu(); + for (size_t row = 0; row + 1 < menu.size(); row++){ + if (menu[row] == floor){ + return row; + } + } + return std::nullopt; +} + +} // namespace + +std::optional parse_lift_floor_variant(std::string_view variant){ + if (!variant.starts_with(LIFT_FLOOR_PREFIX)){ + return std::nullopt; + } + const std::string_view floors = variant.substr(LIFT_FLOOR_PREFIX.size()); + const size_t space = floors.find(' '); + if (space == std::string_view::npos){ + return std::nullopt; + } + const std::optional from = floor_row(floors.substr(0, space)); + const std::optional to = floor_row(floors.substr(space + 1)); + if (!from || !to){ + return std::nullopt; + } + return LiftRide{*from, *to}; +} + +MenuPresses lift_menu_presses(const LiftRide& ride, size_t entries){ + const size_t down = (ride.to + entries - ride.from) % entries; + const size_t up = (ride.from + entries - ride.to) % entries; + if (entries <= 3){ + // No wrap: the cursor goes straight there. + return ride.to >= ride.from + ? MenuPresses{Pokemon::Direction::SOUTH, ride.to - ride.from} + : MenuPresses{Pokemon::Direction::NORTH, ride.from - ride.to}; + } + return down <= up ? MenuPresses{Pokemon::Direction::SOUTH, down} : MenuPresses{Pokemon::Direction::NORTH, up}; +} + +bool is_hideout_variant(std::string_view variant){ + return variant == POSTER_SWITCH_VARIANT || parse_lift_floor_variant(variant).has_value(); +} + + +namespace{ + +// How long a message and its menu take to show on fast text, before the +// menu takes the D-pad. Not measured on the console. +constexpr Milliseconds LIFT_MENU_WAIT = 1500ms; +constexpr Milliseconds MENU_TAP = 80ms; +constexpr Milliseconds MENU_TAP_RELEASE = 240ms; +// The lift's shake and the doors, then the overworld, A press to the end. +constexpr Milliseconds HIDEOUT_SCENE_TIMEOUT = 30000ms; + +} // namespace + +void ride_lift(ConsoleHandle& console, ProControllerContext& context, const LiftRide& ride){ + const MenuPresses presses = lift_menu_presses(ride, rocket_hideout_lift_menu().size()); + console.log( + "Story: lift from " + rocket_hideout_lift_menu()[ride.from] + " to " + rocket_hideout_lift_menu()[ride.to] + + ": " + std::to_string(presses.presses) + " press(es) " + Pokemon::direction_name(presses.direction) + "." + ); + pbf_press_button(context, BUTTON_A, 160ms, LIFT_MENU_WAIT); + const DpadPosition dpad = presses.direction == Pokemon::Direction::SOUTH ? DPAD_DOWN : DPAD_UP; + for (size_t c = 0; c < presses.presses; c++){ + pbf_press_dpad(context, dpad, MENU_TAP, MENU_TAP_RELEASE); + } + pbf_press_button(context, BUTTON_A, 160ms, 800ms); + // B during the ride does nothing; the menu took the A. + mash_until_overworld(console, context, HIDEOUT_SCENE_TIMEOUT); +} + +void push_poster_switch(ConsoleHandle& console, ProControllerContext& context){ + console.log("Story: the switch behind the poster."); + pbf_press_button(context, BUTTON_A, 160ms, 800ms); + mash_until_overworld(console, context, HIDEOUT_SCENE_TIMEOUT); +} + + + +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryHideout.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryHideout.h new file mode 100644 index 0000000000..4e4249af77 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryHideout.h @@ -0,0 +1,86 @@ +/* AutoStory Rocket Hideout Steps + * + * From: https://github.com/PokemonAutomation/ + * + * The two story steps the Rocket Hideout needs that no other place does: + * the switch behind the Celadon Game Corner's poster, which opens the + * stairs down, and the lift's floor panel. Both are GAME steps named by + * their variant, as the draft plans write them (StoryData/Draft). + * + */ + +#ifndef PokemonAutomation_PokemonFRLG_AutoStoryHideout_H +#define PokemonAutomation_PokemonFRLG_AutoStoryHideout_H + +#include +#include +#include +#include +#include +#include "Common/Cpp/TestRunners/UnitTestDatabase.h" +#include "NintendoSwitch/Controllers/Procon/NintendoSwitch_ProController.h" +#include "NintendoSwitch/NintendoSwitch_ConsoleHandle.h" +#include "Pokemon/Navigation/Pokemon_Grid.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +// The GAME step that pushes the switch behind the Game Corner's poster, +// the player facing it (pokefirered CeladonCity_GameCorner_EventScript_ +// Poster): A, "Hey! A switch behind the POSTER!? Let's push it!", and the +// stairs (15..17, 2..3) open for good. The Grunt in front of it must be +// beaten first. +inline const std::string POSTER_SWITCH_VARIANT = "poster_switch"; + +// The GAME step that rides the Rocket Hideout's lift, the player facing +// its panel with the LIFT KEY: "lift_floor ", floors as the +// panel names them (B1F, B2F, B4F). `from` is the floor the lift stands +// at, where the panel's cursor starts. +inline constexpr std::string_view LIFT_FLOOR_PREFIX = "lift_floor "; + +// The lift panel's menu, top first (pokefirered +// sMultichoiceList_RocketHideoutElevator): B1F, B2F, B4F, EXIT. +const std::vector& rocket_hideout_lift_menu(); + +// A ride: the menu rows the cursor starts on and is moved to. +struct LiftRide{ + size_t from = 0; + size_t to = 0; +}; +// The variant for a ride from floor `from` to `to`. +std::string lift_floor_variant(std::string_view from, std::string_view to); +// The ride a "lift_floor " variant names, or nothing if it is +// not one, or names a floor the panel hasn't. +std::optional parse_lift_floor_variant(std::string_view variant); + +// The fewest presses that move the cursor from `ride.from` to `ride.to` +// on a menu of `entries` rows. A multichoice of more than three rows +// wraps round (pokefirered DrawVerticalMultichoiceMenu()). +struct MenuPresses{ + Pokemon::Direction direction = Pokemon::Direction::SOUTH; + size_t presses = 0; +}; +MenuPresses lift_menu_presses(const LiftRide& ride, size_t entries); + +// Whether `variant` is one of this file's steps. +bool is_hideout_variant(std::string_view variant); + +// On the console: A on the panel, the floor picked, the ride waited out +// back to the overworld, the player still in the lift. +void ride_lift(ConsoleHandle& console, ProControllerContext& context, const LiftRide& ride); +// On the console: A on the poster, its message closed back to the +// overworld. +void push_poster_switch(ConsoleHandle& console, ProControllerContext& context); + + +// Registers the hideout steps' tests. +void add_tests_AutoStoryHideout(UnitTestDatabase& database); + + + +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation +#endif diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryHideout_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryHideout_Tests.cpp new file mode 100644 index 0000000000..a9518e3890 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryHideout_Tests.cpp @@ -0,0 +1,207 @@ +/* AutoStory Rocket Hideout Steps Tests + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include +#include +#include "Common/Cpp/TestRunners/UnitTest.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_MovementMechanics.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h" +#include "PokemonFRLG_AutoStoryHideout.h" +#include "PokemonFRLG_AutoStoryRowSim.h" +#include "PokemonFRLG_AutoStoryTableSteps.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +namespace{ + +struct LiftInput{ + std::string name; + std::string variant; +}; +struct LiftExpectation{ + // Nothing: the variant isn't a ride. + std::optional presses; +}; +struct LiftCase{ + LiftInput input; + LiftExpectation expectation; +}; + +class Test_AutoStoryHideout : public UnitTest{ +public: + explicit Test_AutoStoryHideout(LiftCase test_case) + : UnitTest("PokemonFRLG::AutoStoryHideout - " + test_case.input.name) + , m_case(std::move(test_case)) + {} + virtual UnitTestResult run(Logger&, CancellableScope&) const override{ + const std::optional ride = parse_lift_floor_variant(m_case.input.variant); + const std::optional& expected = m_case.expectation.presses; + if (!ride || !expected){ + if (ride.has_value() != expected.has_value()){ + return std::format("\"{}\": a ride {}, expected {}.", m_case.input.variant, ride.has_value(), expected.has_value()); + } + return true; + } + const MenuPresses got = lift_menu_presses(*ride, rocket_hideout_lift_menu().size()); + if (got.direction != expected->direction || got.presses != expected->presses){ + return std::format( + "\"{}\": {} press(es) {}, expected {} {}.", m_case.input.variant, + got.presses, Pokemon::direction_name(got.direction), expected->presses, Pokemon::direction_name(expected->direction) + ); + } + if (!is_hideout_variant(m_case.input.variant)){ + return "A ride that isn't a hideout variant."; + } + return true; + } + +private: + LiftCase m_case; +}; + +class Test_AutoStoryHideoutVariants : public UnitTest{ +public: + Test_AutoStoryHideoutVariants() + : UnitTest("PokemonFRLG::AutoStoryHideout - the poster and lift variants") + {} + virtual UnitTestResult run(Logger&, CancellableScope&) const override{ + if (!is_hideout_variant(POSTER_SWITCH_VARIANT) || is_hideout_variant("cut_tree") || is_hideout_variant("")){ + return "Expected only the poster switch and lift rides as hideout variants."; + } + if (lift_floor_variant("B2F", "B4F") != "lift_floor B2F B4F"){ + return "Expected \"lift_floor B2F B4F\", got \"" + lift_floor_variant("B2F", "B4F") + "\"."; + } + // A three-row menu doesn't wrap. + const MenuPresses no_wrap = lift_menu_presses(LiftRide{0, 2}, 3); + if (no_wrap.direction != Pokemon::Direction::SOUTH || no_wrap.presses != 2){ + return "Expected 2 down on a three-row menu."; + } + return true; + } +}; + +// The table's steps, run as a row runs them on the sim: the poster's +// switch opens the stairs north-east of it, and the lift lets out on the +// floor picked. +class Test_AutoStoryHideoutSim : public UnitTest{ +public: + Test_AutoStoryHideoutSim() + : UnitTest("PokemonFRLG::AutoStoryHideout - table steps on the sim") + {} + virtual UnitTestResult run(Logger&, CancellableScope&) const override{ + const std::string poster = poster_on_sim(); + if (!poster.empty()){ + return poster; + } + return lift_on_sim(); + } + +private: + static std::string poster_on_sim(){ + const MapData room = parse_map(R"({ + "name": "Corner", "source": {"decomp_file": "", "revision": "test", "date": "2026-11-09"}, + "tiles": ["#####", "#...#", "#...#", "#####"], + "objects": [ + {"name": "poster", "kind": "sign", "x": 2, "y": 1}, + {"name": "poster_stairs", "kind": "npc", "x": 3, "y": 1} + ] + })", "Corner"); + OverworldSim sim({room}, MapPosition{"Corner", {2, 2}}, Direction::NORTH); + StoryPose at{"Corner", {2, 2}, Direction::NORTH}; + const std::string failure = row_step_on_sim(sim, at, StoryTable::poster_switch("poster_switch", "test"), "00.31"); + if (!failure.empty()){ + return failure; + } + const Milliseconds tile = MovementMechanics::TILE_TIME; + sim.hold(Direction::EAST, tile * 2, MovementMechanics::STEP_RELEASE, tile * 2, {}); + sim.hold(Direction::NORTH, tile * 2, MovementMechanics::STEP_RELEASE, tile * 2, {}); + if (!(sim.state().position == MapPosition{"Corner", {3, 1}})){ + return "After the poster step, walked onto " + to_string(sim.state().position) + ", not the stairs."; + } + return ""; + } + static std::string lift_on_sim(){ + std::vector maps; + maps.push_back(parse_map(R"({ + "name": "Lift", "source": {"decomp_file": "", "revision": "test", "date": "2026-11-09"}, + "tiles": ["###", "#.#", "#.#", "###"], + "warps": [{"x": 1, "y": 2, "enter": "walk_south"}] + })", "Lift")); + maps.push_back(parse_map(R"({ + "name": "B4F", "source": {"decomp_file": "", "revision": "test", "date": "2026-11-09"}, + "tiles": ["###", "#.#", "###"] + })", "B4F")); + SimScript script; + const MapPosition b4f{"B4F", {1, 1}}; + script.lift_floors["Lift"] = {MapPosition{"Lift", {1, 1}}, MapPosition{"Lift", {1, 1}}, b4f}; + OverworldSim sim(maps, MapPosition{"Lift", {1, 2}}, Direction::SOUTH, script); + StoryPose at{"Lift", {1, 2}, Direction::SOUTH}; + const std::string failure = row_step_on_sim(sim, at, StoryTable::lift_floor("lift_to_b4f", "B2F", "B4F", "test"), "00.32"); + if (!failure.empty()){ + return failure; + } + sim.hold(Direction::SOUTH, MovementMechanics::TILE_TIME, MovementMechanics::STEP_RELEASE, std::chrono::milliseconds(3000), {}); + if (!(sim.state().position == b4f)){ + return "After the lift step, the door let out on " + to_string(sim.state().position) + "."; + } + return ""; + } +}; + +const LiftCase LIFT_CASES[] = { + { + .input = {.name = "B2F to B4F: one down", .variant = "lift_floor B2F B4F"}, + .expectation = {.presses = MenuPresses{Pokemon::Direction::SOUTH, 1}}, + }, + { + .input = {.name = "B4F to B1F: two up", .variant = "lift_floor B4F B1F"}, + .expectation = {.presses = MenuPresses{Pokemon::Direction::NORTH, 2}}, + }, + { + // B1F up past the top wraps to EXIT, then B4F: two up equal two + // down; down is taken. + .input = {.name = "B1F to B4F: two down", .variant = "lift_floor B1F B4F"}, + .expectation = {.presses = MenuPresses{Pokemon::Direction::SOUTH, 2}}, + }, + { + .input = {.name = "the lift's own floor: no press", .variant = "lift_floor B4F B4F"}, + .expectation = {.presses = MenuPresses{Pokemon::Direction::SOUTH, 0}}, + }, + { + .input = {.name = "no B3F on the panel", .variant = "lift_floor B2F B3F"}, + .expectation = {.presses = std::nullopt}, + }, + { + .input = {.name = "EXIT is no floor", .variant = "lift_floor B2F EXIT"}, + .expectation = {.presses = std::nullopt}, + }, + { + .input = {.name = "one floor only", .variant = "lift_floor B2F"}, + .expectation = {.presses = std::nullopt}, + }, +}; + +} // namespace + + +void add_tests_AutoStoryHideout(UnitTestDatabase& database){ + for (const LiftCase& item : LIFT_CASES){ + database.add(item); + } + database.add(); + database.add(); +} + + + +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.cpp index ff86b55359..adf518879f 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.cpp @@ -15,6 +15,7 @@ #include "Pokemon/AutoStory/Pokemon_TilePose.h" #include "PokemonFRLG_AutoStoryActions.h" #include "PokemonFRLG_AutoStoryBattleDecisions.h" +#include "PokemonFRLG_AutoStoryHideout.h" #include "PokemonFRLG_AutoStoryRowSim.h" namespace PokemonAutomation{ @@ -370,6 +371,8 @@ Pokemon::StoryAction OverworldStepSim::action(StoryActionId id){ case StoryActionId::BATTLE: body = &OverworldStepSim::battle; break; + case StoryActionId::GAME: + return hideout_or_stub(); default: return m_stubs.stub(); } @@ -519,6 +522,33 @@ std::string OverworldStepSim::cut(const StoryStep& step){ return ""; } +// A Rocket Hideout GAME step on the sim: the poster's switch, or the +// lift's floor (SimScript::lift_floors, rows as on the panel); any other +// GAME step is stubbed. +Pokemon::StoryAction OverworldStepSim::hideout_or_stub(){ + Pokemon::StoryAction stub = m_stubs.stub(); + Pokemon::StoryAction sim = played([this](RunState&, const StoryStep& step){ + const std::string failure = hideout(step); + return failure.empty() ? Pokemon::outcome_ok() : Pokemon::outcome_failed("sim_failure", failure); + }); + sim.act = [stub, played_act = sim.act](RunState& state, const StoryStep& step){ + return is_hideout_variant(step.variant) ? played_act(state, step) : stub.act(state, step); + }; + sim.needs_console = stub.needs_console; + return sim; +} + +std::string OverworldStepSim::hideout(const StoryStep& step){ + if (step.variant == POSTER_SWITCH_VARIANT){ + return m_sim.push_poster_switch() ? "" : "no poster ahead."; + } + const std::optional ride = parse_lift_floor_variant(step.variant); + if (!ride || !m_sim.choose_lift_floor(ride->to)){ + return "no lift panel for \"" + step.variant + "\"."; + } + return ""; +} + // A battle from the trainer's message, which must be up, ending where the // table says, the battle loop's end played on the overworld after it. std::string OverworldStepSim::battle(const StoryStep& step){ diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.h index 15655a8162..aa36f4f023 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.h @@ -183,6 +183,8 @@ class OverworldStepSim : public Pokemon::TableExecutor{ std::string step_onto(const StoryStep& step); std::string cut(const StoryStep& step); std::string battle(const StoryStep& step); + Pokemon::StoryAction hideout_or_stub(); + std::string hideout(const StoryStep& step); OverworldSim& m_sim; StoryPose m_at; diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableSteps.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableSteps.cpp index acd9a79de8..372aa1ed5e 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableSteps.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableSteps.cpp @@ -4,6 +4,7 @@ * */ +#include "PokemonFRLG_AutoStoryHideout.h" #include "PokemonFRLG_AutoStoryTableSteps.h" namespace PokemonAutomation{ @@ -154,6 +155,18 @@ StoryStep cut_tree(std::string name, Pokemon::Pose tree, std::string why){ return step; } +StoryStep poster_switch(std::string name, std::string why){ + StoryStep step = make(StoryStepKind::GAME, std::move(name), std::move(why)); + step.variant = POSTER_SWITCH_VARIANT; + return step; +} + +StoryStep lift_floor(std::string name, std::string_view from, std::string_view to, std::string why){ + StoryStep step = make(StoryStepKind::GAME, std::move(name), std::move(why)); + step.variant = lift_floor_variant(from, to); + return step; +} + } // namespace StoryTable diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableSteps.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableSteps.h index d0a314a2ac..d8e92f3d45 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableSteps.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableSteps.h @@ -13,6 +13,7 @@ #include #include +#include #include #include "PokemonFRLG_AutoStoryEvents.h" @@ -182,6 +183,12 @@ StoryStep options_step(); // Cuts the tree the player faces (CUT_TREE_VARIANT) and steps onto its // tile, `tree`, facing the way the step goes. StoryStep cut_tree(std::string name, Pokemon::Pose tree, std::string why); +// Pushes the switch behind the Game Corner's poster the player faces +// (POSTER_SWITCH_VARIANT). +StoryStep poster_switch(std::string name, std::string why); +// Rides the Rocket Hideout's lift, the player facing its panel, from the +// floor it stands at to `to` (lift_floor_variant()): "B1F", "B2F", "B4F". +StoryStep lift_floor(std::string name, std::string_view from, std::string_view to, std::string why); // Rows in files of their own (PokemonFRLG_AutoStoryTableVermilionGym.cpp, diff --git a/SerialPrograms/cmake/SourceFiles.cmake b/SerialPrograms/cmake/SourceFiles.cmake index 08e57e3244..36df2c5b96 100644 --- a/SerialPrograms/cmake/SourceFiles.cmake +++ b/SerialPrograms/cmake/SourceFiles.cmake @@ -1917,6 +1917,9 @@ file(GLOB LIBRARY_SOURCES Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryGrind.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryGrind.h Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryGrind_Tests.cpp + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryHideout.cpp + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryHideout.h + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryHideout_Tests.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryItems_Tests.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryFaultSweep_Tests.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryFaultSweep_Tests.h From cf123e733a97d99ae7ecc77021808c2fca407d08 Mon Sep 17 00:00:00 2001 From: dayfine Date: Mon, 9 Nov 2026 17:22:16 -0800 Subject: [PATCH 05/10] FRLG AutoStory: use items from the bag, renew a Repel, ride the Bicycle Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013VUubhHeYSMGSSDXZkKZss --- .../Menus/PokemonFRLG_ItemListReader.cpp | 5 + .../Menus/PokemonFRLG_ItemListReader.h | 3 +- .../Overworld/PokemonFRLG_MovementMechanics.h | 4 + .../Overworld/PokemonFRLG_OverworldWalk.cpp | 12 +- .../Overworld/PokemonFRLG_OverworldWalk.h | 2 + .../PokemonFRLG/PokemonFRLG_Tests.cpp | 2 + .../AutoStory/PokemonFRLG_AutoStoryEvents.cpp | 14 ++ .../AutoStory/PokemonFRLG_AutoStoryEvents.h | 7 +- .../PokemonFRLG_AutoStoryHideout_Tests.cpp | 16 +- .../PokemonFRLG_AutoStoryItemUse.cpp | 165 +++++++++++++ .../AutoStory/PokemonFRLG_AutoStoryItemUse.h | 127 ++++++++++ .../PokemonFRLG_AutoStoryItemUse_Tests.cpp | 223 ++++++++++++++++++ .../PokemonFRLG_AutoStoryTableSteps.cpp | 14 ++ .../PokemonFRLG_AutoStoryTableSteps.h | 5 + SerialPrograms/cmake/SourceFiles.cmake | 3 + 15 files changed, 596 insertions(+), 6 deletions(-) create mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryItemUse.cpp create mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryItemUse.h create mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryItemUse_Tests.cpp diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_ItemListReader.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_ItemListReader.cpp index 4c89385dbb..9b8a5c1212 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_ItemListReader.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_ItemListReader.cpp @@ -70,6 +70,11 @@ class ItemNameMatcher : public OCR::SmallDictionaryMatcher{ {"parlyz-heal", "Parlyz Heal"}, {"awakening", "Awakening"}, {"poke-ball", "Poke Ball"}, + {"repel", "Repel"}, + {"super-repel", "Super Repel"}, + {"max-repel", "Max Repel"}, + {"escape-rope", "Escape Rope"}, + {"bicycle", "Bicycle"}, {"cancel", "Cancel"}, }){ JsonArray names; diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_ItemListReader.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_ItemListReader.h index 0b36e39959..6565fcbfdd 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_ItemListReader.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_ItemListReader.h @@ -36,7 +36,8 @@ ImageFloatBox item_list_name_box(int row); ImageFloatBox bag_item_count_box(int row); // The item named in row `row`: a slug such as "potion", "antidote", -// "parlyz-heal", "awakening", "poke-ball", or "cancel" for the list's last row, in a +// "parlyz-heal", "awakening", "poke-ball", "repel", +// "super-repel", "max-repel", "escape-rope", "bicycle", or "cancel" for the list's last row, in a // game in `language`; empty if it can't be read, as in a language the // reader has no names for. std::string read_item_list_name(Logger& logger, Language language, const ImageViewRGB32& screen, int row); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_MovementMechanics.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_MovementMechanics.h index 0a20f37a7a..d2f761596c 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_MovementMechanics.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_MovementMechanics.h @@ -48,6 +48,10 @@ inline constexpr Milliseconds TILE_TIME(267); inline constexpr Milliseconds RUN_TILE_TIME(133); // Surfing: a tile per 8 frames. inline constexpr Milliseconds SURF_TILE_TIME(133); +// Riding the Bicycle: a tile per 4 frames (pokefirered bike.c, +// PlayerRideWaterCurrent(): MOVE_SPEED_FAST_2). An estimate from the +// decompilation, not measured on the console. +inline constexpr Milliseconds BIKE_TILE_TIME(67); // The longest tap measured to only turn a player facing another way, and // to take exactly one step for one facing its way. inline constexpr Milliseconds LONGEST_TAP(133); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp index 80c8d798f6..35214508be 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp @@ -191,7 +191,15 @@ Milliseconds tile_time(const RouteStep& step, MoveSpeed speed){ if (step.surfing){ return SURF_TILE_TIME; } - return step_speed(step, speed) == MoveSpeed::RUN ? RUN_TILE_TIME : TILE_TIME; + switch (step_speed(step, speed)){ + case MoveSpeed::RUN: + return RUN_TILE_TIME; + case MoveSpeed::BIKE: + return BIKE_TILE_TIME; + case MoveSpeed::WALK: + break; + } + return TILE_TIME; } Pokemon::LegMover leg_mover(const RouteStep& step, MoveSpeed speed){ @@ -469,6 +477,8 @@ const char* move_speed_name(MoveSpeed speed){ return "walk"; case MoveSpeed::RUN: return "run"; + case MoveSpeed::BIKE: + return "bike"; } return "?"; } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h index d2120b8e04..22eb2eea05 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h @@ -77,6 +77,8 @@ void use_max_repel_from_bag(ConsoleHandle& console, ProControllerContext& contex enum class MoveSpeed{ WALK, RUN, + // On the Bicycle, already mounted (MovementMechanics::BIKE_TILE_TIME). + BIKE, }; // Name of a movement speed. const char* move_speed_name(MoveSpeed speed); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/PokemonFRLG_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/PokemonFRLG_Tests.cpp index 916b0837a2..28816b82aa 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/PokemonFRLG_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/PokemonFRLG_Tests.cpp @@ -49,6 +49,7 @@ #include "Programs/AutoStory/PokemonFRLG_AutoStoryBattle.h" #include "Programs/AutoStory/PokemonFRLG_AutoStoryCapture.h" #include "Programs/AutoStory/PokemonFRLG_AutoStoryHideout.h" +#include "Programs/AutoStory/PokemonFRLG_AutoStoryItemUse.h" #include "Programs/AutoStory/PokemonFRLG_AutoStoryPaceSim.h" #include "Programs/AutoStory/PokemonFRLG_AutoStoryPcStorage.h" #include "Programs/AutoStory/PokemonFRLG_AutoStoryRngLoop.h" @@ -940,6 +941,7 @@ void add_tests(UnitTestDatabase& database){ add_tests_AutoStoryRows(database); add_tests_AutoStoryTrashCans(database); add_tests_AutoStoryHideout(database); + add_tests_AutoStoryItemUse(database); add_tests_AutoStoryPaceSim(database); add_tests_AutoStoryItems(database); add_tests_AutoStoryRngLoop(database); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.cpp index 12ace02fe6..b3bb43b2ec 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.cpp @@ -23,6 +23,7 @@ #include "PokemonFRLG_AutoStoryEvents.h" #include "PokemonFRLG_AutoStoryGrind.h" #include "PokemonFRLG_AutoStoryHideout.h" +#include "PokemonFRLG_AutoStoryItemUse.h" #include "PokemonFRLG_AutoStoryScreenEvents.h" #include "PokemonFRLG_AutoStorySummary.h" #include "PokemonFRLG_AutoStoryTable.h" @@ -366,6 +367,9 @@ const std::vector& story_maps(){ } MoveSpeed move_speed(const StoryStep& step){ + if (step.variant == BIKE_VARIANT){ + return MoveSpeed::BIKE; + } return step.variant == RUN_VARIANT ? MoveSpeed::RUN : MoveSpeed::WALK; } @@ -694,6 +698,16 @@ Outcome other_game_step(StoryEnv& env, RunState& state, const StoryStep& step){ push_poster_switch(env.console, env.context); return Pokemon::outcome_ok(); } + // An item from the bag; an Escape Rope ends where the table says. + if (is_item_use_variant(step.variant)){ + const std::string item = parse_use_item_variant(step.variant).value_or("bicycle"); + ConsoleItemUseActions bag(env.console, env.context, env.language); + const ItemUseEnd end = use_item_from_bag(env.console.logger(), bag, item); + if (end != ItemUseEnd::USED){ + return Pokemon::outcome_failed(std::string("use_item_") + item_use_end_name(end), step.variant); + } + return step.pose_after ? Pokemon::outcome_ok(*step.pose_after) : Pokemon::outcome_ok(); + } const Pokemon::Pose patch = state.pose; ConsoleCaptureActions capture(env.console, env.context, env.language, [&]{ pace_trainer_battle(env, state, step, patch); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.h index 9e089b767c..4ecbd72ab7 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.h @@ -63,6 +63,10 @@ struct StoryPose{ // The variant of a ROUTE or LEGS step that runs, with B held: the player // has the Running Shoes (00.10 on). inline const std::string RUN_VARIANT = "run"; +// The variant of a ROUTE or LEGS step ridden on the Bicycle, mounted by a +// RIDE_BIKE_VARIANT step before it (PokemonFRLG_AutoStoryItemUse.h). +// Indoors, where the game doesn't let the player ride, it walks. +inline const std::string BIKE_VARIANT = "bike"; // The variant of a STEP into a trainer's line: a tile at a time, at most // TRAINER_LINE_TILES, until the trainer sees the player @@ -101,7 +105,8 @@ inline const std::string CLOSE_TRAINER_CARD = "close_trainer_card"; // the overworld. inline const std::string LEAVE_GIFT_SUMMARY = "leave_gift_summary"; -// How `step`, a ROUTE or LEGS, moves the player: RUN_VARIANT runs. +// How `step`, a ROUTE or LEGS, moves the player: RUN_VARIANT runs, +// BIKE_VARIANT rides. MoveSpeed move_speed(const StoryStep& step); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryHideout_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryHideout_Tests.cpp index a9518e3890..0c29251b6a 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryHideout_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryHideout_Tests.cpp @@ -101,7 +101,11 @@ class Test_AutoStoryHideoutSim : public UnitTest{ if (!poster.empty()){ return poster; } - return lift_on_sim(); + const std::string lift = lift_on_sim(); + if (!lift.empty()){ + return lift; + } + return true; } private: @@ -162,8 +166,14 @@ const LiftCase LIFT_CASES[] = { .expectation = {.presses = MenuPresses{Pokemon::Direction::SOUTH, 1}}, }, { - .input = {.name = "B4F to B1F: two up", .variant = "lift_floor B4F B1F"}, - .expectation = {.presses = MenuPresses{Pokemon::Direction::NORTH, 2}}, + // Down past EXIT wraps to B1F: as few presses as up, and down is + // taken. + .input = {.name = "B4F to B1F: two down past EXIT", .variant = "lift_floor B4F B1F"}, + .expectation = {.presses = MenuPresses{Pokemon::Direction::SOUTH, 2}}, + }, + { + .input = {.name = "B4F to B2F: one up", .variant = "lift_floor B4F B2F"}, + .expectation = {.presses = MenuPresses{Pokemon::Direction::NORTH, 1}}, }, { // B1F up past the top wraps to EXIT, then B4F: two up equal two diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryItemUse.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryItemUse.cpp new file mode 100644 index 0000000000..d0a719798d --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryItemUse.cpp @@ -0,0 +1,165 @@ +/* AutoStory Item Use + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include "CommonFramework/VideoPipeline/VideoFeed.h" +#include "NintendoSwitch/Commands/NintendoSwitch_Commands_PushButtons.h" +#include "VideoGames/PokemonFRLG/PokemonFRLG_Navigation.h" +#include "PokemonFRLG_AutoStoryActions.h" +#include "PokemonFRLG_AutoStoryItemUse.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +using namespace std::chrono_literals; + + +std::optional usable_item_pocket(std::string_view item){ + if (is_repel(item) || item == "escape-rope"){ + return BagPocket::ITEMS; + } + if (item == "bicycle"){ + return BagPocket::KEY_ITEMS; + } + return std::nullopt; +} +bool is_repel(std::string_view item){ + return item == "repel" || item == "super-repel" || item == "max-repel"; +} + +std::string use_item_variant(std::string_view item){ + return std::string(USE_ITEM_PREFIX) + std::string(item); +} +std::optional parse_use_item_variant(std::string_view variant){ + if (!variant.starts_with(USE_ITEM_PREFIX)){ + return std::nullopt; + } + const std::string_view item = variant.substr(USE_ITEM_PREFIX.size()); + if (!usable_item_pocket(item)){ + return std::nullopt; + } + return std::string(item); +} +bool is_item_use_variant(std::string_view variant){ + return variant == RIDE_BIKE_VARIANT || parse_use_item_variant(variant).has_value(); +} + +std::optional bag_row_of(const std::vector& rows, std::string_view item){ + for (size_t row = 0; row < rows.size(); row++){ + if (rows[row].item == item && rows[row].count != 0){ + return row; + } + } + return std::nullopt; +} + +const char* item_use_end_name(ItemUseEnd end){ + switch (end){ + case ItemUseEnd::USED: + return "used"; + case ItemUseEnd::NONE_LEFT: + return "none_left"; + case ItemUseEnd::NO_BAG: + return "no_bag"; + case ItemUseEnd::UNKNOWN_ITEM: + return "unknown_item"; + } + return "?"; +} + +ItemUseEnd use_item_from_bag(Logger& logger, ItemUseActions& actions, std::string_view item){ + const std::optional pocket = usable_item_pocket(item); + if (!pocket){ + return ItemUseEnd::UNKNOWN_ITEM; + } + if (!actions.open_pocket(*pocket)){ + logger.log("Bag: the bag didn't open."); + return ItemUseEnd::NO_BAG; + } + const std::optional row = bag_row_of(actions.read_pocket(), item); + if (!row){ + logger.log("Bag: no " + std::string(item) + " left."); + actions.close_bag(); + return ItemUseEnd::NONE_LEFT; + } + logger.log("Bag: using the " + std::string(item) + " on row " + std::to_string(*row + 1) + "."); + actions.use_row(*row); + actions.close_bag(); + return ItemUseEnd::USED; +} + +ItemUseEnd renew_repel(Logger& logger, ItemUseActions& actions, std::string_view item){ + if (!is_repel(item)){ + return ItemUseEnd::UNKNOWN_ITEM; + } + logger.log("Bag: the " + std::string(item) + " wore off."); + return use_item_from_bag(logger, actions, item); +} + + +namespace{ + +// The bag slides in, and ignores presses until it is done (as +// use_max_repel_from_bag()). +constexpr Milliseconds BAG_SLIDE_IN = 1000ms; +// Out of the bag and its messages, or the Escape Rope's warp. +constexpr Milliseconds BAG_CLOSE_TIMEOUT = 30000ms; + +} // namespace + +ConsoleItemUseActions::ConsoleItemUseActions(ConsoleHandle& console, ProControllerContext& context, Language language) + : m_console(console) + , m_context(context) + , m_language(language) +{} + +bool ConsoleItemUseActions::open_pocket(BagPocket pocket){ + open_bag_from_overworld(m_console, m_context); + pbf_wait(m_context, BAG_SLIDE_IN); + // Left to the Items pocket, which doesn't wrap, then right to `pocket`. + for (int c = 0; c < 3; c++){ + pbf_press_dpad(m_context, DPAD_LEFT, 100ms, 600ms); + } + for (int c = 0; c < (int)pocket; c++){ + pbf_press_dpad(m_context, DPAD_RIGHT, 100ms, 600ms); + } + for (int c = 0; c < 20; c++){ + pbf_press_dpad(m_context, DPAD_UP, 100ms, 100ms); + } + return true; +} + +std::vector ConsoleItemUseActions::read_pocket(){ + m_context.wait_for_all_requests(); + VideoSnapshot screen = m_console.video().snapshot(); + if (!screen){ + return {}; + } + return read_bag_pocket(m_console.logger(), m_language, screen); +} + +void ConsoleItemUseActions::use_row(size_t row){ + for (size_t c = 0; c < row; c++){ + pbf_press_dpad(m_context, DPAD_DOWN, 100ms, 300ms); + } + // The item's menu, USE on top. + pbf_press_button(m_context, BUTTON_A, 160ms, 800ms); + pbf_press_button(m_context, BUTTON_A, 160ms, 1500ms); +} + +void ConsoleItemUseActions::close_bag(){ + // B until the overworld: past " used the REPEL.", out of the + // bag and the START menu. The Escape Rope and the Bicycle leave the + // bag by themselves. + mash_until_overworld(m_console, m_context, BAG_CLOSE_TIMEOUT); +} + + + +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryItemUse.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryItemUse.h new file mode 100644 index 0000000000..bf0a5c86c7 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryItemUse.h @@ -0,0 +1,127 @@ +/* AutoStory Item Use + * + * From: https://github.com/PokemonAutomation/ + * + * Items the story uses from the bag on the overworld, as PokeGuy's run + * does: a Repel or Super Repel before long walks through grass and caves, + * renewed when it wears off; an Escape Rope out of the Rocket Hideout; + * and the Bicycle. The bag is read with read_bag_pocket(), so the step + * finds its item wherever the pocket keeps it. + * + */ + +#ifndef PokemonAutomation_PokemonFRLG_AutoStoryItemUse_H +#define PokemonAutomation_PokemonFRLG_AutoStoryItemUse_H + +#include +#include +#include +#include +#include +#include "Common/Cpp/Logging/AbstractLogger.h" +#include "Common/Cpp/TestRunners/UnitTestDatabase.h" +#include "CommonFramework/Language.h" +#include "NintendoSwitch/Controllers/Procon/NintendoSwitch_ProController.h" +#include "NintendoSwitch/NintendoSwitch_ConsoleHandle.h" +#include "VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_ItemListReader.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +// The GAME step that uses an item from the bag on the overworld: +// "use_item ", the slug as read_item_list_name() names it. +inline constexpr std::string_view USE_ITEM_PREFIX = "use_item "; +// The GAME step that gets on the Bicycle (the Key Items pocket's BICYCLE, +// USE). The ROUTE steps after it ride (BIKE_VARIANT). +inline const std::string RIDE_BIKE_VARIANT = "ride_bike"; + +// The bag's pockets, in the order LEFT and RIGHT go through them. +enum class BagPocket{ + ITEMS, + KEY_ITEMS, + POKE_BALLS, +}; + +// The pocket of an item the story uses from the bag: repel, super-repel, +// max-repel, escape-rope (Items) or bicycle (Key Items); nothing for +// another. +std::optional usable_item_pocket(std::string_view item); +// Whether `item` is a Repel of any kind. +bool is_repel(std::string_view item); + +// The variant that uses `item`. +std::string use_item_variant(std::string_view item); +// The item a "use_item " variant uses, or nothing if it isn't one +// or the item isn't one the story uses from the bag. +std::optional parse_use_item_variant(std::string_view variant); +// Whether `variant` is one of this file's steps. +bool is_item_use_variant(std::string_view variant); + +// The row of `item` in `rows` (read_bag_pocket()'s, from the top), if it +// has one left: a count of -1 (unread) counts as some. +std::optional bag_row_of(const std::vector& rows, std::string_view item); + + +// The bag, as use_item_from_bag() drives it: the console's, or a test's. +class ItemUseActions{ +public: + virtual ~ItemUseActions() = default; + // From the overworld, opens the bag on `pocket`, scrolled to its top. + virtual bool open_pocket(BagPocket pocket) = 0; + // The open pocket's rows, from the top. + virtual std::vector read_pocket() = 0; + // Moves to row `row`, USE, and through what the item says. + virtual void use_row(size_t row) = 0; + // Back to the overworld (nothing to do if the item closed the bag). + virtual void close_bag() = 0; +}; + +enum class ItemUseEnd{ + USED, + // The pocket has none left (or the name isn't read). + NONE_LEFT, + // The bag didn't open. + NO_BAG, + // Not an item the story uses from the bag. + UNKNOWN_ITEM, +}; +const char* item_use_end_name(ItemUseEnd end); + +// Opens the item's pocket, finds it and uses it, then back to the +// overworld. +ItemUseEnd use_item_from_bag(Logger& logger, ItemUseActions& actions, std::string_view item); + +// After "REPEL's effect wore off..." has closed: FRLG asks nothing, so +// another of the same Repel is used if the bag has one. NONE_LEFT is no +// failure: the walk goes on without. +ItemUseEnd renew_repel(Logger& logger, ItemUseActions& actions, std::string_view item); + + +// The console's bag. +class ConsoleItemUseActions : public ItemUseActions{ +public: + ConsoleItemUseActions(ConsoleHandle& console, ProControllerContext& context, Language language); + + virtual bool open_pocket(BagPocket pocket) override; + virtual std::vector read_pocket() override; + virtual void use_row(size_t row) override; + virtual void close_bag() override; + +private: + ConsoleHandle& m_console; + ProControllerContext& m_context; + Language m_language; +}; + + +// Registers the item use tests. +void add_tests_AutoStoryItemUse(UnitTestDatabase& database); + + + +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation +#endif diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryItemUse_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryItemUse_Tests.cpp new file mode 100644 index 0000000000..391644a4cf --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryItemUse_Tests.cpp @@ -0,0 +1,223 @@ +/* AutoStory Item Use Tests + * + * From: https://github.com/PokemonAutomation/ + * + * use_item_from_bag() and renew_repel() on a made-up bag, and the bike's + * pace. + * + */ + +#include +#include +#include +#include +#include "Common/Cpp/Logging/AbstractLogger.h" +#include "Common/Cpp/TestRunners/UnitTest.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_MovementMechanics.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h" +#include "PokemonFRLG_AutoStoryEvents.h" +#include "PokemonFRLG_AutoStoryItemUse.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +namespace{ + +// A bag on the sim: its pockets, each item used takes one, a bag that may +// not open. +class SimBag : public ItemUseActions{ +public: + std::map> pockets; + bool opens = true; + std::vector used; + bool open = false; + + virtual bool open_pocket(BagPocket pocket) override{ + m_pocket = pocket; + open = opens; + return opens; + } + virtual std::vector read_pocket() override{ + return pockets[m_pocket]; + } + virtual void use_row(size_t row){ + BagRow& item = pockets[m_pocket][row]; + used.push_back(item.item); + if (item.count > 0){ + item.count--; + } + } + virtual void close_bag() override{ + open = false; + } + +private: + BagPocket m_pocket = BagPocket::ITEMS; +}; + +struct ItemUseInput{ + std::string name; + std::string variant; + std::vector items; + std::vector key_items; + bool opens = true; + // renew_repel() instead of use_item_from_bag(). + bool renew = false; +}; +struct ItemUseExpectation{ + ItemUseEnd end; + std::vector used; +}; +struct ItemUseCase{ + ItemUseInput input; + ItemUseExpectation expectation; +}; + +class Test_AutoStoryItemUse : public UnitTest{ +public: + explicit Test_AutoStoryItemUse(ItemUseCase test_case) + : UnitTest("PokemonFRLG::AutoStoryItemUse - " + test_case.input.name) + , m_case(std::move(test_case)) + {} + virtual UnitTestResult run(Logger& logger, CancellableScope&) const override{ + SimBag bag; + bag.pockets[BagPocket::ITEMS] = m_case.input.items; + bag.pockets[BagPocket::KEY_ITEMS] = m_case.input.key_items; + bag.opens = m_case.input.opens; + const std::string variant = m_case.input.variant; + const std::string item = variant == RIDE_BIKE_VARIANT ? "bicycle" : parse_use_item_variant(variant).value_or(variant); + const ItemUseEnd end = m_case.input.renew ? renew_repel(logger, bag, item) : use_item_from_bag(logger, bag, item); + if (end != m_case.expectation.end || bag.used != m_case.expectation.used || bag.open){ + return std::format( + "\"{}\": {} using {} item(s), bag {}; expected {} using {}.", variant, + item_use_end_name(end), bag.used.size(), bag.open ? "open" : "closed", + item_use_end_name(m_case.expectation.end), m_case.expectation.used.size() + ); + } + return true; + } + +private: + ItemUseCase m_case; +}; + +const ItemUseCase ITEM_USE_CASES[] = { + { + .input = { + .name = "a Repel below the Potions", + .variant = "use_item repel", + .items = {{"potion", 15}, {"repel", 5}, {"escape-rope", 2}}, + }, + .expectation = {.end = ItemUseEnd::USED, .used = {"repel"}}, + }, + { + .input = { + .name = "the Escape Rope with its count unread", + .variant = "use_item escape-rope", + .items = {{"potion", 15}, {"", -1}, {"escape-rope", -1}}, + }, + .expectation = {.end = ItemUseEnd::USED, .used = {"escape-rope"}}, + }, + { + .input = { + .name = "no Super Repel bought", + .variant = "use_item super-repel", + .items = {{"potion", 15}, {"repel", 5}}, + }, + .expectation = {.end = ItemUseEnd::NONE_LEFT, .used = {}}, + }, + { + .input = { + .name = "the bag doesn't open", + .variant = "use_item repel", + .items = {{"repel", 5}}, + .opens = false, + }, + .expectation = {.end = ItemUseEnd::NO_BAG, .used = {}}, + }, + { + .input = { + .name = "the Bicycle from the Key Items", + .variant = "ride_bike", + .items = {{"bicycle", 1}}, + .key_items = {{"", -1}, {"bicycle", -1}}, + }, + .expectation = {.end = ItemUseEnd::USED, .used = {"bicycle"}}, + }, + { + .input = { + .name = "a Repel wore off: another used", + .variant = "use_item repel", + .items = {{"repel", 1}}, + .renew = true, + }, + .expectation = {.end = ItemUseEnd::USED, .used = {"repel"}}, + }, + { + .input = { + .name = "the last Repel wore off: none left", + .variant = "use_item repel", + .items = {{"repel", 0}}, + .renew = true, + }, + .expectation = {.end = ItemUseEnd::NONE_LEFT, .used = {}}, + }, + { + .input = { + .name = "an Escape Rope is no Repel to renew", + .variant = "use_item escape-rope", + .items = {{"escape-rope", 2}}, + .renew = true, + }, + .expectation = {.end = ItemUseEnd::UNKNOWN_ITEM, .used = {}}, + }, +}; + +class Test_AutoStoryItemUseVariants : public UnitTest{ +public: + Test_AutoStoryItemUseVariants() + : UnitTest("PokemonFRLG::AutoStoryItemUse - variants and the bike's pace") + {} + virtual UnitTestResult run(Logger&, CancellableScope&) const override{ + if (parse_use_item_variant("use_item potion") || parse_use_item_variant("use_item") || !parse_use_item_variant(use_item_variant("super-repel"))){ + return "Expected only the story's bag items as use_item variants."; + } + if (!is_item_use_variant(RIDE_BIKE_VARIANT) || is_item_use_variant("poster_switch")){ + return "Expected ride_bike, not poster_switch, as an item use."; + } + StoryStep ride; + ride.kind = Pokemon::StoryStepKind::ROUTE; + ride.variant = BIKE_VARIANT; + if (move_speed(ride) != MoveSpeed::BIKE){ + return "A bike ROUTE isn't ridden."; + } + RouteStep outdoors; + outdoors.tiles = 10; + RouteStep indoors = outdoors; + indoors.walk_only = true; + if (OverworldWalkSteps::tile_time(outdoors, MoveSpeed::BIKE) != MovementMechanics::BIKE_TILE_TIME + || OverworldWalkSteps::tile_time(indoors, MoveSpeed::BIKE) != MovementMechanics::TILE_TIME + ){ + return "Expected the bike's pace outdoors and a walk indoors."; + } + return true; + } +}; + +} // namespace + + +void add_tests_AutoStoryItemUse(UnitTestDatabase& database){ + for (const ItemUseCase& item : ITEM_USE_CASES){ + database.add(item); + } + database.add(); +} + + + +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableSteps.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableSteps.cpp index 372aa1ed5e..81140d3520 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableSteps.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableSteps.cpp @@ -5,6 +5,7 @@ */ #include "PokemonFRLG_AutoStoryHideout.h" +#include "PokemonFRLG_AutoStoryItemUse.h" #include "PokemonFRLG_AutoStoryTableSteps.h" namespace PokemonAutomation{ @@ -167,6 +168,19 @@ StoryStep lift_floor(std::string name, std::string_view from, std::string_view t return step; } +StoryStep use_item(std::string name, std::string_view item, std::string why, std::optional pose_after){ + StoryStep step = make(StoryStepKind::GAME, std::move(name), std::move(why)); + step.variant = use_item_variant(item); + step.pose_after = std::move(pose_after); + return step; +} + +StoryStep ride_bike(std::string name, std::string why){ + StoryStep step = make(StoryStepKind::GAME, std::move(name), std::move(why)); + step.variant = RIDE_BIKE_VARIANT; + return step; +} + } // namespace StoryTable diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableSteps.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableSteps.h index d8e92f3d45..0e28ad3048 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableSteps.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableSteps.h @@ -189,6 +189,11 @@ StoryStep poster_switch(std::string name, std::string why); // Rides the Rocket Hideout's lift, the player facing its panel, from the // floor it stands at to `to` (lift_floor_variant()): "B1F", "B2F", "B4F". StoryStep lift_floor(std::string name, std::string_view from, std::string_view to, std::string why); +// Uses `item` from the bag on the overworld (use_item_variant()): a +// Repel, or an Escape Rope, which leaves the player on `pose_after`. +StoryStep use_item(std::string name, std::string_view item, std::string why, std::optional pose_after = std::nullopt); +// Gets on the Bicycle (RIDE_BIKE_VARIANT). +StoryStep ride_bike(std::string name, std::string why); // Rows in files of their own (PokemonFRLG_AutoStoryTableVermilionGym.cpp, diff --git a/SerialPrograms/cmake/SourceFiles.cmake b/SerialPrograms/cmake/SourceFiles.cmake index 36df2c5b96..9de4d7fee3 100644 --- a/SerialPrograms/cmake/SourceFiles.cmake +++ b/SerialPrograms/cmake/SourceFiles.cmake @@ -1920,6 +1920,9 @@ file(GLOB LIBRARY_SOURCES Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryHideout.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryHideout.h Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryHideout_Tests.cpp + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryItemUse.cpp + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryItemUse.h + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryItemUse_Tests.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryItems_Tests.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryFaultSweep_Tests.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryFaultSweep_Tests.h From a078ce6c5dec2f80624625399ec7b35027a13641 Mon Sep 17 00:00:00 2001 From: dayfine Date: Mon, 9 Nov 2026 17:22:52 -0800 Subject: [PATCH 06/10] FRLG AutoStory: mark the sim bag's use_row override Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013VUubhHeYSMGSSDXZkKZss --- .../Programs/AutoStory/PokemonFRLG_AutoStoryItemUse_Tests.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryItemUse_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryItemUse_Tests.cpp index 391644a4cf..2ea359f252 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryItemUse_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryItemUse_Tests.cpp @@ -42,7 +42,7 @@ class SimBag : public ItemUseActions{ virtual std::vector read_pocket() override{ return pockets[m_pocket]; } - virtual void use_row(size_t row){ + virtual void use_row(size_t row) override{ BagRow& item = pockets[m_pocket][row]; used.push_back(item.item); if (item.count > 0){ From 620d6ceca27bed1f776c0af380da2b63faaf3b8a Mon Sep 17 00:00:00 2001 From: dayfine Date: Mon, 9 Nov 2026 17:28:43 -0800 Subject: [PATCH 07/10] FRLG: code health: field actions in their own file, route tests in a case table Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013VUubhHeYSMGSSDXZkKZss --- .../Overworld/PokemonFRLG_OverworldMap.h | 5 + .../Overworld/PokemonFRLG_OverworldRoute.cpp | 466 --------------- .../PokemonFRLG_OverworldRoute_Tests.cpp | 552 ++++++++++++++++++ .../Overworld/PokemonFRLG_OverworldSim.cpp | 83 +-- .../Overworld/PokemonFRLG_OverworldSim.h | 62 +- .../PokemonFRLG_OverworldSimField.cpp | 125 ++++ .../PokemonFRLG_OverworldSpin_Tests.cpp | 88 ++- .../PokemonFRLG/PokemonFRLG_Tests.cpp | 4 - .../AutoStory/PokemonFRLG_AutoStoryEvents.cpp | 32 +- .../PokemonFRLG_AutoStoryHideout.cpp | 24 + .../AutoStory/PokemonFRLG_AutoStoryHideout.h | 18 +- .../PokemonFRLG_AutoStoryHideout_Tests.cpp | 193 +++--- .../PokemonFRLG_AutoStoryItemUse.cpp | 8 + .../AutoStory/PokemonFRLG_AutoStoryItemUse.h | 2 + .../PokemonFRLG_AutoStoryItemUse_Tests.cpp | 81 ++- .../AutoStory/PokemonFRLG_AutoStoryRowSim.cpp | 6 +- .../PokemonFRLG_AutoStoryStepTable_Tests.cpp | 5 + ...okemonFRLG_AutoStoryVermilionGym_Tests.cpp | 2 +- SerialPrograms/cmake/SourceFiles.cmake | 2 + 19 files changed, 1032 insertions(+), 726 deletions(-) create mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute_Tests.cpp create mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSimField.cpp diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMap.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMap.h index e6d7490370..84bf33ea49 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMap.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMap.h @@ -164,10 +164,15 @@ PlayerState after_step_onto( // MB_SPIN_UP, MB_SPIN_DOWN, MB_SPIN_LEFT, MB_SPIN_RIGHT and // MB_STOP_SPINNING. The tile glyphs can't say so, so a map lists them in // these regions, as it does WILD_REGION. +// "spin_north": MB_SPIN_UP. extern const char SPIN_NORTH_REGION[]; +// "spin_south": MB_SPIN_DOWN. extern const char SPIN_SOUTH_REGION[]; +// "spin_west": MB_SPIN_LEFT. extern const char SPIN_WEST_REGION[]; +// "spin_east": MB_SPIN_RIGHT. extern const char SPIN_EAST_REGION[]; +// "spin_stop": MB_STOP_SPINNING. extern const char SPIN_STOP_REGION[]; // The way the arrow on this tile slides the player, if it is a spinner. std::optional spin_direction(const MapData& map, const TilePos& point); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.cpp index cdea398515..d16f0ca592 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.cpp @@ -561,472 +561,6 @@ MapPosition follow_overworld_route( -namespace{ - -class Test_OverworldRoute : public UnitTest{ -public: - Test_OverworldRoute(std::string name, std::function body) - : UnitTest("PokemonFRLG::OverworldRoute - " + std::move(name)) - , m_body(std::move(body)) - {} - virtual UnitTestResult run(Logger&, CancellableScope&) const override{ - try{ - return m_body(); - }catch (Exception& e){ - return std::string(e.name()) + ": " + e.message(); - } - } - -private: - std::function m_body; -}; - -std::string describe(const std::vector& route){ - std::string text; - for (const RouteStep& step : route){ - text += "\n " + to_string(step); - } - return text; -} - -// A 5x4 map for the step rules: a pond, a raised ledge reached by a stair -// tile, and a tile that can't be entered moving south. -const MapData& small_map(){ - static const MapData MAP = parse_map(R"({ - "name": "Small", "source": {"decomp_file": "", "revision": "test", "date": "2026-10-31"}, - "tiles": ["#####", "#.8.#", "#.w.#", "#####"], - "elevation": ["33333", "33403", "33133", "33333"] - })", "Small"); - return MAP; -} - -UnitTestResult test_step_rules(){ - const MapData& map = small_map(); - PlayerState state{{1, 1}, 3, false}; - if (classify_step(map, state, Direction::EAST) != StepKind::BLOCKED){ - return "Walked from elevation 3 onto elevation 4."; - } - if (classify_step(map, PlayerState{{2, 0}, 3, false}, Direction::SOUTH) != StepKind::BLOCKED){ - return "Entered a north-blocked tile moving south."; - } - state = PlayerState{{1, 2}, 3, false}; - if (classify_step(map, state, Direction::EAST) != StepKind::START_SURF){ - return "Can't start surfing onto the pond."; - } - state = after_step(map, state, Direction::EAST, StepKind::START_SURF); - if (!state.surfing || state.elevation != 1){ - return "Surfing didn't put the player on the water."; - } - if (classify_step(map, state, Direction::EAST) != StepKind::STOP_SURFING){ - return "Can't land from the pond."; - } - if (classify_step(map, PlayerState{{3, 2}, 3, false}, Direction::NORTH) != StepKind::WALK){ - return "Can't step from elevation 3 onto the elevation-0 stair."; - } - return true; -} - -// The maps after the Hall of Fame, which clears the way to Cerulean Cave. -std::vector post_game_maps(){ - return overworld_maps(map_flag_set(overworld_maps(), "after_hall_of_fame")); -} - -MapPosition point_on(const std::vector& maps, const std::string& map, const std::string& point){ - return MapPosition{map, map_point(maps, map, point)}; -} - -const MapData* map_named(const std::vector& maps, const std::string& name){ - const int index = find_map(maps, name); - return index < 0 ? nullptr : &maps[index]; -} - -UnitTestResult test_maps(){ - const std::vector maps = post_game_maps(); - const MapData* b1f = map_named(maps, "CeruleanCave_B1F"); - const MapData* city = map_named(maps, "CeruleanCity"); - if (b1f == nullptr || city == nullptr || map_named(maps, "CeruleanCity_PokemonCenter_1F") == nullptr){ - return "Missing a map."; - } - if (b1f->tiles.width != 40 || b1f->tiles.height != 23 || city->tiles.width != 48 || city->tiles.height != 40){ - return "Unexpected map size."; - } - const TilePos mewtwo_tile = map_point(maps, "CeruleanCave_B1F", "mewtwo"); - const MapObject* mewtwo = object_at(*b1f, mewtwo_tile); - if (mewtwo == nullptr || mewtwo->name != "mewtwo"){ - return "Mewtwo is not on B1F at " + to_string(mewtwo_tile) + "."; - } - if (object_at(*city, {1, 13}) != nullptr){ - return "The cave guard is still there after the Hall of Fame."; - } - if (object_at(*map_named(overworld_maps(), "CeruleanCity"), {1, 13}) == nullptr){ - return "The cave guard is missing before the Hall of Fame."; - } - return true; -} - -// Plans `start` to `goal`, replays it with the step rules and checks where it ends. -UnitTestResult check_route( - const std::vector& maps, - const MapPosition& start, const RouteGoal& goal, - std::vector& route -){ - const std::optional> planned = plan_overworld_route(maps, start, goal); - if (!planned){ - return "No route from " + to_string(start) + " to " + to_string(goal.position) + "."; - } - route = *planned; - const MapPosition end = follow_overworld_route(maps, start, route); - if (!(end == goal.position)){ - return "Route ends at " + to_string(end) + ":" + describe(route); - } - if (goal.facing && route.back().direction != *goal.facing){ - return "Doesn't end facing " + std::string(direction_name(*goal.facing)) + ":" + describe(route); - } - return true; -} - -// Below Mewtwo, facing it, to talk to it. -RouteGoal facing_mewtwo(const std::vector& maps){ - const TilePos mewtwo = map_point(maps, "CeruleanCave_B1F", "mewtwo"); - return RouteGoal{{"CeruleanCave_B1F", mewtwo.next(Direction::SOUTH)}, Direction::NORTH}; -} - -UnitTestResult test_entrance_to_mewtwo(){ - const std::vector maps = post_game_maps(); - std::vector route; - UnitTestResult result = check_route(maps, point_on(maps, "CeruleanCave_1F", "entrance"), facing_mewtwo(maps), route); - if (result.result != UnitTestResult::PASSED){ - return result; - } - bool surfs = false; - bool reaches_b1f = false; - for (const RouteStep& step : route){ - surfs |= step.action == RouteAction::SURF; - reaches_b1f |= step.warps && step.end.map == "CeruleanCave_B1F"; - } - if (!surfs || !reaches_b1f){ - return "Expected to take the ladder to B1F and to surf:" + describe(route); - } - return true; -} - -UnitTestResult test_pokemon_center_to_mewtwo(){ - const std::vector maps = post_game_maps(); - std::vector route; - UnitTestResult result = check_route( - maps, point_on(maps, "CeruleanCity_PokemonCenter_1F", "pc"), facing_mewtwo(maps), route - ); - if (result.result != UnitTestResult::PASSED){ - return result; - } - // Out through the exit mat, onto the city's step below the door, and - // into the cave by its entrance. - std::vector maps_in_order; - for (const RouteStep& step : route){ - if (step.warps){ - maps_in_order.push_back(step.end.map); - } - } - if (maps_in_order.size() < 2 || maps_in_order[0] != "CeruleanCity" || maps_in_order[1] != "CeruleanCave_1F"){ - return "Expected to leave by the mat and enter the cave next:" + describe(route); - } - if (!(route[0].end.map == "CeruleanCity_PokemonCenter_1F" || route[0].warps)){ - return "Unexpected first step:" + describe(route); - } - return true; -} - -UnitTestResult test_cave_guard_blocks(){ - // Before the Hall of Fame the guard stands in the cave entrance. - const std::vector maps = overworld_maps(); - if (plan_overworld_route(maps, point_on(maps, "CeruleanCity_PokemonCenter_1F", "pc"), facing_mewtwo(maps))){ - return "Planned past the cave guard."; - } - return true; -} - -UnitTestResult test_mewtwo_tile_unreachable(){ - const std::vector maps = post_game_maps(); - const std::optional> route = plan_overworld_route( - maps, - point_on(maps, "CeruleanCave_1F", "entrance"), - {point_on(maps, "CeruleanCave_B1F", "mewtwo"), std::nullopt} - ); - if (route){ - return "Planned a route onto Mewtwo itself:" + describe(*route); - } - return true; -} - -UnitTestResult test_story_maps(){ - const std::vector maps = overworld_maps(); - for (const char* name : { - "PalletTown_PlayersHouse_2F", "PalletTown_ProfessorOaksLab", "Route1", "ViridianCity_Mart", - "Route22", "Route2", "ViridianForest", "PewterCity", "PewterCity_Museum_1F", "PewterCity_Gym", - }){ - if (map_named(maps, name) == nullptr){ - return std::string("Missing map ") + name + "."; - } - } - return true; -} - -// Which maps `route` warps into, in order. -std::vector maps_warped_into(const std::vector& route){ - std::vector maps; - for (const RouteStep& step : route){ - if (step.warps){ - maps.push_back(step.end.map); - } - } - return maps; -} - -UnitTestResult test_bedroom_to_oaks_lab(){ - const std::vector maps = overworld_maps(); - std::vector route; - UnitTestResult result = check_route( - maps, point_on(maps, "PalletTown_PlayersHouse_2F", "start"), - {point_on(maps, "PalletTown", "oaks_lab_front"), Direction::NORTH}, route - ); - if (result.result != UnitTestResult::PASSED){ - return result; - } - const std::vector warped = maps_warped_into(route); - if (warped != std::vector{"PalletTown_PlayersHouse_1F", "PalletTown"}){ - return "Expected to take the stairs and leave by the front door:" + describe(route); - } - return true; -} - -UnitTestResult test_viridian_mart_to_pewter_gym(){ - const std::vector maps = overworld_maps(); - std::vector route; - UnitTestResult result = check_route( - maps, point_on(maps, "ViridianCity_Mart", "clerk_front"), - {point_on(maps, "PewterCity", "gym_front"), Direction::NORTH}, route - ); - if (result.result != UnitTestResult::PASSED){ - return result; - } - const std::vector warped = maps_warped_into(route); - if (std::find(warped.begin(), warped.end(), "ViridianForest") == warped.end()){ - return "Expected to go through Viridian Forest:" + describe(route); - } - return true; -} - -// A room with a sign on its north wall, above the third column. -const char* const TEST_SIGN_ROOM_JSON = R"({ - "name": "SignRoom", "source": {"decomp_file": "", "revision": "test", "date": "2026-10-31"}, - "tiles": ["##S##", "#...#", "#...#", "#...#", "#####"], - "elevation": ["33333", "33333", "33333", "33333", "33333"] -})"; - -// Walking north under a sign stops by counting tiles, never by holding into -// the sign; under the wall beside it, the wall stops the player. -UnitTestResult test_no_hold_into_sign(){ - const std::vector maps{parse_map(TEST_SIGN_ROOM_JSON, "SignRoom")}; - std::vector route; - UnitTestResult result = check_route(maps, {"SignRoom", {2, 3}}, {{"SignRoom", {2, 1}}, std::nullopt}, route); - if (result.result != UnitTestResult::PASSED){ - return result; - } - for (const RouteStep& step : route){ - if (step.to_wall && step.end.point == TilePos{2, 1}){ - return "Expected no hold into the sign:" + describe(route); - } - } - route.clear(); - result = check_route(maps, {"SignRoom", {1, 3}}, {{"SignRoom", {1, 1}}, std::nullopt}, route); - if (result.result != UnitTestResult::PASSED){ - return result; - } - if (route.empty() || !route.back().to_wall){ - return "Expected the wall to stop the player beside the sign:" + describe(route); - } - return true; -} - -// A yard with a row of ledges across it. -const char* const TEST_LEDGE_YARD_JSON = R"({ - "name": "LedgeYard", "source": {"decomp_file": "", "revision": "test", "date": "2026-10-31"}, - "tiles": ["#####", "#...#", "#...#", "#vvv#", "#...#", "#####"], - "elevation": ["33333", "33333", "33333", "33333", "33333", "33333"] -})"; - -// A ledge isn't a wall to hold into: moving the way it faces the player -// would hop it, and routes don't hop ledges. Viridian City, -// from the Pokemon Center's door south to the row above the ledge. -UnitTestResult test_no_hold_into_ledge(){ - const std::vector maps{parse_map(TEST_LEDGE_YARD_JSON, "LedgeYard")}; - std::vector route; - UnitTestResult result = check_route(maps, {"LedgeYard", {2, 1}}, {{"LedgeYard", {2, 2}}, std::nullopt}, route); - if (result.result != UnitTestResult::PASSED){ - return result; - } - for (const RouteStep& step : route){ - if (step.to_wall){ - return "Expected no hold into the ledge:" + describe(route); - } - } - route.clear(); - const std::vector world = overworld_maps(); - result = check_route(world, {"ViridianCity", {26, 27}}, {{"ViridianCity", {26, 29}}, std::nullopt}, route); - if (result.result != UnitTestResult::PASSED){ - return result; - } - for (const RouteStep& step : route){ - if (step.to_wall && step.end.point == TilePos{26, 29}){ - return "Expected no hold into the ledge below the Pokemon Center:" + describe(route); - } - } - return true; -} - -// Two small maps that don't need the map files: a room left by the exit mat -// at its bottom, onto the door of a yard, out of which the game walks the -// player. -// No wild battle can start between the bedroom and Oak's Lab: Pallet Town's -// streets have no tall grass. Route 2 and Viridian Forest do. -UnitTestResult test_wild_steps(){ - const std::vector maps = overworld_maps(); - std::vector route; - UnitTestResult result = check_route( - maps, point_on(maps, "PalletTown_PlayersHouse_2F", "start"), - {point_on(maps, "PalletTown", "oaks_lab_front"), Direction::NORTH}, route - ); - if (result.result != UnitTestResult::PASSED){ - return result; - } - for (const RouteStep& step : route){ - if (step.wild){ - return "Expected no wild step from the bedroom to Oak's Lab, got " + to_string(step) + ":" + describe(route); - } - } - route.clear(); - result = check_route( - maps, point_on(maps, "ViridianCity_Mart", "clerk_front"), - {point_on(maps, "PewterCity", "gym_front"), Direction::NORTH}, route - ); - if (result.result != UnitTestResult::PASSED){ - return result; - } - bool forest = false; - for (const RouteStep& step : route){ - forest = forest || (step.wild && step.end.map == "ViridianForest"); - } - if (!forest){ - return "Expected a wild step in Viridian Forest:" + describe(route); - } - // The leg from Pallet Town north onto Route 1 crosses Route 1's grass - // after the map's edge. - route.clear(); - result = check_route( - maps, point_on(maps, "PalletTown", "oaks_lab_front"), - {point_on(maps, "ViridianCity_Mart", "clerk_front"), std::nullopt}, route - ); - if (result.result != UnitTestResult::PASSED){ - return result; - } - for (const RouteStep& step : route){ - if (step.end.map == "Route1" && step.direction == Direction::NORTH && step.tiles > 14 && !step.wild){ - return "Expected the leg from Pallet Town onto Route 1 to be wild, got " + to_string(step) + ":" + describe(route); - } - } - return true; -} - -const char* const TEST_ROOM_JSON = R"({ - "name": "Room", "source": {"decomp_file": "", "revision": "test", "date": "2026-10-31"}, - "tiles": ["#####", "#...#", "#...#", "##.##"], - "elevation": ["33333", "33333", "33333", "33333"], - "points": {"warp_0": [2, 3]}, - "warps": [{"x": 2, "y": 3, "enter": "walk_south", "to_map": "Yard", "to_point": "warp_0"}] -})"; -const char* const TEST_YARD_JSON = R"({ - "name": "Yard", "source": {"decomp_file": "", "revision": "test", "date": "2026-10-31"}, - "tiles": ["#####", "##D##", "#...#", "#####"], - "elevation": ["33333", "33333", "33333", "33333"], - "points": {"warp_0": [2, 1]}, - "warps": [{"x": 2, "y": 1, "enter": "walk_into", "to_map": "Room", "to_point": "warp_0"}] -})"; - -UnitTestResult test_route_through_warp(){ - const std::vector maps{ - parse_map(TEST_ROOM_JSON, "Room"), - parse_map(TEST_YARD_JSON, "Yard"), - }; - std::vector route; - UnitTestResult result = check_route(maps, {"Room", {2, 1}}, {{"Yard", {1, 2}}, std::nullopt}, route); - if (result.result != UnitTestResult::PASSED){ - return result; - } - for (const RouteStep& step : route){ - if (step.warps && step.end.map == "Yard"){ - return true; - } - } - return "Expected to warp into the yard:" + describe(route); -} - -// `json` with its elevation grid replaced by `rows`, or left out if empty. -std::string with_elevation(std::string json, const std::string& rows){ - const size_t begin = json.find("\"elevation\""); - const size_t end = json.find("\n", begin); - json.erase(begin, end - begin + 1); - if (!rows.empty()){ - json.insert(begin, "\"elevation\": " + rows + ",\n"); - } - return json; -} - -// Room and yard as the decompilation writes them, their walls and the -// door at elevation 0, plan the same as with no elevation grid, which -// frlg_maps.py leaves out when every tile the player can stand on is ground. -UnitTestResult test_ground_only_elevation_left_out(){ - const std::vector written{ - parse_map(with_elevation(TEST_ROOM_JSON, R"(["00000", "03330", "03330", "00300"])"), "Room"), - parse_map(with_elevation(TEST_YARD_JSON, R"(["00000", "00000", "03330", "00000"])"), "Yard"), - }; - const std::vector left_out{ - parse_map(with_elevation(TEST_ROOM_JSON, ""), "Room"), - parse_map(with_elevation(TEST_YARD_JSON, ""), "Yard"), - }; - for (const MapPosition& start : {MapPosition{"Room", {1, 1}}, MapPosition{"Room", {3, 2}}}){ - for (const MapPosition& goal : {MapPosition{"Yard", {1, 2}}, MapPosition{"Yard", {3, 2}}, MapPosition{"Room", {2, 2}}}){ - const std::optional> a = plan_overworld_route(written, start, {goal, std::nullopt}); - const std::optional> b = plan_overworld_route(left_out, start, {goal, std::nullopt}); - if (!a || !b || describe(*a) != describe(*b)){ - return "Plans differ from " + to_string(start) + " to " + to_string(goal) + ":" - + (a ? describe(*a) : " none") + "\nwithout elevation:" + (b ? describe(*b) : " none"); - } - } - } - return true; -} - -} // namespace - -void add_tests_OverworldRoute(UnitTestDatabase& database){ - database.add("route through a warp", test_route_through_warp); - database.add("ground-only elevation left out", test_ground_only_elevation_left_out); - database.add("maps to Brock", test_story_maps); - database.add("bedroom to Oak's Lab", test_bedroom_to_oaks_lab); - database.add("Viridian Mart to Pewter Gym", test_viridian_mart_to_pewter_gym); - database.add("wild steps", test_wild_steps); - database.add("no hold into a sign", test_no_hold_into_sign); - database.add("no hold into a ledge", test_no_hold_into_ledge); - database.add("step rules", test_step_rules); - database.add("maps", test_maps); - database.add("Cerulean Cave entrance to Mewtwo", test_entrance_to_mewtwo); - database.add("Cerulean Pokemon Center to Mewtwo", test_pokemon_center_to_mewtwo); - database.add("cave guard blocks the way", test_cave_guard_blocks); - database.add("Mewtwo's tile is blocked", test_mewtwo_tile_unreachable); -} - - - } // namespace PokemonFRLG } // namespace NintendoSwitch } // namespace PokemonAutomation diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute_Tests.cpp new file mode 100644 index 0000000000..419fe87757 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute_Tests.cpp @@ -0,0 +1,552 @@ +/* Overworld Route Tests + * + * From: https://github.com/PokemonAutomation/ + * + * Moved from PokemonFRLG_OverworldRoute.cpp, the tests registered from a + * table of cases. + * + */ + +#include +#include +#include +#include +#include "Common/Cpp/Exceptions.h" +#include "Common/Cpp/TestRunners/UnitTest.h" +#include "PokemonFRLG_OverworldMaps.h" +#include "PokemonFRLG_OverworldRoute.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +namespace{ + +struct RouteTestInput{ + std::string name; + UnitTestResult (*body)(); +}; +struct RouteTestExpectation{ + const char* description; +}; +struct RouteTestCase{ + RouteTestInput input; + RouteTestExpectation expectation; +}; + +class Test_OverworldRoute : public UnitTest{ +public: + explicit Test_OverworldRoute(RouteTestCase test_case) + : UnitTest("PokemonFRLG::OverworldRoute - " + test_case.input.name) + , m_case(std::move(test_case)) + {} + virtual UnitTestResult run(Logger&, CancellableScope&) const override{ + try{ + return m_case.input.body(); + }catch (Exception& e){ + return std::string(e.name()) + ": " + e.message(); + } + } + +private: + RouteTestCase m_case; +}; + +std::string describe(const std::vector& route){ + std::string text; + for (const RouteStep& step : route){ + text += "\n " + to_string(step); + } + return text; +} + +// A 5x4 map for the step rules: a pond, a raised ledge reached by a stair +// tile, and a tile that can't be entered moving south. +const MapData& small_map(){ + static const MapData MAP = parse_map(R"({ + "name": "Small", "source": {"decomp_file": "", "revision": "test", "date": "2026-10-31"}, + "tiles": ["#####", "#.8.#", "#.w.#", "#####"], + "elevation": ["33333", "33403", "33133", "33333"] + })", "Small"); + return MAP; +} + +UnitTestResult test_step_rules(){ + const MapData& map = small_map(); + PlayerState state{{1, 1}, 3, false}; + if (classify_step(map, state, Direction::EAST) != StepKind::BLOCKED){ + return "Walked from elevation 3 onto elevation 4."; + } + if (classify_step(map, PlayerState{{2, 0}, 3, false}, Direction::SOUTH) != StepKind::BLOCKED){ + return "Entered a north-blocked tile moving south."; + } + state = PlayerState{{1, 2}, 3, false}; + if (classify_step(map, state, Direction::EAST) != StepKind::START_SURF){ + return "Can't start surfing onto the pond."; + } + state = after_step(map, state, Direction::EAST, StepKind::START_SURF); + if (!state.surfing || state.elevation != 1){ + return "Surfing didn't put the player on the water."; + } + if (classify_step(map, state, Direction::EAST) != StepKind::STOP_SURFING){ + return "Can't land from the pond."; + } + if (classify_step(map, PlayerState{{3, 2}, 3, false}, Direction::NORTH) != StepKind::WALK){ + return "Can't step from elevation 3 onto the elevation-0 stair."; + } + return true; +} + +// The maps after the Hall of Fame, which clears the way to Cerulean Cave. +std::vector post_game_maps(){ + return overworld_maps(map_flag_set(overworld_maps(), "after_hall_of_fame")); +} + +MapPosition point_on(const std::vector& maps, const std::string& map, const std::string& point){ + return MapPosition{map, map_point(maps, map, point)}; +} + +const MapData* map_named(const std::vector& maps, const std::string& name){ + const int index = find_map(maps, name); + return index < 0 ? nullptr : &maps[index]; +} + +UnitTestResult test_maps(){ + const std::vector maps = post_game_maps(); + const MapData* b1f = map_named(maps, "CeruleanCave_B1F"); + const MapData* city = map_named(maps, "CeruleanCity"); + if (b1f == nullptr || city == nullptr || map_named(maps, "CeruleanCity_PokemonCenter_1F") == nullptr){ + return "Missing a map."; + } + if (b1f->tiles.width != 40 || b1f->tiles.height != 23 || city->tiles.width != 48 || city->tiles.height != 40){ + return "Unexpected map size."; + } + const TilePos mewtwo_tile = map_point(maps, "CeruleanCave_B1F", "mewtwo"); + const MapObject* mewtwo = object_at(*b1f, mewtwo_tile); + if (mewtwo == nullptr || mewtwo->name != "mewtwo"){ + return "Mewtwo is not on B1F at " + to_string(mewtwo_tile) + "."; + } + if (object_at(*city, {1, 13}) != nullptr){ + return "The cave guard is still there after the Hall of Fame."; + } + if (object_at(*map_named(overworld_maps(), "CeruleanCity"), {1, 13}) == nullptr){ + return "The cave guard is missing before the Hall of Fame."; + } + return true; +} + +// Plans `start` to `goal`, replays it with the step rules and checks where it ends. +UnitTestResult check_route( + const std::vector& maps, + const MapPosition& start, const RouteGoal& goal, + std::vector& route +){ + const std::optional> planned = plan_overworld_route(maps, start, goal); + if (!planned){ + return "No route from " + to_string(start) + " to " + to_string(goal.position) + "."; + } + route = *planned; + const MapPosition end = follow_overworld_route(maps, start, route); + if (!(end == goal.position)){ + return "Route ends at " + to_string(end) + ":" + describe(route); + } + if (goal.facing && route.back().direction != *goal.facing){ + return "Doesn't end facing " + std::string(direction_name(*goal.facing)) + ":" + describe(route); + } + return true; +} + +// Below Mewtwo, facing it, to talk to it. +RouteGoal facing_mewtwo(const std::vector& maps){ + const TilePos mewtwo = map_point(maps, "CeruleanCave_B1F", "mewtwo"); + return RouteGoal{{"CeruleanCave_B1F", mewtwo.next(Direction::SOUTH)}, Direction::NORTH}; +} + +UnitTestResult test_entrance_to_mewtwo(){ + const std::vector maps = post_game_maps(); + std::vector route; + UnitTestResult result = check_route(maps, point_on(maps, "CeruleanCave_1F", "entrance"), facing_mewtwo(maps), route); + if (result.result != UnitTestResult::PASSED){ + return result; + } + bool surfs = false; + bool reaches_b1f = false; + for (const RouteStep& step : route){ + surfs |= step.action == RouteAction::SURF; + reaches_b1f |= step.warps && step.end.map == "CeruleanCave_B1F"; + } + if (!surfs || !reaches_b1f){ + return "Expected to take the ladder to B1F and to surf:" + describe(route); + } + return true; +} + +UnitTestResult test_pokemon_center_to_mewtwo(){ + const std::vector maps = post_game_maps(); + std::vector route; + UnitTestResult result = check_route( + maps, point_on(maps, "CeruleanCity_PokemonCenter_1F", "pc"), facing_mewtwo(maps), route + ); + if (result.result != UnitTestResult::PASSED){ + return result; + } + // Out through the exit mat, onto the city's step below the door, and + // into the cave by its entrance. + std::vector maps_in_order; + for (const RouteStep& step : route){ + if (step.warps){ + maps_in_order.push_back(step.end.map); + } + } + if (maps_in_order.size() < 2 || maps_in_order[0] != "CeruleanCity" || maps_in_order[1] != "CeruleanCave_1F"){ + return "Expected to leave by the mat and enter the cave next:" + describe(route); + } + if (!(route[0].end.map == "CeruleanCity_PokemonCenter_1F" || route[0].warps)){ + return "Unexpected first step:" + describe(route); + } + return true; +} + +UnitTestResult test_cave_guard_blocks(){ + // Before the Hall of Fame the guard stands in the cave entrance. + const std::vector maps = overworld_maps(); + if (plan_overworld_route(maps, point_on(maps, "CeruleanCity_PokemonCenter_1F", "pc"), facing_mewtwo(maps))){ + return "Planned past the cave guard."; + } + return true; +} + +UnitTestResult test_mewtwo_tile_unreachable(){ + const std::vector maps = post_game_maps(); + const std::optional> route = plan_overworld_route( + maps, + point_on(maps, "CeruleanCave_1F", "entrance"), + {point_on(maps, "CeruleanCave_B1F", "mewtwo"), std::nullopt} + ); + if (route){ + return "Planned a route onto Mewtwo itself:" + describe(*route); + } + return true; +} + +UnitTestResult test_story_maps(){ + const std::vector maps = overworld_maps(); + for (const char* name : { + "PalletTown_PlayersHouse_2F", "PalletTown_ProfessorOaksLab", "Route1", "ViridianCity_Mart", + "Route22", "Route2", "ViridianForest", "PewterCity", "PewterCity_Museum_1F", "PewterCity_Gym", + }){ + if (map_named(maps, name) == nullptr){ + return std::string("Missing map ") + name + "."; + } + } + return true; +} + +// Which maps `route` warps into, in order. +std::vector maps_warped_into(const std::vector& route){ + std::vector maps; + for (const RouteStep& step : route){ + if (step.warps){ + maps.push_back(step.end.map); + } + } + return maps; +} + +UnitTestResult test_bedroom_to_oaks_lab(){ + const std::vector maps = overworld_maps(); + std::vector route; + UnitTestResult result = check_route( + maps, point_on(maps, "PalletTown_PlayersHouse_2F", "start"), + {point_on(maps, "PalletTown", "oaks_lab_front"), Direction::NORTH}, route + ); + if (result.result != UnitTestResult::PASSED){ + return result; + } + const std::vector warped = maps_warped_into(route); + if (warped != std::vector{"PalletTown_PlayersHouse_1F", "PalletTown"}){ + return "Expected to take the stairs and leave by the front door:" + describe(route); + } + return true; +} + +UnitTestResult test_viridian_mart_to_pewter_gym(){ + const std::vector maps = overworld_maps(); + std::vector route; + UnitTestResult result = check_route( + maps, point_on(maps, "ViridianCity_Mart", "clerk_front"), + {point_on(maps, "PewterCity", "gym_front"), Direction::NORTH}, route + ); + if (result.result != UnitTestResult::PASSED){ + return result; + } + const std::vector warped = maps_warped_into(route); + if (std::find(warped.begin(), warped.end(), "ViridianForest") == warped.end()){ + return "Expected to go through Viridian Forest:" + describe(route); + } + return true; +} + +// A room with a sign on its north wall, above the third column. +const char* const TEST_SIGN_ROOM_JSON = R"({ + "name": "SignRoom", "source": {"decomp_file": "", "revision": "test", "date": "2026-10-31"}, + "tiles": ["##S##", "#...#", "#...#", "#...#", "#####"], + "elevation": ["33333", "33333", "33333", "33333", "33333"] +})"; + +// Walking north under a sign stops by counting tiles, never by holding into +// the sign; under the wall beside it, the wall stops the player. +UnitTestResult test_no_hold_into_sign(){ + const std::vector maps{parse_map(TEST_SIGN_ROOM_JSON, "SignRoom")}; + std::vector route; + UnitTestResult result = check_route(maps, {"SignRoom", {2, 3}}, {{"SignRoom", {2, 1}}, std::nullopt}, route); + if (result.result != UnitTestResult::PASSED){ + return result; + } + for (const RouteStep& step : route){ + if (step.to_wall && step.end.point == TilePos{2, 1}){ + return "Expected no hold into the sign:" + describe(route); + } + } + route.clear(); + result = check_route(maps, {"SignRoom", {1, 3}}, {{"SignRoom", {1, 1}}, std::nullopt}, route); + if (result.result != UnitTestResult::PASSED){ + return result; + } + if (route.empty() || !route.back().to_wall){ + return "Expected the wall to stop the player beside the sign:" + describe(route); + } + return true; +} + +// A yard with a row of ledges across it. +const char* const TEST_LEDGE_YARD_JSON = R"({ + "name": "LedgeYard", "source": {"decomp_file": "", "revision": "test", "date": "2026-10-31"}, + "tiles": ["#####", "#...#", "#...#", "#vvv#", "#...#", "#####"], + "elevation": ["33333", "33333", "33333", "33333", "33333", "33333"] +})"; + +// A ledge isn't a wall to hold into: moving the way it faces the player +// would hop it, and routes don't hop ledges. Viridian City, +// from the Pokemon Center's door south to the row above the ledge. +UnitTestResult test_no_hold_into_ledge(){ + const std::vector maps{parse_map(TEST_LEDGE_YARD_JSON, "LedgeYard")}; + std::vector route; + UnitTestResult result = check_route(maps, {"LedgeYard", {2, 1}}, {{"LedgeYard", {2, 2}}, std::nullopt}, route); + if (result.result != UnitTestResult::PASSED){ + return result; + } + for (const RouteStep& step : route){ + if (step.to_wall){ + return "Expected no hold into the ledge:" + describe(route); + } + } + route.clear(); + const std::vector world = overworld_maps(); + result = check_route(world, {"ViridianCity", {26, 27}}, {{"ViridianCity", {26, 29}}, std::nullopt}, route); + if (result.result != UnitTestResult::PASSED){ + return result; + } + for (const RouteStep& step : route){ + if (step.to_wall && step.end.point == TilePos{26, 29}){ + return "Expected no hold into the ledge below the Pokemon Center:" + describe(route); + } + } + return true; +} + +// Two small maps that don't need the map files: a room left by the exit mat +// at its bottom, onto the door of a yard, out of which the game walks the +// player. +// No wild battle can start between the bedroom and Oak's Lab: Pallet Town's +// streets have no tall grass. Route 2 and Viridian Forest do. +UnitTestResult test_wild_steps(){ + const std::vector maps = overworld_maps(); + std::vector route; + UnitTestResult result = check_route( + maps, point_on(maps, "PalletTown_PlayersHouse_2F", "start"), + {point_on(maps, "PalletTown", "oaks_lab_front"), Direction::NORTH}, route + ); + if (result.result != UnitTestResult::PASSED){ + return result; + } + for (const RouteStep& step : route){ + if (step.wild){ + return "Expected no wild step from the bedroom to Oak's Lab, got " + to_string(step) + ":" + describe(route); + } + } + route.clear(); + result = check_route( + maps, point_on(maps, "ViridianCity_Mart", "clerk_front"), + {point_on(maps, "PewterCity", "gym_front"), Direction::NORTH}, route + ); + if (result.result != UnitTestResult::PASSED){ + return result; + } + bool forest = false; + for (const RouteStep& step : route){ + forest = forest || (step.wild && step.end.map == "ViridianForest"); + } + if (!forest){ + return "Expected a wild step in Viridian Forest:" + describe(route); + } + // The leg from Pallet Town north onto Route 1 crosses Route 1's grass + // after the map's edge. + route.clear(); + result = check_route( + maps, point_on(maps, "PalletTown", "oaks_lab_front"), + {point_on(maps, "ViridianCity_Mart", "clerk_front"), std::nullopt}, route + ); + if (result.result != UnitTestResult::PASSED){ + return result; + } + for (const RouteStep& step : route){ + if (step.end.map == "Route1" && step.direction == Direction::NORTH && step.tiles > 14 && !step.wild){ + return "Expected the leg from Pallet Town onto Route 1 to be wild, got " + to_string(step) + ":" + describe(route); + } + } + return true; +} + +const char* const TEST_ROOM_JSON = R"({ + "name": "Room", "source": {"decomp_file": "", "revision": "test", "date": "2026-10-31"}, + "tiles": ["#####", "#...#", "#...#", "##.##"], + "elevation": ["33333", "33333", "33333", "33333"], + "points": {"warp_0": [2, 3]}, + "warps": [{"x": 2, "y": 3, "enter": "walk_south", "to_map": "Yard", "to_point": "warp_0"}] +})"; +const char* const TEST_YARD_JSON = R"({ + "name": "Yard", "source": {"decomp_file": "", "revision": "test", "date": "2026-10-31"}, + "tiles": ["#####", "##D##", "#...#", "#####"], + "elevation": ["33333", "33333", "33333", "33333"], + "points": {"warp_0": [2, 1]}, + "warps": [{"x": 2, "y": 1, "enter": "walk_into", "to_map": "Room", "to_point": "warp_0"}] +})"; + +UnitTestResult test_route_through_warp(){ + const std::vector maps{ + parse_map(TEST_ROOM_JSON, "Room"), + parse_map(TEST_YARD_JSON, "Yard"), + }; + std::vector route; + UnitTestResult result = check_route(maps, {"Room", {2, 1}}, {{"Yard", {1, 2}}, std::nullopt}, route); + if (result.result != UnitTestResult::PASSED){ + return result; + } + for (const RouteStep& step : route){ + if (step.warps && step.end.map == "Yard"){ + return true; + } + } + return "Expected to warp into the yard:" + describe(route); +} + +// `json` with its elevation grid replaced by `rows`, or left out if empty. +std::string with_elevation(std::string json, const std::string& rows){ + const size_t begin = json.find("\"elevation\""); + const size_t end = json.find("\n", begin); + json.erase(begin, end - begin + 1); + if (!rows.empty()){ + json.insert(begin, "\"elevation\": " + rows + ",\n"); + } + return json; +} + +// Room and yard as the decompilation writes them, their walls and the +// door at elevation 0, plan the same as with no elevation grid, which +// frlg_maps.py leaves out when every tile the player can stand on is ground. +UnitTestResult test_ground_only_elevation_left_out(){ + const std::vector written{ + parse_map(with_elevation(TEST_ROOM_JSON, R"(["00000", "03330", "03330", "00300"])"), "Room"), + parse_map(with_elevation(TEST_YARD_JSON, R"(["00000", "00000", "03330", "00000"])"), "Yard"), + }; + const std::vector left_out{ + parse_map(with_elevation(TEST_ROOM_JSON, ""), "Room"), + parse_map(with_elevation(TEST_YARD_JSON, ""), "Yard"), + }; + for (const MapPosition& start : {MapPosition{"Room", {1, 1}}, MapPosition{"Room", {3, 2}}}){ + for (const MapPosition& goal : {MapPosition{"Yard", {1, 2}}, MapPosition{"Yard", {3, 2}}, MapPosition{"Room", {2, 2}}}){ + const std::optional> a = plan_overworld_route(written, start, {goal, std::nullopt}); + const std::optional> b = plan_overworld_route(left_out, start, {goal, std::nullopt}); + if (!a || !b || describe(*a) != describe(*b)){ + return "Plans differ from " + to_string(start) + " to " + to_string(goal) + ":" + + (a ? describe(*a) : " none") + "\nwithout elevation:" + (b ? describe(*b) : " none"); + } + } + } + return true; +} + +const RouteTestCase ROUTE_CASES[] = { + { + .input = {.name = "route through a warp", .body = test_route_through_warp}, + .expectation = {.description = "route through a warp"}, + }, + { + .input = {.name = "ground-only elevation left out", .body = test_ground_only_elevation_left_out}, + .expectation = {.description = "ground-only elevation left out"}, + }, + { + .input = {.name = "maps to Brock", .body = test_story_maps}, + .expectation = {.description = "maps to Brock"}, + }, + { + .input = {.name = "bedroom to Oak's Lab", .body = test_bedroom_to_oaks_lab}, + .expectation = {.description = "bedroom to Oak's Lab"}, + }, + { + .input = {.name = "Viridian Mart to Pewter Gym", .body = test_viridian_mart_to_pewter_gym}, + .expectation = {.description = "Viridian Mart to Pewter Gym"}, + }, + { + .input = {.name = "wild steps", .body = test_wild_steps}, + .expectation = {.description = "wild steps"}, + }, + { + .input = {.name = "no hold into a sign", .body = test_no_hold_into_sign}, + .expectation = {.description = "no hold into a sign"}, + }, + { + .input = {.name = "no hold into a ledge", .body = test_no_hold_into_ledge}, + .expectation = {.description = "no hold into a ledge"}, + }, + { + .input = {.name = "step rules", .body = test_step_rules}, + .expectation = {.description = "step rules"}, + }, + { + .input = {.name = "maps", .body = test_maps}, + .expectation = {.description = "maps"}, + }, + { + .input = {.name = "Cerulean Cave entrance to Mewtwo", .body = test_entrance_to_mewtwo}, + .expectation = {.description = "Cerulean Cave entrance to Mewtwo"}, + }, + { + .input = {.name = "Cerulean Pokemon Center to Mewtwo", .body = test_pokemon_center_to_mewtwo}, + .expectation = {.description = "Cerulean Pokemon Center to Mewtwo"}, + }, + { + .input = {.name = "cave guard blocks the way", .body = test_cave_guard_blocks}, + .expectation = {.description = "cave guard blocks the way"}, + }, + { + .input = {.name = "Mewtwo's tile is blocked", .body = test_mewtwo_tile_unreachable}, + .expectation = {.description = "Mewtwo's tile is blocked"}, + }, +}; + +} // namespace + +void add_tests_OverworldRoute(UnitTestDatabase& database){ + for (const RouteTestCase& item : ROUTE_CASES){ + database.add(item); + } +} + + + +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp index 0b9483bf2c..b0cd746e07 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp @@ -96,8 +96,6 @@ namespace{ constexpr Milliseconds NO_BATTLE_WAIT(15000); constexpr Milliseconds BATTLE_TIME(10000); constexpr Milliseconds MESSAGE_CLOSE(1000); -// The questions, " used CUT!" and the tree falling. -constexpr Milliseconds CUT_TIME(4000); // Rows of black below a small room, seen from this near its bottom. constexpr int BLACK_BELOW_ROWS = 5; constexpr int SMALL_ROOM_ROWS = 20; @@ -246,14 +244,8 @@ std::optional OverworldSim::take_warp(int map_index, const T if (warp == nullptr){ return std::nullopt; } - const auto lift = m_script.lift_floors.find(m_maps[map_index].name); - if (lift != m_script.lift_floors.end() && m_lift_floor){ - const MapPosition& out = lift->second[*m_lift_floor]; - const int destination = find_map(m_maps, out.map); - if (destination < 0){ - return std::nullopt; - } - return Step{destination, PlayerState{out.point, elevation_at(m_maps[destination], out.point), false}, true, false, false}; + if (m_lift_floor && m_script.lift_floors.contains(m_maps[map_index].name)){ + return lift_warp(map_index); } if (!warp->to){ return std::nullopt; @@ -325,12 +317,8 @@ std::optional OverworldSim::press(Direction direction) const if (warps_on_step(map(), ahead)){ return take_warp(m_map, ahead); } - if (spin_direction(map(), ahead)){ - step.spin = spin_slide(map(), step.player); - if (!step.spin){ - return std::nullopt; - } - step.player = step.spin->end; + if (!slide_on(step)){ + return std::nullopt; } return step; } @@ -510,13 +498,7 @@ void OverworldSim::take_steps(Direction direction, Milliseconds pressed, Millise if (step->walked_out){ m_state.facing = Direction::SOUTH; } - if (step->spin){ - // The game moves the player through the slide; the hold, if - // it goes on, walks on from its end. - m_state.facing = step->spin->facing; - note(std::format("slid {} tile(s) to {}.", step->spin->tiles, to_string(step->spin->end.position))); - pressed += MovementMechanics::SPIN_TILE_TIME * step->spin->tiles; - } + pressed += after_slide(*step); if (after_step(*step, pressed)){ return; } @@ -677,51 +659,6 @@ bool OverworldSim::close_message(){ return true; } -bool OverworldSim::cut_tree(){ - std::vector& objects = m_maps[m_map].objects; - const TilePos ahead = m_player.position.next(m_state.facing); - for (size_t c = 0; c < objects.size(); c++){ - if (objects[c].name == "cut_tree" && objects[c].x == ahead.x && objects[c].y == ahead.y - && m_state.screen == SimScreen::OVERWORLD && !m_frozen - ){ - m_cut_trees.emplace_back(m_map, objects[c]); - objects.erase(objects.begin() + c); - advance_to(m_now + CUT_TIME); - note("cut the tree on " + to_string(MapPosition{map().name, ahead}) + "."); - return true; - } - } - violation("no tree to cut " + std::string(direction_name(m_state.facing)) + " of " + to_string(m_state.position) + "."); - return false; -} - -bool OverworldSim::choose_lift_floor(size_t floor){ - const auto lift = m_script.lift_floors.find(map().name); - if (lift == m_script.lift_floors.end() || floor >= lift->second.size() || m_frozen){ - violation("no lift panel on " + to_string(m_state.position) + " with a floor " + std::to_string(floor) + "."); - return false; - } - m_lift_floor = floor; - advance_to(m_now + m_script.timing.message); - note("the lift goes to " + to_string(lift->second[floor]) + "."); - return true; -} - -bool OverworldSim::push_poster_switch(){ - std::vector& objects = m_maps[m_map].objects; - const TilePos ahead = m_player.position.next(m_state.facing); - const auto poster = std::find_if(objects.begin(), objects.end(), [&](const MapObject& object){ - return object.name == "poster" && object.x == ahead.x && object.y == ahead.y; - }); - if (poster == objects.end() || m_state.screen != SimScreen::OVERWORLD || m_frozen){ - violation("no poster " + std::string(direction_name(m_state.facing)) + " of " + to_string(m_state.position) + "."); - return false; - } - std::erase_if(objects, [](const MapObject& object){ return object.name == "poster_stairs"; }); - advance_to(m_now + m_script.timing.message); - note("pushed the switch behind the poster: the stairs are open."); - return true; -} @@ -813,6 +750,12 @@ size_t OverworldSimWalker::clear_late(const std::vector& interrupts, } } +void OverworldSimWalker::wait_out_slide(const RouteStep& step){ + if (step.spin_tiles){ + m_sim.watch({}, SPIN_TILE_TIME * *step.spin_tiles + SPIN_STOP_SETTLE); + } +} + void OverworldSimWalker::check_overworld(const RouteStep& step){ const SimScreen screen = m_sim.state().screen; if (m_rules.check_overworld && (screen == SimScreen::START_MENU || screen == SimScreen::MESSAGE)){ @@ -900,9 +843,7 @@ std::optional OverworldSimWalker::walk_step(const RouteStep& step, if (result.stopped){ return dark_stop(step, walked_near(step, result.tiles, TILE_TIME)); } - if (step.spin_tiles){ - m_sim.watch({}, SPIN_TILE_TIME * *step.spin_tiles + SPIN_STOP_SETTLE); - } + wait_out_slide(step); const Milliseconds fade_watched_from = m_rules.watch_fade_along_hold ? m_leg_start : m_sim.state().time; if (step.warps && !after_warp(step, warps_before, fade_watched_from)){ if (!m_rules.find_player_after_warp_battle){ diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h index 4eb0884abc..f2dc04326b 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h @@ -60,7 +60,7 @@ enum class SimScreen{ BATTLE, // a battle's text box with the HP boxes FADE, // a warp's fade out and in }; -// The human-readable name of a simulated screen. +// The human-readable name of a simulated screen. const char* sim_screen_name(SimScreen screen); // What the walker watches the screen with. @@ -206,14 +206,30 @@ enum class SimBattleChoice{ RUN, FIGHT, }; -// How a simulated battle concluded. +// How a simulated battle concluded. enum class SimBattleEnd{ ENDED, CANT_RUN, // a trainer's battle: "No! There's no running from a TRAINER battle!" NO_BATTLE, // there was none, on screen or about to show }; -// Simulates overworld movement, encounters and dialogs. +// What A does on the overworld, facing something (OverworldSim::use_field()). +enum class SimField{ + // Cuts the tree the player faces (an object named "cut_tree") with + // YES to the game's question: it is gone until the player next warps, + // as its hide flag is a FLAG_TEMP. + CUT_TREE, + // The poster the player faces (an object named "poster"): the switch + // behind it opens the stairs, which takes away the objects named + // "poster_stairs" for good (pokefirered + // CeladonCity_GameCorner_EventScript_Poster's setmetatile). + POSTER_SWITCH, + // The panel of the lift the player stands in (SimScript::lift_floors): + // the floor picked, the lift's door now lets out there. + LIFT_FLOOR, +}; + +// Simulates overworld movement, encounters and dialogs. class OverworldSim{ public: OverworldSim(std::vector maps, MapPosition start, Direction facing, SimScript script = {}); @@ -255,22 +271,9 @@ class OverworldSim{ // battle follows it. bool close_message(); - // Cuts the tree the player faces (an object named "cut_tree") with - // YES to the game's question: it is gone until the player next warps, - // as its hide flag is a FLAG_TEMP. Returns whether there was one. - bool cut_tree(); - - // A on the panel of the lift the player stands in (SimScript:: - // lift_floors) and floor `floor` picked: the lift's door now lets out - // there. Returns whether there was such a lift and floor. - bool choose_lift_floor(size_t floor); - - // A on the poster the player faces (an object named "poster"): the - // switch behind it opens the stairs, which takes away the objects - // named "poster_stairs" for good (pokefirered - // CeladonCity_GameCorner_EventScript_Poster's setmetatile). Returns - // whether there was a poster. - bool push_poster_switch(); + // A on what the player faces, as `field` says (`floor`: the lift's). + // Returns whether it was there. (PokemonFRLG_OverworldSimField.cpp) + bool use_field(SimField field, size_t floor = 0); private: // A screen that shows at `at`; `unfreeze`: the player can move again. @@ -314,6 +317,17 @@ class OverworldSim{ std::optional press(Direction direction) const; std::optional take_warp(int map_index, const TilePos& tile) const; std::optional cross_edge(Direction direction) const; + // The lift's door to the floor picked, if `map_index` is a lift and + // one was picked. + std::optional lift_warp(int map_index) const; + // A step that landed on a spinner slides on to the slide's end; false + // if the slide never stops. + bool slide_on(Step& step) const; + // The slide's time after `step`, and its facing; 0 for no slide. + Milliseconds after_slide(const Step& step); + bool cut_tree(); + bool choose_lift_floor(size_t floor); + bool push_poster_switch(); bool after_step(const Step& step, Milliseconds pressed); bool wild_battle(Milliseconds pressed); std::optional next_encounter(); @@ -516,6 +530,8 @@ class OverworldSimWalker{ StepEnd watch_for_trainer(const RouteStep& step, int approach_tiles); bool moved_past_wall(const RouteStep& step); void check_overworld(const RouteStep& step); + // After a leg onto a spinner: the slide's time. + void wait_out_slide(const RouteStep& step); const std::vector& interrupts(const RouteStep& step) const; std::vector watchers(const std::vector& interrupts) const; [[noreturn]] void fail(const std::string& message); @@ -532,7 +548,7 @@ class OverworldSimWalker{ }; -// Register overworld simulation unit tests. +// Register overworld simulation unit tests. void add_tests_OverworldSim(UnitTestDatabase& database); // Register the tests of a warp's fade over before the hold onto it ends. void add_tests_OverworldWarpFade(UnitTestDatabase& database); @@ -542,7 +558,7 @@ void add_tests_OverworldSpin(UnitTestDatabase& database); -} // namespace PokemonFRLG -} // namespace NintendoSwitch -} // namespace PokemonAutomation +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation #endif diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSimField.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSimField.cpp new file mode 100644 index 0000000000..2f657677a6 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSimField.cpp @@ -0,0 +1,125 @@ +/* Overworld Sim: the Field + * + * From: https://github.com/PokemonAutomation/ + * + * What A does on the sim's overworld, facing something: a tree to cut, + * the Game Corner's poster, the lift's panel (OverworldSim::use_field()). + * + */ + +#include +#include +#include "PokemonFRLG_OverworldSim.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +namespace{ + +// The questions, " used CUT!" and the tree falling. +constexpr Milliseconds CUT_TIME(4000); + +} // namespace + + +bool OverworldSim::use_field(SimField field, size_t floor){ + switch (field){ + case SimField::CUT_TREE: + return cut_tree(); + case SimField::POSTER_SWITCH: + return push_poster_switch(); + case SimField::LIFT_FLOOR: + return choose_lift_floor(floor); + } + return false; +} + +bool OverworldSim::slide_on(Step& step) const{ + if (!spin_direction(m_maps[step.map], step.player.position)){ + return true; + } + step.spin = spin_slide(m_maps[step.map], step.player); + if (!step.spin){ + return false; + } + step.player = step.spin->end; + return true; +} + +Milliseconds OverworldSim::after_slide(const Step& step){ + if (!step.spin){ + return Milliseconds(0); + } + // The game moves the player through the slide; the hold, if it goes + // on, walks on from its end. + m_state.facing = step.spin->facing; + note(std::format("slid {} tile(s) to {}.", step.spin->tiles, to_string(step.spin->end.position))); + return MovementMechanics::SPIN_TILE_TIME * step.spin->tiles; +} + +std::optional OverworldSim::lift_warp(int map_index) const{ + const auto lift = m_script.lift_floors.find(m_maps[map_index].name); + if (lift == m_script.lift_floors.end() || !m_lift_floor){ + return std::nullopt; + } + const MapPosition& out = lift->second[*m_lift_floor]; + const auto to = std::find_if(m_maps.begin(), m_maps.end(), [&](const MapData& map){ return map.name == out.map; }); + if (to == m_maps.end()){ + return std::nullopt; + } + return Step{(int)(to - m_maps.begin()), PlayerState{out.point, elevation_at(*to, out.point), false}, true, false, false}; +} + +bool OverworldSim::cut_tree(){ + std::vector& objects = m_maps[m_map].objects; + const TilePos ahead = m_player.position.next(m_state.facing); + for (size_t c = 0; c < objects.size(); c++){ + if (objects[c].name == "cut_tree" && objects[c].x == ahead.x && objects[c].y == ahead.y + && m_state.screen == SimScreen::OVERWORLD && !m_frozen + ){ + m_cut_trees.emplace_back(m_map, objects[c]); + objects.erase(objects.begin() + c); + advance_to(m_now + CUT_TIME); + note("cut the tree on " + to_string(MapPosition{map().name, ahead}) + "."); + return true; + } + } + violation("no tree to cut " + std::string(direction_name(m_state.facing)) + " of " + to_string(m_state.position) + "."); + return false; +} + +bool OverworldSim::choose_lift_floor(size_t floor){ + const auto lift = m_script.lift_floors.find(map().name); + if (lift == m_script.lift_floors.end() || floor >= lift->second.size() || m_frozen){ + violation("no lift panel on " + to_string(m_state.position) + " with a floor " + std::to_string(floor) + "."); + return false; + } + m_lift_floor = floor; + advance_to(m_now + m_script.timing.message); + note("the lift goes to " + to_string(lift->second[floor]) + "."); + return true; +} + +bool OverworldSim::push_poster_switch(){ + std::vector& objects = m_maps[m_map].objects; + const TilePos ahead = m_player.position.next(m_state.facing); + const auto poster = std::find_if(objects.begin(), objects.end(), [&](const MapObject& object){ + return object.name == "poster" && object.x == ahead.x && object.y == ahead.y; + }); + if (poster == objects.end() || m_state.screen != SimScreen::OVERWORLD || m_frozen){ + violation("no poster " + std::string(direction_name(m_state.facing)) + " of " + to_string(m_state.position) + "."); + return false; + } + std::erase_if(objects, [](const MapObject& object){ return object.name == "poster_stairs"; }); + advance_to(m_now + m_script.timing.message); + note("pushed the switch behind the poster: the stairs are open."); + return true; +} + + + +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSpin_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSpin_Tests.cpp index 298e3cb156..afe24a3f8b 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSpin_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSpin_Tests.cpp @@ -10,7 +10,6 @@ */ #include -#include #include #include #include "Common/Cpp/Exceptions.h" @@ -28,22 +27,34 @@ namespace PokemonFRLG{ namespace{ +struct SpinInput{ + std::string name; + UnitTestResult (*body)(); +}; +struct SpinExpectation{ + const char* description; +}; +struct SpinCase{ + SpinInput input; + SpinExpectation expectation; +}; + class Test_OverworldSpin : public UnitTest{ public: - Test_OverworldSpin(std::string name, std::function body) - : UnitTest("PokemonFRLG::OverworldSpin - " + std::move(name)) - , m_body(std::move(body)) + explicit Test_OverworldSpin(SpinCase test_case) + : UnitTest("PokemonFRLG::OverworldSpin - " + test_case.input.name) + , m_case(std::move(test_case)) {} virtual UnitTestResult run(Logger&, CancellableScope&) const override{ try{ - return m_body(); + return m_case.input.body(); }catch (Exception& e){ return std::string(e.name()) + ": " + e.message(); } } private: - std::function m_body; + SpinCase m_case; }; // A room with spinners: @@ -204,7 +215,7 @@ UnitTestResult lift_ride(bool pick, const MapPosition& expected){ SimScript script; script.lift_floors["Lift"] = {MapPosition{"FloorA", {1, 1}}, MapPosition{"FloorB", {1, 1}}}; OverworldSim sim(lift_maps(), MapPosition{"Lift", {1, 2}}, Direction::SOUTH, script); - if (pick && !sim.choose_lift_floor(1)){ + if (pick && !sim.use_field(SimField::LIFT_FLOOR, 1)){ return std::string("No floor 1 on the lift's panel."); } sim.hold(Direction::SOUTH, MovementMechanics::TILE_TIME, MovementMechanics::STEP_RELEASE, std::chrono::milliseconds(3000), {}); @@ -238,7 +249,7 @@ const MapData& poster_room(){ MapPosition poster_walk(bool push){ OverworldSim sim({poster_room()}, MapPosition{"Corner", {2, 2}}, Direction::NORTH); if (push){ - sim.push_poster_switch(); + sim.use_field(SimField::POSTER_SWITCH); } const Milliseconds tile = MovementMechanics::TILE_TIME; // Each hold turns the player first, which costs a tile's time. @@ -258,21 +269,60 @@ UnitTestResult test_poster_opens_stairs(){ return true; } +const SpinCase SPIN_CASES[] = { + { + .input = {.name = "slide up to a wall", .body = test_slide_into_wall}, + .expectation = {.description = "slide up to a wall"}, + }, + { + .input = {.name = "slide turned by an arrow", .body = test_slide_turned_by_arrow}, + .expectation = {.description = "slide turned by an arrow"}, + }, + { + .input = {.name = "slide to a stop tile", .body = test_slide_to_stop_tile}, + .expectation = {.description = "slide to a stop tile"}, + }, + { + .input = {.name = "arrows in a loop never stop", .body = test_slide_loop}, + .expectation = {.description = "arrows in a loop never stop"}, + }, + { + .input = {.name = "route ends with a slide", .body = test_route_ends_on_slide}, + .expectation = {.description = "route ends with a slide"}, + }, + { + .input = {.name = "a spinner back into a dead end traps", .body = test_dead_end_traps}, + .expectation = {.description = "a spinner back into a dead end traps"}, + }, + { + .input = {.name = "sim slides the player", .body = test_sim_slides}, + .expectation = {.description = "sim slides the player"}, + }, + { + .input = {.name = "sim walker walks a slide", .body = test_sim_walker_walks_slide}, + .expectation = {.description = "sim walker walks a slide"}, + }, + { + .input = {.name = "lift lets out on the floor picked", .body = test_lift_floor}, + .expectation = {.description = "lift lets out on the floor picked"}, + }, + { + .input = {.name = "lift with no floor picked stays", .body = test_lift_no_floor}, + .expectation = {.description = "lift with no floor picked stays"}, + }, + { + .input = {.name = "poster switch opens the stairs", .body = test_poster_opens_stairs}, + .expectation = {.description = "poster switch opens the stairs"}, + }, +}; + } // namespace void add_tests_OverworldSpin(UnitTestDatabase& database){ - database.add("slide up to a wall", test_slide_into_wall); - database.add("slide turned by an arrow", test_slide_turned_by_arrow); - database.add("slide to a stop tile", test_slide_to_stop_tile); - database.add("arrows in a loop never stop", test_slide_loop); - database.add("route ends with a slide", test_route_ends_on_slide); - database.add("a spinner back into a dead end traps", test_dead_end_traps); - database.add("sim slides the player", test_sim_slides); - database.add("sim walker walks a slide", test_sim_walker_walks_slide); - database.add("lift lets out on the floor picked", test_lift_floor); - database.add("lift with no floor picked stays", test_lift_no_floor); - database.add("poster switch opens the stairs", test_poster_opens_stairs); + for (const SpinCase& item : SPIN_CASES){ + database.add(item); + } } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/PokemonFRLG_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/PokemonFRLG_Tests.cpp index 28816b82aa..2bf7183092 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/PokemonFRLG_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/PokemonFRLG_Tests.cpp @@ -48,8 +48,6 @@ #include "Programs/AutoStory/PokemonFRLG_AutoStoryAbilities.h" #include "Programs/AutoStory/PokemonFRLG_AutoStoryBattle.h" #include "Programs/AutoStory/PokemonFRLG_AutoStoryCapture.h" -#include "Programs/AutoStory/PokemonFRLG_AutoStoryHideout.h" -#include "Programs/AutoStory/PokemonFRLG_AutoStoryItemUse.h" #include "Programs/AutoStory/PokemonFRLG_AutoStoryPaceSim.h" #include "Programs/AutoStory/PokemonFRLG_AutoStoryPcStorage.h" #include "Programs/AutoStory/PokemonFRLG_AutoStoryRngLoop.h" @@ -940,8 +938,6 @@ void add_tests(UnitTestDatabase& database){ add_tests_AutoStorySimulation(database); add_tests_AutoStoryRows(database); add_tests_AutoStoryTrashCans(database); - add_tests_AutoStoryHideout(database); - add_tests_AutoStoryItemUse(database); add_tests_AutoStoryPaceSim(database); add_tests_AutoStoryItems(database); add_tests_AutoStoryRngLoop(database); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.cpp index b3bb43b2ec..e62adfbf53 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.cpp @@ -23,7 +23,6 @@ #include "PokemonFRLG_AutoStoryEvents.h" #include "PokemonFRLG_AutoStoryGrind.h" #include "PokemonFRLG_AutoStoryHideout.h" -#include "PokemonFRLG_AutoStoryItemUse.h" #include "PokemonFRLG_AutoStoryScreenEvents.h" #include "PokemonFRLG_AutoStorySummary.h" #include "PokemonFRLG_AutoStoryTable.h" @@ -366,13 +365,6 @@ const std::vector& story_maps(){ return maps; } -MoveSpeed move_speed(const StoryStep& step){ - if (step.variant == BIKE_VARIANT){ - return MoveSpeed::BIKE; - } - return step.variant == RUN_VARIANT ? MoveSpeed::RUN : MoveSpeed::WALK; -} - Pokemon::Pose to_pose(const StoryPose& pose){ return Pokemon::tile_pose(pose.map, {pose.tile}, pose.facing); } @@ -689,31 +681,15 @@ Outcome open_gym_door(StoryEnv& env, RunState& state, const StoryStep& step){ // The GAME steps without a row of their own: a capture or a PC deposit or // withdrawal, a MART, TEACH, LEVEL, GRIND or fossil CHOICE. Outcome other_game_step(StoryEnv& env, RunState& state, const StoryStep& step){ - // The Rocket Hideout's steps leave the player where they stand. - if (const std::optional ride = parse_lift_floor_variant(step.variant)){ - ride_lift(env.console, env.context, *ride); - return Pokemon::outcome_ok(); - } - if (step.variant == POSTER_SWITCH_VARIANT){ - push_poster_switch(env.console, env.context); - return Pokemon::outcome_ok(); - } - // An item from the bag; an Escape Rope ends where the table says. - if (is_item_use_variant(step.variant)){ - const std::string item = parse_use_item_variant(step.variant).value_or("bicycle"); - ConsoleItemUseActions bag(env.console, env.context, env.language); - const ItemUseEnd end = use_item_from_bag(env.console.logger(), bag, item); - if (end != ItemUseEnd::USED){ - return Pokemon::outcome_failed(std::string("use_item_") + item_use_end_name(end), step.variant); - } - return step.pose_after ? Pokemon::outcome_ok(*step.pose_after) : Pokemon::outcome_ok(); - } const Pokemon::Pose patch = state.pose; ConsoleCaptureActions capture(env.console, env.context, env.language, [&]{ pace_trainer_battle(env, state, step, patch); }); ConsolePcStorageActions pc(env.console, env.context); - std::optional done = run_storage_game_step(env.console.logger(), &env.console, step, capture, pc); + std::optional done = run_field_game_step(env.console, env.context, env.language, step); + if (!done){ + done = run_storage_game_step(env.console.logger(), &env.console, step, capture, pc); + } if (!done){ done = run_abilities(env, state, step); } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryHideout.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryHideout.cpp index f1208ac841..3726c116a4 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryHideout.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryHideout.cpp @@ -8,6 +8,7 @@ #include "NintendoSwitch/Commands/NintendoSwitch_Commands_PushButtons.h" #include "PokemonFRLG_AutoStoryActions.h" #include "PokemonFRLG_AutoStoryHideout.h" +#include "PokemonFRLG_AutoStoryItemUse.h" namespace PokemonAutomation{ namespace NintendoSwitch{ @@ -109,6 +110,29 @@ void push_poster_switch(ConsoleHandle& console, ProControllerContext& context){ mash_until_overworld(console, context, HIDEOUT_SCENE_TIMEOUT); } +std::optional run_field_game_step( + ConsoleHandle& console, ProControllerContext& context, Language language, const Pokemon::StoryStep& step +){ + if (const std::optional ride = parse_lift_floor_variant(step.variant)){ + ride_lift(console, context, *ride); + return Pokemon::outcome_ok(); + } + if (step.variant == POSTER_SWITCH_VARIANT){ + push_poster_switch(console, context); + return Pokemon::outcome_ok(); + } + if (!is_item_use_variant(step.variant)){ + return std::nullopt; + } + const std::string item = parse_use_item_variant(step.variant).value_or("bicycle"); + ConsoleItemUseActions bag(console, context, language); + const ItemUseEnd end = use_item_from_bag(console.logger(), bag, item); + if (end != ItemUseEnd::USED){ + return Pokemon::outcome_failed(std::string("use_item_") + item_use_end_name(end), step.variant); + } + return step.pose_after ? Pokemon::outcome_ok(*step.pose_after) : Pokemon::outcome_ok(); +} + } // namespace PokemonFRLG diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryHideout.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryHideout.h index 4e4249af77..1282c2d880 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryHideout.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryHideout.h @@ -20,6 +20,9 @@ #include "Common/Cpp/TestRunners/UnitTestDatabase.h" #include "NintendoSwitch/Controllers/Procon/NintendoSwitch_ProController.h" #include "NintendoSwitch/NintendoSwitch_ConsoleHandle.h" +#include "CommonFramework/Language.h" +#include "Pokemon/AutoStory/Pokemon_StoryEvents.h" +#include "Pokemon/AutoStory/Pokemon_StorySteps.h" #include "Pokemon/Navigation/Pokemon_Grid.h" namespace PokemonAutomation{ @@ -55,13 +58,14 @@ std::string lift_floor_variant(std::string_view from, std::string_view to); // not one, or names a floor the panel hasn't. std::optional parse_lift_floor_variant(std::string_view variant); -// The fewest presses that move the cursor from `ride.from` to `ride.to` -// on a menu of `entries` rows. A multichoice of more than three rows -// wraps round (pokefirered DrawVerticalMultichoiceMenu()). +// Presses of the D-pad on a menu: which way, how many. struct MenuPresses{ Pokemon::Direction direction = Pokemon::Direction::SOUTH; size_t presses = 0; }; +// The fewest presses that move the cursor from `ride.from` to `ride.to` +// on a menu of `entries` rows. A multichoice of more than three rows +// wraps round (pokefirered DrawVerticalMultichoiceMenu()). MenuPresses lift_menu_presses(const LiftRide& ride, size_t entries); // Whether `variant` is one of this file's steps. @@ -74,6 +78,14 @@ void ride_lift(ConsoleHandle& console, ProControllerContext& context, const Lift // overworld. void push_poster_switch(ConsoleHandle& console, ProControllerContext& context); +// On the console, a GAME step of this file's or an item from the bag +// (is_item_use_variant()); nothing for another GAME step. The hideout's +// steps leave the player where they stand; an item used ends on the +// step's pose_after (an Escape Rope's), if it has one. +std::optional run_field_game_step( + ConsoleHandle& console, ProControllerContext& context, Language language, const Pokemon::StoryStep& step +); + // Registers the hideout steps' tests. void add_tests_AutoStoryHideout(UnitTestDatabase& database); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryHideout_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryHideout_Tests.cpp index 0c29251b6a..12db0549e2 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryHideout_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryHideout_Tests.cpp @@ -67,97 +67,125 @@ class Test_AutoStoryHideout : public UnitTest{ LiftCase m_case; }; -class Test_AutoStoryHideoutVariants : public UnitTest{ -public: - Test_AutoStoryHideoutVariants() - : UnitTest("PokemonFRLG::AutoStoryHideout - the poster and lift variants") - {} - virtual UnitTestResult run(Logger&, CancellableScope&) const override{ - if (!is_hideout_variant(POSTER_SWITCH_VARIANT) || is_hideout_variant("cut_tree") || is_hideout_variant("")){ - return "Expected only the poster switch and lift rides as hideout variants."; - } - if (lift_floor_variant("B2F", "B4F") != "lift_floor B2F B4F"){ - return "Expected \"lift_floor B2F B4F\", got \"" + lift_floor_variant("B2F", "B4F") + "\"."; - } - // A three-row menu doesn't wrap. - const MenuPresses no_wrap = lift_menu_presses(LiftRide{0, 2}, 3); - if (no_wrap.direction != Pokemon::Direction::SOUTH || no_wrap.presses != 2){ - return "Expected 2 down on a three-row menu."; - } - return true; +UnitTestResult test_variants(){ + if (!is_hideout_variant(POSTER_SWITCH_VARIANT) || is_hideout_variant("cut_tree") || is_hideout_variant("")){ + return "Expected only the poster switch and lift rides as hideout variants."; } -}; + if (lift_floor_variant("B2F", "B4F") != "lift_floor B2F B4F"){ + return "Expected \"lift_floor B2F B4F\", got \"" + lift_floor_variant("B2F", "B4F") + "\"."; + } + // A three-row menu doesn't wrap. + const MenuPresses no_wrap = lift_menu_presses(LiftRide{0, 2}, 3); + if (no_wrap.direction != Pokemon::Direction::SOUTH || no_wrap.presses != 2){ + return "Expected 2 down on a three-row menu."; + } + return true; +} // The table's steps, run as a row runs them on the sim: the poster's // switch opens the stairs north-east of it, and the lift lets out on the // floor picked. -class Test_AutoStoryHideoutSim : public UnitTest{ +std::string poster_on_sim(); +std::string lift_on_sim(); + +UnitTestResult test_steps_on_sim(){ + const std::string poster = poster_on_sim(); + if (!poster.empty()){ + return poster; + } + const std::string lift = lift_on_sim(); + if (!lift.empty()){ + return lift; + } + return true; +} + +std::string poster_on_sim(){ + const MapData room = parse_map(R"({ + "name": "Corner", "source": {"decomp_file": "", "revision": "test", "date": "2026-11-09"}, + "tiles": ["#####", "#...#", "#...#", "#####"], + "objects": [ + {"name": "poster", "kind": "sign", "x": 2, "y": 1}, + {"name": "poster_stairs", "kind": "npc", "x": 3, "y": 1} + ] + })", "Corner"); + OverworldSim sim({room}, MapPosition{"Corner", {2, 2}}, Direction::NORTH); + StoryPose at{"Corner", {2, 2}, Direction::NORTH}; + const std::string failure = row_step_on_sim(sim, at, StoryTable::poster_switch("poster_switch", "test"), "00.31"); + if (!failure.empty()){ + return failure; + } + const Milliseconds tile = MovementMechanics::TILE_TIME; + sim.hold(Direction::EAST, tile * 2, MovementMechanics::STEP_RELEASE, tile * 2, {}); + sim.hold(Direction::NORTH, tile * 2, MovementMechanics::STEP_RELEASE, tile * 2, {}); + if (!(sim.state().position == MapPosition{"Corner", {3, 1}})){ + return "After the poster step, walked onto " + to_string(sim.state().position) + ", not the stairs."; + } + return ""; +} + +std::string lift_on_sim(){ + std::vector maps; + maps.push_back(parse_map(R"({ + "name": "Lift", "source": {"decomp_file": "", "revision": "test", "date": "2026-11-09"}, + "tiles": ["###", "#.#", "#.#", "###"], + "warps": [{"x": 1, "y": 2, "enter": "walk_south"}] + })", "Lift")); + maps.push_back(parse_map(R"({ + "name": "B4F", "source": {"decomp_file": "", "revision": "test", "date": "2026-11-09"}, + "tiles": ["###", "#.#", "###"] + })", "B4F")); + SimScript script; + const MapPosition b4f{"B4F", {1, 1}}; + script.lift_floors["Lift"] = {MapPosition{"Lift", {1, 1}}, MapPosition{"Lift", {1, 1}}, b4f}; + OverworldSim sim(maps, MapPosition{"Lift", {1, 2}}, Direction::SOUTH, script); + StoryPose at{"Lift", {1, 2}, Direction::SOUTH}; + const std::string failure = row_step_on_sim(sim, at, StoryTable::lift_floor("lift_to_b4f", "B2F", "B4F", "test"), "00.32"); + if (!failure.empty()){ + return failure; + } + sim.hold(Direction::SOUTH, MovementMechanics::TILE_TIME, MovementMechanics::STEP_RELEASE, std::chrono::milliseconds(3000), {}); + if (!(sim.state().position == b4f)){ + return "After the lift step, the door let out on " + to_string(sim.state().position) + "."; + } + return ""; +} + +struct AutoStoryHideoutBodyInput{ + std::string name; + UnitTestResult (*body)(); +}; +struct AutoStoryHideoutBodyExpectation{ + const char* description; +}; +struct AutoStoryHideoutBodyCase{ + AutoStoryHideoutBodyInput input; + AutoStoryHideoutBodyExpectation expectation; +}; + +class Test_AutoStoryHideoutBody : public UnitTest{ public: - Test_AutoStoryHideoutSim() - : UnitTest("PokemonFRLG::AutoStoryHideout - table steps on the sim") + explicit Test_AutoStoryHideoutBody(AutoStoryHideoutBodyCase test_case) + : UnitTest("PokemonFRLG::AutoStoryHideout - " + test_case.input.name) + , m_case(std::move(test_case)) {} virtual UnitTestResult run(Logger&, CancellableScope&) const override{ - const std::string poster = poster_on_sim(); - if (!poster.empty()){ - return poster; - } - const std::string lift = lift_on_sim(); - if (!lift.empty()){ - return lift; - } - return true; + return m_case.input.body(); } private: - static std::string poster_on_sim(){ - const MapData room = parse_map(R"({ - "name": "Corner", "source": {"decomp_file": "", "revision": "test", "date": "2026-11-09"}, - "tiles": ["#####", "#...#", "#...#", "#####"], - "objects": [ - {"name": "poster", "kind": "sign", "x": 2, "y": 1}, - {"name": "poster_stairs", "kind": "npc", "x": 3, "y": 1} - ] - })", "Corner"); - OverworldSim sim({room}, MapPosition{"Corner", {2, 2}}, Direction::NORTH); - StoryPose at{"Corner", {2, 2}, Direction::NORTH}; - const std::string failure = row_step_on_sim(sim, at, StoryTable::poster_switch("poster_switch", "test"), "00.31"); - if (!failure.empty()){ - return failure; - } - const Milliseconds tile = MovementMechanics::TILE_TIME; - sim.hold(Direction::EAST, tile * 2, MovementMechanics::STEP_RELEASE, tile * 2, {}); - sim.hold(Direction::NORTH, tile * 2, MovementMechanics::STEP_RELEASE, tile * 2, {}); - if (!(sim.state().position == MapPosition{"Corner", {3, 1}})){ - return "After the poster step, walked onto " + to_string(sim.state().position) + ", not the stairs."; - } - return ""; - } - static std::string lift_on_sim(){ - std::vector maps; - maps.push_back(parse_map(R"({ - "name": "Lift", "source": {"decomp_file": "", "revision": "test", "date": "2026-11-09"}, - "tiles": ["###", "#.#", "#.#", "###"], - "warps": [{"x": 1, "y": 2, "enter": "walk_south"}] - })", "Lift")); - maps.push_back(parse_map(R"({ - "name": "B4F", "source": {"decomp_file": "", "revision": "test", "date": "2026-11-09"}, - "tiles": ["###", "#.#", "###"] - })", "B4F")); - SimScript script; - const MapPosition b4f{"B4F", {1, 1}}; - script.lift_floors["Lift"] = {MapPosition{"Lift", {1, 1}}, MapPosition{"Lift", {1, 1}}, b4f}; - OverworldSim sim(maps, MapPosition{"Lift", {1, 2}}, Direction::SOUTH, script); - StoryPose at{"Lift", {1, 2}, Direction::SOUTH}; - const std::string failure = row_step_on_sim(sim, at, StoryTable::lift_floor("lift_to_b4f", "B2F", "B4F", "test"), "00.32"); - if (!failure.empty()){ - return failure; - } - sim.hold(Direction::SOUTH, MovementMechanics::TILE_TIME, MovementMechanics::STEP_RELEASE, std::chrono::milliseconds(3000), {}); - if (!(sim.state().position == b4f)){ - return "After the lift step, the door let out on " + to_string(sim.state().position) + "."; - } - return ""; - } + AutoStoryHideoutBodyCase m_case; +}; + +const AutoStoryHideoutBodyCase HIDEOUT_BODY_CASES[] = { + { + .input = {.name = "the poster and lift variants", .body = test_variants}, + .expectation = {.description = "only the poster switch and lift rides are hideout steps"}, + }, + { + .input = {.name = "table steps on the sim", .body = test_steps_on_sim}, + .expectation = {.description = "the poster opens the stairs; the lift lets out on the floor picked"}, + }, }; const LiftCase LIFT_CASES[] = { @@ -206,8 +234,9 @@ void add_tests_AutoStoryHideout(UnitTestDatabase& database){ for (const LiftCase& item : LIFT_CASES){ database.add(item); } - database.add(); - database.add(); + for (const AutoStoryHideoutBodyCase& item : HIDEOUT_BODY_CASES){ + database.add(item); + } } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryItemUse.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryItemUse.cpp index d0a719798d..1f0c4bdeb9 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryItemUse.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryItemUse.cpp @@ -8,6 +8,7 @@ #include "NintendoSwitch/Commands/NintendoSwitch_Commands_PushButtons.h" #include "VideoGames/PokemonFRLG/PokemonFRLG_Navigation.h" #include "PokemonFRLG_AutoStoryActions.h" +#include "PokemonFRLG_AutoStoryEvents.h" #include "PokemonFRLG_AutoStoryItemUse.h" namespace PokemonAutomation{ @@ -18,6 +19,13 @@ namespace PokemonFRLG{ using namespace std::chrono_literals; +MoveSpeed move_speed(const StoryStep& step){ + if (step.variant == BIKE_VARIANT){ + return MoveSpeed::BIKE; + } + return step.variant == RUN_VARIANT ? MoveSpeed::RUN : MoveSpeed::WALK; +} + std::optional usable_item_pocket(std::string_view item){ if (is_repel(item) || item == "escape-rope"){ return BagPocket::ITEMS; diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryItemUse.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryItemUse.h index bf0a5c86c7..3836b7a78a 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryItemUse.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryItemUse.h @@ -78,6 +78,7 @@ class ItemUseActions{ virtual void close_bag() = 0; }; +// How use_item_from_bag() ended. enum class ItemUseEnd{ USED, // The pocket has none left (or the name isn't read). @@ -87,6 +88,7 @@ enum class ItemUseEnd{ // Not an item the story uses from the bag. UNKNOWN_ITEM, }; +// "used", "none_left": the end's name in logs and failures. const char* item_use_end_name(ItemUseEnd end); // Opens the item's pocket, finds it and uses it, then back to the diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryItemUse_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryItemUse_Tests.cpp index 2ea359f252..ab33b1a43c 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryItemUse_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryItemUse_Tests.cpp @@ -175,35 +175,62 @@ const ItemUseCase ITEM_USE_CASES[] = { }, }; -class Test_AutoStoryItemUseVariants : public UnitTest{ +UnitTestResult test_variants_and_bike(){ + if (parse_use_item_variant("use_item potion") || parse_use_item_variant("use_item") || !parse_use_item_variant(use_item_variant("super-repel"))){ + return "Expected only the story's bag items as use_item variants."; + } + if (!is_item_use_variant(RIDE_BIKE_VARIANT) || is_item_use_variant("poster_switch")){ + return "Expected ride_bike, not poster_switch, as an item use."; + } + StoryStep ride; + ride.kind = Pokemon::StoryStepKind::ROUTE; + ride.variant = BIKE_VARIANT; + if (move_speed(ride) != MoveSpeed::BIKE){ + return "A bike ROUTE isn't ridden."; + } + RouteStep outdoors; + outdoors.tiles = 10; + RouteStep indoors = outdoors; + indoors.walk_only = true; + if (OverworldWalkSteps::tile_time(outdoors, MoveSpeed::BIKE) != MovementMechanics::BIKE_TILE_TIME + || OverworldWalkSteps::tile_time(indoors, MoveSpeed::BIKE) != MovementMechanics::TILE_TIME + ){ + return "Expected the bike's pace outdoors and a walk indoors."; + } + return true; +} + +struct AutoStoryItemUseBodyInput{ + std::string name; + UnitTestResult (*body)(); +}; +struct AutoStoryItemUseBodyExpectation{ + const char* description; +}; +struct AutoStoryItemUseBodyCase{ + AutoStoryItemUseBodyInput input; + AutoStoryItemUseBodyExpectation expectation; +}; + +class Test_AutoStoryItemUseBody : public UnitTest{ public: - Test_AutoStoryItemUseVariants() - : UnitTest("PokemonFRLG::AutoStoryItemUse - variants and the bike's pace") + explicit Test_AutoStoryItemUseBody(AutoStoryItemUseBodyCase test_case) + : UnitTest("PokemonFRLG::AutoStoryItemUse - " + test_case.input.name) + , m_case(std::move(test_case)) {} virtual UnitTestResult run(Logger&, CancellableScope&) const override{ - if (parse_use_item_variant("use_item potion") || parse_use_item_variant("use_item") || !parse_use_item_variant(use_item_variant("super-repel"))){ - return "Expected only the story's bag items as use_item variants."; - } - if (!is_item_use_variant(RIDE_BIKE_VARIANT) || is_item_use_variant("poster_switch")){ - return "Expected ride_bike, not poster_switch, as an item use."; - } - StoryStep ride; - ride.kind = Pokemon::StoryStepKind::ROUTE; - ride.variant = BIKE_VARIANT; - if (move_speed(ride) != MoveSpeed::BIKE){ - return "A bike ROUTE isn't ridden."; - } - RouteStep outdoors; - outdoors.tiles = 10; - RouteStep indoors = outdoors; - indoors.walk_only = true; - if (OverworldWalkSteps::tile_time(outdoors, MoveSpeed::BIKE) != MovementMechanics::BIKE_TILE_TIME - || OverworldWalkSteps::tile_time(indoors, MoveSpeed::BIKE) != MovementMechanics::TILE_TIME - ){ - return "Expected the bike's pace outdoors and a walk indoors."; - } - return true; + return m_case.input.body(); } + +private: + AutoStoryItemUseBodyCase m_case; +}; + +const AutoStoryItemUseBodyCase ITEM_USE_BODY_CASES[] = { + { + .input = {.name = "variants and the bike's pace", .body = test_variants_and_bike}, + .expectation = {.description = "the story's bag items, ride_bike, and the bike's pace outdoors only"}, + }, }; } // namespace @@ -213,7 +240,9 @@ void add_tests_AutoStoryItemUse(UnitTestDatabase& database){ for (const ItemUseCase& item : ITEM_USE_CASES){ database.add(item); } - database.add(); + for (const AutoStoryItemUseBodyCase& item : ITEM_USE_BODY_CASES){ + database.add(item); + } } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.cpp index adf518879f..936d342526 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.cpp @@ -511,7 +511,7 @@ std::string OverworldStepSim::cut(const StoryStep& step){ if (m_sim.state().facing != step.direction){ sim_turn_to(m_sim, step.direction); } - if (!m_sim.cut_tree()){ + if (!m_sim.use_field(SimField::CUT_TREE)){ return "no tree to cut."; } sim_step_once(m_sim, step.direction); @@ -540,10 +540,10 @@ Pokemon::StoryAction OverworldStepSim::hideout_or_stub(){ std::string OverworldStepSim::hideout(const StoryStep& step){ if (step.variant == POSTER_SWITCH_VARIANT){ - return m_sim.push_poster_switch() ? "" : "no poster ahead."; + return m_sim.use_field(SimField::POSTER_SWITCH) ? "" : "no poster ahead."; } const std::optional ride = parse_lift_floor_variant(step.variant); - if (!ride || !m_sim.choose_lift_floor(ride->to)){ + if (!ride || !m_sim.use_field(SimField::LIFT_FLOOR, ride->to)){ return "no lift panel for \"" + step.variant + "\"."; } return ""; diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryStepTable_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryStepTable_Tests.cpp index 33302b794b..8eeb70211c 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryStepTable_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryStepTable_Tests.cpp @@ -19,6 +19,8 @@ #include "PokemonFRLG_AutoStoryStepInterrupts.h" #include "PokemonFRLG_AutoStoryStepTable.h" #include "PokemonFRLG_AutoStoryEvents.h" +#include "PokemonFRLG_AutoStoryHideout.h" +#include "PokemonFRLG_AutoStoryItemUse.h" #include "PokemonFRLG_AutoStoryTable.h" #include "PokemonFRLG_AutoStoryTrashCans.h" @@ -249,6 +251,9 @@ void add_tests_AutoStoryStepTable(UnitTestDatabase& database){ database.add(scenario); } add_tests_AutoStoryStepInterrupts(database); + // The GAME variants with logic of their own. + add_tests_AutoStoryHideout(database); + add_tests_AutoStoryItemUse(database); } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryVermilionGym_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryVermilionGym_Tests.cpp index b59faf6d75..3188bb3189 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryVermilionGym_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryVermilionGym_Tests.cpp @@ -325,7 +325,7 @@ UnitTestResult test_tree_shuts_the_yard(){ // The sim: no step onto the tree; cut, it's gone; a warp grows it back. OverworldSim sim(story_maps(), MapPosition{StoryTable::VERMILION, {19, 23}}, Direction::SOUTH); sim.tap(Direction::SOUTH); - if (!(sim.state().position == MapPosition{StoryTable::VERMILION, {19, 23}}) || !sim.cut_tree()){ + if (!(sim.state().position == MapPosition{StoryTable::VERMILION, {19, 23}}) || !sim.use_field(SimField::CUT_TREE)){ return "Expected the tree to stop the player, then be cut."; } sim.tap(Direction::SOUTH); diff --git a/SerialPrograms/cmake/SourceFiles.cmake b/SerialPrograms/cmake/SourceFiles.cmake index 9de4d7fee3..3b38e8424d 100644 --- a/SerialPrograms/cmake/SourceFiles.cmake +++ b/SerialPrograms/cmake/SourceFiles.cmake @@ -1866,8 +1866,10 @@ file(GLOB LIBRARY_SOURCES Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.cpp Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRouteStrings.cpp Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.h + Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute_Tests.cpp Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h + Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSimField.cpp Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim_Tests.cpp Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSpin_Tests.cpp Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp From ce645d1600df281b494eb593e45c972a4a6c038e Mon Sep 17 00:00:00 2001 From: dayfine Date: Mon, 9 Nov 2026 17:29:09 -0800 Subject: [PATCH 08/10] FRLG: route tests find maps without the route's internals Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013VUubhHeYSMGSSDXZkKZss --- .../Overworld/PokemonFRLG_OverworldRoute_Tests.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute_Tests.cpp index 419fe87757..0f9fcb753a 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute_Tests.cpp @@ -108,8 +108,8 @@ MapPosition point_on(const std::vector& maps, const std::string& map, c } const MapData* map_named(const std::vector& maps, const std::string& name){ - const int index = find_map(maps, name); - return index < 0 ? nullptr : &maps[index]; + const auto map = std::find_if(maps.begin(), maps.end(), [&](const MapData& item){ return item.name == name; }); + return map == maps.end() ? nullptr : &*map; } UnitTestResult test_maps(){ From fd9f6b904a7371484c1daf79bb856a1b3874398a Mon Sep 17 00:00:00 2001 From: dayfine Date: Mon, 9 Nov 2026 17:32:55 -0800 Subject: [PATCH 09/10] FRLG drafts: restore Lavender to Celadon as 00.30, hideout as 00.31-00.32; the steps and maps they need Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013VUubhHeYSMGSSDXZkKZss --- .../AutoStory/StoryData/Draft/README.md | 133 +++++++++++------- .../StoryData/Draft/frlg-00.30.plan.json | 103 ++++++++------ .../StoryData/Draft/frlg-00.31.plan.json | 104 ++++++-------- .../StoryData/Draft/frlg-00.32.plan.json | 106 ++++++++++++++ 4 files changed, 291 insertions(+), 155 deletions(-) create mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/Draft/frlg-00.32.plan.json diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/Draft/README.md b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/Draft/README.md index bff9a09871..532ef68bfa 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/Draft/README.md +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/Draft/README.md @@ -9,53 +9,80 @@ Each file is a `pokesim-authored-checkpoint/1` plan in the same format as `../frlg-parcel-00.05.plan.json`. The order and the item stops follow PokeGuy's LeafGreen run (PokemonSimulation `data/runs/pokeguy-leafgreen-15934/events.json` and `external/pokeguy-notes.md`). 00.28 and 00.29 are table rows on -`frlg/stage8-rocktunnel` (#293), not drafts: 00.29 ends at Lavender's nurse. -The draft that also used the id 00.29 (Lavender Mart, Route 8, the Underground -Path to Celadon's Center) was dropped in favour of #293's row, so nothing here -covers Lavender's nurse to Celadon's yet; its events are in commit 8b656d1a2. +`frlg/stage8-rocktunnel` (#293); 00.29 ends at Lavender's nurse, where 00.30 +starts. | Id | Title | Events | |---|---|---:| -| 00.30 | Celadon Game Corner and the Rocket Hideout down to B4F: the poster guard, the Lift Key Grunt, the LIFT KEY and B3F's Rare Candy | 12 | -| 00.31 | Rocket Hideout: the lift to B4F, the two door guards, two Rare Candies, Blastoise, Giovanni and the SILPH SCOPE | 16 | -| 00.32 | Celadon: Department Store 5F battle items, HM02 Fly on Route 16 for the Pidgey, the Tea, then the Center | 20 | -| 00.32b | Celadon Mansion roof room: the Eevee (Gift RNG placeholder) | 4 | -| 00.33 | Fly to Lavender, the Center, Pokemon Tower 2F: the rival | 8 | -| 00.34 | Pokemon Tower 3F to 7F: Tammy, Jennifer, Emilia, the Marowak, three Grunts, Mr. Fuji | 21 | -| 00.35 | The POKE FLUTE, Fly to Celadon, Route 16's Snorlax, Cycling Road's Rare Candy and Max Elixir, Fuchsia | 16 | -| 00.36 | Safari Zone: a Full Restore, the GOLD TEETH, HM03 Surf from the Secret House | 12 | -| 00.37 | Fuchsia: Surf to the lead, Juggler Kirk, Koga, then HM04 Strength from the Warden | 14 | -| 00.38 | Fly to Pallet, Surf down Route 21 to Cinnabar, the Mansion: Elixir, TM14 Blizzard and the SECRET KEY | 15 | -| 00.38b | Cinnabar Lab: the Helix Fossil revived (Gift RNG placeholder) | 6 | -| 00.39 | Cinnabar Gym: the quiz doors, Blaine | 9 | -| 00.40 | Fly to Celadon: two Rare Candies, Blizzard, Celadon Gym: Tamia, Lori, Erika | 21 | -| 00.41 | East to Saffron: the TEA at the gate, the Fighting Dojo's Hitmonchan (Gift RNG placeholder), the Center | 13 | -| 00.42 | Silph Co.: the CARD KEY, the rival on 7F, the Lapras (Gift RNG placeholder) | 10 | -| 00.43 | Silph Co. 11F: the boardroom Grunt, Giovanni; then Sabrina | 13 | -| 00.44 | Fly to Viridian: Viridian Gym: Takashi, Warren, Giovanni | 14 | -| 00.45 | Route 22: the rival; Route 23's badge gates; Strength | 7 | -| 00.46 | Victory Road's boulders, Indigo Plateau: Full Restores, the nurse, the save | 8 | -| 00.47 | Lorelei | 4 | -| 00.48 | Bruno | 3 | -| 00.49 | Agatha | 3 | -| 00.50 | Lance | 3 | -| 00.51 | The Champion and the Hall of Fame | 4 | - -## Why 00.30 and 00.31 are not table rows yet - -They can't run or be simulated on this branch's data: - -- The map resource lock (PokemonSimulation `6c2f5c8`, `runtime-manifest.json`) - has none of CeladonCity, CeladonCity_GameCorner or RocketHideout_B1F..B4F; - they exist only in `source-candidate`. CeladonCity_PokemonCenter_1F (00.30's - start) and RocketHideout_Elevator (00.31's lift) are in neither. Adding them - to the runtime pack is a PokemonSimulation publish and a lock bump. -- 00.30's start, Celadon's nurse, isn't reached by any row: 00.29 ends in - Lavender. -- The walker has no spinner (arrow) tiles, which B2F and B3F need, and no - step for the lift's floor menu or the poster switch. -- `STORY_TRAINERS` names none of the hideout's Grunts or Giovanni yet; - their parties are in `BattleData/`. +| 00.30 | Lavender Mart, then west over Route 8 on the Bicycle under a Repel: Gamer Rich, a Pidgey for Fly, the Underground Path to Celadon's Pokemon Center | 14 | +| 00.31 | Celadon Game Corner and the Rocket Hideout down to B4F: the poster guard, the poster switch, the Lift Key Grunt, the LIFT KEY and B3F's Rare Candy | 12 | +| 00.32 | Rocket Hideout: the lift to B4F, the two door guards, two Rare Candies, Giovanni, the SILPH SCOPE, an Escape Rope out | 16 | + +The later drafts (Celadon Department Store to the Hall of Fame) are on +`frlg/draft-story-50-51` (#303) under the ids 00.32 to 00.51; with 00.30 +restored here they each move up by one when that stack takes this one in. + +## Story order after the hideout + +As PokeGuy plays it: the Celadon Department Store 5F (6 X Speed, 21 X Special, +5 X Accuracy, 2 Guard Spec), then Pokemon Tower (a Super Repel on 3F to 7F, +the floors with wild Pokemon), Route 16's Snorlax, Fuchsia and Koga, the +Pokemon Mansion and Blaine, Erika, Silph Co., Sabrina, Viridian's Giovanni, and +the Elite Four. + +Items from the bag, as PokeGuy uses them: a Repel over Route 8 and the +Underground Path (00.30; 5 bought in 00.26), renewed when "REPEL's effect wore +off" shows (`renew_repel()`); Super Repels in Pokemon Tower; an Escape Rope out +of the hideout after Giovanni (00.32), which puts the player at the Game +Corner's door, CeladonCity (34, 22) (pokefirered `UpdateEscapeWarp()`: the last +outdoor door, a tile south). The Rocket Hideout has no wild Pokemon +(`wild_encounters.json` lists none of its floors), so no Repel is used there. +The BICYCLE (00.26) is ridden on Route 8's open legs (`bike`); it is +`BIKE_TILE_TIME`, an estimate from the decompilation, until measured. + +## The maps each draft needs + +The story can't run or simulate these drafts until their maps are in the map +resource lock (PokemonSimulation `6c2f5c8`, `data/maps/runtime-manifest.json`). +Status there, in PokemonSimulation's `data/games/frlg/maps/source-candidate`, +or neither: + +| Map | Draft | Status | +|---|---|---| +| LavenderTown | 00.30 | published | +| LavenderTown_Mart | 00.30 | candidate | +| Route8 | 00.30 | candidate | +| UndergroundPath_EastEntrance | 00.30 | candidate | +| UndergroundPath_EastWestTunnel | 00.30 | candidate | +| UndergroundPath_WestEntrance | 00.30 | candidate | +| Route7 | 00.30 | candidate | +| CeladonCity | 00.30 to 00.32 | candidate | +| CeladonCity_PokemonCenter_1F | 00.30, 00.31 | missing | +| CeladonCity_GameCorner | 00.31, 00.32 | candidate | +| RocketHideout_B1F | 00.31, 00.32 | candidate | +| RocketHideout_B2F | 00.31, 00.32 | candidate | +| RocketHideout_B3F | 00.31 | candidate | +| RocketHideout_B4F | 00.31, 00.32 | candidate | +| RocketHideout_Elevator | 00.32 | missing | + +Beyond publishing (a PokemonSimulation publish and a lock bump, which need the +user's approval), the code added for these drafts reads three things the +candidate maps don't carry yet: + +- B2F's and B3F's spinner tiles as regions `spin_north`, `spin_south`, + `spin_west`, `spin_east` and `spin_stop` (pokefirered `MB_SPIN_*`, + `MB_STOP_SPINNING`; `spin_slide()` in PokemonFRLG_OverworldMap.h); +- the Game Corner's poster as an object `poster` at (11, 1), and its stairs + (16..17, 2..3) as objects `poster_stairs`, which the switch removes + (`SimField::POSTER_SWITCH`); +- the lift's floors: its door is a dynamic warp (`setdynamicwarp`), so the sim + is told where each floor lets out (`SimScript::lift_floors`: B1F (24, 25), + B2F (28, 16), B4F (20, 23)) and the planner can't route through it yet. + +`STORY_TRAINERS` names the battles these drafts fight: CeladonCity_GameCorner +`rocket_grunt` (GRUNT_7), and on RocketHideout_B4F `lift_key_grunt` (GRUNT_18), +`left_guard` (GRUNT_16), `right_guard` (GRUNT_17) and `giovanni`. When the +drafts become table rows, their battle steps take those names. ## Conventions @@ -74,9 +101,13 @@ They can't run or be simulated on this branch's data: `gift_rng ` handoff, for the Eevee (00.32b), the Omanyte from the Helix Fossil (00.38b), the Hitmonchan (00.41) and the Lapras (00.42). The authored format can't yet say `handoff`/`live_start` as 00.12/00.12b do in C++. -- New `game_action` variants that no executor has yet: `party_room`, - `gift_rng`, `use_item`, `fly`, `flee_wild`, `surf`, `strength_puzzle`, - `reenter_map`. The `mart` variants name Marts the Mart table doesn't list +- `game_action` variants the C++ executor now has (no console run yet): + `poster_switch`, `lift_floor ` (PokemonFRLG_AutoStoryHideout.h), + `use_item ` and `ride_bike` + (PokemonFRLG_AutoStoryItemUse.h), and the `bike` route variant. Variants + that no executor has yet: `party_room`, `gift_rng`, `use_item rare-candy + slot=N` (an item used on a party member), `fly`, `flee_wild`, `surf`, + `strength_puzzle`, `reenter_map`. The `mart` variants name Marts the Mart table doesn't list yet (Lavender, Celadon 5F, Indigo Plateau). - HMs and key items come in story order: LIFT KEY, SILPH SCOPE, HM02 Fly, TEA, POKE FLUTE, HM03 Surf, GOLD TEETH, HM04 Strength, SECRET KEY, CARD KEY. @@ -84,8 +115,8 @@ They can't run or be simulated on this branch's data: ## Decisions for the user - **A Pidgey for Fly.** PokeGuy flies everywhere from 1:03; the story party has - no flier. The dropped Lavender-to-Celadon draft caught a Pidgey in Route 7's grass (Route 8's grass is behind - two Cut trees). Without Fly, Cinnabar needs a long walk round to Pallet. + no flier. 00.30 catches a Pidgey in Route 7's grass (Route 8's grass is + behind two Cut trees). Without Fly, Cinnabar needs a long walk round to Pallet. - **TM14 Blizzard over Mega Kick** (00.40) is a one-shot TM: it needs the team plan first. - **Strength over Water Pulse** (00.45) and **Surf over Water Gun** (00.37) @@ -111,8 +142,8 @@ wall tiles, and Indigo Plateau's door glyph is at (11, 5) with its warp at | Skipped leg | Why | |---|---| -| 00.31/b2f_lift | B2F's lift corner is reached over arrow tiles the planner does not solve | -| 00.31/b4f_below_guards | RocketHideout_Elevator map not in source-candidate | +| 00.32/b2f_lift | B2F's lift corner is reached over arrow tiles: the planner solves them now, but the candidate map has no `spin_*` regions | +| 00.32/b4f_below_guards | RocketHideout_Elevator map not in source-candidate | | 00.32/x_item_clerk | CeladonCity_DepartmentStore_Elevator map not in source-candidate | | 00.32/route16_below_tree | CeladonCity_DepartmentStore_Elevator map not in source-candidate | | 00.32/celadon_nurse | map not in source-candidate | diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/Draft/frlg-00.30.plan.json b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/Draft/frlg-00.30.plan.json index 0b256ee6be..4e01206a12 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/Draft/frlg-00.30.plan.json +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/Draft/frlg-00.30.plan.json @@ -3,82 +3,99 @@ "game": "frlg", "checkpoint": { "id": "00.30", - "title": "Celadon Game Corner and the Rocket Hideout down to B4F: the poster guard, the Lift Key Grunt, the LIFT KEY and B3F's Rare Candy", - "start": {"certainty": "tile", "map": "CeladonCity_PokemonCenter_1F", "where": {"facing": "north", "kind": "tiles", "tiles": [[7, 4]]}}, + "title": "Lavender Mart, then west over Route 8: Gamer Rich, a Pidgey for Fly, the Underground Path to Celadon", + "start": {"certainty": "tile", "map": "LavenderTown_PokemonCenter_1F", "where": {"facing": "north", "kind": "tiles", "tiles": [[7, 4]]}}, "events": [ { - "id": "celadon_below_game_corner", + "id": "lavender_mart_clerk", "kind": "route", - "target": {"certainty": "tile", "map": "CeladonCity", "where": {"facing": "north", "kind": "tiles", "tiles": [[34, 22]]}}, + "target": {"certainty": "tile", "map": "LavenderTown_Mart", "where": {"facing": "west", "kind": "tiles", "tiles": [[4, 3]]}}, "variant": "run", - "why": "out of the Center, west along row 12, down column 41..43 to row 22, west to below the Game Corner (34, 21)" + "why": "out of the Center, east along row 6 and down to the Mart (20, 15), to its clerk" }, { - "id": "below_poster_guard", - "kind": "route", - "target": {"certainty": "tile", "map": "CeladonCity_GameCorner", "where": {"facing": "north", "kind": "tiles", "tiles": [[11, 3]]}}, - "variant": "run", - "why": "into the Game Corner, up the slot rows to below the Grunt (11, 2) in front of the poster" + "id": "buy_ropes_and_super_repels", + "kind": "game_action", + "variant": "mart LavenderTown_Mart budget=5700 floor=9000 escape-rope=4 super-repel=7", + "why": "pokeguy 54:18: 4 Escape Ropes and 7 Super Repels" }, { - "id": "talk_poster_guard", - "kind": "talk", - "why": "the Grunt guarding the poster" - }, - { - "id": "hideout-poster-guard", - "kind": "battle", - "declared_after": {"certainty": "tile", "map": "CeladonCity_GameCorner", "where": {"facing": "north", "kind": "tiles", "tiles": [[11, 3]]}}, - "why": "Rocket Grunt (TRAINER_TEAM_ROCKET_GRUNT_7): Raticate, Zubat Lv20; he runs off" + "id": "repel_route8", + "kind": "game_action", + "variant": "use_item repel", + "why": "a Repel (5 bought in 00.26) for Route 8's grass and the Underground Path; renewed when it wears off" }, { - "id": "poster_switch", - "kind": "talk", - "why": "the poster (11, 1): the switch opens the stairs (15, 2)" + "id": "ride_bike", + "kind": "game_action", + "variant": "ride_bike", + "why": "on the BICYCLE (00.26) for Route 8; the Underground Path's gate, indoors, gets the player off" }, { - "id": "hideout_b4f_west", + "id": "east_of_rich_line", "kind": "route", - "target": {"certainty": "tile", "map": "RocketHideout_B4F", "where": {"facing": "west", "kind": "tiles", "tiles": [[5, 3]]}}, - "variant": "run", - "why": "the stairs (15, 2) down to B1F, B1F to B2F (17, 2), B2F's arrow tiles to B3F (21, 2), B3F to B4F (15, 18), round to (5, 3), east of the tile below the Grunt (4, 2)" + "target": {"certainty": "tile", "map": "Route8", "where": {"facing": "west", "kind": "tiles", "tiles": [[56, 18]]}}, + "variant": "bike", + "why": "west out of Lavender onto Route 8, down column 64 east of Julia (62, 14) and west along row 18" }, { - "id": "key_grunt_line", + "id": "rich_line", "kind": "step", "direction": "west", "variant": "trainer_line", - "why": "onto (4, 3), in the Grunt's line (sight 1)" + "why": "onto (55, 18), the end of Rich's line (55, 16..18): he walks down to (55, 17)" }, { - "id": "hideout-key-grunt", + "id": "route8-rich", "kind": "battle", - "declared_after": {"certainty": "tile", "map": "RocketHideout_B4F", "where": {"facing": "west", "kind": "tiles", "tiles": [[4, 3]]}}, - "why": "Rocket Grunt (TRAINER_TEAM_ROCKET_GRUNT_18): Koffing, Zubat Lv21; he drops the LIFT KEY" + "declared_after": {"certainty": "tile", "map": "Route8", "where": {"facing": "north", "kind": "tiles", "tiles": [[55, 18]]}}, + "why": "Gamer Rich (TRAINER_GAMER_RICH): Growlithe, Vulpix Lv24" + }, + { + "id": "below_underground_door", + "kind": "route", + "target": {"certainty": "tile", "map": "Route8", "where": {"facing": "north", "kind": "tiles", "tiles": [[13, 5]]}}, + "variant": "bike", + "why": "west along rows 14..18, up column 14 east of Aidan's line (10..13, 6), to below the Underground Path door (13, 4)" }, { - "id": "below_lift_key", + "id": "route7_underground_exit", "kind": "route", - "target": {"certainty": "tile", "map": "RocketHideout_B4F", "where": {"facing": "north", "kind": "tiles", "tiles": [[3, 3]]}}, + "target": {"certainty": "tile", "map": "Route7", "where": {"facing": "south", "kind": "tiles", "tiles": [[7, 15]]}}, "variant": "run", - "why": "a tile west, below the LIFT KEY (3, 2)" + "why": "through the door, the east entrance, the east-west tunnel and the west entrance, out onto Route 7" }, { - "id": "lift_key", - "kind": "talk", - "why": "pick up the LIFT KEY (pokeguy 57:33)" + "id": "pidgey_grass", + "kind": "route", + "target": {"certainty": "tile", "map": "Route7", "where": {"facing": "east", "kind": "tiles", "tiles": [[14, 5]]}}, + "variant": "run", + "why": "north round the ledges into Route 7's grass (11..16, 2..5), a grass tile east of it free; Route 8's grass is behind two Cut trees" + }, + { + "id": "catch_pidgey", + "kind": "game_action", + "variant": "capture pidgey catches=1 encounters=30 balls=5 pace=east", + "why": "a Pidgey to carry HM02 Fly (pokeguy flies with a Pidgey from 1:03 on); the party has no other flier" + }, + { + "id": "celadon_below_center", + "kind": "route", + "target": {"certainty": "tile", "map": "CeladonCity", "where": {"facing": "north", "kind": "tiles", "tiles": [[48, 12]]}}, + "variant": "run", + "why": "north round the grass, west onto Celadon (row 12..15) and along row 12 to below the Center door (48, 11)" }, { - "id": "beside_b3f_rare_candy", + "id": "celadon_nurse", "kind": "route", - "target": {"certainty": "tile", "map": "RocketHideout_B3F", "where": {"facing": "north", "kind": "tiles", "tiles": [[12, 13]]}}, + "target": {"certainty": "tile", "map": "CeladonCity_PokemonCenter_1F", "where": {"facing": "north", "kind": "tiles", "tiles": [[7, 4]]}}, "variant": "run", - "why": "back up the stairs to B3F, below the Rare Candy (12, 12) beside the arrow tiles" + "why": "into the Center, to the nurse" }, { - "id": "b3f_rare_candy", - "kind": "talk", - "why": "pick up the Rare Candy: two, with Route 9's" + "id": "heal_celadon", + "kind": "heal", + "why": "the nurse heals the party" } ] }, diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/Draft/frlg-00.31.plan.json b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/Draft/frlg-00.31.plan.json index e040f84d88..bef003ddb2 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/Draft/frlg-00.31.plan.json +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/Draft/frlg-00.31.plan.json @@ -3,101 +3,83 @@ "game": "frlg", "checkpoint": { "id": "00.31", - "title": "Rocket Hideout: the lift to B4F, the two door guards, two Rare Candies, Blastoise, Giovanni and the SILPH SCOPE", - "start": {"certainty": "tile", "map": "RocketHideout_B3F", "where": {"facing": "north", "kind": "tiles", "tiles": [[12, 13]]}}, + "title": "Celadon Game Corner and the Rocket Hideout down to B4F: the poster guard, the Lift Key Grunt, the LIFT KEY and B3F's Rare Candy", + "start": {"certainty": "tile", "map": "CeladonCity_PokemonCenter_1F", "where": {"facing": "north", "kind": "tiles", "tiles": [[7, 4]]}}, "events": [ { - "id": "b2f_lift", + "id": "celadon_below_game_corner", "kind": "route", - "target": {"certainty": "tile", "map": "RocketHideout_B2F", "where": {"facing": "north", "kind": "tiles", "tiles": [[28, 17]]}}, + "target": {"certainty": "tile", "map": "CeladonCity", "where": {"facing": "north", "kind": "tiles", "tiles": [[34, 22]]}}, "variant": "run", - "why": "up to B2F, along the arrow tiles to the lift (28..29, 16)" + "why": "out of the Center, west along row 12, down column 41..43 to row 22, west to below the Game Corner (34, 21)" }, { - "id": "lift_to_b4f", - "kind": "scene", - "why": "the LIFT KEY works the lift: B4F" - }, - { - "id": "b4f_below_guards", + "id": "below_poster_guard", "kind": "route", - "target": {"certainty": "tile", "map": "RocketHideout_B4F", "where": {"facing": "north", "kind": "tiles", "tiles": [[17, 16]]}}, + "target": {"certainty": "tile", "map": "CeladonCity_GameCorner", "where": {"facing": "north", "kind": "tiles", "tiles": [[11, 3]]}}, "variant": "run", - "why": "out of the lift to below the two Grunts (16, 14) and (19, 14)" + "why": "into the Game Corner, up the slot rows to below the Grunt (11, 2) in front of the poster" }, { - "id": "talk_left_guard", + "id": "talk_poster_guard", "kind": "talk", - "why": "the left door guard" + "why": "the Grunt guarding the poster" }, { - "id": "hideout-left-guard", + "id": "hideout-poster-guard", "kind": "battle", - "declared_after": {"certainty": "tile", "map": "RocketHideout_B4F", "where": {"facing": "north", "kind": "tiles", "tiles": [[17, 16]]}}, - "why": "Rocket Grunt (TRAINER_TEAM_ROCKET_GRUNT_16): Sandshrew, Sandslash, Ekans Lv23" - }, - { - "id": "talk_right_guard", - "kind": "talk", - "why": "the right door guard" + "declared_after": {"certainty": "tile", "map": "CeladonCity_GameCorner", "where": {"facing": "north", "kind": "tiles", "tiles": [[11, 3]]}}, + "why": "Rocket Grunt (TRAINER_TEAM_ROCKET_GRUNT_7): Raticate, Zubat Lv20; he runs off" }, { - "id": "hideout-right-guard", - "kind": "battle", - "declared_after": {"certainty": "tile", "map": "RocketHideout_B4F", "where": {"facing": "north", "kind": "tiles", "tiles": [[17, 16]]}}, - "why": "Rocket Grunt (TRAINER_TEAM_ROCKET_GRUNT_17): Ekans, Sandshrew, Arbok Lv23; the door opens" + "id": "poster_switch", + "kind": "game_action", + "variant": "poster_switch", + "why": "the poster (11, 1): the switch opens the stairs (15, 2)" }, { - "id": "rare_candy_1", - "kind": "game_action", - "variant": "use_item rare-candy slot=1", - "why": "Rare Candy: Lv35 (pokeguy 1:00:25)" + "id": "hideout_b4f_west", + "kind": "route", + "target": {"certainty": "tile", "map": "RocketHideout_B4F", "where": {"facing": "west", "kind": "tiles", "tiles": [[5, 3]]}}, + "variant": "run", + "why": "the stairs (15, 2) down to B1F, B1F to B2F (17, 2), B2F's arrow tiles to B3F (21, 2), B3F to B4F (15, 18), round to (5, 3), east of the tile below the Grunt (4, 2)" }, { - "id": "rare_candy_2", - "kind": "game_action", - "variant": "use_item rare-candy slot=1", - "why": "Rare Candy: Lv36, Wartortle evolves" + "id": "key_grunt_line", + "kind": "step", + "direction": "west", + "variant": "trainer_line", + "why": "onto (4, 3), in the Grunt's line (sight 1)" }, { - "id": "evolve_blastoise", - "kind": "scene", - "why": "Wartortle evolves into Blastoise; do not press B" + "id": "hideout-key-grunt", + "kind": "battle", + "declared_after": {"certainty": "tile", "map": "RocketHideout_B4F", "where": {"facing": "west", "kind": "tiles", "tiles": [[4, 3]]}}, + "why": "Rocket Grunt (TRAINER_TEAM_ROCKET_GRUNT_18): Koffing, Zubat Lv21; he drops the LIFT KEY" }, { - "id": "below_giovanni", + "id": "below_lift_key", "kind": "route", - "target": {"certainty": "tile", "map": "RocketHideout_B4F", "where": {"facing": "north", "kind": "tiles", "tiles": [[19, 5]]}}, - "why": "through the door to below Giovanni (19, 4)" + "target": {"certainty": "tile", "map": "RocketHideout_B4F", "where": {"facing": "north", "kind": "tiles", "tiles": [[3, 3]]}}, + "variant": "run", + "why": "a tile west, below the LIFT KEY (3, 2)" }, { - "id": "talk_giovanni", + "id": "lift_key", "kind": "talk", - "why": "Giovanni" - }, - { - "id": "hideout-giovanni", - "kind": "battle", - "declared_after": {"certainty": "tile", "map": "RocketHideout_B4F", "where": {"facing": "north", "kind": "tiles", "tiles": [[19, 5]]}}, - "why": "Giovanni (TRAINER_BOSS_GIOVANNI): Onix, Rhyhorn, Kangaskhan" + "why": "pick up the LIFT KEY (pokeguy 57:33)" }, { - "id": "beside_silph_scope", + "id": "beside_b3f_rare_candy", "kind": "route", - "target": {"certainty": "tile", "map": "RocketHideout_B4F", "where": {"facing": "west", "kind": "tiles", "tiles": [[21, 5]]}}, - "why": "round to east of the SILPH SCOPE (20, 5)" + "target": {"certainty": "tile", "map": "RocketHideout_B3F", "where": {"facing": "north", "kind": "tiles", "tiles": [[12, 13]]}}, + "variant": "run", + "why": "back up the stairs to B3F, below the Rare Candy (12, 12) beside the arrow tiles" }, { - "id": "silph_scope", + "id": "b3f_rare_candy", "kind": "talk", - "why": "pick up the SILPH SCOPE" - }, - { - "id": "escape_rope_hideout", - "kind": "game_action", - "declared_after": {"certainty": "tile", "map": "CeladonCity", "where": {"facing": "south", "kind": "tiles", "tiles": [[34, 22]]}}, - "variant": "use_item escape-rope", - "why": "out to the Game Corner's door (pokeguy 1:02:10)" + "why": "pick up the Rare Candy: two, with Route 9's" } ] }, diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/Draft/frlg-00.32.plan.json b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/Draft/frlg-00.32.plan.json new file mode 100644 index 0000000000..4351f396c4 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/Draft/frlg-00.32.plan.json @@ -0,0 +1,106 @@ +{ + "format": "pokesim-authored-checkpoint/1", + "game": "frlg", + "checkpoint": { + "id": "00.32", + "title": "Rocket Hideout: the lift to B4F, the two door guards, two Rare Candies, Blastoise, Giovanni and the SILPH SCOPE", + "start": {"certainty": "tile", "map": "RocketHideout_B3F", "where": {"facing": "north", "kind": "tiles", "tiles": [[12, 13]]}}, + "events": [ + { + "id": "b2f_lift", + "kind": "route", + "target": {"certainty": "tile", "map": "RocketHideout_B2F", "where": {"facing": "north", "kind": "tiles", "tiles": [[28, 17]]}}, + "variant": "run", + "why": "up to B2F, along the arrow tiles to the lift (28..29, 16)" + }, + { + "id": "lift_to_b4f", + "kind": "game_action", + "variant": "lift_floor B2F B4F", + "why": "the LIFT KEY works the lift: B4F" + }, + { + "id": "b4f_below_guards", + "kind": "route", + "target": {"certainty": "tile", "map": "RocketHideout_B4F", "where": {"facing": "north", "kind": "tiles", "tiles": [[17, 16]]}}, + "variant": "run", + "why": "out of the lift to below the two Grunts (16, 14) and (19, 14)" + }, + { + "id": "talk_left_guard", + "kind": "talk", + "why": "the left door guard" + }, + { + "id": "hideout-left-guard", + "kind": "battle", + "declared_after": {"certainty": "tile", "map": "RocketHideout_B4F", "where": {"facing": "north", "kind": "tiles", "tiles": [[17, 16]]}}, + "why": "Rocket Grunt (TRAINER_TEAM_ROCKET_GRUNT_16): Sandshrew, Sandslash, Ekans Lv23" + }, + { + "id": "talk_right_guard", + "kind": "talk", + "why": "the right door guard" + }, + { + "id": "hideout-right-guard", + "kind": "battle", + "declared_after": {"certainty": "tile", "map": "RocketHideout_B4F", "where": {"facing": "north", "kind": "tiles", "tiles": [[17, 16]]}}, + "why": "Rocket Grunt (TRAINER_TEAM_ROCKET_GRUNT_17): Ekans, Sandshrew, Arbok Lv23; the door opens" + }, + { + "id": "rare_candy_1", + "kind": "game_action", + "variant": "use_item rare-candy slot=1", + "why": "Rare Candy: Lv35 (pokeguy 1:00:25)" + }, + { + "id": "rare_candy_2", + "kind": "game_action", + "variant": "use_item rare-candy slot=1", + "why": "Rare Candy: Lv36, Wartortle evolves" + }, + { + "id": "evolve_blastoise", + "kind": "scene", + "why": "Wartortle evolves into Blastoise; do not press B" + }, + { + "id": "below_giovanni", + "kind": "route", + "target": {"certainty": "tile", "map": "RocketHideout_B4F", "where": {"facing": "north", "kind": "tiles", "tiles": [[19, 5]]}}, + "why": "through the door to below Giovanni (19, 4)" + }, + { + "id": "talk_giovanni", + "kind": "talk", + "why": "Giovanni" + }, + { + "id": "hideout-giovanni", + "kind": "battle", + "declared_after": {"certainty": "tile", "map": "RocketHideout_B4F", "where": {"facing": "north", "kind": "tiles", "tiles": [[19, 5]]}}, + "why": "Giovanni (TRAINER_BOSS_GIOVANNI): Onix, Rhyhorn, Kangaskhan" + }, + { + "id": "beside_silph_scope", + "kind": "route", + "target": {"certainty": "tile", "map": "RocketHideout_B4F", "where": {"facing": "west", "kind": "tiles", "tiles": [[21, 5]]}}, + "why": "round to east of the SILPH SCOPE (20, 5)" + }, + { + "id": "silph_scope", + "kind": "talk", + "why": "pick up the SILPH SCOPE" + }, + { + "id": "escape_rope_hideout", + "kind": "game_action", + "declared_after": {"certainty": "tile", "map": "CeladonCity", "where": {"facing": "south", "kind": "tiles", "tiles": [[34, 22]]}}, + "variant": "use_item escape-rope", + "why": "out to the Game Corner's door (pokeguy 1:02:10)" + } + ] + }, + "execution": {"menu": "full"} +} From 8ebc21b3c7b3b109bd052caeea7e718e777aa0c2 Mon Sep 17 00:00:00 2001 From: dayfine Date: Mon, 9 Nov 2026 18:02:19 -0800 Subject: [PATCH 10/10] FRLG: script Pokemon Centers and the hideout lift; code spinners and the poster Centers share one layout and the lift is 5x7 tiles, so the story walks them by fixed taps (center_enter, center_exit, lift_floor from below the door to the destination's door) and the sim builds their rooms from code read off pokefirered. B2F/B3F spinner tiles and the Game Corner poster and stairs are coded too, so the candidate maps need no extra regions or objects. Bike pace and bag waits from console B (2026-11-09). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013VUubhHeYSMGSSDXZkKZss --- .../Overworld/PokemonFRLG_CodedRooms.cpp | 211 ++++++++++++++++++ .../Overworld/PokemonFRLG_CodedRooms.h | 96 ++++++++ .../Overworld/PokemonFRLG_MovementMechanics.h | 10 +- .../Overworld/PokemonFRLG_OverworldMap.cpp | 6 +- .../Overworld/PokemonFRLG_OverworldMap.h | 5 +- .../Overworld/PokemonFRLG_OverworldSim.cpp | 1 + .../Overworld/PokemonFRLG_OverworldSim.h | 8 +- .../PokemonFRLG_OverworldSimField.cpp | 7 + .../PokemonFRLG_AutoStoryHideout.cpp | 21 +- .../AutoStory/PokemonFRLG_AutoStoryHideout.h | 21 +- .../PokemonFRLG_AutoStoryHideout_Tests.cpp | 107 ++++++--- .../PokemonFRLG_AutoStoryItemUse.cpp | 20 +- .../AutoStory/PokemonFRLG_AutoStoryRooms.cpp | 91 ++++++++ .../AutoStory/PokemonFRLG_AutoStoryRooms.h | 81 +++++++ .../PokemonFRLG_AutoStoryRooms_Tests.cpp | 125 +++++++++++ .../AutoStory/PokemonFRLG_AutoStoryRowSim.cpp | 59 ++++- .../AutoStory/PokemonFRLG_AutoStoryRowSim.h | 1 + .../PokemonFRLG_AutoStoryStepTable_Tests.cpp | 2 + .../PokemonFRLG_AutoStoryTableSteps.cpp | 20 ++ .../PokemonFRLG_AutoStoryTableSteps.h | 12 +- .../AutoStory/StoryData/Draft/README.md | 60 +++-- .../StoryData/Draft/frlg-00.30.plan.json | 17 +- .../StoryData/Draft/frlg-00.31.plan.json | 9 +- .../StoryData/Draft/frlg-00.32.plan.json | 5 +- SerialPrograms/cmake/SourceFiles.cmake | 5 + 25 files changed, 910 insertions(+), 90 deletions(-) create mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_CodedRooms.cpp create mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_CodedRooms.h create mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRooms.cpp create mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRooms.h create mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRooms_Tests.cpp diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_CodedRooms.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_CodedRooms.cpp new file mode 100644 index 0000000000..64a103cf0a --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_CodedRooms.cpp @@ -0,0 +1,211 @@ +/* Coded Rooms and Tiles + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include +#include "PokemonFRLG_CodedRooms.h" +#include "PokemonFRLG_OverworldMaps.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +namespace{ + +// A floor's spinner tiles, by the way each slides the player. +struct SpinnerFloor{ + std::string_view map; + std::vector north; + std::vector south; + std::vector west; + std::vector east; + std::vector stop; +}; + +// Read off pokefirered: each floor's layout (data/layouts/RocketHideout_ +// B2F, _B3F map.bin, primary tileset gTileset_Building, secondary +// gTileset_SilphCo) with each metatile's behaviour from the tilesets' +// metatile_attributes.bin: MB_SPIN_UP (0x56), MB_SPIN_DOWN (0x57), +// MB_SPIN_LEFT (0x55), MB_SPIN_RIGHT (0x54), MB_STOP_SPINNING (0x58). +const std::vector& spinner_floors(){ + static const std::vector FLOORS{ + { + "RocketHideout_B2F", + {{9, 5}, {11, 6}, {7, 7}, {11, 8}, {15, 9}, {7, 10}, {9, 10}, {3, 11}, {15, 11}, {13, 12}, {15, 13}, {9, 14}, {5, 17}, {7, 18}, {5, 19}}, + {{12, 5}, {8, 9}, {10, 9}, {14, 11}, {10, 13}, {8, 17}}, + {{3, 4}, {7, 4}, {9, 4}, {11, 4}, {16, 5}, {16, 6}, {12, 13}, {3, 14}, {7, 14}, {12, 14}, {12, 17}, {12, 18}}, + {{3, 6}, {12, 7}, {4, 9}, {3, 10}, {10, 11}, {12, 11}, {9, 12}, {11, 12}, {9, 20}}, + {{1, 4}, {7, 6}, {13, 7}, {15, 8}, {13, 10}, {8, 11}, {14, 13}, {1, 14}, {5, 15}, {10, 15}, {8, 19}, {13, 20}}, + }, + { + "RocketHideout_B3F", + {{8, 11}, {10, 14}, {6, 15}, {6, 17}, {4, 18}}, + {{9, 10}, {7, 16}}, + {{4, 9}}, + {{6, 9}, {2, 11}, {6, 13}, {5, 14}, {7, 14}, {4, 15}, {3, 16}, {2, 17}}, + {{2, 9}, {8, 9}, {6, 11}, {10, 13}, {9, 14}, {7, 20}}, + }, + }; + return FLOORS; +} + +const SpinnerFloor* spinner_floor(std::string_view map){ + for (const SpinnerFloor& floor : spinner_floors()){ + if (floor.map == map){ + return &floor; + } + } + return nullptr; +} + +bool contains(const std::vector& tiles, const TilePos& point){ + return std::any_of(tiles.begin(), tiles.end(), [&](const TilePos& tile){ + return tile.x == point.x && tile.y == point.y; + }); +} + +bool has_object(const MapData& map, std::string_view name){ + return std::any_of(map.objects.begin(), map.objects.end(), [&](const MapObject& object){ + return object.name == name; + }); +} + +MapObject object(std::string name, Pokemon::ObjectKind kind, const TilePos& tile){ + MapObject out; + out.name = std::move(name); + out.kind = kind; + out.x = tile.x; + out.y = tile.y; + return out; +} + +// LAYOUT_POKEMON_CENTER_1F's collision and elevation, as the map lock's +// seven Centers (PokemonSimulation 6c2f5c8, data/games/frlg/maps/runtime/ +// *_PokemonCenter_1F.json) all have them. The mat's warps: (6..8, 8), +// the middle one MB_SOUTH_ARROW_WARP-like ("walk_south"); the stairs to +// 2F at (1, 6), which isn't loaded. +const char CENTER_ROOM_JSON[] = R"({ + "name": "PokemonCenter_1F", + "source": {"decomp_file": "data/layouts/PokemonCenter_1F/map.bin", "revision": "ce6406d8c2eaf46d543c8a2606de0c3cb1951982", "date": "2026-11-09"}, + "allow_running": false, + "tiles": ["#.#############", "###############", "....###..##....", "....#######....", "...............", "...............", "#..........##..", "...........##..", "...............", "###############"], + "elevation": ["030000000000000", "000000000000000", "333300033003333", "333300000003333", "333333333333333", "330333333333333", "044033333330033", "330333333330033", "333333333333333", "000000000000000"], + "points": {"warp_0": [6, 8], "warp_1": [7, 8], "warp_2": [8, 8], "nurse_front": [7, 4]}, + "warps": [ + {"x": 6, "y": 8, "enter": "walk_into"}, + {"x": 7, "y": 8, "enter": "walk_south"}, + {"x": 8, "y": 8, "enter": "walk_into"}, + {"x": 1, "y": 6, "enter": "step_on"} + ], + "objects": [{"name": "nurse", "kind": "npc", "x": 7, "y": 2}] +})"; + +// LAYOUT_ROCKET_HIDEOUT_ELEVATOR's collision (map.bin: rows 0 to 2 and 6 +// walls, 3 to 5 floor), the panel's bg event at (0, 2), and warp 1 at +// (2, 5), the one MB_SOUTH_ARROW_WARP tile (warp 0 at (1, 5) has no +// behaviour, so never fires). +const char LIFT_ROOM_JSON[] = R"({ + "name": "RocketHideout_Elevator", + "source": {"decomp_file": "data/layouts/RocketHideout_Elevator/map.bin", "revision": "ce6406d8c2eaf46d543c8a2606de0c3cb1951982", "date": "2026-11-09"}, + "tiles": ["#####", "#####", "S####", ".....", ".....", ".....", "#####"], + "points": {"warp_0": [1, 5], "warp_1": [2, 5]}, + "warps": [{"x": 2, "y": 5, "enter": "walk_south"}] +})"; + +} // namespace + + +std::optional coded_spin_direction(std::string_view map, const TilePos& point){ + const SpinnerFloor* floor = spinner_floor(map); + if (floor == nullptr){ + return std::nullopt; + } + if (contains(floor->north, point)){ + return Direction::NORTH; + } + if (contains(floor->south, point)){ + return Direction::SOUTH; + } + if (contains(floor->west, point)){ + return Direction::WEST; + } + if (contains(floor->east, point)){ + return Direction::EAST; + } + return std::nullopt; +} + +bool coded_spin_stop(std::string_view map, const TilePos& point){ + const SpinnerFloor* floor = spinner_floor(map); + return floor != nullptr && contains(floor->stop, point); +} + + +void add_coded_objects(MapData& map){ + if (map.name != GAME_CORNER_MAP){ + return; + } + if (!has_object(map, "poster")){ + map.objects.push_back(object("poster", Pokemon::ObjectKind::SIGN, GAME_CORNER_POSTER)); + } + if (!has_object(map, "poster_stairs")){ + for (const TilePos& tile : {TilePos{15, 2}, TilePos{16, 2}, TilePos{17, 2}, TilePos{16, 3}, TilePos{17, 3}}){ + map.objects.push_back(object("poster_stairs", Pokemon::ObjectKind::NPC, tile)); + } + } +} + + +bool is_pokemon_center(std::string_view map){ + return map.ends_with("_PokemonCenter_1F"); +} + +MapData pokemon_center_room(const std::string& name, const Pokemon::WarpDestination& outside){ + MapData room = parse_map(CENTER_ROOM_JSON, name); + room.name = name; + for (MapWarp& warp : room.warps){ + if (warp.y == CENTER_MAT.y){ + warp.to = outside; + } + } + return room; +} + +std::optional add_pokemon_center_room(std::vector& maps, size_t town){ + for (const MapWarp& warp : maps[town].warps){ + if (!warp.to || !is_pokemon_center(warp.to->map)){ + continue; + } + const std::string center = warp.to->map; + const std::string door = center + "_door"; + maps[town].points[door] = TilePos{warp.x, warp.y}; + Pokemon::WarpDestination outside{maps[town].name, door}; + maps.push_back(pokemon_center_room(center, outside)); + return center; + } + return std::nullopt; +} + + +MapData rocket_hideout_lift_room(){ + return parse_map(LIFT_ROOM_JSON, std::string(LIFT_MAP)); +} + +const std::vector& rocket_hideout_lift_doors(){ + static const std::vector DOORS{ + {"RocketHideout_B1F", {24, 25}}, + {"RocketHideout_B2F", {28, 16}}, + {"RocketHideout_B4F", {20, 23}}, + }; + return DOORS; +} + + + +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_CodedRooms.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_CodedRooms.h new file mode 100644 index 0000000000..348aba0492 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_CodedRooms.h @@ -0,0 +1,96 @@ +/* Coded Rooms and Tiles + * + * From: https://github.com/PokemonAutomation/ + * + * Places the story scripts rather than maps (the user's call, 2026-11-09): + * every Pokemon Center's ground floor, which all share one layout, the + * Rocket Hideout's lift, a room of 5 by 7 tiles, and the tiles that maps + * can't say in glyphs: the Rocket Hideout's spinners and the Game + * Corner's poster. All of it is read off pret/pokefirered, cited where it + * is defined, so no map file has to carry it. + * + */ + +#ifndef PokemonAutomation_PokemonFRLG_CodedRooms_H +#define PokemonAutomation_PokemonFRLG_CodedRooms_H + +#include +#include +#include +#include +#include "PokemonFRLG_OverworldMap.h" +#include "PokemonFRLG_OverworldRoute.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +// The spinner arrow on `point` of the map named `map`, from the +// decompilation's metatile behaviours, if the map is one of the hideout's +// spinner floors (RocketHideout_B2F, RocketHideout_B3F). +std::optional coded_spin_direction(std::string_view map, const TilePos& point); +// Whether `point` of the map named `map` is a coded MB_STOP_SPINNING tile. +bool coded_spin_stop(std::string_view map, const TilePos& point); + + +// The Game Corner, whose poster hides the hideout's switch. +inline constexpr std::string_view GAME_CORNER_MAP = "CeladonCity_GameCorner"; +// The poster (pokefirered CeladonCity_GameCorner map.json, the bg event +// CeladonCity_GameCorner_EventScript_Poster at (11, 1)). +inline constexpr TilePos GAME_CORNER_POSTER{11, 1}; +// Adds what the Game Corner's map can't say to `map`, if it is the Game +// Corner and hasn't them: the poster, an object "poster", and the tiles +// the switch turns into stairs (CeladonCity_GameCorner_EventScript_ +// OpenRocketHideout's setmetatile: (15, 2), (16..17, 2..3)), objects +// "poster_stairs" that block them until it is pushed. +void add_coded_objects(MapData& map); + + +// A Pokemon Center's ground floor. pokefirered gives all of Kanto's and +// the Sevii Islands' but the Indigo Plateau's and One Island's the one +// layout, LAYOUT_POKEMON_CENTER_1F; the seven in the map lock have the +// same tiles and differ only in who stands where, none of them on the +// nurse's column. +// The mat inside the door: the player arrives on it and leaves from it, +// walking south. +inline constexpr TilePos CENTER_MAT{7, 8}; +// Below the nurse's counter, facing her (the nurse at (7, 2)). +inline constexpr TilePos CENTER_NURSE_FRONT{7, 4}; +// Whether `map` names a Pokemon Center's ground floor. +bool is_pokemon_center(std::string_view map); +// The ground floor of the Center named `name`, whose mat lets out on +// `outside` (the door tile of the town's map): its tiles, the nurse, and +// the mat's warps. No person but the nurse: who stands where differs, and +// the script keeps to the nurse's column. +MapData pokemon_center_room(const std::string& name, const Pokemon::WarpDestination& outside); +// For a sim: adds the coded room of the Center whose door is on +// `maps[town]` to `maps`, and returns its name; nothing if the town has +// no Center door. The town gets a point "
_door" on its door tile +// for the room's mat to let out on. +std::optional add_pokemon_center_room(std::vector& maps, size_t town); + + +// The Rocket Hideout's lift (pokefirered LAYOUT_ROCKET_HIDEOUT_ELEVATOR, +// 5 by 7 tiles, rows 3 to 5 walkable). +inline constexpr std::string_view LIFT_MAP = "RocketHideout_Elevator"; +// Where the floors' lift doors put the player (warp 1, an +// MB_SOUTH_ARROW_WARP tile): south from it leaves the lift. +inline constexpr TilePos LIFT_ARRIVAL{2, 5}; +// Below the panel (a bg event at (0, 2), RocketHideout_Elevator_ +// EventScript_FloorSelect), facing north. +inline constexpr TilePos LIFT_PANEL_FRONT{0, 3}; +// The lift's room. Its door is a dynamic warp: the sim takes it to the +// floor picked (SimScript::lift_floors with rocket_hideout_lift_doors()). +MapData rocket_hideout_lift_room(); +// Where the lift lets out for each floor on its panel, top first +// (setdynamicwarp in RocketHideout_Elevator's scripts): B1F (24, 25), +// B2F (28, 16), B4F (20, 23), each its floor's lift door. +const std::vector& rocket_hideout_lift_doors(); + + + +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation +#endif diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_MovementMechanics.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_MovementMechanics.h index d2f761596c..de3c682440 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_MovementMechanics.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_MovementMechanics.h @@ -48,10 +48,12 @@ inline constexpr Milliseconds TILE_TIME(267); inline constexpr Milliseconds RUN_TILE_TIME(133); // Surfing: a tile per 8 frames. inline constexpr Milliseconds SURF_TILE_TIME(133); -// Riding the Bicycle: a tile per 4 frames (pokefirered bike.c, -// PlayerRideWaterCurrent(): MOVE_SPEED_FAST_2). An estimate from the -// decompilation, not measured on the console. -inline constexpr Milliseconds BIKE_TILE_TIME(67); +// Riding the Bicycle, measured on console B (2026-11-09, Lavender Town +// row 13, wall to wall, from rest): 16 tiles (1, 13) to (17, 13) in +// 1800 ms of motion, east and back west alike, 112 ms a tile; 5 tiles +// from rest in 600 ms (120 ms a tile, the start up included). Not the +// decompilation's 4 frames a tile (67 ms): the bike speeds up from rest. +inline constexpr Milliseconds BIKE_TILE_TIME(112); // The longest tap measured to only turn a player facing another way, and // to take exactly one step for one facing its way. inline constexpr Milliseconds LONGEST_TAP(133); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMap.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMap.cpp index fb2317c92a..41a578fdde 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMap.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMap.cpp @@ -6,6 +6,7 @@ #include #include "Common/Cpp/Exceptions.h" +#include "PokemonFRLG_CodedRooms.h" #include "PokemonFRLG_OverworldMap.h" namespace PokemonAutomation{ @@ -246,6 +247,9 @@ bool in_region(const MapData& map, const char* region, const TilePos& point){ } // namespace std::optional spin_direction(const MapData& map, const TilePos& point){ + if (const std::optional coded = coded_spin_direction(map.name, point)){ + return coded; + } static const std::pair ARROWS[] = { {SPIN_NORTH_REGION, Direction::NORTH}, {SPIN_SOUTH_REGION, Direction::SOUTH}, @@ -260,7 +264,7 @@ std::optional spin_direction(const MapData& map, const TilePos& point return std::nullopt; } bool stops_spinning(const MapData& map, const TilePos& point){ - return in_region(map, SPIN_STOP_REGION, point); + return coded_spin_stop(map.name, point) || in_region(map, SPIN_STOP_REGION, point); } std::optional spin_slide(const MapData& map, const PlayerState& state){ diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMap.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMap.h index 84bf33ea49..aecc3af3c0 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMap.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMap.h @@ -162,8 +162,9 @@ PlayerState after_step_onto( // Spinner tiles (Rocket Hideout B2F and B3F, Viridian Gym): pokefirered's // MB_SPIN_UP, MB_SPIN_DOWN, MB_SPIN_LEFT, MB_SPIN_RIGHT and -// MB_STOP_SPINNING. The tile glyphs can't say so, so a map lists them in -// these regions, as it does WILD_REGION. +// MB_STOP_SPINNING. The tile glyphs can't say so: the hideout's floors +// have them coded (coded_spin_direction(), PokemonFRLG_CodedRooms.h); +// another map may list them in these regions, as it does WILD_REGION. // "spin_north": MB_SPIN_UP. extern const char SPIN_NORTH_REGION[]; // "spin_south": MB_SPIN_DOWN. diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp index b0cd746e07..2e9b85afac 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp @@ -145,6 +145,7 @@ OverworldSim::OverworldSim(std::vector maps, MapPosition start, Directi if (m_map < 0){ throw InternalProgramError(nullptr, PA_CURRENT_FUNCTION, "Sim: start on a map that isn't loaded: " + start.map); } + add_coded_field(); m_player = PlayerState{start.point, elevation_at(map(), start.point), false}; m_state.facing = facing; show(m_script.start_menu_open ? SimScreen::START_MENU : SimScreen::OVERWORLD, m_now); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h index f2dc04326b..94fdd1d063 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h @@ -140,7 +140,8 @@ struct SimScript{ // A lift's map, and where its door lets the player out for each floor // on its panel's menu, top first (pokefirered setdynamicwarp in // RocketHideout_Elevator's scripts): the door's own warp is the - // game's placeholder. + // game's placeholder. The hideout's: rocket_hideout_lift_room() under + // LIFT_MAP, with rocket_hideout_lift_doors(). std::map> lift_floors = {}; SimTiming timing; }; @@ -222,7 +223,8 @@ enum class SimField{ // The poster the player faces (an object named "poster"): the switch // behind it opens the stairs, which takes away the objects named // "poster_stairs" for good (pokefirered - // CeladonCity_GameCorner_EventScript_Poster's setmetatile). + // CeladonCity_GameCorner_EventScript_Poster's setmetatile). The sim + // adds both to the Game Corner from code (add_coded_objects()). POSTER_SWITCH, // The panel of the lift the player stands in (SimScript::lift_floors): // the floor picked, the lift's door now lets out there. @@ -325,6 +327,8 @@ class OverworldSim{ bool slide_on(Step& step) const; // The slide's time after `step`, and its facing; 0 for no slide. Milliseconds after_slide(const Step& step); + // What the maps can't say, from code (add_coded_objects()). + void add_coded_field(); bool cut_tree(); bool choose_lift_floor(size_t floor); bool push_poster_switch(); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSimField.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSimField.cpp index 2f657677a6..ac198790ac 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSimField.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSimField.cpp @@ -9,6 +9,7 @@ #include #include +#include "PokemonFRLG_CodedRooms.h" #include "PokemonFRLG_OverworldSim.h" namespace PokemonAutomation{ @@ -36,6 +37,12 @@ bool OverworldSim::use_field(SimField field, size_t floor){ return false; } +void OverworldSim::add_coded_field(){ + for (MapData& loaded : m_maps){ + add_coded_objects(loaded); + } +} + bool OverworldSim::slide_on(Step& step) const{ if (!spin_direction(m_maps[step.map], step.player.position)){ return true; diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryHideout.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryHideout.cpp index 3726c116a4..8c11c1fa7d 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryHideout.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryHideout.cpp @@ -9,6 +9,7 @@ #include "PokemonFRLG_AutoStoryActions.h" #include "PokemonFRLG_AutoStoryHideout.h" #include "PokemonFRLG_AutoStoryItemUse.h" +#include "PokemonFRLG_AutoStoryRooms.h" namespace PokemonAutomation{ namespace NintendoSwitch{ @@ -88,7 +89,7 @@ constexpr Milliseconds HIDEOUT_SCENE_TIMEOUT = 30000ms; } // namespace -void ride_lift(ConsoleHandle& console, ProControllerContext& context, const LiftRide& ride){ +void pick_lift_floor(ConsoleHandle& console, ProControllerContext& context, const LiftRide& ride){ const MenuPresses presses = lift_menu_presses(ride, rocket_hideout_lift_menu().size()); console.log( "Story: lift from " + rocket_hideout_lift_menu()[ride.from] + " to " + rocket_hideout_lift_menu()[ride.to] @@ -104,6 +105,12 @@ void ride_lift(ConsoleHandle& console, ProControllerContext& context, const Lift mash_until_overworld(console, context, HIDEOUT_SCENE_TIMEOUT); } +void ride_lift(ConsoleHandle& console, ProControllerContext& context, const LiftRide& ride){ + walk_room_legs(console, context, lift_in_legs(), Pokemon::Direction::NORTH); + pick_lift_floor(console, context, ride); + walk_room_legs(console, context, lift_out_legs(), Pokemon::Direction::NORTH); +} + void push_poster_switch(ConsoleHandle& console, ProControllerContext& context){ console.log("Story: the switch behind the poster."); pbf_press_button(context, BUTTON_A, 160ms, 800ms); @@ -113,9 +120,17 @@ void push_poster_switch(ConsoleHandle& console, ProControllerContext& context){ std::optional run_field_game_step( ConsoleHandle& console, ProControllerContext& context, Language language, const Pokemon::StoryStep& step ){ + const auto done = [&step]{ + return step.pose_after ? Pokemon::outcome_ok(*step.pose_after) : Pokemon::outcome_ok(); + }; if (const std::optional ride = parse_lift_floor_variant(step.variant)){ ride_lift(console, context, *ride); - return Pokemon::outcome_ok(); + return done(); + } + if (is_center_variant(step.variant)){ + // Both start facing north: at the door, or at the nurse. + walk_room_legs(console, context, center_legs(step.variant), Pokemon::Direction::NORTH); + return done(); } if (step.variant == POSTER_SWITCH_VARIANT){ push_poster_switch(console, context); @@ -130,7 +145,7 @@ std::optional run_field_game_step( if (end != ItemUseEnd::USED){ return Pokemon::outcome_failed(std::string("use_item_") + item_use_end_name(end), step.variant); } - return step.pose_after ? Pokemon::outcome_ok(*step.pose_after) : Pokemon::outcome_ok(); + return done(); } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryHideout.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryHideout.h index 1282c2d880..bf8ed55c83 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryHideout.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryHideout.h @@ -37,10 +37,12 @@ namespace PokemonFRLG{ // beaten first. inline const std::string POSTER_SWITCH_VARIANT = "poster_switch"; -// The GAME step that rides the Rocket Hideout's lift, the player facing -// its panel with the LIFT KEY: "lift_floor ", floors as the -// panel names them (B1F, B2F, B4F). `from` is the floor the lift stands -// at, where the panel's cursor starts. +// The GAME step that rides the Rocket Hideout's lift with the LIFT KEY: +// "lift_floor ", floors as the panel names them (B1F, B2F, +// B4F). `from` is the floor the lift stands at, where the panel's cursor +// starts. The player starts below `from`'s lift door, facing north, and +// ends on `to`'s lift door (rocket_hideout_lift_doors()): the lift is +// walked by a script (lift_in_legs(), lift_out_legs()), not a map. inline constexpr std::string_view LIFT_FLOOR_PREFIX = "lift_floor "; // The lift panel's menu, top first (pokefirered @@ -73,15 +75,18 @@ bool is_hideout_variant(std::string_view variant); // On the console: A on the panel, the floor picked, the ride waited out // back to the overworld, the player still in the lift. +void pick_lift_floor(ConsoleHandle& console, ProControllerContext& context, const LiftRide& ride); +// On the console, the whole ride: into the lift from below the door, +// the floor picked, out onto the floor's lift door. void ride_lift(ConsoleHandle& console, ProControllerContext& context, const LiftRide& ride); // On the console: A on the poster, its message closed back to the // overworld. void push_poster_switch(ConsoleHandle& console, ProControllerContext& context); -// On the console, a GAME step of this file's or an item from the bag -// (is_item_use_variant()); nothing for another GAME step. The hideout's -// steps leave the player where they stand; an item used ends on the -// step's pose_after (an Escape Rope's), if it has one. +// On the console, a GAME step of this file's, a Pokemon Center's +// (is_center_variant()) or an item from the bag (is_item_use_variant()); +// nothing for another GAME step. The step ends on its pose_after, if it +// has one (an Escape Rope's, a lift ride's, a Center's). std::optional run_field_game_step( ConsoleHandle& console, ProControllerContext& context, Language language, const Pokemon::StoryStep& step ); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryHideout_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryHideout_Tests.cpp index 12db0549e2..ae6b342f3e 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryHideout_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryHideout_Tests.cpp @@ -8,6 +8,7 @@ #include #include #include "Common/Cpp/TestRunners/UnitTest.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_CodedRooms.h" #include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_MovementMechanics.h" #include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.h" #include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h" @@ -100,57 +101,99 @@ UnitTestResult test_steps_on_sim(){ return true; } +// The Game Corner with no poster or stairs in its map: the sim adds them +// from code (add_coded_objects()). The room ends at x 16, so the walk +// east ends on (15, 2), the stairs' top, only once the switch opens it. std::string poster_on_sim(){ - const MapData room = parse_map(R"({ - "name": "Corner", "source": {"decomp_file": "", "revision": "test", "date": "2026-11-09"}, - "tiles": ["#####", "#...#", "#...#", "#####"], - "objects": [ - {"name": "poster", "kind": "sign", "x": 2, "y": 1}, - {"name": "poster_stairs", "kind": "npc", "x": 3, "y": 1} - ] - })", "Corner"); - OverworldSim sim({room}, MapPosition{"Corner", {2, 2}}, Direction::NORTH); - StoryPose at{"Corner", {2, 2}, Direction::NORTH}; + std::string rows; + for (int y = 0; y < 5; y++){ + rows += std::string(y == 0 ? "" : ", ") + (y == 0 || y == 4 ? "\"#################\"" : "\"#...............#\""); + } + const MapData room = parse_map( + "{\"name\": \"CeladonCity_GameCorner\", \"source\": {\"decomp_file\": \"\", \"revision\": \"test\", \"date\": \"2026-11-09\"}," + " \"tiles\": [" + rows + "]}", + "CeladonCity_GameCorner" + ); + const MapPosition below_poster{"CeladonCity_GameCorner", {GAME_CORNER_POSTER.x, GAME_CORNER_POSTER.y + 1}}; + OverworldSim sim({room}, below_poster, Direction::NORTH); + StoryPose at{below_poster.map, below_poster.point, Direction::NORTH}; const std::string failure = row_step_on_sim(sim, at, StoryTable::poster_switch("poster_switch", "test"), "00.31"); if (!failure.empty()){ return failure; } const Milliseconds tile = MovementMechanics::TILE_TIME; - sim.hold(Direction::EAST, tile * 2, MovementMechanics::STEP_RELEASE, tile * 2, {}); - sim.hold(Direction::NORTH, tile * 2, MovementMechanics::STEP_RELEASE, tile * 2, {}); - if (!(sim.state().position == MapPosition{"Corner", {3, 1}})){ - return "After the poster step, walked onto " + to_string(sim.state().position) + ", not the stairs."; + sim.hold(Direction::EAST, tile * 6, MovementMechanics::STEP_RELEASE, tile * 6, {}); + if (!(sim.state().position == MapPosition{"CeladonCity_GameCorner", {15, 2}})){ + return "After the poster step, walked onto " + to_string(sim.state().position) + ", not the stairs' (15, 2)."; } return ""; } +// A hideout floor of 32 by 26 open tiles with its lift door, which lets +// into the lift as pokefirered's floors' warps do (warp 1 of the lift). +MapData hideout_floor(const std::string& name, const TilePos& door){ + std::string rows; + for (int y = 0; y < 26; y++){ + std::string row(32, '.'); + if (y == door.y){ + row[door.x] = 'D'; + } + rows += (y == 0 ? "\"" : ", \"") + row + "\""; + } + return parse_map(std::format( + "{{\"name\": \"{}\", \"source\": {{\"decomp_file\": \"\", \"revision\": \"test\", \"date\": \"2026-11-09\"}}," + " \"tiles\": [{}], \"warps\": [{{\"x\": {}, \"y\": {}, \"enter\": \"walk_into\", \"to_map\": \"{}\", \"to_point\": \"warp_1\"}}]}}", + name, rows, door.x, door.y, LIFT_MAP + ), name); +} + +// The ride from B2F to B4F with the lift's room coded, not mapped: from +// below B2F's lift door, in, the panel, out onto B4F's lift door. std::string lift_on_sim(){ - std::vector maps; - maps.push_back(parse_map(R"({ - "name": "Lift", "source": {"decomp_file": "", "revision": "test", "date": "2026-11-09"}, - "tiles": ["###", "#.#", "#.#", "###"], - "warps": [{"x": 1, "y": 2, "enter": "walk_south"}] - })", "Lift")); - maps.push_back(parse_map(R"({ - "name": "B4F", "source": {"decomp_file": "", "revision": "test", "date": "2026-11-09"}, - "tiles": ["###", "#.#", "###"] - })", "B4F")); + const std::vector& doors = rocket_hideout_lift_doors(); + std::vector maps{ + hideout_floor("RocketHideout_B2F", doors[1].point), + hideout_floor("RocketHideout_B4F", doors[2].point), + rocket_hideout_lift_room(), + }; SimScript script; - const MapPosition b4f{"B4F", {1, 1}}; - script.lift_floors["Lift"] = {MapPosition{"Lift", {1, 1}}, MapPosition{"Lift", {1, 1}}, b4f}; - OverworldSim sim(maps, MapPosition{"Lift", {1, 2}}, Direction::SOUTH, script); - StoryPose at{"Lift", {1, 2}, Direction::SOUTH}; + script.lift_floors[std::string(LIFT_MAP)] = doors; + const MapPosition below_door{"RocketHideout_B2F", doors[1].point.next(Direction::SOUTH)}; + OverworldSim sim(maps, below_door, Direction::NORTH, script); + StoryPose at{below_door.map, below_door.point, Direction::NORTH}; const std::string failure = row_step_on_sim(sim, at, StoryTable::lift_floor("lift_to_b4f", "B2F", "B4F", "test"), "00.32"); if (!failure.empty()){ return failure; } - sim.hold(Direction::SOUTH, MovementMechanics::TILE_TIME, MovementMechanics::STEP_RELEASE, std::chrono::milliseconds(3000), {}); - if (!(sim.state().position == b4f)){ + if (!(sim.state().position == doors[2])){ return "After the lift step, the door let out on " + to_string(sim.state().position) + "."; } + if (!sim.state().violations.empty()){ + return "The lift step broke the game's rules: " + sim.state().violations.front(); + } return ""; } +// The hideout's spinners are coded: a map named as B2F or B3F slides the +// player with no spin_* regions of its own. +UnitTestResult test_coded_spinners(){ + MapData b3f = parse_map(R"({ + "name": "RocketHideout_B3F", "source": {"decomp_file": "", "revision": "test", "date": "2026-11-09"}, + "tiles": ["##########", "#........#", "#........#", "##########"] + })", "RocketHideout_B3F"); + if (spin_direction(b3f, TilePos{4, 9}) != Direction::WEST || !stops_spinning(b3f, TilePos{2, 9})){ + return "Expected B3F (4, 9) to slide west and (2, 9) to stop a slide."; + } + if (coded_spin_direction("RocketHideout_B2F", TilePos{3, 4}) != Direction::WEST + || coded_spin_direction("RocketHideout_B2F", TilePos{9, 5}) != Direction::NORTH + || !coded_spin_stop("RocketHideout_B2F", TilePos{1, 4}) + || coded_spin_direction("RocketHideout_B1F", TilePos{3, 4}).has_value() + ){ + return "Expected B2F's arrows and stops from pokefirered, and none on B1F."; + } + return true; +} + struct AutoStoryHideoutBodyInput{ std::string name; UnitTestResult (*body)(); @@ -186,6 +229,10 @@ const AutoStoryHideoutBodyCase HIDEOUT_BODY_CASES[] = { .input = {.name = "table steps on the sim", .body = test_steps_on_sim}, .expectation = {.description = "the poster opens the stairs; the lift lets out on the floor picked"}, }, + { + .input = {.name = "coded spinners", .body = test_coded_spinners}, + .expectation = {.description = "B2F's and B3F's arrows come from code, not the map"}, + }, }; const LiftCase LIFT_CASES[] = { diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryItemUse.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryItemUse.cpp index 1f0c4bdeb9..3bce9b0ae6 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryItemUse.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryItemUse.cpp @@ -111,9 +111,17 @@ ItemUseEnd renew_repel(Logger& logger, ItemUseActions& actions, std::string_view namespace{ -// The bag slides in, and ignores presses until it is done (as -// use_max_repel_from_bag()). -constexpr Milliseconds BAG_SLIDE_IN = 1000ms; +// The waits that worked on console B (2026-11-09, the Bicycle from Key +// Items: TEACHY TV, TM CASE, HELIX FOSSIL, FAME CHECKER, S.S. TICKET, +// BICYCLE; USE the first option, on the bike about 1.5 s after its A). +// The bag slides in, and ignores presses until it is done. +constexpr Milliseconds BAG_SLIDE_IN = 1500ms; +// A pocket switch (left or right), press and wait. +constexpr Milliseconds POCKET_TAP = 100ms; +constexpr Milliseconds POCKET_SWITCH_WAIT = 600ms; +// A cursor step in a pocket, press and wait: 250 ms in all. +constexpr Milliseconds CURSOR_TAP = 100ms; +constexpr Milliseconds CURSOR_WAIT = 150ms; // Out of the bag and its messages, or the Escape Rope's warp. constexpr Milliseconds BAG_CLOSE_TIMEOUT = 30000ms; @@ -130,10 +138,10 @@ bool ConsoleItemUseActions::open_pocket(BagPocket pocket){ pbf_wait(m_context, BAG_SLIDE_IN); // Left to the Items pocket, which doesn't wrap, then right to `pocket`. for (int c = 0; c < 3; c++){ - pbf_press_dpad(m_context, DPAD_LEFT, 100ms, 600ms); + pbf_press_dpad(m_context, DPAD_LEFT, POCKET_TAP, POCKET_SWITCH_WAIT); } for (int c = 0; c < (int)pocket; c++){ - pbf_press_dpad(m_context, DPAD_RIGHT, 100ms, 600ms); + pbf_press_dpad(m_context, DPAD_RIGHT, POCKET_TAP, POCKET_SWITCH_WAIT); } for (int c = 0; c < 20; c++){ pbf_press_dpad(m_context, DPAD_UP, 100ms, 100ms); @@ -152,7 +160,7 @@ std::vector ConsoleItemUseActions::read_pocket(){ void ConsoleItemUseActions::use_row(size_t row){ for (size_t c = 0; c < row; c++){ - pbf_press_dpad(m_context, DPAD_DOWN, 100ms, 300ms); + pbf_press_dpad(m_context, DPAD_DOWN, CURSOR_TAP, CURSOR_WAIT); } // The item's menu, USE on top. pbf_press_button(m_context, BUTTON_A, 160ms, 800ms); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRooms.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRooms.cpp new file mode 100644 index 0000000000..52de05543b --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRooms.cpp @@ -0,0 +1,91 @@ +/* AutoStory Scripted Rooms + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h" +#include "PokemonFRLG_AutoStoryActions.h" +#include "PokemonFRLG_AutoStoryRooms.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +bool is_center_variant(std::string_view variant){ + return variant == CENTER_ENTER_VARIANT || variant == CENTER_EXIT_VARIANT; +} + +const std::vector& center_enter_legs(){ + static const std::vector LEGS{ + {Pokemon::Direction::NORTH, 1, true}, + {Pokemon::Direction::NORTH, 4, false}, + }; + return LEGS; +} +const std::vector& center_exit_legs(){ + static const std::vector LEGS{ + {Pokemon::Direction::SOUTH, 4, false}, + {Pokemon::Direction::SOUTH, 1, true}, + }; + return LEGS; +} +const std::vector& lift_in_legs(){ + static const std::vector LEGS{ + {Pokemon::Direction::NORTH, 1, true}, + {Pokemon::Direction::NORTH, 2, false}, + {Pokemon::Direction::WEST, 2, false}, + {Pokemon::Direction::NORTH, 0, false}, + }; + return LEGS; +} +const std::vector& lift_out_legs(){ + static const std::vector LEGS{ + {Pokemon::Direction::EAST, 2, false}, + {Pokemon::Direction::SOUTH, 2, false}, + {Pokemon::Direction::SOUTH, 1, true}, + }; + return LEGS; +} +const std::vector& center_legs(std::string_view variant){ + static const std::vector NONE; + if (variant == CENTER_ENTER_VARIANT){ + return center_enter_legs(); + } + if (variant == CENTER_EXIT_VARIANT){ + return center_exit_legs(); + } + return NONE; +} + + +void walk_room_legs( + ConsoleHandle& console, ProControllerContext& context, + const std::vector& legs, std::optional facing +){ + for (const RoomLeg& leg : legs){ + if (leg.tiles == 0){ + OverworldWalkSteps::turn_to(console, context, leg.direction); + facing = leg.direction; + continue; + } + for (int tile = 0; tile < leg.tiles; tile++){ + step_once(console, context, leg.direction, facing); + facing = leg.direction; + } + if (leg.warps){ + RouteStep step; + step.direction = leg.direction; + step.tiles = 1; + step.warps = true; + OverworldWalkSteps::wait_out_warp(console, context, step); + } + } +} + + + +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRooms.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRooms.h new file mode 100644 index 0000000000..ee5b3d9f91 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRooms.h @@ -0,0 +1,81 @@ +/* AutoStory Scripted Rooms + * + * From: https://github.com/PokemonAutomation/ + * + * Rooms the story walks by a fixed script of taps, not a planned route: + * every Pokemon Center's ground floor (one layout, so one script) and the + * Rocket Hideout's lift. Their tiles are coded (PokemonFRLG_CodedRooms.h), + * not mapped: the user's call, 2026-11-09 ("mostly the same"; "small area, + * high overhead, just script it"). + * + */ + +#ifndef PokemonAutomation_PokemonFRLG_AutoStoryRooms_H +#define PokemonAutomation_PokemonFRLG_AutoStoryRooms_H + +#include +#include +#include +#include +#include "Common/Cpp/TestRunners/UnitTestDatabase.h" +#include "NintendoSwitch/Controllers/Procon/NintendoSwitch_ProController.h" +#include "NintendoSwitch/NintendoSwitch_ConsoleHandle.h" +#include "Pokemon/Navigation/Pokemon_Grid.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +// The GAME step into a Pokemon Center: from the tile below its door, +// facing north, through the door onto the mat (CENTER_MAT) and up the +// nurse's column to below her counter (CENTER_NURSE_FRONT), facing her. +// The HEAL step after it talks to her. +inline const std::string CENTER_ENTER_VARIANT = "center_enter"; +// The GAME step out of a Pokemon Center: from below the nurse's counter +// down to the mat and out; the game walks the player a tile out of the +// door, onto the tile below it, facing south. +inline const std::string CENTER_EXIT_VARIANT = "center_exit"; +// Whether `variant` is one of the Center's steps. +bool is_center_variant(std::string_view variant); + +// A straight piece of a room's script: `tiles` steps `direction`, one +// tap a tile; none: a turn to face `direction`. `warps`: the last step +// takes a warp, whose fade is waited out. +struct RoomLeg{ + Pokemon::Direction direction = Pokemon::Direction::NORTH; + int tiles = 0; + bool warps = false; +}; +// Into a Center: north through the door, north 4 to the nurse. +const std::vector& center_enter_legs(); +// Out of a Center: south 4 to the mat, south through the door. +const std::vector& center_exit_legs(); +// Into the hideout's lift, from below a floor's lift door facing north: +// north through the door onto LIFT_ARRIVAL, north 2, west 2 to below the +// panel (LIFT_PANEL_FRONT), facing it. +const std::vector& lift_in_legs(); +// Out of the lift, from below the panel: east 2, south 2 onto the arrow +// warp, south through it, onto the floor picked's lift door. +const std::vector& lift_out_legs(); +// The legs of the Center's step `variant`; empty if it isn't one. +const std::vector& center_legs(std::string_view variant); + +// On the console: the legs, a step tap a tile (step_once()), each warp's +// fade waited out. `facing` is the way the player faces at the start, if +// known. +void walk_room_legs( + ConsoleHandle& console, ProControllerContext& context, + const std::vector& legs, std::optional facing +); + + +// Registers the scripted rooms' tests. +void add_tests_AutoStoryRooms(UnitTestDatabase& database); + + + +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation +#endif diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRooms_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRooms_Tests.cpp new file mode 100644 index 0000000000..00884dc0b3 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRooms_Tests.cpp @@ -0,0 +1,125 @@ +/* AutoStory Scripted Rooms Tests + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include +#include "Common/Cpp/TestRunners/UnitTest.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_CodedRooms.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h" +#include "PokemonFRLG_AutoStoryRooms.h" +#include "PokemonFRLG_AutoStoryRowSim.h" +#include "PokemonFRLG_AutoStoryTableSteps.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +namespace{ + +struct CenterInput{ + // The town or route whose Center it is. + std::string town; +}; +struct CenterExpectation{ + std::string center; +}; +struct CenterCase{ + CenterInput input; + CenterExpectation expectation; +}; + +// The Center's coded room has the tiles of its map in the lock. +std::string same_tiles(const std::string& center){ + const MapData mapped = load_map(center); + const MapData coded = pokemon_center_room(center, {}); + if (mapped.tiles.width != coded.tiles.width || mapped.tiles.height != coded.tiles.height){ + return center + ": the coded room is not the map's size."; + } + for (int y = 0; y < coded.tiles.height; y++){ + for (int x = 0; x < coded.tiles.width; x++){ + if (tile_at(mapped, TilePos{x, y}) != tile_at(coded, TilePos{x, y})){ + return center + ": the coded room differs at (" + std::to_string(x) + ", " + std::to_string(y) + ")."; + } + } + } + return ""; +} + +// On the sim, with the town's map and the Center's coded room: in from +// below the door to the nurse, out again to below the door. +std::string in_and_out(const std::string& town, const std::string& center){ + std::vector maps{load_map(town)}; + const std::optional added = add_pokemon_center_room(maps, 0); + if (added != center){ + return town + ": no door to " + center + "."; + } + const TilePos door = maps[0].points.at(center + "_door"); + const MapPosition below_door{town, door.next(Direction::SOUTH)}; + OverworldSim sim(maps, below_door, Direction::NORTH); + StoryPose at{town, below_door.point, Direction::NORTH}; + std::string failure = row_step_on_sim(sim, at, StoryTable::center_enter("to_nurse", center.c_str(), "test"), "test"); + if (!failure.empty()){ + return failure; + } + const Pokemon::Pose outside = StoryTable::at(town.c_str(), below_door.point.x, below_door.point.y, Direction::SOUTH); + failure = row_step_on_sim(sim, at, StoryTable::center_exit("out", outside, "test"), "test"); + if (!failure.empty()){ + return failure; + } + if (!sim.state().violations.empty()){ + return town + ": " + sim.state().violations.front(); + } + return ""; +} + +class Test_AutoStoryCenter : public UnitTest{ +public: + explicit Test_AutoStoryCenter(CenterCase test_case) + : UnitTest("PokemonFRLG::AutoStoryRooms - Center: " + test_case.expectation.center) + , m_case(std::move(test_case)) + {} + virtual UnitTestResult run(Logger&, CancellableScope&) const override{ + const std::string& center = m_case.expectation.center; + if (std::string failure = same_tiles(center); !failure.empty()){ + return failure; + } + if (std::string failure = in_and_out(m_case.input.town, center); !failure.empty()){ + return failure; + } + return true; + } + +private: + CenterCase m_case; +}; + +// The seven Centers in the map lock. +const CenterCase CENTER_CASES[] = { + {.input = {.town = "ViridianCity"}, .expectation = {.center = "ViridianCity_PokemonCenter_1F"}}, + {.input = {.town = "PewterCity"}, .expectation = {.center = "PewterCity_PokemonCenter_1F"}}, + {.input = {.town = "Route4"}, .expectation = {.center = "Route4_PokemonCenter_1F"}}, + {.input = {.town = "CeruleanCity"}, .expectation = {.center = "CeruleanCity_PokemonCenter_1F"}}, + {.input = {.town = "VermilionCity"}, .expectation = {.center = "VermilionCity_PokemonCenter_1F"}}, + {.input = {.town = "Route10"}, .expectation = {.center = "Route10_PokemonCenter_1F"}}, + {.input = {.town = "LavenderTown"}, .expectation = {.center = "LavenderTown_PokemonCenter_1F"}}, +}; + +} // namespace + + +void add_tests_AutoStoryRooms(UnitTestDatabase& database){ + for (const CenterCase& item : CENTER_CASES){ + database.add(item); + } +} + + + +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.cpp index 936d342526..089904ef6a 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.cpp @@ -15,7 +15,9 @@ #include "Pokemon/AutoStory/Pokemon_TilePose.h" #include "PokemonFRLG_AutoStoryActions.h" #include "PokemonFRLG_AutoStoryBattleDecisions.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h" #include "PokemonFRLG_AutoStoryHideout.h" +#include "PokemonFRLG_AutoStoryRooms.h" #include "PokemonFRLG_AutoStoryRowSim.h" namespace PokemonAutomation{ @@ -522,9 +524,30 @@ std::string OverworldStepSim::cut(const StoryStep& step){ return ""; } -// A Rocket Hideout GAME step on the sim: the poster's switch, or the -// lift's floor (SimScript::lift_floors, rows as on the panel); any other -// GAME step is stubbed. +namespace{ + +// walk_room_legs() on the sim: a step tap a tile, each warp's fade and +// the walk out of a door waited out. +void sim_walk_room_legs(OverworldSim& sim, const std::vector& legs){ + for (const RoomLeg& leg : legs){ + if (leg.tiles == 0){ + sim_turn_to(sim, leg.direction); + continue; + } + for (int tile = 0; tile < leg.tiles; tile++){ + sim_step_once(sim, leg.direction); + } + if (leg.warps){ + sim.watch({}, OverworldWalkSteps::AFTER_WARP); + } + } +} + +} // namespace + +// A scripted GAME step on the sim: the poster's switch, the lift's ride +// (its room coded, SimScript::lift_floors, rows as on the panel), or a +// Pokemon Center's way in or out; any other GAME step is stubbed. Pokemon::StoryAction OverworldStepSim::hideout_or_stub(){ Pokemon::StoryAction stub = m_stubs.stub(); Pokemon::StoryAction sim = played([this](RunState&, const StoryStep& step){ @@ -532,7 +555,9 @@ Pokemon::StoryAction OverworldStepSim::hideout_or_stub(){ return failure.empty() ? Pokemon::outcome_ok() : Pokemon::outcome_failed("sim_failure", failure); }); sim.act = [stub, played_act = sim.act](RunState& state, const StoryStep& step){ - return is_hideout_variant(step.variant) ? played_act(state, step) : stub.act(state, step); + return is_hideout_variant(step.variant) || is_center_variant(step.variant) + ? played_act(state, step) + : stub.act(state, step); }; sim.needs_console = stub.needs_console; return sim; @@ -542,10 +567,34 @@ std::string OverworldStepSim::hideout(const StoryStep& step){ if (step.variant == POSTER_SWITCH_VARIANT){ return m_sim.use_field(SimField::POSTER_SWITCH) ? "" : "no poster ahead."; } + if (is_center_variant(step.variant)){ + sim_walk_room_legs(m_sim, center_legs(step.variant)); + return ended_on(step); + } const std::optional ride = parse_lift_floor_variant(step.variant); - if (!ride || !m_sim.use_field(SimField::LIFT_FLOOR, ride->to)){ + if (!ride){ + return "no lift ride \"" + step.variant + "\"."; + } + sim_walk_room_legs(m_sim, lift_in_legs()); + if (!m_sim.use_field(SimField::LIFT_FLOOR, ride->to)){ return "no lift panel for \"" + step.variant + "\"."; } + sim_walk_room_legs(m_sim, lift_out_legs()); + return ended_on(step); +} + +// After a scripted step: the player's pose, and whether it is the step's +// pose_after, if it has one. +std::string OverworldStepSim::ended_on(const StoryStep& step){ + const SimState& state = m_sim.state(); + m_at = StoryPose{state.position.map, state.position.point, state.facing}; + if (!step.pose_after){ + return ""; + } + const std::optional expected = story_pose(*step.pose_after); + if (!expected || !(MapPosition{expected->map, expected->tile} == state.position)){ + return "expected to end on " + (expected ? to_string(MapPosition{expected->map, expected->tile}) : std::string("a pose")) + "."; + } return ""; } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.h index aa36f4f023..401b5b9b34 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.h @@ -185,6 +185,7 @@ class OverworldStepSim : public Pokemon::TableExecutor{ std::string battle(const StoryStep& step); Pokemon::StoryAction hideout_or_stub(); std::string hideout(const StoryStep& step); + std::string ended_on(const StoryStep& step); OverworldSim& m_sim; StoryPose m_at; diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryStepTable_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryStepTable_Tests.cpp index 8eeb70211c..c61f6c10ce 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryStepTable_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryStepTable_Tests.cpp @@ -20,6 +20,7 @@ #include "PokemonFRLG_AutoStoryStepTable.h" #include "PokemonFRLG_AutoStoryEvents.h" #include "PokemonFRLG_AutoStoryHideout.h" +#include "PokemonFRLG_AutoStoryRooms.h" #include "PokemonFRLG_AutoStoryItemUse.h" #include "PokemonFRLG_AutoStoryTable.h" #include "PokemonFRLG_AutoStoryTrashCans.h" @@ -253,6 +254,7 @@ void add_tests_AutoStoryStepTable(UnitTestDatabase& database){ add_tests_AutoStoryStepInterrupts(database); // The GAME variants with logic of their own. add_tests_AutoStoryHideout(database); + add_tests_AutoStoryRooms(database); add_tests_AutoStoryItemUse(database); } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableSteps.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableSteps.cpp index 81140d3520..70d3a2b8f0 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableSteps.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableSteps.cpp @@ -4,8 +4,10 @@ * */ +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_CodedRooms.h" #include "PokemonFRLG_AutoStoryHideout.h" #include "PokemonFRLG_AutoStoryItemUse.h" +#include "PokemonFRLG_AutoStoryRooms.h" #include "PokemonFRLG_AutoStoryTableSteps.h" namespace PokemonAutomation{ @@ -165,6 +167,24 @@ StoryStep poster_switch(std::string name, std::string why){ StoryStep lift_floor(std::string name, std::string_view from, std::string_view to, std::string why){ StoryStep step = make(StoryStepKind::GAME, std::move(name), std::move(why)); step.variant = lift_floor_variant(from, to); + if (const std::optional ride = parse_lift_floor_variant(step.variant)){ + const MapPosition& door = rocket_hideout_lift_doors()[ride->to]; + step.pose_after = to_pose(StoryPose{door.map, door.point, Direction::SOUTH}); + } + return step; +} + +StoryStep center_enter(std::string name, const char* center, std::string why){ + StoryStep step = make(StoryStepKind::GAME, std::move(name), std::move(why)); + step.variant = CENTER_ENTER_VARIANT; + step.pose_after = at(center, CENTER_NURSE_FRONT.x, CENTER_NURSE_FRONT.y, Direction::NORTH); + return step; +} + +StoryStep center_exit(std::string name, Pokemon::Pose below_door, std::string why){ + StoryStep step = make(StoryStepKind::GAME, std::move(name), std::move(why)); + step.variant = CENTER_EXIT_VARIANT; + step.pose_after = std::move(below_door); return step; } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableSteps.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableSteps.h index 0e28ad3048..8729198d44 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableSteps.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableSteps.h @@ -186,9 +186,17 @@ StoryStep cut_tree(std::string name, Pokemon::Pose tree, std::string why); // Pushes the switch behind the Game Corner's poster the player faces // (POSTER_SWITCH_VARIANT). StoryStep poster_switch(std::string name, std::string why); -// Rides the Rocket Hideout's lift, the player facing its panel, from the -// floor it stands at to `to` (lift_floor_variant()): "B1F", "B2F", "B4F". +// Rides the Rocket Hideout's lift from the floor it stands at to `to` +// (lift_floor_variant()): "B1F", "B2F", "B4F". The player starts below +// `from`'s lift door, facing north, and ends on `to`'s, facing south. StoryStep lift_floor(std::string name, std::string_view from, std::string_view to, std::string why); +// Into the Pokemon Center `center` (a map name) from below its door, +// facing north, to below the nurse's counter (CENTER_ENTER_VARIANT): the +// same script in every Center, with no map of its rooms. +StoryStep center_enter(std::string name, const char* center, std::string why); +// Out of the Center from below the nurse's counter (CENTER_EXIT_VARIANT) +// to `below_door`, the tile below its door outside, facing south. +StoryStep center_exit(std::string name, Pokemon::Pose below_door, std::string why); // Uses `item` from the bag on the overworld (use_item_variant()): a // Repel, or an Escape Rope, which leaves the player on `pose_after`. StoryStep use_item(std::string name, std::string_view item, std::string why, std::optional pose_after = std::nullopt); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/Draft/README.md b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/Draft/README.md index 532ef68bfa..0d23b8ff91 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/Draft/README.md +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/Draft/README.md @@ -37,8 +37,8 @@ of the hideout after Giovanni (00.32), which puts the player at the Game Corner's door, CeladonCity (34, 22) (pokefirered `UpdateEscapeWarp()`: the last outdoor door, a tile south). The Rocket Hideout has no wild Pokemon (`wild_encounters.json` lists none of its floors), so no Repel is used there. -The BICYCLE (00.26) is ridden on Route 8's open legs (`bike`); it is -`BIKE_TILE_TIME`, an estimate from the decompilation, until measured. +The BICYCLE (00.26) is ridden on Route 8's open legs (`bike`) at +`BIKE_TILE_TIME`, 112 ms a tile, measured on console B (2026-11-09). ## The maps each draft needs @@ -57,27 +57,45 @@ or neither: | UndergroundPath_WestEntrance | 00.30 | candidate | | Route7 | 00.30 | candidate | | CeladonCity | 00.30 to 00.32 | candidate | -| CeladonCity_PokemonCenter_1F | 00.30, 00.31 | missing | | CeladonCity_GameCorner | 00.31, 00.32 | candidate | | RocketHideout_B1F | 00.31, 00.32 | candidate | | RocketHideout_B2F | 00.31, 00.32 | candidate | | RocketHideout_B3F | 00.31 | candidate | | RocketHideout_B4F | 00.31, 00.32 | candidate | -| RocketHideout_Elevator | 00.32 | missing | - -Beyond publishing (a PokemonSimulation publish and a lock bump, which need the -user's approval), the code added for these drafts reads three things the -candidate maps don't carry yet: - -- B2F's and B3F's spinner tiles as regions `spin_north`, `spin_south`, - `spin_west`, `spin_east` and `spin_stop` (pokefirered `MB_SPIN_*`, - `MB_STOP_SPINNING`; `spin_slide()` in PokemonFRLG_OverworldMap.h); -- the Game Corner's poster as an object `poster` at (11, 1), and its stairs - (16..17, 2..3) as objects `poster_stairs`, which the switch removes - (`SimField::POSTER_SWITCH`); -- the lift's floors: its door is a dynamic warp (`setdynamicwarp`), so the sim - is told where each floor lets out (`SimScript::lift_floors`: B1F (24, 25), - B2F (28, 16), B4F (20, 23)) and the planner can't route through it yet. + +### Scripted, not mapped + +Pokemon Centers and the hideout's lift have no maps (the user's call, +2026-11-09): every Center's ground floor shares pokefirered's +`LAYOUT_POKEMON_CENTER_1F`, and the lift is 5 by 7 tiles. The seven Centers in +the map lock have the same tiles; they differ only in the people standing in +them, none on the nurse's column (x 7). So the story walks both by a fixed +script (`PokemonFRLG_AutoStoryRooms.h`), and the sim builds their rooms from +code (`PokemonFRLG_CodedRooms.h`): + +- `center_enter`: from below the Center's door, facing north, through the door + onto the mat (7, 8) and north 4 to below the nurse (7, 4); `heal` follows. + `center_exit`: south 4 and out; the game walks the player back onto the tile + below the door. 00.30 and 00.31 use them for Lavender's and Celadon's + Centers; no `CeladonCity_PokemonCenter_1F` map is needed. +- `lift_floor `: from below `from`'s lift door, facing north: in + onto (2, 5), north 2, west 2 to below the panel (0, 2), the floor picked, + east 2, south 2 and south through the arrow warp onto `to`'s lift door + (`setdynamicwarp`: B1F (24, 25), B2F (28, 16), B4F (20, 23)). No + `RocketHideout_Elevator` map is needed. + +The two other things the candidate maps can't say in glyphs are coded too, +from pokefirered, so the maps need no extra regions or objects: + +- B2F's and B3F's spinner tiles (`MB_SPIN_*`, `MB_STOP_SPINNING` from the + floors' `map.bin` and the tilesets' metatile attributes; + `coded_spin_direction()`); +- the Game Corner's poster at (11, 1) and the tiles its switch turns into + stairs, (15, 2) and (16..17, 2..3) (`OpenRocketHideout`'s `setmetatile`), + added to the sim's Game Corner by `add_coded_objects()`. + +Beyond those, the drafts need a PokemonSimulation publish and a lock bump for +the candidate maps above, which need the user's approval. `STORY_TRAINERS` names the battles these drafts fight: CeladonCity_GameCorner `rocket_grunt` (GRUNT_7), and on RocketHideout_B4F `lift_key_grunt` (GRUNT_18), @@ -103,6 +121,7 @@ drafts become table rows, their battle steps take those names. format can't yet say `handoff`/`live_start` as 00.12/00.12b do in C++. - `game_action` variants the C++ executor now has (no console run yet): `poster_switch`, `lift_floor ` (PokemonFRLG_AutoStoryHideout.h), + `center_enter`, `center_exit` (PokemonFRLG_AutoStoryRooms.h), `use_item ` and `ride_bike` (PokemonFRLG_AutoStoryItemUse.h), and the `bike` route variant. Variants that no executor has yet: `party_room`, `gift_rng`, `use_item rare-candy @@ -140,10 +159,13 @@ the Elite Four rooms' arrival points (e.g. Lorelei's `warp_0` (6, 12)) are wall tiles, and Indigo Plateau's door glyph is at (11, 5) with its warp at (11, 6). +The `*_nurse` legs below (drafts on #303) need no Center maps once they become +`center_enter` steps, as 00.30's has. + | Skipped leg | Why | |---|---| | 00.32/b2f_lift | B2F's lift corner is reached over arrow tiles: the planner solves them now, but the candidate map has no `spin_*` regions | -| 00.32/b4f_below_guards | RocketHideout_Elevator map not in source-candidate | +| 00.32/b4f_below_guards | planned before the lift was scripted; it now starts on B4F's lift door (20, 23) | | 00.32/x_item_clerk | CeladonCity_DepartmentStore_Elevator map not in source-candidate | | 00.32/route16_below_tree | CeladonCity_DepartmentStore_Elevator map not in source-candidate | | 00.32/celadon_nurse | map not in source-candidate | diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/Draft/frlg-00.30.plan.json b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/Draft/frlg-00.30.plan.json index 4e01206a12..6ccbd8d610 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/Draft/frlg-00.30.plan.json +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/Draft/frlg-00.30.plan.json @@ -6,12 +6,19 @@ "title": "Lavender Mart, then west over Route 8: Gamer Rich, a Pidgey for Fly, the Underground Path to Celadon", "start": {"certainty": "tile", "map": "LavenderTown_PokemonCenter_1F", "where": {"facing": "north", "kind": "tiles", "tiles": [[7, 4]]}}, "events": [ + { + "id": "out_of_lavender_center", + "kind": "game_action", + "declared_after": {"certainty": "tile", "map": "LavenderTown", "where": {"facing": "south", "kind": "tiles", "tiles": [[6, 6]]}}, + "variant": "center_exit", + "why": "the Center script out: south to the mat and out of the door (6, 5)" + }, { "id": "lavender_mart_clerk", "kind": "route", "target": {"certainty": "tile", "map": "LavenderTown_Mart", "where": {"facing": "west", "kind": "tiles", "tiles": [[4, 3]]}}, "variant": "run", - "why": "out of the Center, east along row 6 and down to the Mart (20, 15), to its clerk" + "why": "east along row 6 and down to the Mart (20, 15), to its clerk" }, { "id": "buy_ropes_and_super_repels", @@ -87,10 +94,10 @@ }, { "id": "celadon_nurse", - "kind": "route", - "target": {"certainty": "tile", "map": "CeladonCity_PokemonCenter_1F", "where": {"facing": "north", "kind": "tiles", "tiles": [[7, 4]]}}, - "variant": "run", - "why": "into the Center, to the nurse" + "kind": "game_action", + "declared_after": {"certainty": "tile", "map": "CeladonCity_PokemonCenter_1F", "where": {"facing": "north", "kind": "tiles", "tiles": [[7, 4]]}}, + "variant": "center_enter", + "why": "the Center script in: through the door and up to the nurse; no map of the Center" }, { "id": "heal_celadon", diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/Draft/frlg-00.31.plan.json b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/Draft/frlg-00.31.plan.json index bef003ddb2..39dd599fd8 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/Draft/frlg-00.31.plan.json +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/Draft/frlg-00.31.plan.json @@ -6,12 +6,19 @@ "title": "Celadon Game Corner and the Rocket Hideout down to B4F: the poster guard, the Lift Key Grunt, the LIFT KEY and B3F's Rare Candy", "start": {"certainty": "tile", "map": "CeladonCity_PokemonCenter_1F", "where": {"facing": "north", "kind": "tiles", "tiles": [[7, 4]]}}, "events": [ + { + "id": "out_of_celadon_center", + "kind": "game_action", + "declared_after": {"certainty": "tile", "map": "CeladonCity", "where": {"facing": "south", "kind": "tiles", "tiles": [[48, 12]]}}, + "variant": "center_exit", + "why": "the Center script out: south to the mat and out of the door (48, 11)" + }, { "id": "celadon_below_game_corner", "kind": "route", "target": {"certainty": "tile", "map": "CeladonCity", "where": {"facing": "north", "kind": "tiles", "tiles": [[34, 22]]}}, "variant": "run", - "why": "out of the Center, west along row 12, down column 41..43 to row 22, west to below the Game Corner (34, 21)" + "why": "west along row 12, down column 41..43 to row 22, west to below the Game Corner (34, 21)" }, { "id": "below_poster_guard", diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/Draft/frlg-00.32.plan.json b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/Draft/frlg-00.32.plan.json index 4351f396c4..d5170089d4 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/Draft/frlg-00.32.plan.json +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/Draft/frlg-00.32.plan.json @@ -16,15 +16,16 @@ { "id": "lift_to_b4f", "kind": "game_action", + "declared_after": {"certainty": "tile", "map": "RocketHideout_B4F", "where": {"facing": "south", "kind": "tiles", "tiles": [[20, 23]]}}, "variant": "lift_floor B2F B4F", - "why": "the LIFT KEY works the lift: B4F" + "why": "the lift script: in through the door (28, 16), to the panel, B4F with the LIFT KEY, out onto B4F's lift door; no map of the lift" }, { "id": "b4f_below_guards", "kind": "route", "target": {"certainty": "tile", "map": "RocketHideout_B4F", "where": {"facing": "north", "kind": "tiles", "tiles": [[17, 16]]}}, "variant": "run", - "why": "out of the lift to below the two Grunts (16, 14) and (19, 14)" + "why": "from the lift door to below the two Grunts (16, 14) and (19, 14)" }, { "id": "talk_left_guard", diff --git a/SerialPrograms/cmake/SourceFiles.cmake b/SerialPrograms/cmake/SourceFiles.cmake index 3b38e8424d..35375a29ec 100644 --- a/SerialPrograms/cmake/SourceFiles.cmake +++ b/SerialPrograms/cmake/SourceFiles.cmake @@ -1846,6 +1846,8 @@ file(GLOB LIBRARY_SOURCES Source/VideoGames/PokemonFRLG/Inference/Sounds/PokemonFRLG_CatchFanfareDetector.h Source/VideoGames/PokemonFRLG/Inference/Sounds/PokemonFRLG_ShinySoundDetector.cpp Source/VideoGames/PokemonFRLG/Inference/Sounds/PokemonFRLG_ShinySoundDetector.h + Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_CodedRooms.cpp + Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_CodedRooms.h Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_MovementMechanics.h Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldLateBattle.cpp Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldLateBattle.h @@ -1951,6 +1953,9 @@ file(GLOB LIBRARY_SOURCES Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRngLoop_Tests.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRngOptions.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRngOptions.h + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRooms.cpp + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRooms.h + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRooms_Tests.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRowSim.h Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryRunPlan_Tests.cpp