mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 16:31:05 +02:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 94be169e35 | |||
| 36188ef18a | |||
| 7a1b4e5c45 | |||
| eba6e8d752 | |||
| ec5a72b365 | |||
| 9325d56046 |
@@ -0,0 +1,101 @@
|
||||
name: Bug report
|
||||
description: Something in the game is broken, wrong, or not behaving like the original.
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
A screenshot is worth more than any description. If you can grab one, grab one.
|
||||
If you genuinely can't, that's fine, but then the details below need to be thorough
|
||||
enough that someone can find the bug without ever seeing your screen.
|
||||
|
||||
- type: input
|
||||
id: summary
|
||||
attributes:
|
||||
label: One line summary
|
||||
description: What's broken, in a sentence.
|
||||
placeholder: Warp at the south end of the Route 5-8 underground path drops you in the wrong spot
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: os
|
||||
attributes:
|
||||
label: Which build are you running
|
||||
options:
|
||||
- macOS
|
||||
- Windows
|
||||
- Both
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Version
|
||||
description: The release you downloaded. It's in the release title, like 0.1.4.
|
||||
placeholder: 0.1.4
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: location
|
||||
attributes:
|
||||
label: Where in the game
|
||||
description: >
|
||||
Be specific. Route, town, building, floor, menu, battle. If it's a map
|
||||
transition, say which side you came in from.
|
||||
placeholder: Underground path between Route 5 and Route 8, south entrance
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: screenshot
|
||||
attributes:
|
||||
label: Screenshot, or a detailed description if you couldn't take one
|
||||
description: >
|
||||
Drag the image straight into this box. If you couldn't get a screenshot,
|
||||
write "no screenshot" and then describe exactly what was on screen: where
|
||||
the player sprite was, what the tiles around it looked like, any text on
|
||||
screen, whether anything was frozen or flickering. The more concrete, the better.
|
||||
placeholder: |
|
||||
Drop the image here.
|
||||
|
||||
Or, with no screenshot:
|
||||
Screen faded out like a normal warp, then faded back in with the player
|
||||
standing on the grass tile north of the Route 6 gate house instead of
|
||||
inside the tunnel. Player was facing down. No text box. Music kept playing
|
||||
the underground path track instead of switching to the Route 6 track.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: How to make it happen
|
||||
description: Numbered steps, starting from a point someone else can get to.
|
||||
placeholder: |
|
||||
1. Start a new game, get to Cerulean
|
||||
2. Go south to Route 5, enter the underground path building
|
||||
3. Walk to the south end of the tunnel
|
||||
4. Step onto the stairs
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: What should have happened
|
||||
placeholder: Should have come out inside the Route 8 side entrance building, facing up.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: extra
|
||||
attributes:
|
||||
label: Anything else
|
||||
description: >
|
||||
Does it happen every time or only sometimes. Did it start after a specific
|
||||
release. Anything you were doing right before it. Leave blank if nothing comes to mind.
|
||||
validations:
|
||||
required: false
|
||||
@@ -0,0 +1 @@
|
||||
blank_issues_enabled: false
|
||||
@@ -0,0 +1,88 @@
|
||||
name: Feature request
|
||||
description: Ask for something new, or a change to how something already works.
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
"Can we add X" on its own is hard to act on. Say what you want, why you want it,
|
||||
and how you picture it working. A request with real detail is one someone can
|
||||
actually build without guessing at what you meant.
|
||||
|
||||
- type: input
|
||||
id: summary
|
||||
attributes:
|
||||
label: One line summary
|
||||
description: What you want, in a sentence.
|
||||
placeholder: Add a run toggle so you can move at bike speed without the bike
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: what
|
||||
attributes:
|
||||
label: What do you want
|
||||
description: >
|
||||
Describe it properly. What is it, where does it live in the game, what does the
|
||||
player see or do. If it changes something that already exists, say what it does today
|
||||
and what it should do instead.
|
||||
placeholder: |
|
||||
A hold-to-run button, the way later Pokemon games do it. Hold B while moving on
|
||||
foot and the player moves at bike speed. Release and you go back to walking.
|
||||
Doesn't work indoors where the bike doesn't work, doesn't work in battles or menus.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: why
|
||||
attributes:
|
||||
label: Why is this worth doing
|
||||
description: >
|
||||
What's annoying or missing right now. What does this fix. If it's just because you
|
||||
think it would be fun, say that, it's a real answer.
|
||||
placeholder: |
|
||||
Backtracking across routes you've already cleared is slow, and the bike is a menu
|
||||
dive away and doesn't work in a lot of places. Running would cut a lot of dead time
|
||||
without making the game easier.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: how
|
||||
attributes:
|
||||
label: How should it work
|
||||
description: >
|
||||
The specifics. Which button, which menu, what happens in the edge cases. If you don't
|
||||
know, say what you'd expect as a player and leave the rest open.
|
||||
placeholder: |
|
||||
- Hold B on the overworld to run
|
||||
- Same speed as the bike
|
||||
- Disabled anywhere the bike is disabled
|
||||
- Sprite should probably need a running animation, not sure what that takes
|
||||
- No effect on encounter rates
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: scope
|
||||
attributes:
|
||||
label: Does this change how the original game plays
|
||||
description: >
|
||||
Some requests are quality of life, some change the actual game. Both are fine,
|
||||
it just helps to know which one you're asking for.
|
||||
options:
|
||||
- Quality of life, original game is untouched
|
||||
- Changes how the game plays
|
||||
- Not sure
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: extra
|
||||
attributes:
|
||||
label: Anything else
|
||||
description: >
|
||||
Reference screenshots, how another game does it, related issues. Leave blank
|
||||
if nothing comes to mind.
|
||||
validations:
|
||||
required: false
|
||||
@@ -14,6 +14,12 @@ name: Release
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
# CI/workflow and docs-only changes don't ship anything to users, so they
|
||||
# don't earn a release. A push touching these *and* real source still
|
||||
# releases; only pushes confined entirely to these paths are skipped.
|
||||
paths-ignore:
|
||||
- '.github/**'
|
||||
- '**.md'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
@@ -23,6 +29,8 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
issues: read
|
||||
pull-requests: read
|
||||
|
||||
concurrency:
|
||||
group: release
|
||||
@@ -206,7 +214,40 @@ jobs:
|
||||
v="${{ steps.ver.outputs.version }}"
|
||||
tag="${{ steps.ver.outputs.tag }}"
|
||||
|
||||
# Issues this release closes, per GitHub's own "closing issues" links
|
||||
# (works for squash, rebase, and merge commits alike). Scans every
|
||||
# commit since the previous tag so a skipped release run doesn't drop
|
||||
# issues on the floor.
|
||||
prev_tag="$(git tag -l 'v*' --sort=-v:refname | grep -v "^${tag}$" | head -1 || true)"
|
||||
range="${prev_tag:+${prev_tag}..}$GITHUB_SHA"
|
||||
|
||||
closed=""
|
||||
for pr in $(git log --pretty=%H "$range" \
|
||||
| xargs -I{} gh api "repos/$GITHUB_REPOSITORY/commits/{}/pulls" \
|
||||
--jq '.[].number' 2>/dev/null \
|
||||
| sort -un || true); do
|
||||
closed+="$(gh api graphql \
|
||||
-f owner="${GITHUB_REPOSITORY%/*}" \
|
||||
-f name="${GITHUB_REPOSITORY#*/}" \
|
||||
-F pr="$pr" \
|
||||
-f query='
|
||||
query($owner:String!, $name:String!, $pr:Int!) {
|
||||
repository(owner:$owner, name:$name) {
|
||||
pullRequest(number:$pr) {
|
||||
closingIssuesReferences(first:50) { nodes { number title } }
|
||||
}
|
||||
}
|
||||
}' \
|
||||
--jq '.data.repository.pullRequest.closingIssuesReferences.nodes[]
|
||||
| "- #\(.number) \(.title)"' 2>/dev/null || true)"$'\n'
|
||||
done
|
||||
closed="$(printf '%s' "$closed" | grep . | sort -t'#' -k2 -n || true)"
|
||||
|
||||
notes="Download the correct version for your computer below."
|
||||
if [ -n "$closed" ]; then
|
||||
notes+=$'\n\n## Issues closed\n\n'"$closed"
|
||||
fi
|
||||
printf 'Release notes:\n%s\n' "$notes"
|
||||
|
||||
gh release create "$tag" \
|
||||
--target "$GITHUB_SHA" \
|
||||
|
||||
@@ -55,6 +55,21 @@ reference — lives on the
|
||||
|
||||
Shipped example mods, one per kind of author, live in [`mods/`](mods/).
|
||||
|
||||
## Bugs and Ideas
|
||||
|
||||
Found a bug? A warp dropping you somewhere it shouldn't, a battle doing math
|
||||
that looks wrong, text in the wrong box, anything that does not match the
|
||||
original game.
|
||||
[Open a bug report](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/issues/new?template=bug_report.yml).
|
||||
Attach a screenshot if you can. It saves a lot of back and forth, and if you
|
||||
can't get one, the form asks you to describe what you saw instead.
|
||||
|
||||
Thought of a feature that could be good, or a way to improve one that already
|
||||
exists?
|
||||
[Open a feature request](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/issues/new?template=feature_request.yml).
|
||||
Say what you want, why it is worth doing, and how you picture it working. A
|
||||
request with real detail is one that can actually get built.
|
||||
|
||||
## More
|
||||
|
||||
- [Link play](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Guide-Link-Play)
|
||||
|
||||
@@ -1,22 +1,18 @@
|
||||
-- Viridian City flavor dialogue (pokered/scripts/ViridianCity.asm).
|
||||
-- Ports the text_asm bodies for GAMBLER1, YOUNGSTER2, GIRL and OLD_MAN.
|
||||
-- Ports the text_asm bodies for GAMBLER1, YOUNGSTER2 and GIRL.
|
||||
--
|
||||
-- Not ported here (already handled elsewhere / not talk-reachable):
|
||||
-- * TEXT_VIRIDIANCITY_FISHER (TM42 gift) -- already ported as a
|
||||
-- `gift()` entry in data/scripts/story5.lua's M.VIRIDIAN_CITY.talk.
|
||||
-- * TEXT_VIRIDIANCITY_OLD_MAN_SLEEPY / TEXT_VIRIDIANCITY_GYM_LOCKED --
|
||||
-- these are step-triggered blocking texts (ViridianCityCheckGotPokedexScript /
|
||||
-- ViridianCityCheckGymOpenScript), not npc talk text_asm bodies; the
|
||||
-- gates themselves are already implemented via story5.lua's onStep
|
||||
-- chain (viridianOldManStep / viridianGymLock) for this map.
|
||||
-- * The old man's catch-training minigame trigger (SCRIPT_VIRIDIANCITY_
|
||||
-- OLD_MAN_START_CATCH_TRAINING / battle vs. WEEDLE) is a full
|
||||
-- scripted-battle cutscene outside this task's Commands vocabulary
|
||||
-- (no static_battle-style "battle a scripted old-man WEEDLE" command
|
||||
-- exists); we port the real YES/NO branch text he speaks but the
|
||||
-- "yes" branch here just shows the "I'll show you how" line rather
|
||||
-- than actually starting the minigame, since that machinery isn't
|
||||
-- ported to this map yet.
|
||||
-- * TEXT_VIRIDIANCITY_OLD_MAN (the walking man at (17,5)) and
|
||||
-- TEXT_VIRIDIANCITY_OLD_MAN_SLEEPY (the sleeper at (18,9)) -- both
|
||||
-- live in data/scripts/story.lua, which owns this map's onStep gate
|
||||
-- and can reach the `old_man_demo` command for the real catch
|
||||
-- tutorial. Keep them there: story.lua loads BEFORE this file, so a
|
||||
-- duplicate here would silently win the merge.
|
||||
-- * TEXT_VIRIDIANCITY_GYM_LOCKED -- a step-triggered blocking text
|
||||
-- (ViridianCityCheckGymOpenScript), implemented by story5.lua's
|
||||
-- onStep chain (viridianGymLock -> viridianOldManStep) for this map.
|
||||
|
||||
local M = {}
|
||||
|
||||
@@ -91,24 +87,6 @@ M.VIRIDIAN_CITY = {
|
||||
end
|
||||
end,
|
||||
|
||||
-- ViridianCityOldManText (scripts/ViridianCity.asm): once he's had
|
||||
-- his coffee, he asks (YES/NO) whether you want to learn how to
|
||||
-- catch Pokemon. YES leads into the catch-training minigame
|
||||
-- (SCRIPT_VIRIDIANCITY_OLD_MAN_START_CATCH_TRAINING, not ported --
|
||||
-- see file header); NO just brushes you off ("Time is money...").
|
||||
TEXT_VIRIDIANCITY_OLD_MAN = function(game, ow, npc, done)
|
||||
local t = text(game)
|
||||
ask(game, t._ViridianCityOldManHadMyCoffeeNowText
|
||||
or "Ahh, I've had my\ncoffee now and I\nfeel great!\nSure you can go\nthrough!\nAre you in a\nhurry?", function(yes)
|
||||
if yes then
|
||||
push(game, t._ViridianCityOldManKnowHowToCatchPokemonText
|
||||
or "I see you're using\na POKéDEX.\nWhen you catch a\nPOKéMON, POKéDEX\nis automatically\nupdated.\nWhat? Don't you\nknow how to catch\nPOKéMON?\nI'll show you\nhow to then.", done)
|
||||
else
|
||||
push(game, t._ViridianCityOldManTimeIsMoneyText
|
||||
or "Time is money...\nGo along then.", done)
|
||||
end
|
||||
end)
|
||||
end,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
+28
-21
@@ -58,33 +58,40 @@ return {
|
||||
TEXT_OAKSLAB_OAK1 = {
|
||||
{ "face_player" }, -- 1
|
||||
{ "check_flag", "EVENT_GOT_OAKS_PARCEL" }, -- 2
|
||||
{ "jump_if_false", 12 }, -- 3
|
||||
{ "jump_if_false", 14 }, -- 3
|
||||
{ "check_flag", "EVENT_OAK_GOT_PARCEL" }, -- 4
|
||||
{ "jump_if_true", 12 }, -- 5
|
||||
{ "jump_if_true", 14 }, -- 5
|
||||
{ "show_text", "_OaksLabOak1DeliverParcelText" }, -- 6
|
||||
{ "take_item", "OAKS_PARCEL", 1 }, -- 7
|
||||
{ "set_flag", "EVENT_OAK_GOT_PARCEL" }, -- 8
|
||||
{ "show_text", "_OaksLabOak1PokemonAroundTheWorldText" }, -- 9
|
||||
{ "set_flag", "EVENT_GOT_POKEDEX" }, -- 10
|
||||
{ "jump", 30 }, -- 11
|
||||
{ "check_flag", "EVENT_GOT_STARTER" }, -- 12
|
||||
{ "jump_if_false", 27 }, -- 13
|
||||
{ "check_item", "POKE_BALL" }, -- 14
|
||||
{ "jump_if_true", 25 }, -- 15
|
||||
{ "check_flag", "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE" }, -- 16
|
||||
{ "jump_if_false", 29 }, -- 17
|
||||
{ "check_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, -- 18
|
||||
{ "jump_if_true", 25 }, -- 19
|
||||
{ "set_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, -- 20
|
||||
{ "give_item", "POKE_BALL", 5, false }, -- 21
|
||||
{ "show_text", "_OaksLabOak1ReceivedPokeballsText" }, -- 22
|
||||
{ "show_text", "_OaksLabGivePokeballsExplanationText" }, -- 23
|
||||
{ "jump", 30 }, -- 24
|
||||
{ "show_text", "_OaksLabOak1ComeSeeMeSometimesText" }, -- 25
|
||||
{ "jump", 30 }, -- 26
|
||||
{ "show_text", "_OaksLabOak1WhichPokemonDoYouWantText" }, -- 27
|
||||
{ "jump", 30 }, -- 28
|
||||
{ "show_text", "_OaksLabOak1RaiseYourYoungPokemonText" }, -- 29 (30 = end)
|
||||
-- the Pokédex swaps Viridian's two old men (OaksLab.asm:602-606:
|
||||
-- HideObject TOGGLE_LYING_OLD_MAN / ShowObject TOGGLE_OLD_MAN).
|
||||
-- Until this ran, the walking man at (17,5) -- who owns the coffee
|
||||
-- ask and the catch tutorial -- stayed OFF for the whole game
|
||||
-- (toggleable_objects.asm seeds him OFF, the sleeper ON).
|
||||
{ "hide_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN_SLEEPY" }, -- 11
|
||||
{ "show_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN" }, -- 12
|
||||
{ "jump", 32 }, -- 13
|
||||
{ "check_flag", "EVENT_GOT_STARTER" }, -- 14
|
||||
{ "jump_if_false", 29 }, -- 15
|
||||
{ "check_item", "POKE_BALL" }, -- 16
|
||||
{ "jump_if_true", 27 }, -- 17
|
||||
{ "check_flag", "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE" }, -- 18
|
||||
{ "jump_if_false", 31 }, -- 19
|
||||
{ "check_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, -- 20
|
||||
{ "jump_if_true", 27 }, -- 21
|
||||
{ "set_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, -- 22
|
||||
{ "give_item", "POKE_BALL", 5, false }, -- 23
|
||||
{ "show_text", "_OaksLabOak1ReceivedPokeballsText" }, -- 24
|
||||
{ "show_text", "_OaksLabGivePokeballsExplanationText" }, -- 25
|
||||
{ "jump", 32 }, -- 26
|
||||
{ "show_text", "_OaksLabOak1ComeSeeMeSometimesText" }, -- 27
|
||||
{ "jump", 32 }, -- 28
|
||||
{ "show_text", "_OaksLabOak1WhichPokemonDoYouWantText" }, -- 29
|
||||
{ "jump", 32 }, -- 30
|
||||
{ "show_text", "_OaksLabOak1RaiseYourYoungPokemonText" }, -- 31 (32 = end)
|
||||
},
|
||||
|
||||
TEXT_OAKSLAB_CHARMANDER_POKE_BALL =
|
||||
|
||||
+79
-35
@@ -10,6 +10,31 @@ local M = {}
|
||||
-- -------------------------------------------------------------------
|
||||
|
||||
M.VIRIDIAN_MART = {
|
||||
-- scripts/ViridianMart.asm: the parcel hand-off is the map's DEFAULT
|
||||
-- script, not a talk. Entering with a starter and no parcel runs
|
||||
-- ViridianMartDefaultScript -- the clerk calls out, then
|
||||
-- StartSimulatingJoypadStates walks the player to the counter
|
||||
-- (.PlayerMovement: PAD_LEFT 1, PAD_UP 2, door (3,7) -> counter (2,5))
|
||||
-- and ViridianMartOaksParcelScript hands the parcel over. The player
|
||||
-- never presses A. This matters beyond convenience: the parcel gates
|
||||
-- Oak's Pokedex and the old man clearing Route 2, so vanilla guarantees
|
||||
-- it on entry rather than letting you walk out without it.
|
||||
--
|
||||
-- The talk branch below is kept as the fallback for a save that reaches
|
||||
-- the counter without this having fired.
|
||||
onEnter = function(game, ow)
|
||||
local f = game.save.flags
|
||||
if f.EVENT_OAK_GOT_PARCEL or f.EVENT_GOT_OAKS_PARCEL then return end
|
||||
if not f.EVENT_GOT_STARTER then return end
|
||||
ow:queueScript({
|
||||
{ "show_text", "_ViridianMartClerkYouCameFromPalletTownText" },
|
||||
{ "move_player", "left", 1 },
|
||||
{ "move_player", "up", 2 },
|
||||
-- the quest text's last page is "{PLAYER} got\nOAK's PARCEL!"
|
||||
{ "give_item", "OAKS_PARCEL", 1, "_ViridianMartClerkParcelQuestText" },
|
||||
{ "set_flag", "EVENT_GOT_OAKS_PARCEL" },
|
||||
})
|
||||
end,
|
||||
talk = {
|
||||
TEXT_VIRIDIANMART_CLERK = {
|
||||
{ "check_flag", "EVENT_OAK_GOT_PARCEL" }, -- 1
|
||||
@@ -33,42 +58,48 @@ M.VIRIDIAN_MART = {
|
||||
|
||||
M.VIRIDIAN_CITY = {
|
||||
talk = {
|
||||
-- the old man napping on the north path (scripts/ViridianCity.asm)
|
||||
-- after his coffee, the old man offers the catch tutorial: not in a
|
||||
-- hurry -> he demos catching a wild mon (BATTLE_TYPE_OLD_MAN)
|
||||
-- The GAMBLER_ASLEEP at (18,9) (ViridianCityOldManSleepyText): he
|
||||
-- only ever grumbles and shoves you back down -- he never wakes,
|
||||
-- moves or hides. The coffee ask and the catch tutorial belong to
|
||||
-- the *other* old man, the walking SPRITE_GAMBLER at (17,5)
|
||||
-- (TEXT_VIRIDIANCITY_OLD_MAN, below). The two are swapped by the
|
||||
-- Pokédex in data/scripts/oaks_lab.lua, not by talking to either.
|
||||
TEXT_VIRIDIANCITY_OLD_MAN_SLEEPY = {
|
||||
{ "check_flag", "EVENT_OAK_GOT_PARCEL" }, -- 1
|
||||
{ "jump_if_true", 5 }, -- 2
|
||||
{ "show_text", "_ViridianCityOldManSleepyPrivatePropertyText" }, -- 3
|
||||
{ "jump", 14 }, -- 4
|
||||
{ "face_player" }, -- 5
|
||||
{ "ask", "_ViridianCityOldManHadMyCoffeeNowText" }, -- 6
|
||||
{ "jump_if_true", 12 }, -- 7 (in a hurry)
|
||||
{ "show_text", "_ViridianCityOldManKnowHowToCatchPokemonText" }, -- 8
|
||||
{ "show_text", "_ViridianCityOldManYouNeedToWeakenTheTargetText" }, -- 9
|
||||
{ "old_man_demo" }, -- 10
|
||||
{ "jump", 13 }, -- 11
|
||||
{ "show_text", "_ViridianCityOldManTimeIsMoneyText" }, -- 12
|
||||
{ "hide_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN_SLEEPY" }, -- 13
|
||||
{ "show_text", "_ViridianCityOldManSleepyPrivatePropertyText" }, -- 1
|
||||
{ "move_player", "down", 1 }, -- 2
|
||||
},
|
||||
|
||||
-- The walking old man at (17,5), shown once the Pokédex swaps him in
|
||||
-- (ViridianCityOldManText). "Are you in a hurry?" -- YES brushes you
|
||||
-- off, NO leads into the catch tutorial: he explains, demos a catch
|
||||
-- on a wild WEEDLE (BATTLE_TYPE_OLD_MAN), then comments afterwards.
|
||||
-- pokered prints YouNeedToWeakenTheTarget *after* the demo battle
|
||||
-- (ViridianCityOldManEndCatchTrainingScript), not before it.
|
||||
TEXT_VIRIDIANCITY_OLD_MAN = {
|
||||
{ "face_player" }, -- 1
|
||||
{ "ask", "_ViridianCityOldManHadMyCoffeeNowText" }, -- 2
|
||||
{ "jump_if_true", 8 }, -- 3 (yes = in a hurry)
|
||||
{ "show_text", "_ViridianCityOldManKnowHowToCatchPokemonText" }, -- 4
|
||||
{ "old_man_demo" }, -- 5
|
||||
{ "show_text", "_ViridianCityOldManYouNeedToWeakenTheTargetText" },-- 6
|
||||
{ "jump", 9 }, -- 7
|
||||
{ "show_text", "_ViridianCityOldManTimeIsMoneyText" }, -- 8 (9 = end)
|
||||
},
|
||||
},
|
||||
-- the sleeping old man lies across the Route 22 path (18,9): until
|
||||
-- he moves you can't slip past on either side (scripts/ViridianCity
|
||||
-- blocks the whole corridor, not just his tile)
|
||||
-- ViridianCityCheckGotPokedexScript: the north corridor is gated on
|
||||
-- EVENT_GOT_POKEDEX, NOT on the sleeper being hidden, and it triggers
|
||||
-- on exactly one cell -- (19,9), the gap east of the sleeper (18,9)
|
||||
-- and the girl (17,9). With the Pokédex the check returns immediately
|
||||
-- and you simply walk past at x=19.
|
||||
onStep = function(game, ow, x, y)
|
||||
local gone = game.save.objectToggles and game.save.objectToggles.VIRIDIAN_CITY
|
||||
and game.save.objectToggles.VIRIDIAN_CITY.VIRIDIANCITY_OLD_MAN_SLEEPY == false
|
||||
if gone then return false end
|
||||
-- crossing north of his row through the 3-wide gap (x 17-19, y<=8)
|
||||
if y <= 8 and x >= 17 and x <= 19 then
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game,
|
||||
game.data.text._ViridianCityOldManSleepyPrivatePropertyText
|
||||
or "You can't go\nthrough here!\fThis is private\nproperty!",
|
||||
function() ow:scriptMove(ow.player, "down", 1) end))
|
||||
return true
|
||||
end
|
||||
return false
|
||||
if game.save.flags and game.save.flags.EVENT_GOT_POKEDEX then return false end
|
||||
if x ~= 19 or y ~= 9 then return false end
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game,
|
||||
game.data.text._ViridianCityOldManSleepyPrivatePropertyText
|
||||
or "You can't go\nthrough here!\fThis is private\nproperty!",
|
||||
function() ow:scriptMove(ow.player, "down", 1) end))
|
||||
return true
|
||||
end,
|
||||
}
|
||||
|
||||
@@ -104,7 +135,7 @@ M.BILLS_HOUSE = {
|
||||
talk = {
|
||||
TEXT_BILLSHOUSE_BILL_POKEMON = {
|
||||
{ "check_flag", "EVENT_GOT_SS_TICKET" }, -- 1
|
||||
{ "jump_if_true", 11 }, -- 2
|
||||
{ "jump_if_true", 13 }, -- 2
|
||||
{ "show_text", "_BillsHouseBillImNotAPokemonText" }, -- 3
|
||||
{ "show_text", "_BillsHouseBillNoYouGottaHelpText" }, -- 4
|
||||
-- the cell-separator PC throws its switch
|
||||
@@ -118,8 +149,21 @@ M.BILLS_HOUSE = {
|
||||
{ "give_item", "S_S_TICKET", 1, false }, -- 7
|
||||
{ "show_text", "_SSTicketReceivedText" }, -- 8
|
||||
{ "set_flag", "EVENT_GOT_SS_TICKET" }, -- 9
|
||||
{ "jump", 12 }, -- 10
|
||||
{ "show_text", "_BillsHouseBillCheckOutMyRarePokemonText" }, -- 11
|
||||
-- The two Cerulean guards are a SWAP PAIR, not scenery
|
||||
-- (BillsHouse.asm:174-178): handing over the ticket shows GUARD1 at
|
||||
-- (28,12) and hides GUARD2 at (27,12). This matters far more than it
|
||||
-- looks: (27,12) is the ONLY walkable neighbour of the trashed
|
||||
-- house's south door at (27,11), and that house is one of the two
|
||||
-- ways through the fence that splits Cerulean in half (the badge
|
||||
-- house is the other). Leaving GUARD2 up forever severs the city --
|
||||
-- the gym/mart half can never reach the Route 5 exit -- which is
|
||||
-- exactly what stranded the bot after it beat Misty.
|
||||
-- Same swap fires after the TM28 Rocket (CeruleanCity_2.asm
|
||||
-- CeruleanHideRocket), so either route opens the path.
|
||||
{ "show_object", "CERULEAN_CITY", "CERULEANCITY_GUARD1" }, -- 10
|
||||
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_GUARD2" }, -- 11
|
||||
{ "jump", 14 }, -- 12
|
||||
{ "show_text", "_BillsHouseBillCheckOutMyRarePokemonText" }, -- 13
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
+43
-12
@@ -278,6 +278,23 @@ M.PALLET_TOWN = {
|
||||
|
||||
local DRINKS = { "FRESH_WATER", "SODA_POP", "LEMONADE" }
|
||||
|
||||
-- Hand over the first drink in the bag, if any. Mirrors RemoveGuardDrink
|
||||
-- (engine/items/inventory.asm), which walks the same three item ids and
|
||||
-- removes ONE, and the caller's BIT_GAVE_SAFFRON_GUARDS_DRINK.
|
||||
local function takeGuardDrink(game)
|
||||
for _, drink in ipairs(DRINKS) do
|
||||
if (game.save.inventory[drink] or 0) > 0 then
|
||||
game.save.inventory[drink] = game.save.inventory[drink] - 1
|
||||
if game.save.inventory[drink] == 0 then
|
||||
game.save.inventory[drink] = nil
|
||||
end
|
||||
game.save.flags.EVENT_GAVE_GUARDS_DRINK = true
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function saffronGate(guardText, triggers, horizontal)
|
||||
return {
|
||||
talk = {
|
||||
@@ -289,18 +306,11 @@ local function saffronGate(guardText, triggers, horizontal)
|
||||
t._SaffronGateGuardThanksForTheDrinkText or "Gee, that was\ntasty!", done))
|
||||
return
|
||||
end
|
||||
for _, drink in ipairs(DRINKS) do
|
||||
if (game.save.inventory[drink] or 0) > 0 then
|
||||
game.save.inventory[drink] = game.save.inventory[drink] - 1
|
||||
if game.save.inventory[drink] == 0 then
|
||||
game.save.inventory[drink] = nil
|
||||
end
|
||||
game.save.flags.EVENT_GAVE_GUARDS_DRINK = true
|
||||
game.stack:push(TextBox.new(game,
|
||||
(t._SaffronGateGuardYouCanGoOnThroughText or
|
||||
"Thanks! You can\ngo on through!"), done))
|
||||
return
|
||||
end
|
||||
if takeGuardDrink(game) then
|
||||
game.stack:push(TextBox.new(game,
|
||||
(t._SaffronGateGuardYouCanGoOnThroughText or
|
||||
"Thanks! You can\ngo on through!"), done))
|
||||
return
|
||||
end
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._SaffronGateGuardGeeImThirstyText or "Gee, I'm thirsty\nthough!", done))
|
||||
@@ -317,6 +327,27 @@ local function saffronGate(guardText, triggers, horizontal)
|
||||
if game.save.flags.EVENT_GAVE_GUARDS_DRINK then return false end
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local t = game.data.text
|
||||
-- Stepping on the trigger WITH a drink hands it over right here.
|
||||
--
|
||||
-- Route5GateDefaultScript (scripts/Route5Gate.asm) runs
|
||||
-- `farcall RemoveGuardDrink` before it decides anything: the coord
|
||||
-- trigger itself takes the drink and sets
|
||||
-- BIT_GAVE_SAFFRON_GUARDS_DRINK, and only a player carrying nothing
|
||||
-- gets the thirsty line and the walk-back. We had the removal on the
|
||||
-- guard's TALK handler only, so walking up with a FRESH_WATER in the
|
||||
-- bag was turned away and the four gates stayed shut unless you
|
||||
-- happened to talk to him -- which vanilla never requires.
|
||||
--
|
||||
-- Saffron is the middle of the map, so this sealed it: every route
|
||||
-- through the city (Celadon <-> Lavender, Vermilion <-> Cerulean the
|
||||
-- short way) was unreachable, and the bot could not get to Lavender
|
||||
-- for the POKE_FLUTE at all.
|
||||
if takeGuardDrink(game) then
|
||||
game.stack:push(TextBox.new(game,
|
||||
(t._SaffronGateGuardYouCanGoOnThroughText or
|
||||
"Thanks! You can\ngo on through!")))
|
||||
return true
|
||||
end
|
||||
local back
|
||||
if horizontal then
|
||||
back = ow.player.facing == "left" and "right" or "left"
|
||||
|
||||
+27
-13
@@ -63,7 +63,15 @@ M.ROUTE_12_SUPER_ROD_HOUSE.talk.TEXT_ROUTE12SUPERRODHOUSE_FISHING_GURU[9] =
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
-- The ghost Marowak (scripts/PokemonTower6F.asm): blocks the stairs at
|
||||
-- (10,16) until identified with the Silph Scope and defeated.
|
||||
-- (10,16) until defeated.
|
||||
--
|
||||
-- PokemonTower6FDefaultScript starts the RESTLESS SOUL battle with NO
|
||||
-- Silph Scope check at the trigger -- the scope only decides whether the
|
||||
-- battle is disguised (IsGhostBattle -> makeGhost: "too scared to move",
|
||||
-- balls dodged). An earlier version of this port turned the player back
|
||||
-- without the scope and never opened the battle, which made 6F
|
||||
-- impassable on any route that skips Rocket Hideout; vanilla lets the
|
||||
-- battle open and a POKE_DOLL end it (see wBattleResult below).
|
||||
-- -------------------------------------------------------------------
|
||||
|
||||
M.POKEMON_TOWER_6F = {
|
||||
@@ -71,24 +79,30 @@ M.POKEMON_TOWER_6F = {
|
||||
if game.save.flags.EVENT_BEAT_GHOST_MAROWAK then return false end
|
||||
if x ~= 10 or y ~= 16 then return false end
|
||||
local TextBox = require("src.render.TextBox")
|
||||
if not game.save.inventory.SILPH_SCOPE then
|
||||
game.stack:push(TextBox.new(game,
|
||||
"A GHOST blocks\nthe way...\fDarn! You can't\nidentify it!",
|
||||
function()
|
||||
local back = ow.player.facing == "up" and "down" or "up"
|
||||
ow:scriptMove(ow.player, back, 1)
|
||||
end))
|
||||
return true
|
||||
end
|
||||
local t = game.data.text
|
||||
game.stack:push(TextBox.new(game,
|
||||
"The GHOST was\nMAROWAK!\fThe restless soul\nattacks!", function()
|
||||
t._PokemonTower6FBeGoneText or "Be gone...\nIntruders...", function()
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local battle = BattleState.newWild(game, "MAROWAK", 30)
|
||||
if not game.save.inventory.SILPH_SCOPE then
|
||||
battle:makeGhost()
|
||||
end
|
||||
battle.onFinish = function(result)
|
||||
if result == "win" then
|
||||
-- wBattleResult parity (PokemonTower6FMarowakBattleScript's
|
||||
-- "and a / jr nz"): losing writes $1 and running writes $2, but
|
||||
-- ItemUsePokeDoll ends the battle WITHOUT touching it, so the
|
||||
-- script reads 0 -- defeated. That is the famous Poke Doll
|
||||
-- trick, and the speedrun route this bot follows depends on it.
|
||||
if result == "win" or battle.pokeDollEscape then
|
||||
game.save.flags.EVENT_BEAT_GHOST_MAROWAK = true
|
||||
game.stack:push(TextBox.new(game,
|
||||
"The restless soul\ncalmed down and\ndeparted!"))
|
||||
t._PokemonTower6FSoulWasCalmedText
|
||||
or "The mother's soul\nwas calmed.\012It departed to\nthe afterlife!"))
|
||||
elseif result ~= "lose" then
|
||||
-- .did_not_defeat: one simulated step right, off the trigger,
|
||||
-- so fleeing does not leave you standing on a cell that
|
||||
-- immediately re-fires.
|
||||
ow:scriptMove(ow.player, "right", 1)
|
||||
end
|
||||
ow:afterBattle(result)
|
||||
end
|
||||
|
||||
+10
-1
@@ -360,7 +360,7 @@ local rocketRows = {
|
||||
{ "jump_if_true", 9 }, -- 5
|
||||
{ "show_text", "_CeruleanCityRocketText" }, -- 6
|
||||
{ "start_battle", "trainer", "OPP_ROCKET", 5 }, -- 7
|
||||
{ "jump_if_false", 16 }, -- 8
|
||||
{ "jump_if_false", 18 }, -- 8
|
||||
{ "show_text", "_CeruleanCityRocketIllReturnTheTMText" }, -- 9
|
||||
{ "set_flag", "EVENT_BEAT_CERULEAN_ROCKET_THIEF" }, -- 10
|
||||
{ "give_item", "TM_DIG", 1, false }, -- 11 (row 13 prints)
|
||||
@@ -368,6 +368,15 @@ local rocketRows = {
|
||||
{ "show_text", "_CeruleanCityRocketReceivedTM28Text" }, -- 13
|
||||
{ "show_text", "_CeruleanCityRocketIBetterGetMovingText" }, -- 14
|
||||
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_ROCKET" }, -- 15
|
||||
-- CeruleanHideRocket (scripts/CeruleanCity_2.asm) does all three behind
|
||||
-- one fade: the ROCKET goes, GUARD1 (28,12) appears and GUARD2 (27,12)
|
||||
-- disappears. GUARD2 stands on the only walkable neighbour of the
|
||||
-- trashed house's south door, which is one of the two ways through the
|
||||
-- fence dividing Cerulean, so this swap is what reconnects the city.
|
||||
-- Bill's ticket performs the same swap (data/scripts/story.lua), and
|
||||
-- both are idempotent, so whichever the player reaches first opens it.
|
||||
{ "show_object", "CERULEAN_CITY", "CERULEANCITY_GUARD1" }, -- 16
|
||||
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_GUARD2" }, -- 17
|
||||
}
|
||||
|
||||
M.CERULEAN_CITY = {
|
||||
|
||||
@@ -13,6 +13,19 @@ local driverCo -- optional frame-driver (POKEPORT_DRIVER=file.lua): a
|
||||
-- coroutine that receives `Game` and yields once per
|
||||
-- frame; used headless (xvfb) for scripted screenshots
|
||||
|
||||
-- --speed N / POKEPORT_SPEED=N: run the logic clock N times faster without
|
||||
-- touching audio (src/core/GameSpeed.lua). Overrides the saved option so a
|
||||
-- bot or screenshot run is not at the mercy of the player's last choice.
|
||||
local speedOverride = tonumber(os.getenv("POKEPORT_SPEED"))
|
||||
|
||||
-- How many times to run a scripted act+step loop per rendered frame. Only
|
||||
-- scripted runs use this; interactive play fast-forwards through
|
||||
-- Game.speedOverride / the GAME SPEED option instead.
|
||||
local function scriptedIterations()
|
||||
if not (autopilot or driverCo) then return 1 end
|
||||
return math.max(1, math.floor(require("src.core.GameSpeed").clamp(speedOverride)))
|
||||
end
|
||||
|
||||
local function bootGame()
|
||||
Game = require("src.core.Game")
|
||||
Game:load()
|
||||
@@ -24,6 +37,10 @@ local function bootGame()
|
||||
local fn = assert(loadfile(driverPath))()
|
||||
driverCo = coroutine.create(fn)
|
||||
end
|
||||
-- After the two above are known: a scripted run drives the multiplier
|
||||
-- from love.update's loop, so the in-engine one must stay at 1 or the
|
||||
-- two would compound (10x10 = 100 steps per observation).
|
||||
Game.speedOverride = (autopilot or driverCo) and 1 or speedOverride
|
||||
end
|
||||
|
||||
function love.load(args)
|
||||
@@ -33,6 +50,8 @@ function love.load(args)
|
||||
editorMode = true
|
||||
elseif a == "--save" and args[i + 1] and args[i + 1] ~= "" then
|
||||
savePath = args[i + 1]
|
||||
elseif a == "--speed" and tonumber(args[i + 1]) then
|
||||
speedOverride = tonumber(args[i + 1])
|
||||
end
|
||||
end
|
||||
love.graphics.setDefaultFilter("nearest", "nearest")
|
||||
@@ -67,23 +86,36 @@ function love.update(dt)
|
||||
if editorMode then return EditorApp.update(dt) end
|
||||
if Importer then return Importer:update(dt) end
|
||||
|
||||
-- Scripted runs (autopilot / POKEPORT_DRIVER) observe and act exactly
|
||||
-- once per Game:update, so they must keep a 1:1 relationship with the
|
||||
-- logic step. Fast-forwarding them by scaling the step inside
|
||||
-- Game:update would run N steps per observation: a held direction walks
|
||||
-- through all N, the player slides past the waypoint, and the script
|
||||
-- re-plans from an overshot cell. So iterate the whole act+step loop
|
||||
-- instead -- same script, just more of it per rendered frame.
|
||||
local iterations = scriptedIterations()
|
||||
|
||||
if autopilot then
|
||||
autopilot.update()
|
||||
Game:update(1 / 60) -- deterministic stepping for the autopilot
|
||||
for _ = 1, iterations do
|
||||
autopilot.update()
|
||||
Game:update(1 / 60) -- deterministic stepping for the autopilot
|
||||
end
|
||||
return
|
||||
end
|
||||
if driverCo then
|
||||
local ok, err = coroutine.resume(driverCo, Game)
|
||||
if not ok then
|
||||
print("driver error: " .. tostring(err))
|
||||
love.event.quit(1)
|
||||
return
|
||||
for _ = 1, iterations do
|
||||
local ok, err = coroutine.resume(driverCo, Game)
|
||||
if not ok then
|
||||
print("driver error: " .. tostring(err))
|
||||
love.event.quit(1)
|
||||
return
|
||||
end
|
||||
if coroutine.status(driverCo) == "dead" then
|
||||
love.event.quit()
|
||||
return
|
||||
end
|
||||
Game:update(1 / 60)
|
||||
end
|
||||
if coroutine.status(driverCo) == "dead" then
|
||||
love.event.quit()
|
||||
return
|
||||
end
|
||||
Game:update(1 / 60)
|
||||
return
|
||||
end
|
||||
Game:update(dt)
|
||||
|
||||
+11
-18
@@ -78,23 +78,14 @@ run_tier "T4 mod-SDK" "$LUA" tests/run_modkit.lua
|
||||
# ------- content tier: only meaningful with an imported ROM
|
||||
|
||||
|
||||
# tests/run_tests.lua carries two pre-existing failures that are stale
|
||||
# about the chip-audio architecture rather than real defects:
|
||||
#
|
||||
# Pikachu cry WAV exists nothing writes .wav any more -- cries are
|
||||
# synthesized at play time from
|
||||
# Data.audio.cries + programs.bin
|
||||
# low-health alarm sfx extracted the importer deliberately does not
|
||||
# extract it; Sound.startLoop falls back to
|
||||
# ChipAudio.newLowHealthAlarm (Sound.lua:268)
|
||||
#
|
||||
# They are left in place (fixing them is a separate, reviewed change), so
|
||||
# the tier passes on exactly this baseline and fails the moment a third
|
||||
# failure appears or one of these two changes identity. Ignoring the exit
|
||||
# code outright would hide every future content regression.
|
||||
KNOWN_CONTENT_FAILURES=2
|
||||
KNOWN_CONTENT_LINES="FAIL Pikachu cry WAV exists
|
||||
FAIL low-health alarm sfx extracted"
|
||||
# tests/run_tests.lua is expected to be clean. It used to carry two stale
|
||||
# chip-audio assertions on the allowlist below (Pikachu cry WAV exists /
|
||||
# low-health alarm sfx extracted); both have since been fixed, so the
|
||||
# baseline is zero and any failure fails the tier. Keep the allowlist
|
||||
# mechanism rather than ignoring the exit code -- that would hide every
|
||||
# future content regression.
|
||||
KNOWN_CONTENT_FAILURES=0
|
||||
KNOWN_CONTENT_LINES=""
|
||||
|
||||
run_content_behavior() {
|
||||
local out
|
||||
@@ -107,7 +98,9 @@ run_content_behavior() {
|
||||
if [ "$count" -eq "$KNOWN_CONTENT_FAILURES" ] \
|
||||
&& [ "$lines" = "$(printf '%s\n' "$KNOWN_CONTENT_LINES" | sort)" ]; then
|
||||
printf '%s\n' "$out" | tail -3
|
||||
echo "(the $KNOWN_CONTENT_FAILURES known stale audio assertions, unchanged)"
|
||||
if [ "$KNOWN_CONTENT_FAILURES" -gt 0 ]; then
|
||||
echo "(the $KNOWN_CONTENT_FAILURES known stale assertions, unchanged)"
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
|
||||
|
||||
@@ -2559,15 +2559,25 @@ function BattleState:learnMove(mon, moveId)
|
||||
end
|
||||
|
||||
function BattleState:playerMonFainted()
|
||||
if self.result then return end -- double faint: the battle is decided
|
||||
local nextMon = Party.firstHealthy(self.game.save.party)
|
||||
if not nextMon then
|
||||
-- Being out of useable POKéMON blacks you out even when the battle was
|
||||
-- already decided in our favour. A double faint -- our last mon dying
|
||||
-- to residual damage on the turn it lands the KO -- used to hit the
|
||||
-- "battle is decided" guard below and return with result = "win", so
|
||||
-- afterBattle never took the lose branch: no revive, no warp to the
|
||||
-- heal point, and the player was left standing on the map with a party
|
||||
-- at 0 HP. Nothing recovers from that state (every later encounter
|
||||
-- aborts with "no healthy party"), and it is not reachable in pokered:
|
||||
-- HandlePlayerMonFainted runs the player-side check on its own, so
|
||||
-- losing your last mon always blacks you out whatever the enemy did.
|
||||
if not nextMon and self.result ~= "lose" then
|
||||
self:sayNext(("%s is out of\nuseable POKéMON!"):format(self.game.save.player.name))
|
||||
self:sayNext(("%s blacked\nout!"):format(self.game.save.player.name))
|
||||
self.result = "lose"
|
||||
self.afterQueue = "finish"
|
||||
return
|
||||
end
|
||||
if self.result then return end -- double faint: the battle is decided
|
||||
-- DoUseNextMonDialogue (core.asm:1052-1078): only WILD battles ask
|
||||
-- "Use next POKéMON?"; NO goes through the run check with party slot
|
||||
-- 1's speed, and a failed run still forces the party menu. Trainer
|
||||
@@ -3026,6 +3036,17 @@ function BattleState:finish()
|
||||
self.phase = "messages"
|
||||
return
|
||||
end
|
||||
-- Invariant: a battle can never hand the overworld a party with nothing
|
||||
-- healthy in it. afterBattle only revives and warps to the heal point on
|
||||
-- "lose", so any other result here strands the player at 0 HP with no way
|
||||
-- back -- an unrecoverable state, not merely a wrong one. playerMonFainted
|
||||
-- is the path that should have caught this; if we land here it did not, so
|
||||
-- say so rather than silently papering over it.
|
||||
if self.result ~= "lose" and not Party.firstHealthy(self.game.save.party) then
|
||||
Logger.warn("battle finished %s with no healthy party; forcing blackout",
|
||||
tostring(self.result))
|
||||
self.result = "lose"
|
||||
end
|
||||
self.lockedBall = nil
|
||||
-- pokered never writes Mimic's copy into the party struct; leaving
|
||||
-- battle discards the battle copy, so the original ids come back
|
||||
|
||||
+5
-1
@@ -41,7 +41,11 @@ local CONSTANT_DEFAULTS = {
|
||||
-- field.boot is the total-conversion override point for the new game; the
|
||||
-- values match what SaveData.newGame and the Oak speech used to inline.
|
||||
local BOOT_DEFAULTS = {
|
||||
startMap = "PALLET_TOWN", startX = 5, startY = 6, startFacing = "down",
|
||||
-- special_warps.asm NewGameWarp: REDS_HOUSE_2F, 3, 6 -- the bedroom, not
|
||||
-- the tile outside the house. lastHeal is deliberately absent: SaveData
|
||||
-- derives the vanilla blackout point, and seeding it here would leak into
|
||||
-- total conversions that patch the spawn without naming a heal point.
|
||||
startMap = "REDS_HOUSE_2F", startX = 3, startY = 6, startFacing = "down",
|
||||
playerName = "RED", rivalName = "BLUE",
|
||||
startMoney = 3000,
|
||||
screens = { splash = "IntroMovie", title = "TitleState", newGame = "OakSpeech" },
|
||||
|
||||
+26
-2
@@ -148,14 +148,38 @@ function Game:step(dt)
|
||||
self.stack:update(dt)
|
||||
-- play time for the trainer card / save screen
|
||||
self.save.playTime = (self.save.playTime or 0) + dt
|
||||
require("src.core.Music").update(Data)
|
||||
-- Music.update is NOT serviced here: it decrements fade counters and
|
||||
-- drives ChipAudio once per call, so running it inside the logic step
|
||||
-- would pitch music and sfx up under fast-forward. Game:update advances
|
||||
-- it on its own real-time 60Hz accumulator instead.
|
||||
end
|
||||
|
||||
-- The logic multiplier for this frame. Read live rather than cached so the
|
||||
-- Options row takes effect immediately; speedOverride is the --speed /
|
||||
-- POKEPORT_SPEED run argument, which wins over the saved option so a bot
|
||||
-- or screenshot run does not depend on whatever the player last chose.
|
||||
function Game:logicSpeed()
|
||||
local GameSpeed = require("src.core.GameSpeed")
|
||||
if self.speedOverride then return GameSpeed.clamp(self.speedOverride) end
|
||||
local opts = self.save and self.save.options
|
||||
return GameSpeed.clamp(opts and opts.speed or GameSpeed.DEFAULT)
|
||||
end
|
||||
|
||||
function Game:update(dt)
|
||||
-- Touch timers / prior-frame auto-releases before the fixed step so
|
||||
-- deferred A and edge pulses land in Input's press queue for this step.
|
||||
TouchInput:update(dt)
|
||||
FixedStep:update(dt)
|
||||
-- Fast-forward scales only the logic clock (see src/core/GameSpeed.lua).
|
||||
FixedStep:update(dt * self:logicSpeed())
|
||||
-- Audio runs off real time at a fixed 60Hz regardless of game speed or
|
||||
-- display refresh, so fades and chip synthesis keep their intended tempo
|
||||
-- whether we are at 1X, 10X, or running with vsync disabled.
|
||||
local step = FixedStep.STEP
|
||||
self.audioAccum = math.min((self.audioAccum or 0) + dt, 0.25)
|
||||
while self.audioAccum >= step do
|
||||
self.audioAccum = self.audioAccum - step
|
||||
require("src.core.Music").update(Data)
|
||||
end
|
||||
-- Overworld tilt toggle tween: presentational, so it runs on the real
|
||||
-- frame dt (not the fixed logic step) for a smooth ~0.25s glide.
|
||||
require("src.render.Tilt").update(dt)
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
-- Fast-forward multiplier for game logic.
|
||||
--
|
||||
-- Speeding up means running the 1/60 fixed step N times per real frame
|
||||
-- (Game:update), so everything driven by the step -- movement, text,
|
||||
-- battle timing, scripts -- advances N times faster while staying
|
||||
-- deterministic. Audio deliberately does NOT scale: Music.update drives
|
||||
-- fade counters and ChipAudio synthesis off its own real-time 60Hz
|
||||
-- accumulator in Game:update, so music and sfx play at normal pitch and
|
||||
-- tempo at every speed.
|
||||
--
|
||||
-- Vsync still caps how much work a real frame can do, so 10X is a target
|
||||
-- rather than a promise on a slow machine -- the logic simply runs as many
|
||||
-- steps as the frame budget allows.
|
||||
|
||||
local GameSpeed = {}
|
||||
|
||||
-- 20X exists for the bot runs (tests/drivers/route.lua): a full-route
|
||||
-- attempt is long enough that the iteration loop, not the engine, is the
|
||||
-- bottleneck. Vsync caps how much a real frame can do, so past 10X the
|
||||
-- multiplier is increasingly a ceiling rather than a rate.
|
||||
GameSpeed.LEVELS = { 1, 2, 4, 10, 20, 30, 50, 75 }
|
||||
GameSpeed.DEFAULT = 1
|
||||
|
||||
function GameSpeed.levelLabel(v)
|
||||
v = tonumber(v) or GameSpeed.DEFAULT
|
||||
if v == 1 then return "NORMAL" end
|
||||
return tostring(v) .. "X"
|
||||
end
|
||||
|
||||
-- nearest valid level for an arbitrary value (a hand-edited options.lua or
|
||||
-- a --speed argument), so a bad number degrades to something sane
|
||||
function GameSpeed.clamp(v)
|
||||
v = tonumber(v)
|
||||
if not v then return GameSpeed.DEFAULT end
|
||||
local best, bestDiff = GameSpeed.DEFAULT, math.huge
|
||||
for _, level in ipairs(GameSpeed.LEVELS) do
|
||||
local diff = math.abs(level - v)
|
||||
if diff < bestDiff then best, bestDiff = level, diff end
|
||||
end
|
||||
return best
|
||||
end
|
||||
|
||||
-- cycle to the next/previous level, wrapping (the options row idiom)
|
||||
function GameSpeed.cycle(v, dir)
|
||||
local levels = GameSpeed.LEVELS
|
||||
local cur = 1
|
||||
for i, level in ipairs(levels) do
|
||||
if level == GameSpeed.clamp(v) then cur = i break end
|
||||
end
|
||||
local nextIdx = (cur - 1 + (dir or 1)) % #levels + 1
|
||||
return levels[nextIdx]
|
||||
end
|
||||
|
||||
return GameSpeed
|
||||
+33
-5
@@ -41,6 +41,8 @@ function SaveData.defaultOptions()
|
||||
musicVol = 7,
|
||||
sfxVol = 7,
|
||||
musicFilter = 0,
|
||||
-- logic fast-forward multiplier; audio is unaffected (GameSpeed.lua)
|
||||
speed = 1,
|
||||
-- port display options (OptionsMenu / hotkeys 2/3/5)
|
||||
colors = "gbc",
|
||||
tilt = 0,
|
||||
@@ -516,8 +518,8 @@ end
|
||||
|
||||
local function scrubMaps(save, data, report)
|
||||
local boot = (data.field and data.field.boot) or {}
|
||||
local spawn = { map = boot.startMap or "PALLET_TOWN",
|
||||
x = boot.startX or 5, y = boot.startY or 6 }
|
||||
local spawn = { map = boot.startMap or "REDS_HOUSE_2F",
|
||||
x = boot.startX or 3, y = boot.startY or 6 }
|
||||
-- heal point first, so the player fallback below always lands somewhere
|
||||
-- valid; boot's heal cell (threaded from field.boot) is the last resort
|
||||
if save.lastHeal and not known(data.maps, save.lastHeal.map) then
|
||||
@@ -627,11 +629,31 @@ end
|
||||
-- boot is Data.field.boot, threaded in by Game: this module must not reach
|
||||
-- into Data itself. Every read falls back to the Red literal it replaced,
|
||||
-- so an absent or partial config still produces the vanilla new game.
|
||||
-- Where blackouts and ESCAPE ROPE return to for a given boot config.
|
||||
--
|
||||
-- In vanilla this is NOT the spawn. wLastBlackoutMap is zero-filled at new
|
||||
-- game and PALLET_TOWN is map 0, so the player starts in the bedroom
|
||||
-- (special_warps.asm NewGameWarp) but blacks out to Pallet Town's fly_warp
|
||||
-- cell (5, 6). A world that moves the spawn without naming a heal point
|
||||
-- keeps the two together -- it may have no Pallet Town at all.
|
||||
--
|
||||
-- Shared with the Hall of Fame reset, which pokered writes as a literal
|
||||
-- (HallOfFameResetEventsAndSaveScript: wLastBlackoutMap := PALLET_TOWN)
|
||||
-- rather than deriving from the spawn.
|
||||
function SaveData.defaultHeal(boot)
|
||||
boot = type(boot) == "table" and boot or {}
|
||||
local h = boot.lastHeal
|
||||
if h then return { map = h.map, x = h.x, y = h.y } end
|
||||
local map = boot.startMap or "REDS_HOUSE_2F"
|
||||
if map == "REDS_HOUSE_2F" then return { map = "PALLET_TOWN", x = 5, y = 6 } end
|
||||
return { map = map, x = boot.startX or 3, y = boot.startY or 6 }
|
||||
end
|
||||
|
||||
function SaveData.newGame(boot)
|
||||
boot = type(boot) == "table" and boot or {}
|
||||
local map = boot.startMap or "PALLET_TOWN"
|
||||
local x, y = boot.startX or 5, boot.startY or 6
|
||||
local heal = boot.lastHeal or {}
|
||||
local map = boot.startMap or "REDS_HOUSE_2F"
|
||||
local x, y = boot.startX or 3, boot.startY or 6
|
||||
local heal = SaveData.defaultHeal(boot)
|
||||
local save = {
|
||||
meta = { format = Version.saveFormat, mods = {} },
|
||||
player = {
|
||||
@@ -655,6 +677,12 @@ function SaveData.newGame(boot)
|
||||
-- where blackouts and ESCAPE ROPE return to (updated by nurses);
|
||||
-- copied, never aliased, so a save never writes back into Data
|
||||
lastHeal = { map = heal.map or map, x = heal.x or x, y = heal.y or y },
|
||||
-- Interiors inherit the SGB palette of the last outdoor map. wLastMap
|
||||
-- is zero-filled at new game and PALLET_TOWN is map 0, so before the
|
||||
-- player has ever been outdoors that palette is Pallet Town's -- which
|
||||
-- matters because the vanilla spawn (REDS_HOUSE_2F) is itself indoors.
|
||||
-- Without this the palette falls through to the ROUTE default.
|
||||
lastOutdoor = { id = heal.map or map, x = heal.x or x, y = heal.y or y },
|
||||
repelSteps = 0,
|
||||
-- per-mod persistence (mod.save) lives under here, keyed by mod id
|
||||
modData = {},
|
||||
|
||||
@@ -523,11 +523,11 @@ function Commands.record_hall_of_fame(ctx)
|
||||
-- save keeps the player standing in the HALL_OF_FAME room. (The
|
||||
-- E4 room-script/event resets that precede the save in pokered are
|
||||
-- the Indigo lobby's re-entry reset here, data/scripts/story6.lua.)
|
||||
-- The reset heal point is field.boot's spawn, PALLET_TOWN (5,6)
|
||||
-- in the vanilla dataset.
|
||||
-- pokered writes PALLET_TOWN here as a literal, not as "the spawn" --
|
||||
-- the vanilla spawn is REDS_HOUSE_2F. SaveData.defaultHeal carries
|
||||
-- that split (and lets a total conversion redirect it).
|
||||
local boot = game.data.field and game.data.field.boot or {}
|
||||
ctx.save.lastHeal = { map = boot.startMap or "PALLET_TOWN",
|
||||
x = boot.startX or 5, y = boot.startY or 6 }
|
||||
ctx.save.lastHeal = require("src.core.SaveData").defaultHeal(boot)
|
||||
if game.writeSave then game:writeSave() end
|
||||
end)
|
||||
end)
|
||||
|
||||
@@ -71,6 +71,12 @@ local function useOn(game, battle, id, target, list, moveIndex)
|
||||
consume(game, id)
|
||||
list:close()
|
||||
showMessages(game, payload, function()
|
||||
-- ItemUsePokeDoll sets wEscapedFromBattle and never touches
|
||||
-- wBattleResult, so a script that reads the result afterwards sees
|
||||
-- 0 -- "defeated". The ghost MAROWAK's script keys on exactly that
|
||||
-- (the Poke Doll trick); the flag lets it tell this escape from an
|
||||
-- ordinary RUN, which writes $2.
|
||||
battle.pokeDollEscape = true
|
||||
battle.result = "run"
|
||||
battle.afterQueue = "finish"
|
||||
battle.phase = "messages"
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local Tilt = require("src.render.Tilt")
|
||||
local GBCFX = require("src.render.GBCFX")
|
||||
local GameSpeed = require("src.core.GameSpeed")
|
||||
local Logger = require("src.core.Logger")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local OptionRows = require("src.ui.OptionRows")
|
||||
@@ -190,6 +191,17 @@ local function buildRows(game)
|
||||
GBCFX.setLevel(o.gbcfx)
|
||||
return true
|
||||
end },
|
||||
-- fast-forward the logic clock only; music and sfx keep their tempo
|
||||
-- (src/core/GameSpeed.lua), so this is safe to leave on
|
||||
{ id = "speed", label = "GAME SPEED",
|
||||
value = function(g)
|
||||
return GameSpeed.levelLabel(g.save.options.speed)
|
||||
end,
|
||||
step = function(g, dir)
|
||||
local o = g.save.options
|
||||
o.speed = GameSpeed.cycle(o.speed, dir)
|
||||
return true
|
||||
end },
|
||||
-- the manager's discoverable home (18-mod-manager-ux); inert until
|
||||
-- opened, so the row costs a vanilla install nothing
|
||||
{ id = "mods", label = "MODS",
|
||||
|
||||
@@ -85,6 +85,13 @@ local PLAYER_SPRITES = {
|
||||
local LAST_MAP_REWRITES = {
|
||||
ROUTE_22_GATE = { axis = "y", rules = { { below = 4, map = "ROUTE_23" },
|
||||
{ map = "ROUTE_22" } } },
|
||||
-- UndergroundPathRoute{5,6,7,8}_Script force wLastMap to their own route on
|
||||
-- map load, so crossing the tunnel and taking the far building's LAST_MAP
|
||||
-- exit lands you on that route instead of the one you entered from.
|
||||
UNDERGROUND_PATH_ROUTE_5 = { rules = { { map = "ROUTE_5" } } },
|
||||
UNDERGROUND_PATH_ROUTE_6 = { rules = { { map = "ROUTE_6" } } },
|
||||
UNDERGROUND_PATH_ROUTE_7 = { rules = { { map = "ROUTE_7" } } },
|
||||
UNDERGROUND_PATH_ROUTE_8 = { rules = { { map = "ROUTE_8" } } },
|
||||
}
|
||||
|
||||
FieldDefaults.FIELD = {
|
||||
|
||||
@@ -360,10 +360,15 @@ function OverworldState:paletteNameFor(map)
|
||||
local palettes = FieldDefaults.field(Game.data, "palettes")
|
||||
local name = map.def.palette or paletteLookup(palettes, map.id, map.def.tileset)
|
||||
if not name then
|
||||
-- interiors inherit the outdoor map they sit in; before the player has
|
||||
-- been outdoors at all that is wherever the game starts
|
||||
-- Interiors inherit the outdoor map they sit in. Before the player has
|
||||
-- been outdoors at all, that is wLastMap's zero-fill -- map 0,
|
||||
-- PALLET_TOWN -- and NOT the spawn: the vanilla spawn (REDS_HOUSE_2F)
|
||||
-- is itself an interior and would fall through to the ROUTE default.
|
||||
-- defaultHeal derives the same zero-fill map (wLastBlackoutMap shares
|
||||
-- the reasoning) and lets a total conversion redirect it.
|
||||
local boot = (Game.data.field and Game.data.field.boot) or {}
|
||||
local last = self.lastOutdoor and self.lastOutdoor.id
|
||||
or FieldDefaults.fieldValue(Game.data, "boot", "startMap")
|
||||
or require("src.core.SaveData").defaultHeal(boot).map
|
||||
local lastDef = last and Game.data.maps[last]
|
||||
name = (last and paletteLookup(palettes, last, lastDef and lastDef.tileset))
|
||||
or palettes.default
|
||||
@@ -2275,8 +2280,19 @@ function OverworldState:onStepComplete()
|
||||
-- arriving on a door/warp tile warp; a non-door warp square also fires
|
||||
-- when the extra check passes and the d-pad is held
|
||||
-- (CheckWarpsNoCollision)
|
||||
-- The cell we warped in on is inert until we step off it: standing on it,
|
||||
-- or being walked back onto it before leaving, does not re-fire (see
|
||||
-- warpEntryCell where it is set). Once we are on any other cell it clears
|
||||
-- and every warp is live again.
|
||||
local entry = self.warpEntryCell
|
||||
if entry and (p.cellX ~= entry.x or p.cellY ~= entry.y) then
|
||||
self.warpEntryCell = nil
|
||||
entry = nil
|
||||
end
|
||||
if self.justWarped then
|
||||
self.justWarped = false
|
||||
elseif entry then
|
||||
-- still standing on the warp we arrived through; do not re-trigger it
|
||||
else
|
||||
local w = Warp.onArrive(self.map, p.cellX, p.cellY)
|
||||
if not w and self:dirHeld() then
|
||||
@@ -2777,6 +2793,12 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts)
|
||||
Game.stack:push(Transition.new(Game, function()
|
||||
self:setMap(mapId, x, y, facing or "down", opts)
|
||||
self.justWarped = true
|
||||
-- The warp we land ON stays inert until we physically step off it, so a
|
||||
-- warp whose destination cell is itself a warp cannot bounce us straight
|
||||
-- back (elevator cars, stacked stair/door mats). This generalizes the
|
||||
-- one-step justWarped guard, which only skipped the very next frame's
|
||||
-- check and so let a mon walked back onto the pad re-trigger it.
|
||||
self.warpEntryCell = { x = x, y = y }
|
||||
-- Fly/Teleport/Dig/Escape-Rope/blackout landings poof the player
|
||||
-- back in (player_animations.asm EnterMapAnim); ordinary door
|
||||
-- warps never take this branch
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -112,8 +112,8 @@ check(#Data.constants.hmMoves == 5 and Data.constants.hmMoves[1] == "CUT",
|
||||
"hmMoves seeded")
|
||||
|
||||
local boot = Data.field.boot
|
||||
check(boot.startMap == "PALLET_TOWN" and boot.startX == 5 and boot.startY == 6
|
||||
and boot.startFacing == "down", "field.boot seeded with the Pallet spawn")
|
||||
check(boot.startMap == "REDS_HOUSE_2F" and boot.startX == 3 and boot.startY == 6
|
||||
and boot.startFacing == "down", "field.boot seeded with the NewGameWarp spawn")
|
||||
check(boot.playerName == "RED" and boot.rivalName == "BLUE"
|
||||
and boot.startMoney == 3000, "field.boot seeded with the Red new-game values")
|
||||
check(boot.namePresets.player[1] == "RED" and boot.namePresets.rival[1] == "BLUE",
|
||||
@@ -312,16 +312,21 @@ check(#worldData.field.ledges == #Data.field.ledges,
|
||||
-- ------- field.boot changes the new game
|
||||
|
||||
local vanillaSave = SaveData.newGame(Data.field.boot)
|
||||
check(vanillaSave.player.map == "PALLET_TOWN" and vanillaSave.player.x == 5
|
||||
-- special_warps.asm NewGameWarp is REDS_HOUSE_2F, 3, 6 -- the bedroom. This
|
||||
-- previously asserted PALLET_TOWN (5, 6), which is where you stand after
|
||||
-- walking out of the house, so a new game skipped Red's house entirely.
|
||||
check(vanillaSave.player.map == "REDS_HOUSE_2F" and vanillaSave.player.x == 3
|
||||
and vanillaSave.player.y == 6 and vanillaSave.player.facing == "down",
|
||||
"the seeded boot config reproduces the Pallet spawn")
|
||||
"the seeded boot config reproduces the NewGameWarp bedroom spawn")
|
||||
check(vanillaSave.player.name == "RED" and vanillaSave.player.rival == "BLUE"
|
||||
and vanillaSave.money == 3000, "the seeded boot config reproduces the Red start")
|
||||
-- the heal point is deliberately NOT the spawn: wLastBlackoutMap is
|
||||
-- zero-filled at new game and PALLET_TOWN is map 0
|
||||
check(vanillaSave.lastHeal.map == "PALLET_TOWN" and vanillaSave.lastHeal.x == 5
|
||||
and vanillaSave.lastHeal.y == 6, "heal point defaults to the spawn")
|
||||
and vanillaSave.lastHeal.y == 6, "blackouts return to Pallet Town, not the spawn")
|
||||
-- an absent config is still the vanilla new game
|
||||
local bareSave = SaveData.newGame()
|
||||
check(bareSave.player.map == "PALLET_TOWN" and bareSave.money == 3000
|
||||
check(bareSave.player.map == "REDS_HOUSE_2F" and bareSave.money == 3000
|
||||
and bareSave.lastHeal.map == "PALLET_TOWN",
|
||||
"newGame without a boot config is unchanged")
|
||||
|
||||
@@ -355,7 +360,7 @@ check(bootData.field.boot.screens.title == "TitleState",
|
||||
check(bootData.field.hiddenItems.CERULEAN_CAVE_1F ~= nil
|
||||
and #bootData.field.flyOrder == #Data.field.flyOrder,
|
||||
"sibling field keys are intact after a boot patch")
|
||||
check(Data.field.boot.startMap == "PALLET_TOWN",
|
||||
check(Data.field.boot.startMap == "REDS_HOUSE_2F",
|
||||
"the boot merge never touched the live Data")
|
||||
|
||||
-- the save table is a copy: writing to it cannot reach back into Data
|
||||
|
||||
@@ -205,7 +205,7 @@ end
|
||||
local om = OptionsMenu.new(optGame())
|
||||
local WANT_IDS = { "textSpeed", "animations", "battleStyle", "ruleset",
|
||||
"musicVol", "sfxVol", "musicFilter", "colors", "tilt",
|
||||
"gbcfx", "mods", "controls" }
|
||||
"gbcfx", "speed", "mods", "controls" }
|
||||
check(#om.rows == #WANT_IDS, "vanilla options row count (plus MODS/CONTROLS)")
|
||||
for i, id in ipairs(WANT_IDS) do
|
||||
check(om.rows[i].id == id, "options row order: " .. id)
|
||||
@@ -238,7 +238,7 @@ check(om.game.save.options.musicVol == 0, "music volume clamps at 0")
|
||||
-- the MODS row is the manager's discoverable home
|
||||
local mgGame = optGame()
|
||||
om = OptionsMenu.new(mgGame)
|
||||
om.rows[11].activate(mgGame)
|
||||
om.rows[12].activate(mgGame)
|
||||
check(getmetatable(mgGame.stack:top()) == ManagerState,
|
||||
"the MODS row opens the manager")
|
||||
check(mgGame.stack:top().screenId == "ManagerState",
|
||||
@@ -248,7 +248,7 @@ check(mgGame.stack:top().screenId == "ManagerState",
|
||||
local BindingsMenu = require("src.ui.BindingsMenu")
|
||||
local cbGame = optGame()
|
||||
om = OptionsMenu.new(cbGame)
|
||||
om.rows[12].activate(cbGame)
|
||||
om.rows[13].activate(cbGame)
|
||||
local bm = cbGame.stack:top()
|
||||
check(getmetatable(bm) == BindingsMenu,
|
||||
"the CONTROLS row opens the rebind list")
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
-- Parity test: a wild battle always returns to the action menu after a
|
||||
-- POKé BALL fails to catch.
|
||||
--
|
||||
-- BattleState's message pump only leaves the "messages" phase through
|
||||
-- afterQueue:
|
||||
--
|
||||
-- if self.phase == "messages" then
|
||||
-- if not self:updateQueue() then
|
||||
-- if self.afterQueue == "menu" then self.phase = "menu"
|
||||
-- elseif self.afterQueue == "finish" then self:finish() end
|
||||
-- end
|
||||
-- return
|
||||
-- end
|
||||
--
|
||||
-- so a drained queue with afterQueue set to anything else -- or to nothing
|
||||
-- -- parks the battle in "messages" for good. Nothing on screen is
|
||||
-- waiting, no input advances it, and the encounter can never end.
|
||||
--
|
||||
-- Found by the route driver, which reported it precisely once it was made
|
||||
-- to give up rather than spin: "catch: livelocked on ROUTE_6 after 1201
|
||||
-- iterations (steps=81, thrown=6, inBattle=true, phase=messages)". Six
|
||||
-- balls thrown, battle still live, phase stuck. Before the guard it span
|
||||
-- 10,229 times on one ODDISH and pinned the whole run.
|
||||
--
|
||||
-- Self-contained; run via `luajit tests/parity_ball_miss.lua`.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
local S = require("tests.harness").suite("parity ball miss")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
-- Drive the phase machine the way BattleState:update does, with a queue
|
||||
-- that drains. The decision under test is what the phase becomes when the
|
||||
-- queue empties, so the queue's contents do not matter -- only afterQueue.
|
||||
local function pump(afterQueue, queued)
|
||||
local b = {
|
||||
phase = "messages",
|
||||
afterQueue = afterQueue,
|
||||
queue = queued or {},
|
||||
finished = false,
|
||||
}
|
||||
b.updateQueue = function(self)
|
||||
if #self.queue > 0 then table.remove(self.queue, 1) return true end
|
||||
return false
|
||||
end
|
||||
b.finish = function(self) self.finished = true end
|
||||
-- the real update()'s messages branch, verbatim
|
||||
for _ = 1, 50 do
|
||||
if b.phase == "messages" then
|
||||
if not b:updateQueue() then
|
||||
if b.afterQueue == "menu" then
|
||||
b.phase = "menu"
|
||||
elseif b.afterQueue == "finish" then
|
||||
b:finish()
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return b
|
||||
end
|
||||
|
||||
-- The contract: "menu" and "finish" both leave the messages phase.
|
||||
eq(pump("menu").phase, "menu", "afterQueue=menu returns to the action menu")
|
||||
check(pump("finish").finished, "afterQueue=finish ends the battle")
|
||||
|
||||
-- ...and anything else is the livelock. This is the assertion that would
|
||||
-- have caught it: a drained queue with no afterQueue never leaves
|
||||
-- "messages", so the battle is unreachable by any input.
|
||||
local stuck = pump(nil, { {}, {}, {} })
|
||||
eq(stuck.phase, "messages", "afterQueue=nil is the stuck state (documented)")
|
||||
check(#stuck.queue == 0, "the queue really did drain -- nothing is pending")
|
||||
|
||||
-- The real thing: openItems -> throwBall -> miss must leave afterQueue as
|
||||
-- "menu" the whole way through, since throwBall itself never sets it.
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local openItems = BattleState.openItems
|
||||
local fake = {
|
||||
queue = {},
|
||||
say = function(self, t) table.insert(self.queue, { text = t }) end,
|
||||
act = function(self, f) table.insert(self.queue, { fn = f }) end,
|
||||
ui = function(self, f) table.insert(self.queue, { ui = f }) end,
|
||||
buildScreen = function() return {} end,
|
||||
}
|
||||
openItems(fake)
|
||||
eq(fake.phase, "messages", "openItems parks the battle in the messages phase")
|
||||
eq(fake.afterQueue, "menu", "openItems arms afterQueue=menu before the bag")
|
||||
check(#fake.queue == 1 and fake.queue[1].ui ~= nil,
|
||||
"openItems queues the bag as a ui item")
|
||||
|
||||
S.finish()
|
||||
@@ -0,0 +1,109 @@
|
||||
-- Parity test: losing your last POKéMON always blacks you out, even when
|
||||
-- the battle was already decided in your favour.
|
||||
--
|
||||
-- A double faint -- the lead dying to residual damage on the same turn it
|
||||
-- lands the KO -- used to leave BattleState:playerMonFainted at its
|
||||
-- "the battle is decided" guard, so it returned with result = "win" still
|
||||
-- set. OverworldState:afterBattle only revives the party and warps to the
|
||||
-- heal point on "lose" (src/world/OverworldController.lua), so the player
|
||||
-- was handed back to the overworld standing on the map with every POKéMON
|
||||
-- at 0 HP. Nothing recovers from that: BattleState refuses to start an
|
||||
-- encounter with no healthy party ("wild battle with no healthy party;
|
||||
-- skipping"), so the save is bricked in place.
|
||||
--
|
||||
-- pokered cannot reach that state -- HandlePlayerMonFainted runs the
|
||||
-- player-side check on its own account, so being out of useable POKéMON
|
||||
-- blacks you out whatever happened to the enemy.
|
||||
--
|
||||
-- Found by the automated route driver: an attempt spent its whole length
|
||||
-- "wiping" on VIRIDIAN_FOREST fourteen times without ever moving, because
|
||||
-- the party was dead but the game had never blacked out.
|
||||
-- Self-contained; run via `luajit tests/parity_double_faint.lua`.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local S = require("tests.harness").suite("parity double faint")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
-- A stand-in carrying only what playerMonFainted touches. Deliberately not
|
||||
-- a real battle: the point is the decision, and a full encounter would need
|
||||
-- a loaded Data, an RNG and a queue to say nothing more than this does.
|
||||
local function battleWith(partyHP, result)
|
||||
local party = {}
|
||||
for i, hp in ipairs(partyHP) do
|
||||
party[i] = { species = "SQUIRTLE", hp = hp, stats = { hp = 20 } }
|
||||
end
|
||||
return {
|
||||
kind = "wild",
|
||||
result = result,
|
||||
afterQueue = nil,
|
||||
said = {},
|
||||
-- the reserves-left path reads the "Use next POKéMON?" prompt off Data
|
||||
data = { text = { _UseNextMonText = "Use next POKéMON?" } },
|
||||
game = { save = { party = party, player = { name = "RED" } } },
|
||||
sayNext = function(self, m) self.said[#self.said + 1] = m end,
|
||||
say = function(self, m) self.said[#self.said + 1] = m end,
|
||||
ui = function() end,
|
||||
}
|
||||
end
|
||||
|
||||
local function saidBlackout(b)
|
||||
for _, m in ipairs(b.said) do
|
||||
if tostring(m):find("blacked") then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- The regression itself: a won battle whose last mon died with it.
|
||||
do
|
||||
local b = battleWith({ 0 }, "win")
|
||||
BattleState.playerMonFainted(b)
|
||||
eq(b.result, "lose", "a win with the last mon dead becomes a blackout")
|
||||
eq(b.afterQueue, "finish", "the blackout ends the battle")
|
||||
check(saidBlackout(b), "the blackout text still prints on a double faint")
|
||||
end
|
||||
|
||||
-- Same for the other non-lose results, so no path can strand the party.
|
||||
for _, result in ipairs({ "run", "caught" }) do
|
||||
local b = battleWith({ 0 }, result)
|
||||
BattleState.playerMonFainted(b)
|
||||
eq(b.result, "lose", ("a %q result with no healthy party becomes a blackout")
|
||||
:format(result))
|
||||
end
|
||||
|
||||
-- An undecided battle keeps behaving exactly as before.
|
||||
do
|
||||
local b = battleWith({ 0 }, nil)
|
||||
BattleState.playerMonFainted(b)
|
||||
eq(b.result, "lose", "the ordinary last-mon faint still blacks out")
|
||||
check(saidBlackout(b), "and still prints the blackout text")
|
||||
end
|
||||
|
||||
-- The guard must not fire while something is still standing: a won battle
|
||||
-- with a healthy reserve stays won, and the fainted-mon flow is untouched.
|
||||
do
|
||||
local b = battleWith({ 0, 15 }, "win")
|
||||
BattleState.playerMonFainted(b)
|
||||
eq(b.result, "win", "a win with a healthy reserve is still a win")
|
||||
check(not saidBlackout(b), "and prints no blackout text")
|
||||
end
|
||||
|
||||
-- Already lost: no second helping of the blackout text.
|
||||
do
|
||||
local b = battleWith({ 0 }, "lose")
|
||||
BattleState.playerMonFainted(b)
|
||||
eq(b.result, "lose", "an already-lost battle stays lost")
|
||||
eq(#b.said, 0, "and does not re-announce the blackout")
|
||||
end
|
||||
|
||||
-- A mid-battle faint with reserves left is the wild "Use next POKéMON?"
|
||||
-- prompt (DoUseNextMonDialogue), not a blackout -- the path the guard
|
||||
-- sits in front of, so prove it still runs.
|
||||
do
|
||||
local b = battleWith({ 0, 15 }, nil)
|
||||
BattleState.playerMonFainted(b)
|
||||
eq(b.result, nil, "a faint with reserves left does not decide the battle")
|
||||
check(not saidBlackout(b), "and does not black out")
|
||||
end
|
||||
|
||||
S.finish()
|
||||
@@ -0,0 +1,161 @@
|
||||
-- Parity test: the ghost MAROWAK battle opens with NO Silph Scope check
|
||||
-- at the trigger, and the Poke Doll escape counts as defeating it.
|
||||
--
|
||||
-- PokemonTower6FDefaultScript (scripts/PokemonTower6F.asm) fires on
|
||||
-- (10,16), shows _PokemonTower6FBeGoneText, and starts the RESTLESS SOUL
|
||||
-- battle unconditionally -- the SILPH_SCOPE is only consulted inside the
|
||||
-- battle (IsGhostBattle: disguised sprite, "too scared to move", balls
|
||||
-- dodged). Our port used to turn the player back without the scope and
|
||||
-- never open the battle, which made 6F impassable on any route that skips
|
||||
-- Rocket Hideout -- including the speedrun route the bot follows, whose
|
||||
-- answer to the MAROWAK is a POKE_DOLL, not the scope.
|
||||
--
|
||||
-- The doll works because of wBattleResult: the battle script's
|
||||
-- "and a / jr nz .did_not_defeat" reads 0 as "defeated". Losing writes $1
|
||||
-- and running writes $2, but ItemUsePokeDoll ends the battle without
|
||||
-- touching it -- so the doll escape reads as a win and sets
|
||||
-- EVENT_BEAT_GHOST_MAROWAK. BagMenu marks that escape as
|
||||
-- battle.pokeDollEscape; the 6F script keys on it.
|
||||
--
|
||||
-- Self-contained; run via `luajit tests/parity_marowak.lua`.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
local S = require("tests.harness").suite("parity ghost marowak")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
-- Real TextBoxes want a Font atlas; the decision under test is which
|
||||
-- branch runs, not how the text renders.
|
||||
package.loaded["src.render.TextBox"] = {
|
||||
new = function(_, text, done) return { text = text, done = done } end,
|
||||
}
|
||||
|
||||
-- A recording stand-in for BattleState: newWild captures the species and
|
||||
-- level, makeGhost sets the flag the real one sets, and the test drives
|
||||
-- onFinish by hand.
|
||||
local madeBattles = {}
|
||||
package.loaded["src.battle.BattleState"] = {
|
||||
newWild = function(_, species, level)
|
||||
local b = { species = species, level = level, ghost = false }
|
||||
b.makeGhost = function(self) self.ghost = true end
|
||||
madeBattles[#madeBattles + 1] = b
|
||||
return b
|
||||
end,
|
||||
}
|
||||
|
||||
local M = dofile("data/scripts/story3.lua")
|
||||
local tower = M.POKEMON_TOWER_6F
|
||||
check(tower ~= nil and tower.onStep ~= nil, "POKEMON_TOWER_6F has a step trigger")
|
||||
|
||||
local function gameWith(inventory, flags)
|
||||
local pushed = {}
|
||||
return {
|
||||
save = { inventory = inventory or {}, flags = flags or {} },
|
||||
data = { text = {} },
|
||||
stack = { push = function(_, box) pushed[#pushed + 1] = box end },
|
||||
}, pushed
|
||||
end
|
||||
|
||||
local function owWith()
|
||||
local moved, after = {}, {}
|
||||
return {
|
||||
player = { facing = "up" },
|
||||
scriptMove = function(_, _, dir, n) moved[#moved + 1] = { dir, n } end,
|
||||
afterBattle = function(_, result) after[#after + 1] = result end,
|
||||
}, moved, after
|
||||
end
|
||||
|
||||
-- Walk the trigger: run onStep, then the Be-gone text's done() to push
|
||||
-- the battle. Returns the battle object (or nil).
|
||||
local function trigger(game, pushed, ow, x, y)
|
||||
local before = #madeBattles
|
||||
local fired = tower.onStep(game, ow, x, y)
|
||||
if not fired then return nil, fired end
|
||||
local box = pushed[#pushed]
|
||||
check(box ~= nil and box.done ~= nil, "the Be-gone text is pushed first")
|
||||
box.done()
|
||||
return madeBattles[#madeBattles], fired, #madeBattles > before
|
||||
end
|
||||
|
||||
-- ---- 1. fires only on (10,16), only while the event is unset ------------
|
||||
do
|
||||
local game, pushed = gameWith()
|
||||
local ow = owWith()
|
||||
check(not tower.onStep(game, ow, 10, 15), "no trigger off the coord")
|
||||
check(not tower.onStep(game, ow, 9, 16), "no trigger off the coord (x)")
|
||||
eq(0, #pushed, "nothing pushed off the coord")
|
||||
end
|
||||
do
|
||||
local game = gameWith({}, { EVENT_BEAT_GHOST_MAROWAK = true })
|
||||
local ow = owWith()
|
||||
check(not tower.onStep(game, ow, 10, 16),
|
||||
"a departed MAROWAK never re-triggers")
|
||||
end
|
||||
|
||||
-- ---- 2. NO scope: the battle still opens, disguised as a ghost ----------
|
||||
do
|
||||
local game, pushed = gameWith({})
|
||||
local ow = owWith()
|
||||
local battle, fired, created = trigger(game, pushed, ow, 10, 16)
|
||||
check(fired, "trigger fires without the SILPH_SCOPE")
|
||||
check(created and battle ~= nil,
|
||||
"the battle OPENS without the scope (vanilla; the old port turned back)")
|
||||
eq("MAROWAK", battle.species, "the opponent is the MAROWAK")
|
||||
eq(30, battle.level, "at level 30")
|
||||
check(battle.ghost, "and it is ghost-disguised without the scope")
|
||||
end
|
||||
|
||||
-- ---- 3. scope: same battle, not disguised -------------------------------
|
||||
do
|
||||
local game, pushed = gameWith({ SILPH_SCOPE = 1 })
|
||||
local ow = owWith()
|
||||
local battle = trigger(game, pushed, ow, 10, 16)
|
||||
check(battle ~= nil and not battle.ghost,
|
||||
"with the scope the battle is not a ghost")
|
||||
end
|
||||
|
||||
-- ---- 4. a win sets the event and shows the departed text ----------------
|
||||
do
|
||||
local game, pushed = gameWith({})
|
||||
local ow, moved, after = owWith()
|
||||
local battle = trigger(game, pushed, ow, 10, 16)
|
||||
battle.onFinish("win")
|
||||
check(game.save.flags.EVENT_BEAT_GHOST_MAROWAK, "win sets the event")
|
||||
eq(0, #moved, "no shove after a win")
|
||||
eq("win", after[#after], "afterBattle still runs")
|
||||
end
|
||||
|
||||
-- ---- 5. the Poke Doll escape counts as a win (wBattleResult trick) ------
|
||||
do
|
||||
local game, pushed = gameWith({})
|
||||
local ow, moved = owWith()
|
||||
local battle = trigger(game, pushed, ow, 10, 16)
|
||||
battle.pokeDollEscape = true
|
||||
battle.onFinish("run")
|
||||
check(game.save.flags.EVENT_BEAT_GHOST_MAROWAK,
|
||||
"the doll escape sets EVENT_BEAT_GHOST_MAROWAK")
|
||||
eq(0, #moved, "and does not shove the player")
|
||||
end
|
||||
|
||||
-- ---- 6. an ordinary flee does NOT count, and steps you right ------------
|
||||
do
|
||||
local game, pushed = gameWith({})
|
||||
local ow, moved = owWith()
|
||||
local battle = trigger(game, pushed, ow, 10, 16)
|
||||
battle.onFinish("run")
|
||||
check(not game.save.flags.EVENT_BEAT_GHOST_MAROWAK,
|
||||
"running away leaves the MAROWAK standing")
|
||||
eq(1, #moved, "and walks the player off the trigger")
|
||||
eq("right", moved[1] and moved[1][1], ".did_not_defeat steps RIGHT")
|
||||
end
|
||||
|
||||
-- ---- 7. a loss neither sets the event nor shoves (blackout handles it) --
|
||||
do
|
||||
local game, pushed = gameWith({})
|
||||
local ow, moved = owWith()
|
||||
local battle = trigger(game, pushed, ow, 10, 16)
|
||||
battle.onFinish("lose")
|
||||
check(not game.save.flags.EVENT_BEAT_GHOST_MAROWAK, "a loss does not clear it")
|
||||
eq(0, #moved, "no scripted step on a loss")
|
||||
end
|
||||
|
||||
S.finish()
|
||||
@@ -0,0 +1,112 @@
|
||||
-- Parity test: mart inventories match pokered, and every item the route
|
||||
-- driver tries to buy is actually on that mart's shelf.
|
||||
--
|
||||
-- Two failures in one, and the second is the one that bit.
|
||||
--
|
||||
-- Our extracted mart data was right all along -- VermilionMartClerkText is
|
||||
-- POKE_BALL, SUPER_POTION, ICE_HEAL, AWAKENING, PARLYZ_HEAL, REPEL
|
||||
-- (data/items/marts.asm:17), and that is exactly what we import. The route
|
||||
-- driver's shopping list asked for plain POTION there, which Vermilion has
|
||||
-- never sold. buyItem logged "shop: no POTION @ VERMILION_MART" into the
|
||||
-- end-of-run summary and returned, so the bot left town with no healing at
|
||||
-- all and then died nine times in Surge's gym and five more around
|
||||
-- Cerulean, every fight taken at whatever HP the last nurse had left it.
|
||||
--
|
||||
-- A shopping list that names an unstocked item cannot work, and nothing
|
||||
-- else in the run reports it loudly enough to notice. This asserts the
|
||||
-- lists against the data instead.
|
||||
--
|
||||
-- Self-contained; run via `luajit tests/parity_mart_stock.lua`.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
local S = require("tests.harness").suite("parity mart stock")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
local T = dofile("data/generated/text_pointers.lua")
|
||||
|
||||
-- MAP_NAME -> text-pointer group ("CELADON_MART_4F" -> "CeladonMart4F")
|
||||
local function groupOf(map)
|
||||
local s = ""
|
||||
for part in tostring(map):gmatch("[^_]+") do
|
||||
if part:match("^%d") then
|
||||
s = s .. part:upper()
|
||||
else
|
||||
s = s .. part:sub(1, 1):upper() .. part:sub(2):lower()
|
||||
end
|
||||
end
|
||||
return s
|
||||
end
|
||||
|
||||
-- every item sold on a map, across all its clerks
|
||||
local function stockFor(map)
|
||||
local group = T[groupOf(map)]
|
||||
if not group then return nil end
|
||||
local set, any = {}, false
|
||||
for _, v in pairs(group) do
|
||||
if type(v) == "table" and v.mart then
|
||||
for _, id in ipairs(v.mart) do set[id] = true; any = true end
|
||||
end
|
||||
end
|
||||
return any and set or nil
|
||||
end
|
||||
|
||||
-- ---- 1. our extracted stock matches pokered's marts.asm -----------------
|
||||
-- Spot-checks transcribed from data/items/marts.asm; the Vermilion row is
|
||||
-- the one the driver got wrong, so it is pinned exactly.
|
||||
local POKERED = {
|
||||
VERMILION_MART = { "POKE_BALL", "SUPER_POTION", "ICE_HEAL", "AWAKENING",
|
||||
"PARLYZ_HEAL", "REPEL" },
|
||||
PEWTER_MART = { "POKE_BALL", "POTION", "ESCAPE_ROPE", "ANTIDOTE",
|
||||
"BURN_HEAL", "AWAKENING", "PARLYZ_HEAL" },
|
||||
LAVENDER_MART = { "GREAT_BALL", "SUPER_POTION", "REVIVE", "ESCAPE_ROPE",
|
||||
"SUPER_REPEL", "ANTIDOTE", "BURN_HEAL", "ICE_HEAL",
|
||||
"PARLYZ_HEAL" },
|
||||
}
|
||||
for map, want in pairs(POKERED) do
|
||||
local sold = stockFor(map)
|
||||
check(sold ~= nil, map .. " has mart data")
|
||||
if sold then
|
||||
for _, id in ipairs(want) do
|
||||
check(sold[id], ("%s stocks %s (marts.asm)"):format(map, id))
|
||||
end
|
||||
end
|
||||
end
|
||||
-- and the absence that caused the bug
|
||||
check(not (stockFor("VERMILION_MART") or {}).POTION,
|
||||
"VERMILION_MART sells SUPER_POTION and NOT plain POTION")
|
||||
|
||||
-- ---- 2. every driver shopping list is actually stocked ------------------
|
||||
-- Mirrors SHOP_STOCK in tests/drivers/route.lua. Kept as a literal rather
|
||||
-- than reached into the driver, which needs a live Game to load.
|
||||
local SHOP_STOCK = {
|
||||
viridianBalls = { "POKE_BALL", "ANTIDOTE", "PARLYZ_HEAL" },
|
||||
pewter = { "ESCAPE_ROPE", "POTION" },
|
||||
vermilion = { "SUPER_POTION", "POKE_BALL" },
|
||||
repels = { "SUPER_POTION" },
|
||||
buffs = {},
|
||||
pokeDoll = { "POKE_DOLL" },
|
||||
tm07 = {}, vending = {}, water = {},
|
||||
}
|
||||
|
||||
local R = dofile("tests/drivers/bot_route.lua")
|
||||
local checked = 0
|
||||
for i, seg in ipairs(R) do
|
||||
for _, step in ipairs(seg.steps) do
|
||||
if step.op == "shop" then
|
||||
local list = SHOP_STOCK[step.list]
|
||||
-- an unknown list name would silently fall back to POTION at runtime
|
||||
check(list ~= nil,
|
||||
("segment %d: shop list %q is known to the driver")
|
||||
:format(i, tostring(step.list)))
|
||||
local sold = stockFor(seg.map)
|
||||
for _, id in ipairs(list or {}) do
|
||||
checked = checked + 1
|
||||
check(sold and sold[id],
|
||||
("segment %d: %s sells %s"):format(i, seg.map, id))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
check(checked > 0, "the route actually contains shop steps to check")
|
||||
|
||||
S.finish()
|
||||
@@ -0,0 +1,120 @@
|
||||
-- Parity test: the Saffron gate guards take the drink from the COORD
|
||||
-- TRIGGER, not only when talked to.
|
||||
--
|
||||
-- Route5GateDefaultScript (scripts/Route5Gate.asm) runs
|
||||
--
|
||||
-- farcall RemoveGuardDrink
|
||||
-- ldh a, [hItemToRemoveID]
|
||||
-- and a
|
||||
-- jr nz, .have_drink
|
||||
--
|
||||
-- before it decides anything, so simply stepping onto the trigger while
|
||||
-- carrying FRESH_WATER / SODA_POP / LEMONADE hands it over and sets
|
||||
-- BIT_GAVE_SAFFRON_GUARDS_DRINK. Only a player carrying none of the three
|
||||
-- gets the "Gee, I'm thirsty" line and the walk-back.
|
||||
--
|
||||
-- Our port had the removal on the guard's talk handler alone, so walking up
|
||||
-- with a drink in the bag was turned away and all four gates stayed shut
|
||||
-- unless the player happened to talk to him -- which vanilla never asks
|
||||
-- for. Saffron is the middle of the map, so this sealed the city: Celadon
|
||||
-- <-> Lavender and the short Vermilion <-> Cerulean crossing both route
|
||||
-- through it, and the route bot could not reach Lavender for the POKE_FLUTE
|
||||
-- at all ("travelTo: no route to MR_FUJIS_HOUSE").
|
||||
--
|
||||
-- Self-contained; run via `luajit tests/parity_saffron_gate.lua`.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
local S = require("tests.harness").suite("parity saffron gate")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
-- The gate pushes real TextBoxes, which want a loaded Font atlas; the
|
||||
-- decision under test is which branch runs, not how the text renders.
|
||||
package.loaded["src.render.TextBox"] = {
|
||||
new = function(_, text, done) return { text = text, done = done } end,
|
||||
}
|
||||
|
||||
local M = dofile("data/scripts/story2.lua")
|
||||
|
||||
-- Enough of a game for the gate's onStep: an inventory, flags, a stack that
|
||||
-- records what was pushed, and a player that records being shoved back.
|
||||
local function gameWith(inventory)
|
||||
local pushed, moved = {}, {}
|
||||
return {
|
||||
save = { inventory = inventory, flags = {} },
|
||||
data = { text = {} },
|
||||
stack = { push = function(_, box) pushed[#pushed + 1] = box end },
|
||||
_pushed = pushed,
|
||||
_moved = moved,
|
||||
}, pushed, moved
|
||||
end
|
||||
|
||||
local function owWith(moved)
|
||||
return {
|
||||
player = { facing = "up" },
|
||||
scriptMove = function(_, _, dir, n) moved[#moved + 1] = { dir, n } end,
|
||||
}
|
||||
end
|
||||
|
||||
-- ROUTE_5_GATE's trigger cells are (3,3) and (4,3) (.PlayerInCoordsArray).
|
||||
local gate = M.ROUTE_5_GATE
|
||||
check(gate ~= nil and gate.onStep ~= nil, "ROUTE_5_GATE has a coord trigger")
|
||||
|
||||
-- Carrying a drink: the trigger takes it and lets us through.
|
||||
for _, drink in ipairs({ "FRESH_WATER", "SODA_POP", "LEMONADE" }) do
|
||||
local game, pushed, moved = gameWith({ [drink] = 1 })
|
||||
local ow = owWith(moved)
|
||||
local handled = gate.onStep(game, ow, 3, 3)
|
||||
check(handled, drink .. ": stepping on the trigger is handled")
|
||||
check(game.save.flags.EVENT_GAVE_GUARDS_DRINK,
|
||||
drink .. ": the guards are marked as having been given a drink")
|
||||
eq(game.save.inventory[drink], nil, drink .. ": exactly one was removed")
|
||||
check(#pushed == 1, drink .. ": the thanks text is shown")
|
||||
check(#moved == 0, drink .. ": we are NOT walked back")
|
||||
end
|
||||
|
||||
-- Only ONE drink is taken, even holding several.
|
||||
do
|
||||
local game = gameWith({ FRESH_WATER = 2, LEMONADE = 1 })
|
||||
gate.onStep(game, owWith({}), 3, 3)
|
||||
eq(game.save.inventory.FRESH_WATER, 1, "only one drink is consumed")
|
||||
eq(game.save.inventory.LEMONADE, 1, "the other drinks are untouched")
|
||||
end
|
||||
|
||||
-- Carrying nothing: thirsty line, and we get walked back the way we came.
|
||||
do
|
||||
local game, pushed, moved = gameWith({})
|
||||
local ow = owWith(moved)
|
||||
local handled = gate.onStep(game, ow, 3, 3)
|
||||
check(handled, "no drink: the trigger still fires")
|
||||
check(not game.save.flags.EVENT_GAVE_GUARDS_DRINK,
|
||||
"no drink: the flag is NOT set")
|
||||
check(#pushed == 1, "no drink: the thirsty text is shown")
|
||||
-- the shove happens when the text box closes, not while it is up
|
||||
if pushed[1] and pushed[1].done then pushed[1].done() end
|
||||
check(#moved == 1 and moved[1][1] == "down",
|
||||
"no drink: we are walked back the way we came")
|
||||
end
|
||||
|
||||
-- Once given, the gate is open for good and stops triggering.
|
||||
do
|
||||
local game, pushed = gameWith({})
|
||||
game.save.flags.EVENT_GAVE_GUARDS_DRINK = true
|
||||
eq(gate.onStep(game, owWith({}), 3, 3), false,
|
||||
"after the drink the trigger no longer blocks")
|
||||
check(#pushed == 0, "and shows nothing")
|
||||
end
|
||||
|
||||
-- Cells that are not trigger cells are ignored.
|
||||
do
|
||||
local game = gameWith({ FRESH_WATER = 1 })
|
||||
eq(gate.onStep(game, owWith({}), 9, 9), false, "a non-trigger cell is ignored")
|
||||
eq(game.save.inventory.FRESH_WATER, 1, "and takes no drink")
|
||||
end
|
||||
|
||||
-- All four gates carry the same behaviour -- one drink opens every one.
|
||||
for _, id in ipairs({ "ROUTE_5_GATE", "ROUTE_6_GATE",
|
||||
"ROUTE_7_GATE", "ROUTE_8_GATE" }) do
|
||||
check(M[id] and M[id].onStep, id .. " has the guard trigger")
|
||||
end
|
||||
|
||||
S.finish()
|
||||
@@ -0,0 +1,95 @@
|
||||
-- Regression: a plan must never be blocked by another map's NPCs.
|
||||
--
|
||||
-- The route driver's BFS treats every entity as a wall, keyed by a folded
|
||||
-- cell id (`y * width + x`). A warp swaps the map id before the entity list
|
||||
-- is rebuilt, so a plan made in that window sees the PREVIOUS map's NPCs --
|
||||
-- and folding hides how wrong that is. On an 8-wide gate, a forest NPC at
|
||||
-- (16,43) folds to 43*8+16 = 360, which is a perfectly ordinary cell of the
|
||||
-- gate; the wall lands somewhere innocent and nothing looks amiss.
|
||||
--
|
||||
-- Observed as: "goto (4,1) unreachable on VIRIDIAN_FOREST_NORTH_GATE; from
|
||||
-- (2,1); npcs: SPRITE_YOUNGSTER@(16,43) SPRITE_YOUNGSTER@(30,33)
|
||||
-- SPRITE_POKE_BALL@(12,29)" -- Viridian Forest coordinates listed against a
|
||||
-- gate the size of a room. Every following segment skipped and the attempt
|
||||
-- was lost.
|
||||
--
|
||||
-- The fix is to ignore any entity outside the current map's bounds. This
|
||||
-- pins the folding arithmetic that makes the bug invisible, so a future
|
||||
-- reader can see why the bounds check is not merely defensive.
|
||||
--
|
||||
-- Self-contained; run via `luajit tests/parity_stale_npc.lua`.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
local S = require("tests.harness").suite("parity stale npc")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
-- the driver's blocking rule, isolated: fold a cell, wall it, but only for
|
||||
-- entities that are actually on this map
|
||||
local function blockedCells(npcs, w, h)
|
||||
local blocked = {}
|
||||
for _, npc in ipairs(npcs) do
|
||||
if npc.x >= 0 and npc.y >= 0 and npc.x < w and npc.y < h then
|
||||
blocked[npc.y * w + npc.x] = true
|
||||
end
|
||||
end
|
||||
return blocked
|
||||
end
|
||||
|
||||
-- VIRIDIAN_FOREST_NORTH_GATE is 8 cells wide; the forest is far larger.
|
||||
local GATE_W, GATE_H = 8, 8
|
||||
|
||||
-- The arithmetic that made this invisible: a foreign NPC folds onto a real
|
||||
-- cell of the small map rather than into obvious nonsense.
|
||||
eq(43 * GATE_W + 16, 360, "forest (16,43) folds onto a plain integer id")
|
||||
eq(1 * GATE_W + 4, 12, "the gate's own (4,1) folds to 12")
|
||||
|
||||
-- Without the bounds check, foreign NPCs wall off cells of the gate.
|
||||
do
|
||||
local naive = {}
|
||||
for _, n in ipairs({ { x = 16, y = 43 }, { x = 30, y = 33 }, { x = 12, y = 29 } }) do
|
||||
naive[n.y * GATE_W + n.x] = true
|
||||
end
|
||||
check(next(naive) ~= nil,
|
||||
"unguarded, off-map NPCs still produce blocked cell ids")
|
||||
end
|
||||
|
||||
-- With it, they are ignored entirely.
|
||||
do
|
||||
local blocked = blockedCells({
|
||||
{ x = 16, y = 43 }, { x = 30, y = 33 }, { x = 12, y = 29 },
|
||||
{ x = 2, y = 18 }, { x = 27, y = 40 },
|
||||
}, GATE_W, GATE_H)
|
||||
check(next(blocked) == nil,
|
||||
"every off-map NPC is ignored on the gate")
|
||||
end
|
||||
|
||||
-- ...while the map's own NPCs still block, which is the whole point.
|
||||
do
|
||||
local blocked = blockedCells({
|
||||
{ x = 3, y = 2 }, -- SPRITE_SUPER_NERD, really in the gate
|
||||
{ x = 2, y = 5 }, -- SPRITE_GRAMPS
|
||||
{ x = 16, y = 43 }, -- stale, from the forest
|
||||
}, GATE_W, GATE_H)
|
||||
check(blocked[2 * GATE_W + 3], "an NPC inside the gate still blocks its cell")
|
||||
check(blocked[5 * GATE_W + 2], "and so does the second one")
|
||||
local n = 0
|
||||
for _ in pairs(blocked) do n = n + 1 end
|
||||
eq(n, 2, "exactly the two real NPCs block -- the stale one does not")
|
||||
end
|
||||
|
||||
-- Edge cases of the bound itself.
|
||||
do
|
||||
local blocked = blockedCells({
|
||||
{ x = GATE_W - 1, y = GATE_H - 1 }, -- last legal cell
|
||||
{ x = GATE_W, y = 0 }, -- one past the right edge
|
||||
{ x = 0, y = GATE_H }, -- one past the bottom
|
||||
{ x = -1, y = 0 }, -- negative
|
||||
}, GATE_W, GATE_H)
|
||||
check(blocked[(GATE_H - 1) * GATE_W + (GATE_W - 1)],
|
||||
"the far corner is in bounds and blocks")
|
||||
local n = 0
|
||||
for _ in pairs(blocked) do n = n + 1 end
|
||||
eq(n, 1, "off-by-one and negative coordinates are all rejected")
|
||||
end
|
||||
|
||||
S.finish()
|
||||
@@ -0,0 +1,190 @@
|
||||
-- Parity test, Viridian City's two old men + the Pokédex object swap.
|
||||
--
|
||||
-- pokered has TWO old men on this map (data/maps/objects/ViridianCity.asm):
|
||||
--
|
||||
-- object_event 18, 9, SPRITE_GAMBLER_ASLEEP, STAY, NONE, ..._OLD_MAN_SLEEPY
|
||||
-- object_event 17, 5, SPRITE_GAMBLER, WALK, LEFT_RIGHT, ..._OLD_MAN
|
||||
--
|
||||
-- The sleeper only ever grumbles "private property" and shoves you back
|
||||
-- down; he never wakes, moves or hides. The coffee ask and the catch
|
||||
-- tutorial belong to the walking man, who starts OFF
|
||||
-- (data/maps/toggleable_objects.asm) and is swapped in for the sleeper
|
||||
-- when Oak hands over the Pokédex (scripts/OaksLab.asm:602-606). The
|
||||
-- north corridor is gated on EVENT_GOT_POKEDEX at exactly (19,9)
|
||||
-- (ViridianCityCheckGotPokedexScript), not on either man's visibility.
|
||||
--
|
||||
-- All three of those were wrong at once: the port merged both men into
|
||||
-- the sleeper, never ran the swap (so the walking man stayed hidden for
|
||||
-- the entire game), and gated the corridor on the sleeper being hidden --
|
||||
-- which made talking to him and answering "yes, I'm in a hurry" the only
|
||||
-- way out of Viridian. Self-contained; run via `luajit tests/parity_viridian.lua`.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
local Data = require("src.core.Data")
|
||||
if not (Data.maps and Data.maps.VIRIDIAN_CITY) then Data:load() end
|
||||
-- The gate builds a real TextBox, which wants a loaded Font atlas we have
|
||||
-- no graphics device for. The hook requires it lazily, so a stub in
|
||||
-- package.loaded is enough to exercise the branch headlessly -- we only
|
||||
-- care that the step was blocked and that a box was pushed, not what it
|
||||
-- rendered (parity_flavor already covers the text labels themselves).
|
||||
package.loaded["src.render.TextBox"] = {
|
||||
new = function(_, text, onDone) return { text = text, onDone = onDone } end,
|
||||
}
|
||||
local init = require("data.scripts.init")
|
||||
local S = require("tests.harness").suite("parity viridian")
|
||||
local check = S.check
|
||||
|
||||
local function rowsOf(script)
|
||||
return type(script) == "table" and script or nil
|
||||
end
|
||||
|
||||
-- find the first row whose command is `cmd`; returns index, row
|
||||
local function findRow(rows, cmd, arg2)
|
||||
for i, row in ipairs(rows or {}) do
|
||||
if row[1] == cmd and (arg2 == nil or row[2] == arg2) then return i, row end
|
||||
end
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- (1) the sleeper is text-and-shove only
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
local sleepy = rowsOf(init.talkScript("VIRIDIAN_CITY", "TEXT_VIRIDIANCITY_OLD_MAN_SLEEPY"))
|
||||
check(sleepy ~= nil, "sleeper resolves to a row-list script")
|
||||
if sleepy then
|
||||
check(findRow(sleepy, "show_text", "_ViridianCityOldManSleepyPrivatePropertyText") ~= nil,
|
||||
"sleeper shows the private-property text")
|
||||
check(findRow(sleepy, "move_player") ~= nil, "sleeper shoves the player back")
|
||||
-- the bugs: his script must NOT own the other man's dialogue, and must
|
||||
-- never hide himself (pokered's HideObject fires from Oak's lab instead)
|
||||
check(findRow(sleepy, "ask") == nil, "sleeper does not ask about coffee")
|
||||
check(findRow(sleepy, "old_man_demo") == nil, "sleeper does not run the catch demo")
|
||||
check(findRow(sleepy, "hide_object") == nil, "sleeper never hides himself")
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- (2) the walking old man owns the coffee ask + the real catch tutorial
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
local oldMan = rowsOf(init.talkScript("VIRIDIAN_CITY", "TEXT_VIRIDIANCITY_OLD_MAN"))
|
||||
-- a Lua function handler here would mean data/scripts/flavor/viridian_city.lua
|
||||
-- (which loads AFTER story.lua) had silently won the talk-table merge back
|
||||
check(oldMan ~= nil, "walking old man resolves to a row-list script, not a handler")
|
||||
if oldMan then
|
||||
local askAt = findRow(oldMan, "ask", "_ViridianCityOldManHadMyCoffeeNowText")
|
||||
check(askAt ~= nil, "walking old man asks the coffee question")
|
||||
check(findRow(oldMan, "old_man_demo") ~= nil, "walking old man runs the catch demo")
|
||||
-- polarity: the question is "Are you in a hurry?", so YES is the refusal
|
||||
-- (ViridianCityOldManText: `and a / jr z, .refused` -- wCurrentMenuItem 0
|
||||
-- is YES). jump_if_true must therefore land on the "Time is money" line.
|
||||
local jAt, jRow = findRow(oldMan, "jump_if_true")
|
||||
check(jAt ~= nil and askAt ~= nil and jAt > askAt, "the yes/no branch follows the ask")
|
||||
if jRow then
|
||||
local target = oldMan[jRow[2]]
|
||||
check(target ~= nil and target[1] == "show_text"
|
||||
and target[2] == "_ViridianCityOldManTimeIsMoneyText",
|
||||
"YES (in a hurry) brushes you off rather than starting the demo")
|
||||
end
|
||||
-- pokered prints this AFTER the demo battle (EndCatchTrainingScript)
|
||||
local demoAt = findRow(oldMan, "old_man_demo")
|
||||
local weakenAt = findRow(oldMan, "show_text", "_ViridianCityOldManYouNeedToWeakenTheTargetText")
|
||||
check(demoAt and weakenAt and weakenAt > demoAt,
|
||||
"the weaken-the-target line comes after the demo, not before it")
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- (3) the Pokédex performs the swap (OaksLab.asm:602-606)
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
local oak1 = rowsOf(init.talkScript("OAKS_LAB", "TEXT_OAKSLAB_OAK1"))
|
||||
check(oak1 ~= nil, "Oak's main script resolves")
|
||||
if oak1 then
|
||||
local dexAt = findRow(oak1, "set_flag", "EVENT_GOT_POKEDEX")
|
||||
check(dexAt ~= nil, "Oak sets EVENT_GOT_POKEDEX")
|
||||
local hideAt = findRow(oak1, "hide_object", "VIRIDIAN_CITY")
|
||||
local showAt = findRow(oak1, "show_object", "VIRIDIAN_CITY")
|
||||
check(hideAt ~= nil, "the Pokédex hides the Viridian sleeper")
|
||||
check(showAt ~= nil, "the Pokédex shows the walking Viridian old man")
|
||||
if hideAt then
|
||||
check(oak1[hideAt][3] == "VIRIDIANCITY_OLD_MAN_SLEEPY", "hides the right object")
|
||||
end
|
||||
if showAt then
|
||||
check(oak1[showAt][3] == "VIRIDIANCITY_OLD_MAN", "shows the right object")
|
||||
end
|
||||
check(dexAt and hideAt and showAt and hideAt > dexAt and showAt > dexAt,
|
||||
"the swap runs on the branch that grants the Pokédex")
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- (4) every jump target in the touched scripts is in range
|
||||
--
|
||||
-- Inserting the two swap rows renumbered Oak's whole 30-row jump table.
|
||||
-- An off-by-one there is invisible until a branch silently runs the wrong
|
||||
-- line, so check every target lands on a real row (or the end sentinel,
|
||||
-- #rows + 1) across both files.
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
local JUMPS = { jump = true, jump_if_true = true, jump_if_false = true }
|
||||
local checkedJumps = 0
|
||||
for _, modname in ipairs({ "data.scripts.oaks_lab", "data.scripts.story" }) do
|
||||
local mod = require(modname)
|
||||
-- oaks_lab returns one map's table; story returns { [mapId] = table }
|
||||
local maps = mod.talk and { [modname] = mod } or mod
|
||||
for mapId, m in pairs(maps) do
|
||||
if type(m) == "table" and m.talk then
|
||||
for const, script in pairs(m.talk) do
|
||||
local rows = rowsOf(script)
|
||||
if rows then
|
||||
for i, row in ipairs(rows) do
|
||||
if type(row) == "table" and JUMPS[row[1]] then
|
||||
local t = row[2]
|
||||
checkedJumps = checkedJumps + 1
|
||||
check(type(t) == "number" and t >= 1 and t <= #rows + 1,
|
||||
("%s/%s row %d: %s -> %s in range"):format(
|
||||
mapId, const, i, tostring(row[1]), tostring(t)))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
check(checkedJumps > 0, "found jump rows to range-check (got " .. checkedJumps .. ")")
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- (5) the corridor gate keys off EVENT_GOT_POKEDEX at (19,9)
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
local onStep = init.get("VIRIDIAN_CITY").onStep
|
||||
check(type(onStep) == "function", "VIRIDIAN_CITY has an onStep hook")
|
||||
|
||||
local function step(flags, x, y)
|
||||
local pushed = 0
|
||||
local game = {
|
||||
save = { flags = flags, inventory = {}, objectToggles = {} },
|
||||
data = Data,
|
||||
stack = { push = function() pushed = pushed + 1 end },
|
||||
}
|
||||
local ow = { player = {}, scriptMove = function() end }
|
||||
local ok, blocked = pcall(onStep, game, ow, x, y)
|
||||
return ok, blocked, pushed
|
||||
end
|
||||
|
||||
if type(onStep) == "function" then
|
||||
local ok, blocked, pushed = step({}, 19, 9)
|
||||
check(ok, "onStep runs at the gate cell")
|
||||
check(ok and blocked == true, "(19,9) is blocked without the Pokédex")
|
||||
check(ok and pushed == 1, "being blocked shows a text box")
|
||||
|
||||
local ok2, blocked2 = step({ EVENT_GOT_POKEDEX = true }, 19, 9)
|
||||
check(ok2 and blocked2 ~= true, "(19,9) is walkable once you have the Pokédex")
|
||||
|
||||
-- the old port blocked the whole 3-wide corridor (x 17-19, y<=8); pokered
|
||||
-- blocks one cell, and the sleeper/girl bodies do the rest
|
||||
local ok3, blocked3 = step({}, 19, 8)
|
||||
check(ok3 and blocked3 ~= true, "(19,8) north of the gate is not itself gated")
|
||||
local ok4, blocked4 = step({}, 17, 8)
|
||||
check(ok4 and blocked4 ~= true, "(17,8) is not gated (only (19,9) triggers)")
|
||||
end
|
||||
|
||||
S.finish()
|
||||
+40
-6
@@ -719,6 +719,30 @@ do
|
||||
eq(x, 8, "south gate lands on Route 22 gate warp x")
|
||||
eq(y, 5, "south gate lands on Route 22 gate warp y")
|
||||
end
|
||||
-- UndergroundPathRoute{5,6,7,8}_Script force wLastMap to their own route on
|
||||
-- map load, so crossing the tunnel and taking the far building's LAST_MAP exit
|
||||
-- lands you on that route rather than the one you entered from (issue #1)
|
||||
do
|
||||
local OW = require("src.world.OverworldController")
|
||||
local FieldDefaults = require("src.world.FieldDefaults")
|
||||
local rewrites = FieldDefaults.field(Data, "lastMapRewrites")
|
||||
local cases = {
|
||||
{ map = "UNDERGROUND_PATH_ROUTE_5", route = "ROUTE_5", x = 17, y = 27 },
|
||||
{ map = "UNDERGROUND_PATH_ROUTE_6", route = "ROUTE_6", x = 17, y = 13 },
|
||||
{ map = "UNDERGROUND_PATH_ROUTE_7", route = "ROUTE_7", x = 5, y = 13 },
|
||||
{ map = "UNDERGROUND_PATH_ROUTE_8", route = "ROUTE_8", x = 13, y = 3 },
|
||||
}
|
||||
for _, c in ipairs(cases) do
|
||||
local rewrite = rewrites[c.map]
|
||||
check(rewrite ~= nil, c.map .. " rewrites wLastMap to its own route")
|
||||
eq(OW.rewrittenLastMap(rewrite, 0, 0), c.route, c.map .. " -> " .. c.route)
|
||||
local door = Data.maps[c.map].warps[1]
|
||||
local m, x, y = Warp.destination(Data, door, { id = c.route, x = 0, y = 0 })
|
||||
eq(m, c.route, c.map .. " door exits onto " .. c.route)
|
||||
eq(x, c.x, c.map .. " door lands on the " .. c.route .. " entrance x")
|
||||
eq(y, c.y, c.map .. " door lands on the " .. c.route .. " entrance y")
|
||||
end
|
||||
end
|
||||
eq(Data.field.forcedMovement.slopeMaps[1], "ROUTE_17", "Cycling Road slope map")
|
||||
check(Data.field.seafoam.SEAFOAM_ISLANDS_B3F.currents[1].moves[1] ~= nil,
|
||||
"Seafoam B3F current movement extracted")
|
||||
@@ -1948,6 +1972,7 @@ do
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local Tilt = require("src.render.Tilt")
|
||||
local GBCFX = require("src.render.GBCFX")
|
||||
local GameSpeed = require("src.core.GameSpeed")
|
||||
local SD = require("src.core.SaveData")
|
||||
-- Isolate from earlier save/options writes in this suite
|
||||
SD.saveOptions(SD.defaultOptions())
|
||||
@@ -2005,19 +2030,28 @@ do
|
||||
for _ = 1, 4 do press("a") end
|
||||
eq(og.save.options.gbcfx, 0, "GBC FX wraps back to OFF")
|
||||
press("down")
|
||||
eq(om.index, 11, "cursor reaches MODS")
|
||||
eq(om.index, 11, "cursor reaches GAME SPEED")
|
||||
press("a")
|
||||
eq(og.save.options.speed, 2, "A cycles GAME SPEED to 2X")
|
||||
-- Driven by the level list rather than a literal press count: adding a
|
||||
-- speed (20X went in for the bot runs) otherwise fails this as a wrap
|
||||
-- bug when the cycling is fine and the row is simply one longer.
|
||||
for _ = 1, #GameSpeed.LEVELS - 1 do press("a") end
|
||||
eq(og.save.options.speed, 1, "GAME SPEED wraps back to NORMAL")
|
||||
press("down")
|
||||
eq(om.index, 12, "cursor reaches CONTROLS")
|
||||
eq(om.index, 12, "cursor reaches MODS")
|
||||
press("down")
|
||||
eq(om.index, 13, "CANCEL stays the fixed final row")
|
||||
eq(om.scroll, 8, "CANCEL keeps the last option boxes on screen")
|
||||
eq(om.index, 13, "cursor reaches CONTROLS")
|
||||
press("down")
|
||||
eq(om.index, 14, "CANCEL stays the fixed final row")
|
||||
eq(om.scroll, 9, "CANCEL keeps the last option boxes on screen")
|
||||
om:draw() -- smoke: scrolled layout draws under the headless stub
|
||||
press("a")
|
||||
check(popped, "A on CANCEL closes the options menu")
|
||||
local om2 = OptionsMenu.new(og)
|
||||
OInput.pressed = { up = true }; om2:update(1 / 60); OInput.pressed = {}
|
||||
eq(om2.index, 13, "up from the top wraps to CANCEL")
|
||||
eq(om2.scroll, 8, "wrapping to CANCEL scrolls to the tail")
|
||||
eq(om2.index, 14, "up from the top wraps to CANCEL")
|
||||
eq(om2.scroll, 9, "wrapping to CANCEL scrolls to the tail")
|
||||
-- headless-safe: no love.audio, setters only update internal state
|
||||
require("src.core.Music").applyOptions(og.save.options)
|
||||
require("src.core.Sound").applyOptions(og.save.options)
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
-- PokeBotBad route converter.
|
||||
--
|
||||
-- luajit tools/botconv/convert.lua <PokeBotBad checkout> [out.lua]
|
||||
--
|
||||
-- Reads PokeBotBad's data/red/paths.lua and emits a route data file for
|
||||
-- the recomp: numeric pokered map ids become our string map names, tile
|
||||
-- waypoints pass through unchanged (the coordinate systems are
|
||||
-- identical), and every strategy/control step is classified by
|
||||
-- table.lua into a generic op, a battle, a manual stub, or nothing.
|
||||
--
|
||||
-- The output is data, not code. tests/drivers/route.lua interprets it
|
||||
-- against the live Game object. Anything table.lua does not cover is a
|
||||
-- hard error -- the converter never silently drops a step.
|
||||
|
||||
local BOT = ... or arg[1]
|
||||
assert(BOT, "usage: luajit tools/botconv/convert.lua <PokeBotBad checkout> [out]")
|
||||
local OUT = arg[2] or "tests/drivers/bot_route.lua"
|
||||
|
||||
local TBL = dofile("tools/botconv/table.lua")
|
||||
local mapOrder = dofile("data/generated/constants.lua").mapOrder
|
||||
|
||||
setmetatable(_G, { __index = function() return 0 end }) -- speedrun-only globals
|
||||
local Paths = assert(loadfile(BOT .. "/data/red/paths.lua"))()
|
||||
|
||||
local stats = {
|
||||
waypoint = 0, dropped = 0, battle = 0, verb = 0, manual = 0, control = 0,
|
||||
}
|
||||
local manualSeen, unknown = {}, {}
|
||||
|
||||
local function face(v) return TBL.face[v] or v end
|
||||
|
||||
-- Translate one {s=...} / {c=...} step into an op, or nil to drop it.
|
||||
local function convertStep(step)
|
||||
if step.s then
|
||||
local name = step.s
|
||||
if TBL.drop[name] then
|
||||
stats.dropped = stats.dropped + 1
|
||||
return nil
|
||||
end
|
||||
if TBL.battle[name] then
|
||||
stats.battle = stats.battle + 1
|
||||
return { op = "battle", face = step.dir and face(step.dir) or nil }
|
||||
end
|
||||
if TBL.manual[name] then
|
||||
stats.manual = stats.manual + 1
|
||||
manualSeen[name] = (manualSeen[name] or 0) + 1
|
||||
return { op = "manual", name = name }
|
||||
end
|
||||
local v = TBL.verb[name]
|
||||
if v then
|
||||
stats.verb = stats.verb + 1
|
||||
local out = { op = v.op }
|
||||
for k, val in pairs(v.fixed or {}) do out[k] = val end
|
||||
for botKey, ourKey in pairs(v.params or {}) do
|
||||
local val = step[botKey]
|
||||
if val ~= nil then
|
||||
out[ourKey] = (ourKey == "face") and face(val) or val
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
unknown[#unknown + 1] = "s=" .. name
|
||||
return nil
|
||||
end
|
||||
|
||||
local name = step.c
|
||||
if TBL.controlDrop[name] then
|
||||
stats.dropped = stats.dropped + 1
|
||||
return nil
|
||||
end
|
||||
local cv = TBL.controlVerb[name]
|
||||
if cv then
|
||||
stats.control = stats.control + 1
|
||||
local out = { op = cv.op }
|
||||
if cv.mon ~= nil then out.mon = cv.mon end
|
||||
return out
|
||||
end
|
||||
unknown[#unknown + 1] = "c=" .. name
|
||||
return nil
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
-- PokeBotBad advances its path list sequentially whenever the map changes
|
||||
-- (action/walk.lua:91-93) and only consults entry[1] when re-syncing after
|
||||
-- a reset, so a wrong map id there never breaks its run -- and some are
|
||||
-- wrong. Fix the ones we have verified rather than importing the typo:
|
||||
-- entry 2 is labelled "Red's house" but carries 39 (BLUES_HOUSE); its
|
||||
-- waypoints continue from Red's stairs, so it is REDS_HOUSE_1F (37).
|
||||
local MAP_FIXUPS = { [2] = 37 }
|
||||
|
||||
local route = {}
|
||||
|
||||
for idx, entry in ipairs(Paths) do
|
||||
local mapId = MAP_FIXUPS[idx] or entry[1]
|
||||
local mapName = mapOrder[mapId + 1]
|
||||
assert(mapName, ("unmapped pokered map id %d"):format(mapId))
|
||||
|
||||
local steps = {}
|
||||
for j = 2, #entry do
|
||||
local step = entry[j]
|
||||
if step.s or step.c then
|
||||
local op = convertStep(step)
|
||||
if op then steps[#steps + 1] = op end
|
||||
else
|
||||
-- a bare {x,y} waypoint. Negative coords are the route's idiom for
|
||||
-- "walk off the map edge into the connecting map"; the runtime
|
||||
-- clamps into the connection rather than pathfinding to a cell that
|
||||
-- does not exist.
|
||||
stats.waypoint = stats.waypoint + 1
|
||||
steps[#steps + 1] = { op = "goto", x = step[1], y = step[2] }
|
||||
end
|
||||
end
|
||||
route[#route + 1] = { map = mapName, steps = steps }
|
||||
end
|
||||
|
||||
if #unknown > 0 then
|
||||
io.stderr:write("unclassified steps (add them to tools/botconv/table.lua):\n")
|
||||
local seen = {}
|
||||
for _, u in ipairs(unknown) do
|
||||
if not seen[u] then seen[u] = true; io.stderr:write(" " .. u .. "\n") end
|
||||
end
|
||||
os.exit(1)
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- emit
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
-- Param values are scalars, or a list of candidate strings -- the route
|
||||
-- writes poke={"oddish","paras"} for "teach this to whichever of these we
|
||||
-- actually caught". Anything else is a bug in table.lua's param mapping
|
||||
-- (an unquoted WRAM address, say) and must not reach the output as a
|
||||
-- stringified pointer.
|
||||
local function quote(v)
|
||||
local t = type(v)
|
||||
if t == "string" then return ("%q"):format(v) end
|
||||
if t == "number" or t == "boolean" then return tostring(v) end
|
||||
if t == "table" then
|
||||
local parts = {}
|
||||
for i, item in ipairs(v) do
|
||||
assert(type(item) == "string",
|
||||
("list param element %d is %s, expected string"):format(i, type(item)))
|
||||
parts[i] = ("%q"):format(item)
|
||||
end
|
||||
assert(#parts > 0, "empty list param")
|
||||
return "{ " .. table.concat(parts, ", ") .. " }"
|
||||
end
|
||||
error("unserializable param value of type " .. t)
|
||||
end
|
||||
|
||||
local buf = {
|
||||
"-- Generated by tools/botconv/convert.lua from PokeBotBad's any% route.",
|
||||
"-- Do not edit; edit tools/botconv/table.lua and regenerate.",
|
||||
"return {",
|
||||
}
|
||||
for _, seg in ipairs(route) do
|
||||
buf[#buf + 1] = (" { map = %q, steps = {"):format(seg.map)
|
||||
for _, s in ipairs(seg.steps) do
|
||||
local parts = {}
|
||||
local keys = {}
|
||||
for k in pairs(s) do if k ~= "op" then keys[#keys + 1] = k end end
|
||||
table.sort(keys)
|
||||
for _, k in ipairs(keys) do
|
||||
parts[#parts + 1] = ("%s = %s"):format(k, quote(s[k]))
|
||||
end
|
||||
buf[#buf + 1] = (" { op = %q%s },"):format(
|
||||
s.op, #parts > 0 and (", " .. table.concat(parts, ", ")) or "")
|
||||
end
|
||||
buf[#buf + 1] = " } },"
|
||||
end
|
||||
buf[#buf + 1] = "}"
|
||||
|
||||
local fh = assert(io.open(OUT, "w"))
|
||||
fh:write(table.concat(buf, "\n"), "\n")
|
||||
fh:close()
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- coverage report
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
print(("wrote %s (%d map segments)"):format(OUT, #route))
|
||||
print((" goto %4d"):format(stats.waypoint))
|
||||
print((" battle %4d"):format(stats.battle))
|
||||
print((" verb %4d"):format(stats.verb))
|
||||
print((" control %4d"):format(stats.control))
|
||||
print((" dropped %4d (speedrun / streaming only)"):format(stats.dropped))
|
||||
print((" manual %4d <- runtime handlers required"):format(stats.manual))
|
||||
|
||||
local names = {}
|
||||
for n in pairs(manualSeen) do names[#names + 1] = n end
|
||||
table.sort(names)
|
||||
print("\nhandlers tests/drivers/route.lua must implement:")
|
||||
for _, n in ipairs(names) do
|
||||
print((" %-20s x%d"):format(n, manualSeen[n]))
|
||||
end
|
||||
@@ -0,0 +1,123 @@
|
||||
-- Inventory pass for the PokeBotBad route converter.
|
||||
--
|
||||
-- Reads PokeBotBad's data/red/paths.lua and reports every distinct step
|
||||
-- shape it contains, so the conversion table is driven by what the route
|
||||
-- actually uses rather than by guesswork.
|
||||
--
|
||||
-- luajit tools/botconv/inventory.lua /path/to/PokeBotBad
|
||||
--
|
||||
-- Route entries look like:
|
||||
-- { <mapId>, {x,y}, {s="talk",dir="Up"}, {c="a",a="Brock's Gym"}, ... }
|
||||
-- entry[1] is a numeric pokered map id; the rest are steps.
|
||||
|
||||
local botRoot = ... or arg[1]
|
||||
assert(botRoot, "usage: luajit tools/botconv/inventory.lua <PokeBotBad checkout>")
|
||||
|
||||
-- paths.lua reads a few speedrun-only globals (client.speedmode targets).
|
||||
-- They are dropped by the converter, so any value will do.
|
||||
setmetatable(_G, { __index = function() return 0 end })
|
||||
|
||||
local pathsFile = botRoot .. "/data/red/paths.lua"
|
||||
local Paths = assert(loadfile(pathsFile))()
|
||||
|
||||
local mapOrder = dofile("data/generated/constants.lua").mapOrder
|
||||
|
||||
local strategies, controls = {}, {}
|
||||
local counts = { waypoint = 0, strategy = 0, control = 0, unknown = 0 }
|
||||
local unknownMaps, sections = {}, {}
|
||||
|
||||
local function note(bucket, name, step, section)
|
||||
local rec = bucket[name]
|
||||
if not rec then
|
||||
rec = { n = 0, params = {}, sections = {} }
|
||||
bucket[name] = rec
|
||||
end
|
||||
rec.n = rec.n + 1
|
||||
rec.sections[section] = true
|
||||
for k in pairs(step) do
|
||||
if k ~= "s" and k ~= "c" then rec.params[k] = true end
|
||||
end
|
||||
end
|
||||
|
||||
-- Section headers are comments, so recover them by scanning the source and
|
||||
-- counting which route entry each header precedes.
|
||||
local function sectionNames()
|
||||
local names, idx, fh = {}, 0, assert(io.open(pathsFile, "r"))
|
||||
local current = "0: INTRO"
|
||||
for line in fh:lines() do
|
||||
local header = line:match("^%-%-%s*(%d+:%s*.+)$")
|
||||
if header then current = header end
|
||||
if line:match("^%s*{%s*%-?%d+%s*,") then
|
||||
idx = idx + 1
|
||||
names[idx] = current
|
||||
end
|
||||
end
|
||||
fh:close()
|
||||
return names
|
||||
end
|
||||
|
||||
local names = sectionNames()
|
||||
|
||||
for i, entry in ipairs(Paths) do
|
||||
local mapId = entry[1]
|
||||
local section = names[i] or "?"
|
||||
sections[section] = true
|
||||
local mapName = mapOrder[mapId + 1]
|
||||
if not mapName then unknownMaps[mapId] = true end
|
||||
|
||||
for j = 2, #entry do
|
||||
local step = entry[j]
|
||||
if type(step) ~= "table" then
|
||||
counts.unknown = counts.unknown + 1
|
||||
elseif step.s then
|
||||
counts.strategy = counts.strategy + 1
|
||||
note(strategies, step.s, step, section)
|
||||
elseif step.c then
|
||||
counts.control = counts.control + 1
|
||||
note(controls, step.c, step, section)
|
||||
elseif type(step[1]) == "number" and type(step[2]) == "number" then
|
||||
counts.waypoint = counts.waypoint + 1
|
||||
else
|
||||
counts.unknown = counts.unknown + 1
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function dump(title, bucket)
|
||||
local keys = {}
|
||||
for k in pairs(bucket) do keys[#keys + 1] = k end
|
||||
table.sort(keys, function(a, b)
|
||||
if bucket[a].n ~= bucket[b].n then return bucket[a].n > bucket[b].n end
|
||||
return a < b
|
||||
end)
|
||||
print(("\n=== %s (%d distinct) ==="):format(title, #keys))
|
||||
for _, k in ipairs(keys) do
|
||||
local rec = bucket[k]
|
||||
local params = {}
|
||||
for p in pairs(rec.params) do params[#params + 1] = p end
|
||||
table.sort(params)
|
||||
local nsec = 0
|
||||
for _ in pairs(rec.sections) do nsec = nsec + 1 end
|
||||
print(("%-28s x%-4d sections:%-3d %s"):format(
|
||||
k, rec.n, nsec,
|
||||
#params > 0 and ("{" .. table.concat(params, ",") .. "}") or ""))
|
||||
end
|
||||
end
|
||||
|
||||
print(("route entries: %d sections: %d"):format(#Paths, (function()
|
||||
local n = 0; for _ in pairs(sections) do n = n + 1 end; return n
|
||||
end)()))
|
||||
print(("waypoints:%d strategies:%d controls:%d unknown:%d"):format(
|
||||
counts.waypoint, counts.strategy, counts.control, counts.unknown))
|
||||
|
||||
local bad = {}
|
||||
for id in pairs(unknownMaps) do bad[#bad + 1] = id end
|
||||
if #bad > 0 then
|
||||
table.sort(bad)
|
||||
print("UNMAPPED MAP IDS: " .. table.concat(bad, ", "))
|
||||
else
|
||||
print("all map ids resolve against data/generated/constants.lua mapOrder")
|
||||
end
|
||||
|
||||
dump("STRATEGIES", strategies)
|
||||
dump("CONTROLS", controls)
|
||||
@@ -0,0 +1,187 @@
|
||||
-- Classification table for the PokeBotBad route converter.
|
||||
--
|
||||
-- Every strategy/control name the route references maps to exactly one
|
||||
-- bucket. convert.lua errors on anything missing, so this file is the
|
||||
-- single place that decides what a PokeBotBad step becomes.
|
||||
--
|
||||
-- DROP speedrun or streaming only; no gameplay effect. Removing it
|
||||
-- cannot make the run unwinnable, only slower.
|
||||
-- BATTLE resolve the fight with the generic battle handler. We are not
|
||||
-- optimizing turns, so every named fight collapses to one op.
|
||||
-- VERB a generic parameterized action; params are rewritten below.
|
||||
-- MANUAL progression-critical and not inferable from the route data.
|
||||
-- Emitted as a stub op the runtime must implement, and listed in
|
||||
-- the coverage report.
|
||||
|
||||
local T = {}
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- DROP
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Timer splits, Twitch/LiveSplit bridge chatter, emulator speed control.
|
||||
-- Stat-boost item pickups (carbos/rare candy) exist to hit damage
|
||||
-- breakpoints on an optimal route; a bot that grinds normally outlevels
|
||||
-- the need. "redbar" deliberately parks a mon at low HP for the Gen 1
|
||||
-- low-HP speed trick and is actively harmful when not speedrunning.
|
||||
-- "dodge*" routes around trainer sight lines to skip fights -- we let
|
||||
-- those trainers engage and the generic battle handler takes them.
|
||||
local DROP = {
|
||||
"split", "splitBrock", "changeSpeed", "battleModeSet", "guess",
|
||||
"tweetBrock", "tweetMisty", "tweetSurge", "tweetVictoryRoad",
|
||||
"reportMtMoon", "announceMachop", "announceOddish", "announceVenonat",
|
||||
"epicCutscene", "centerSkip", "jingleSkip",
|
||||
"dodgeCerulean", "dodgePalletBoy", "dodgeDepartment", "dodgeGirl",
|
||||
"dodgeViridianOldMan",
|
||||
"redbarCubone", "redbarMankey",
|
||||
"cinnabarCarbos", "safariCarbos", "silphCarbos",
|
||||
"drivebyRareCandy", "rareCandyEarly", "rareCandyGiovanni",
|
||||
"tossInSafari", "tossInVictoryRoad",
|
||||
"swapXSpecials", "swapXSpeeds",
|
||||
-- pre-emptive heals sized to an optimal route; the runtime's own
|
||||
-- "heal when below threshold" rule supersedes all of them.
|
||||
"potionBeforeMisty", "potionBeforeCocoons", "potionBeforeHypno",
|
||||
"potionBeforeLorelei", "potionBeforeRaticate", "potionBeforeRocket",
|
||||
"potionBeforeShorts", "potionBeforeSurge", "potionForMankey",
|
||||
"extraFullRestore", "checkEther", "checkGiovanni",
|
||||
-- turn-by-turn battle tactics, superseded by the generic battle AI
|
||||
"thunderboltFirst", "fourTurnThrash", "thrashGeodude", "rivalSandAttack",
|
||||
"swapThrash", "fightGiovanniMachoke", "fightSilphMachoke",
|
||||
}
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- BATTLE
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Named fights. Each becomes {op="battle"} -- walk in, fight until the
|
||||
-- battle state pops. Rival/gym/E4 fights are all the same op; the route
|
||||
-- has already put us in front of the right trainer.
|
||||
local BATTLE = {
|
||||
"fightBrock", "fightMisty", "fightSurge", "fightErika", "fightKoga",
|
||||
"fightGiovanni", "fightSilphGiovanni", "fightBulbasaur", "fightMetapod",
|
||||
"fightWeedle", "fightGrimer", "fightHypno", "fightX",
|
||||
"lorelei", "bruno", "agatha", "lance", "blue", "champion",
|
||||
"viridianRival", "lavenderRival", "silphRival",
|
||||
"bugCatcher", "shortsKid", "digFight", "waitToFight",
|
||||
"hornAttackCaterpie", "catchFlierBackup",
|
||||
}
|
||||
-- NOTE: squirtleIChooseYou is NOT here. Despite sitting between two fights
|
||||
-- in the route it is the starter pick -- walk to the ball, press A, accept
|
||||
-- the prompt -- and is classified as a talk below.
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- VERB
|
||||
-- ---------------------------------------------------------------------
|
||||
-- name -> { op, params = { botKey = ourKey } }
|
||||
-- Params not listed are dropped. `face` values are rewritten from
|
||||
-- PokeBotBad's "Up"/"Down"/"Left"/"Right" to our "up"/"down"/"left"/"right".
|
||||
local VERB = {
|
||||
talk = { op = "talk", params = { dir = "face" } },
|
||||
waitToTalk = { op = "talk", params = { dir = "face" } },
|
||||
interact = { op = "talk", params = { dir = "face" } },
|
||||
dialogue = { op = "talk", params = { dir = "face", decline = "decline" } },
|
||||
take = { op = "pickup", params = { dir = "face" } },
|
||||
grabAntidote = { op = "pickup" },
|
||||
grabForestPotion = { op = "pickup" },
|
||||
grabMaxEther = { op = "pickup" },
|
||||
grabTreePotion = { op = "pickup" },
|
||||
bicycle = { op = "bike" },
|
||||
procureBicycle = { op = "talk" },
|
||||
-- the starter pick: face the ball, A, accept the prompt
|
||||
squirtleIChooseYou = { op = "talk" },
|
||||
fly = { op = "fly", params = { dest = "dest", map = "map" } },
|
||||
push = { op = "push", params = { dir = "face", x = "x", y = "y" } },
|
||||
teach = { op = "teach", params = { move = "move", poke = "mon", replace = "replace" } },
|
||||
-- these two name the move rather than passing it as a param
|
||||
teachThrash = { op = "teach", fixed = { move = "thrash" } },
|
||||
learnThrash = { op = "teach", fixed = { move = "thrash" } },
|
||||
swapMove = { op = "swapMove", params = { move = "move", to = "to" } },
|
||||
swap = { op = "swapItem", params = { item = "item", dest = "dest" } },
|
||||
item = { op = "useItem", params = { item = "item", poke = "mon", all = "all" } },
|
||||
potion = { op = "heal", params = { hp = "hp", full = "full" } },
|
||||
elixer = { op = "useItem", params = { move = "move" } },
|
||||
ether = { op = "useItem", params = { max = "max" } },
|
||||
hikerElixer = { op = "useItem" },
|
||||
lassEther = { op = "useItem" },
|
||||
undergroundElixer = { op = "useItem" },
|
||||
healParalysis = { op = "heal" },
|
||||
-- shops: the route knows the location, the runtime knows the list
|
||||
shopPewterMart = { op = "shop", fixed = { list = "pewter" } },
|
||||
shopViridianPokeballs = { op = "shop", fixed = { list = "viridianBalls" } },
|
||||
shopVermilionMart = { op = "shop", fixed = { list = "vermilion" } },
|
||||
shopRepels = { op = "shop", fixed = { list = "repels" } },
|
||||
shopTM07 = { op = "shop", fixed = { list = "tm07" } },
|
||||
shopPokeDoll = { op = "shop", fixed = { list = "pokeDoll" } },
|
||||
shopVending = { op = "shop", fixed = { list = "vending" } },
|
||||
shopExtraWater = { op = "shop", fixed = { list = "water" } },
|
||||
shopBuffs = { op = "shop", fixed = { list = "buffs" } },
|
||||
-- Not shops, despite the names. prepareForBlue/prepareForLance are
|
||||
-- thin wrappers over strategyFunctions.potion (they fire in Lance's
|
||||
-- and Agatha's rooms, where no mart exists). equipForBrock is a
|
||||
-- level-8 reset gate plus a cure-poison, and the gate is speedrun-only
|
||||
-- -- a bot that grinds normally arrives overlevelled.
|
||||
prepareForBlue = { op = "heal", fixed = { full = true } },
|
||||
prepareForLance = { op = "heal", fixed = { full = true } },
|
||||
equipForBrock = { op = "heal", fixed = { status = true } },
|
||||
-- "skill" is the route's field-move verb (cut/surf/strength/dig/flash)
|
||||
skill = { op = "fieldMove", params = { move = "move", dir = "face",
|
||||
x = "x", y = "y", map = "map" } },
|
||||
}
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- MANUAL
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Progression gates. A generic verb cannot infer these from route data:
|
||||
-- they involve party composition, puzzle state, or menus the route only
|
||||
-- names. Each emits {op="manual", name=...} and the runtime dispatches to
|
||||
-- a hand-written handler keyed by name.
|
||||
local MANUAL = {
|
||||
"catchNidoran", "catchOddish", -- party composition the route assumes
|
||||
"evolveNidoking", "evolveNidorino", -- level/stone gating
|
||||
"trashcans", -- Surge gym can-search puzzle
|
||||
"depositPokemon", -- PC box menu
|
||||
"deptElevator", "silphElevator", -- elevator floor menus
|
||||
"giveWater", -- Saffron guard gate
|
||||
"playPokeFlute", -- Snorlax
|
||||
"pokeDoll", -- Lavender Rocket blocker
|
||||
"talkToBill", -- S.S. Ticket gate
|
||||
"exitForest", -- Viridian Forest exit routing
|
||||
"trainerSightSkip",
|
||||
}
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- CONTROLS ({c="..."} steps)
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Almost all of these are reset conditions or run telemetry. {c="a"} is
|
||||
-- Bridge.chat -- Twitch commentary, 50 of the 98 control calls.
|
||||
local CONTROL_DROP = {
|
||||
"a", "encounters", "trackEncounters", "allowDeath", "pp", "thrash",
|
||||
"moon1Exp", "moon2Exp", "moon3Exp", "startMtMoon",
|
||||
"nidoranBackupExp", "viridianBackupExp", "viridianExp",
|
||||
}
|
||||
-- Catch permission toggles do affect what the bot ends up with.
|
||||
local CONTROL_VERB = {
|
||||
catchNidoran = { op = "allowCatch", mon = "nidoran" },
|
||||
catchOddish = { op = "allowCatch", mon = "oddish" },
|
||||
catchParas = { op = "allowCatch", mon = "paras" },
|
||||
catchFlier = { op = "allowCatch", mon = "flier" },
|
||||
disableCatch = { op = "allowCatch", mon = false },
|
||||
potion = { op = "heal" },
|
||||
}
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
local function set(list)
|
||||
local t = {}
|
||||
for _, name in ipairs(list) do t[name] = true end
|
||||
return t
|
||||
end
|
||||
|
||||
T.drop = set(DROP)
|
||||
T.battle = set(BATTLE)
|
||||
T.verb = VERB
|
||||
T.manual = set(MANUAL)
|
||||
T.controlDrop = set(CONTROL_DROP)
|
||||
T.controlVerb = CONTROL_VERB
|
||||
|
||||
T.face = { Up = "up", Down = "down", Left = "left", Right = "right" }
|
||||
|
||||
return T
|
||||
Reference in New Issue
Block a user