mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-21 13:09:54 +02:00
buggies
This commit is contained in:
@@ -1,18 +1,19 @@
|
||||
# Pokemon Gen 1 Recompilation Project
|
||||
|
||||
A native LÖVE2D recreation of Pokemon Red. The engine and map behavior are
|
||||
hand-written Lua; game data and graphics are decoded from a ROM supplied by
|
||||
the player.
|
||||
A native LÖVE2D recreation of Pokemon Red and Blue. The engine and map
|
||||
behavior are hand-written Lua; game data and graphics are decoded from a ROM
|
||||
supplied by the player.
|
||||
|
||||
SUPPORT AND ANNOUNCEMENTS: [Discord](https://bois.icu)
|
||||
|
||||
This project does not include a ROM, emulate the Game Boy, transpile assembly,
|
||||
or download a disassembly. A canonical US Pokemon Red ROM is the only game
|
||||
content input.
|
||||
or download a disassembly. A canonical US Pokemon Red or Blue ROM is the only
|
||||
game content input.
|
||||
|
||||
The ROM is verified, used during import, and then released from memory. It is
|
||||
not copied into the cache. Later launches load the private generated cache and
|
||||
do not ask for the ROM again.
|
||||
do not ask for the ROM again. Red and Blue can both be imported and played
|
||||
side by side.
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -20,8 +21,12 @@ Open the desktop app. On first boot, choose your legally obtained `.gb` file
|
||||
or drop it onto the window. Import takes a few seconds and the game starts
|
||||
automatically.
|
||||
|
||||
Only the canonical 1 MiB US Red ROM is accepted. The importer verifies SHA-1
|
||||
`ea9bcae617fdf159b045185467ae58b2e4a48b9a` before creating any game data.
|
||||
Only the canonical 1 MiB US Red and Blue ROMs are accepted. The importer
|
||||
verifies SHA-1 before creating any game data:
|
||||
|
||||
- Red: `ea9bcae617fdf159b045185467ae58b2e4a48b9a`
|
||||
- Blue: `d7037c83e1ae5b39bde3c30787637ba1d4c48ce2`
|
||||
|
||||
The packaged app contains neither a ROM nor pre-extracted game data. Music,
|
||||
sound effects, and cries are synthesized while the game runs from compact
|
||||
audio channel programs copied out of the verified ROM.
|
||||
@@ -33,11 +38,11 @@ B; Escape opens START. F1 saves and F2 loads. Controllers are supported.
|
||||
|
||||
## Running From Source
|
||||
|
||||
Requires LÖVE 11.x. Place the ROM in the project folder and double-click
|
||||
`Play-Mac.command` or `Play-Windows.bat`, or run:
|
||||
Requires LÖVE 11.x. Place a Red or Blue ROM in the project folder and
|
||||
double-click `Play-Mac.command` or `Play-Windows.bat`, or run:
|
||||
|
||||
```sh
|
||||
scripts/setup.sh --rom "/path/to/Pokemon Red.gb"
|
||||
scripts/setup.sh --rom "/path/to/Pokemon Red.gb" # or Pokemon Blue.gb
|
||||
scripts/run.sh
|
||||
```
|
||||
|
||||
@@ -107,7 +112,3 @@ request with real detail is one that can actually get built.
|
||||
This project would not be possible without [pret](https://github.com/pret) >
|
||||
the pret band of decompiling maniacs > and their
|
||||
[pokered](https://github.com/pret/pokered) disassembly.
|
||||
|
||||
## Wanna Support My Work?
|
||||
[](https://buymeacoffee.com/bryanthaboi)
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
-- Red's House 2F (pokered data/events/hidden_events.asm +
|
||||
-- engine/events/hidden_objects PrintRedSNESText).
|
||||
--
|
||||
-- The bedroom SNES is a hidden_event at (3, 5) with ANY_FACING that
|
||||
-- prints _RedBedroomSNESText. field.py extracts OpenRedsPC on this map
|
||||
-- but skips PrintRedSNESText, so interaction was a no-op (#135).
|
||||
|
||||
local TextBox = require("src.render.TextBox")
|
||||
|
||||
return {
|
||||
REDS_HOUSE_2F = {
|
||||
-- hidden_events.asm:
|
||||
-- hidden_event 3, 5, PrintRedSNESText, ANY_FACING
|
||||
onInteract = function(game, ow, fx, fy)
|
||||
if fx ~= 3 or fy ~= 5 then return false end
|
||||
local text = game.data.text._RedBedroomSNESText
|
||||
or "{PLAYER} is\nplaying the SNES!"
|
||||
game.stack:push(TextBox.new(game, text))
|
||||
return true
|
||||
end,
|
||||
},
|
||||
}
|
||||
@@ -28,6 +28,7 @@ local files = {
|
||||
"data.scripts.flavor.pokemon_fan_club",
|
||||
"data.scripts.flavor.power_plant",
|
||||
"data.scripts.flavor.reds_house_1f",
|
||||
"data.scripts.flavor.reds_house_2f",
|
||||
"data.scripts.flavor.route11_gate_2f",
|
||||
"data.scripts.flavor.route18_gate_2f",
|
||||
"data.scripts.flavor.route_12_gate_2f",
|
||||
|
||||
@@ -31,13 +31,16 @@ local function starterBall(askText, species, choseFlag, ownBall,
|
||||
{ species = species, forceOwned = true } }, -- 5
|
||||
{ "ask", askText }, -- 6
|
||||
{ "jump_if_false", 21 }, -- 7
|
||||
{ "give_pokemon", species, 5 }, -- 8
|
||||
{ "set_flag", "EVENT_GOT_STARTER" }, -- 9
|
||||
{ "set_flag", choseFlag }, -- 10
|
||||
-- OaksLab.asm prints ReceivedMon then AddPartyMon (AskName lives
|
||||
-- inside give_pokemon). Show the received text first so the
|
||||
-- nickname prompt follows "you got X", matching Gen1.
|
||||
{ "show_text", "_OaksLabReceivedMonText", { RAM = species } }, -- 8
|
||||
{ "give_pokemon", species, 5 }, -- 9
|
||||
{ "set_flag", "EVENT_GOT_STARTER" }, -- 10
|
||||
{ "set_flag", choseFlag }, -- 11
|
||||
-- POKé BALLs are not handed out here in the original -- Oak gives
|
||||
-- them later, at OaksLabOak1Text's .give_poke_balls beat once the
|
||||
-- player has beaten the Route 22 rival (see TEXT_OAKSLAB_OAK1 below)
|
||||
{ "show_text", "_OaksLabReceivedMonText", { RAM = species } }, -- 11
|
||||
{ "hide_object", "OAKS_LAB", ownBall }, -- 12
|
||||
-- the rival walks to the countering ball (around the furniture)
|
||||
{ "move_npc_to", 1, rivalBallX, 4 }, -- 13
|
||||
|
||||
+23
-8
@@ -412,14 +412,23 @@ M.GAME_CORNER = {
|
||||
-- EVENT_FOUND_ROCKET_HIDEOUT is unset, $43 after)
|
||||
onEnter = function(game, ow)
|
||||
local poster = game.data.field.gameCornerPoster
|
||||
if not poster then return end
|
||||
local block = game.save.flags[poster.event] and poster.openBlock
|
||||
or poster.closedBlock
|
||||
ow:replaceBlock(poster.x, poster.y, block)
|
||||
if poster then
|
||||
local block = game.save.flags[poster.event] and poster.openBlock
|
||||
or poster.closedBlock
|
||||
ow:replaceBlock(poster.x, poster.y, block)
|
||||
end
|
||||
-- pick this visit's lucky slot machine
|
||||
-- (wLuckySlotHiddenEventIndex, engine/slots/game_corner_slots2.asm)
|
||||
local seats = game.data.field.slotMachines.GAME_CORNER
|
||||
ow.luckySlot = love.math.random(1, #seats)
|
||||
-- #131: pre-#50 saves beat the poster grunt (defeatedTrainers) but
|
||||
-- never hid him; clear the tile if he is already marked defeated
|
||||
local rocketId = "GAME_CORNER_obj_11"
|
||||
if game.save.defeatedTrainers and game.save.defeatedTrainers[rocketId] then
|
||||
local Commands = require("src.script.Commands")
|
||||
Commands.hide_object({ game = game, save = game.save, overworld = ow },
|
||||
"GAME_CORNER", "GAMECORNER_ROCKET")
|
||||
end
|
||||
end,
|
||||
talk = {
|
||||
-- the poster bg event: pressing A reveals the hidden switch
|
||||
@@ -448,7 +457,16 @@ M.GAME_CORNER = {
|
||||
-- losing he warns the BOSS and leaves the floor for good, freeing
|
||||
-- the tile in front of the hideout switch
|
||||
TEXT_GAMECORNER_ROCKET = function(game, ow, npc, done)
|
||||
local Commands = require("src.script.Commands")
|
||||
local function hideRocket()
|
||||
Commands.hide_object({ game = game, save = game.save,
|
||||
overworld = ow },
|
||||
"GAME_CORNER", "GAMECORNER_ROCKET")
|
||||
end
|
||||
-- already beaten: hide anyway so pre-#50 saves that only have
|
||||
-- defeatedTrainers (no objectToggles hide) clear the poster tile
|
||||
if ow:trainerDefeated(npc) then
|
||||
hideRocket()
|
||||
done()
|
||||
return
|
||||
end
|
||||
@@ -462,10 +480,7 @@ M.GAME_CORNER = {
|
||||
game.data.text._GameCornerRocketAfterBattleText
|
||||
or "Our hideout might\nbe discovered! I\nbetter tell BOSS!",
|
||||
function()
|
||||
local Commands = require("src.script.Commands")
|
||||
Commands.hide_object({ game = game, save = game.save,
|
||||
overworld = ow },
|
||||
"GAME_CORNER", "GAMECORNER_ROCKET")
|
||||
hideRocket()
|
||||
done()
|
||||
end))
|
||||
end)
|
||||
|
||||
+94
-22
@@ -258,6 +258,10 @@ local function makeBattler(data, mon, isPlayer, save)
|
||||
}
|
||||
end
|
||||
|
||||
-- LinkBattle builds clamped copies with save=nil (no badge boosts); wild
|
||||
-- and trainer constructors pass the live save for the player side.
|
||||
BattleState.makeBattler = makeBattler
|
||||
|
||||
-- The battle pic for `species` on the given side (back pic for the
|
||||
-- player side, front pic for the enemy side), tinted PAL_GRAYMON --
|
||||
-- the same path makeBattler uses, but forced gray -- since this is only
|
||||
@@ -832,7 +836,7 @@ function BattleState:computeMusicKind()
|
||||
return "final"
|
||||
elseif isBoss or (self.trainer and self.trainer.id == "OPP_LANCE") then
|
||||
return "gym"
|
||||
elseif self.kind == "trainer" then
|
||||
elseif self.kind == "trainer" or self.kind == "link" then
|
||||
return "trainer"
|
||||
end
|
||||
return "wild"
|
||||
@@ -894,6 +898,17 @@ function BattleState:enter()
|
||||
self.showEnemyTrainer = false
|
||||
self:startGrowIn(self.enemy)
|
||||
end)
|
||||
elseif self.kind == "link" then
|
||||
-- Colosseum has no foe trainer pic, but the enemy mon still grows
|
||||
-- out of the ball after "X sent out Y!" (not the wild "already there"
|
||||
-- intro that LinkBattle previously inherited from newWild).
|
||||
self.enemySendingOut = true
|
||||
self:say(("%s sent\nout %s!"):format(self.opponentName or "FOE",
|
||||
self.enemy.name))
|
||||
self:act(function()
|
||||
self.enemySendingOut = false
|
||||
self:startGrowIn(self.enemy)
|
||||
end)
|
||||
end
|
||||
if not self.ghost then
|
||||
-- the enemy's cry plays as it appears (data/pokemon/cries.asm)
|
||||
@@ -950,20 +965,37 @@ function BattleState:exit()
|
||||
end
|
||||
end
|
||||
|
||||
-- An action the battler is locked into (bypasses the menu), or nil.
|
||||
function BattleState:lockedAction(battler)
|
||||
-- End a trapping sequence (USING_TRAPPING_MOVE). SendOutMon clears the
|
||||
-- foe's bit (core.asm:1761-1762); EnemySendOutFirstMon clears the
|
||||
-- player's (core.asm:1314-1315). Any switch frees the other side.
|
||||
local function clearTrapping(battler)
|
||||
if not battler then return end
|
||||
battler.trappingTurns = nil
|
||||
battler.trapMove = nil
|
||||
battler.trapDamage = nil
|
||||
end
|
||||
|
||||
-- Actions that skip DisplayBattleMenu entirely (core.asm:300-310):
|
||||
-- recharge, Rage, thrash, charge. Bide / trapping / being held do NOT
|
||||
-- skip the menu -- the player can still item/switch (and must press
|
||||
-- FIGHT to continue a trapping sequence).
|
||||
function BattleState:menuLockedAction(battler)
|
||||
if battler.mustRecharge then return { special = "recharge" } end
|
||||
if battler.charging then return battler.charging end
|
||||
if battler.thrashTurns and battler.thrashTurns > 0 then return battler.thrashMove end
|
||||
if battler.rageMove then return battler.rageMove end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- After FIGHT: skip MoveSelectionMenu (core.asm:320-329). Own
|
||||
-- trapping/Bide continues; foe trapping forces CANNOT_MOVE ($ff).
|
||||
function BattleState:fightLockedAction(battler)
|
||||
if battler.trappingTurns and battler.trappingTurns > 0 then
|
||||
return { special = "trapping" }
|
||||
end
|
||||
if battler.bideTurns then return { special = "bide" } end
|
||||
if battler.rageMove then return battler.rageMove end
|
||||
-- held in place while the OPPONENT's trapping move is running
|
||||
-- (core.asm:316-322 reads the live USING_TRAPPING_MOVE bit, so a
|
||||
-- trap ended early by paralysis/faint frees the victim immediately);
|
||||
-- boundTurns is a mirror kept for Status.beforeMove's held check
|
||||
-- held while the OPPONENT's trapping bit is set (live mirror so a
|
||||
-- trap ended early by paralysis/faint frees the victim immediately)
|
||||
local opp = battler.isPlayer and self.enemy or self.player
|
||||
battler.boundTurns = opp and opp.trappingTurns
|
||||
and math.max(1, opp.trappingTurns) or nil
|
||||
@@ -973,6 +1005,11 @@ function BattleState:lockedAction(battler)
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Full lock for AI / callers that need any forced action.
|
||||
function BattleState:lockedAction(battler)
|
||||
return self:menuLockedAction(battler) or self:fightLockedAction(battler)
|
||||
end
|
||||
|
||||
function BattleState:playerHasPP()
|
||||
for i, mv in ipairs(self.player.curMoves) do
|
||||
if mv.pp > 0 and self.player.disabledSlot ~= i then return true end
|
||||
@@ -1079,8 +1116,9 @@ function BattleState:update(dt)
|
||||
if not (self.player.mustRecharge or self.player.rageMove) then
|
||||
self.player.flinched, self.enemy.flinched = false, false
|
||||
end
|
||||
-- locked multi-turn actions skip the menu entirely
|
||||
local locked = self:lockedAction(self.player)
|
||||
-- only recharge/Rage/thrash/charge skip DisplayBattleMenu; trapping
|
||||
-- victims (and wrappers) still get FIGHT/PKMN/ITEM/RUN (core.asm:312)
|
||||
local locked = self:menuLockedAction(self.player)
|
||||
if locked then
|
||||
self:resolveTurn(locked)
|
||||
return
|
||||
@@ -1104,6 +1142,13 @@ function BattleState:update(dt)
|
||||
end)
|
||||
self:act(function() self:endOfTurn() end)
|
||||
elseif choice == "fight" then
|
||||
-- After the menu: own trapping/Bide or foe Wrap skips the move
|
||||
-- list and forces the locked action (core.asm:320-329)
|
||||
local fightLock = self:fightLockedAction(self.player)
|
||||
if fightLock then
|
||||
self:resolveTurn(fightLock)
|
||||
return
|
||||
end
|
||||
if not self:playerHasPP() then
|
||||
-- _NoMovesLeftText, then Struggle engages
|
||||
self:say(("%s has no\nmoves left!"):format(self.player.name))
|
||||
@@ -1514,6 +1559,9 @@ function BattleState:resolveSwitch(newMon)
|
||||
self:restoreMimicked(self.player) -- the battle copy leaves with it
|
||||
local previous = self.player
|
||||
self.player = makeBattler(self.data, newMon, true, self.game.save)
|
||||
-- SendOutMon (core.asm:1761-1762): player's send-out clears the
|
||||
-- foe's USING_TRAPPING_MOVE -- Wrap/Bind/etc. ends on any switch
|
||||
clearTrapping(self.enemy)
|
||||
self:syncSides()
|
||||
Runtime.emit("battle.battler_switched", {
|
||||
battle = self, side = self.sides[1], battler = self.player,
|
||||
@@ -2094,6 +2142,8 @@ function BattleState:executeAction(user, target, action)
|
||||
local oldName = self.enemy.name
|
||||
self.enemyIndex = action.index
|
||||
self.enemy = makeBattler(self.data, self.enemyParty[action.index], false)
|
||||
-- EnemySendOutFirstMon (core.asm:1314-1315): clears player's trap
|
||||
clearTrapping(self.player)
|
||||
self:syncSides()
|
||||
Runtime.emit("battle.battler_switched", {
|
||||
battle = self, side = self.sides[2], battler = self.enemy,
|
||||
@@ -2574,8 +2624,20 @@ function BattleState:enemyMonFainted()
|
||||
self.participants = {}
|
||||
|
||||
if self.kind == "trainer" then
|
||||
if self.enemyIndex < #self.enemyParty then
|
||||
self.enemyIndex = self.enemyIndex + 1
|
||||
-- EnemySendOutFirstMon / AnyEnemyPokemonAliveCheck (core.asm): scan
|
||||
-- the whole enemy party for the first mon with HP left. Blindly
|
||||
-- doing enemyIndex+1 softlocks after an AI switch (Agatha): a later
|
||||
-- slot can already be fainted, so the empty-HP mon comes out, the
|
||||
-- FIGHT menu returns, and executeAction no-ops on target.hp <= 0.
|
||||
local nextIndex
|
||||
for i, mon in ipairs(self.enemyParty) do
|
||||
if mon.hp > 0 then
|
||||
nextIndex = i
|
||||
break
|
||||
end
|
||||
end
|
||||
if nextIndex then
|
||||
self.enemyIndex = nextIndex
|
||||
-- SHIFT battle style (the default): announce the next mon and
|
||||
-- offer a free switch (SET skips the prompt)
|
||||
local nextMon = self.enemyParty[self.enemyIndex]
|
||||
@@ -2599,6 +2661,7 @@ function BattleState:enemyMonFainted()
|
||||
if mon ~= self.player.mon and mon.hp > 0 then
|
||||
local previous = self.player
|
||||
self.player = makeBattler(self.data, mon, true, game.save)
|
||||
clearTrapping(self.enemy) -- SendOutMon clears foe trap
|
||||
self:syncSides()
|
||||
Runtime.emit("battle.battler_switched", {
|
||||
battle = self, side = self.sides[1],
|
||||
@@ -2624,6 +2687,8 @@ function BattleState:enemyMonFainted()
|
||||
self:act(function()
|
||||
local previous = self.enemy
|
||||
self.enemy = makeBattler(self.data, self.enemyParty[self.enemyIndex], false)
|
||||
-- EnemySendOutFirstMon (core.asm:1314-1315): clears player's trap
|
||||
clearTrapping(self.player)
|
||||
self:syncSides()
|
||||
Runtime.emit("battle.battler_switched", {
|
||||
battle = self, side = self.sides[2], battler = self.enemy,
|
||||
@@ -2777,6 +2842,7 @@ function BattleState:openReplacementMenu()
|
||||
self:restoreMimicked(self.player)
|
||||
local previous = self.player
|
||||
self.player = makeBattler(self.data, mon, true, game.save)
|
||||
clearTrapping(self.enemy) -- SendOutMon clears foe trap
|
||||
self:syncSides()
|
||||
Runtime.emit("battle.battler_switched", {
|
||||
battle = self, side = self.sides[1], battler = self.player,
|
||||
@@ -3739,7 +3805,7 @@ function BattleState:drawPicsLayer(slide, sx, sy)
|
||||
end
|
||||
end
|
||||
|
||||
-- Player: back sprite bottom-left (2x like the GB, feet near y=100).
|
||||
-- Player: back sprite at hlcoord 1,5 (x=8), 2x like the GB, feet at y=96.
|
||||
local hidePlayer = self.safari or self.demo
|
||||
if self.showPlayerBack and self.playerBackPic then
|
||||
-- Red's (or the old man's) back pic until "Go!"; it stays up for
|
||||
@@ -3747,7 +3813,7 @@ function BattleState:drawPicsLayer(slide, sx, sy)
|
||||
local img = self:picImage(self.playerBackPic)
|
||||
local pad = imagePadBottom[self.playerBackPic] or 0
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.draw(img, 16 + slide + sx,
|
||||
love.graphics.draw(img, 8 + slide + sx,
|
||||
96 - (img:getHeight() - pad) * 2 + sy, 0, 2, 2)
|
||||
elseif self.player and self.player.sprite and not hidePlayer
|
||||
and not self.sendingOut and not self:fxHidden(self.player) then
|
||||
@@ -3758,14 +3824,14 @@ function BattleState:drawPicsLayer(slide, sx, sy)
|
||||
local gs = self:growInScale(self.player)
|
||||
if gs then
|
||||
-- the player-side AnimateSendingOutMon grow (after the poof,
|
||||
-- core.asm:1757-1762): feet pinned at y=96, center at x=16+w
|
||||
-- core.asm:1757-1762): feet pinned at y=96, center at x=8+w
|
||||
if gs > 0 then
|
||||
love.graphics.draw(img, 16 + img:getWidth() * (1 - gs) + sx,
|
||||
love.graphics.draw(img, 8 + img:getWidth() * (1 - gs) + sx,
|
||||
96 - (img:getHeight() - pad) * 2 * gs + sy,
|
||||
0, 2 * gs, 2 * gs)
|
||||
end
|
||||
else
|
||||
self:drawBattlerPic(self.player, 16 + sx,
|
||||
self:drawBattlerPic(self.player, 8 + sx,
|
||||
96 - (img:getHeight() - pad) * 2 + sy, 2)
|
||||
end
|
||||
end
|
||||
@@ -3820,16 +3886,22 @@ function BattleState:drawHUDs(slide)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(("BALLx%2d"):format(self.safari.balls), 88, 72)
|
||||
end
|
||||
-- trainer-battle party pokeball rows during the intro
|
||||
-- trainer/link party pokeball rows during the intro
|
||||
-- (SetupPlayerAndEnemyPokeballs, draw_hud_pokeball_gfx.asm)
|
||||
if self.kind == "trainer" and (self.showEnemyTrainer or self.showPlayerBack)
|
||||
and slide == 0 then
|
||||
local showIntroBalls = slide == 0 and (
|
||||
(self.kind == "trainer" and (self.showEnemyTrainer or self.showPlayerBack))
|
||||
or (self.kind == "link" and (self.showPlayerBack or self.enemySendingOut))
|
||||
)
|
||||
if showIntroBalls then
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
if self.showEnemyTrainer and self.enemyParty then
|
||||
if self.enemyParty and (
|
||||
(self.kind == "trainer" and self.showEnemyTrainer)
|
||||
or (self.kind == "link" and self.enemySendingOut)
|
||||
) then
|
||||
self:drawBallRow(self.enemyParty, 64, 16, -8)
|
||||
end
|
||||
if self.showPlayerBack then
|
||||
self:drawBallRow(self.game.save.party, 88, 80, 8)
|
||||
self:drawBallRow(self.playerParty or self.game.save.party, 88, 80, 8)
|
||||
end
|
||||
end
|
||||
local hidePlayer = self.safari or self.demo
|
||||
|
||||
@@ -478,6 +478,25 @@ SaveData.addCoreMigration(2, function(save)
|
||||
end
|
||||
end)
|
||||
|
||||
-- #131 / follow-up to #50: Game Corner poster grunt used to stay on the
|
||||
-- floor after defeat (defeatedTrainers only). The #50 script now hides
|
||||
-- him via objectToggles, but saves that already beat him never got the
|
||||
-- toggle -- he still blocks the hideout switch. from=3 so every pre-4
|
||||
-- save reconciles once, then is skipped after re-stamp.
|
||||
SaveData.addCoreMigration(3, function(save)
|
||||
local defeated = save.defeatedTrainers
|
||||
if not defeated or not defeated["GAME_CORNER_obj_11"] then return end
|
||||
save.objectToggles = save.objectToggles or {}
|
||||
local mapToggles = save.objectToggles.GAME_CORNER
|
||||
if not mapToggles then
|
||||
mapToggles = {}
|
||||
save.objectToggles.GAME_CORNER = mapToggles
|
||||
end
|
||||
if mapToggles.GAMECORNER_ROCKET ~= false then
|
||||
mapToggles.GAMECORNER_ROCKET = false
|
||||
end
|
||||
end)
|
||||
|
||||
-- ------- write
|
||||
|
||||
-- Game progress only; options are written separately via saveOptions.
|
||||
|
||||
@@ -7,7 +7,7 @@ local Version = {
|
||||
engine = "1.0.0", -- game/engine release (semver triple)
|
||||
modApi = 2, -- mod API major (manifest `api`)
|
||||
linkProtocol = 2, -- link handshake wire version (Handshake.PROTOCOL)
|
||||
saveFormat = 3, -- save.meta.format
|
||||
saveFormat = 4, -- save.meta.format
|
||||
cache = "rom-cache-v5", -- ROM import cache generation (RomImporter marker)
|
||||
}
|
||||
|
||||
|
||||
+49
-27
@@ -36,18 +36,13 @@ local function makeRng(seed)
|
||||
end
|
||||
end
|
||||
|
||||
-- battler builder shared by both sides -- NO badge boosts, so both
|
||||
-- machines compute identical stats
|
||||
-- Battlers go through BattleState.makeBattler so pics get the same
|
||||
-- Assets.resolve + SGB/GBC palette + padBottom path as wild/trainer
|
||||
-- battles. save=nil skips badge boosts so both machines keep identical
|
||||
-- stats (Gen 1 cable battles famously kept badges; we still diverge).
|
||||
local function mkBattler(data, mon, isPlayer)
|
||||
local def = data.pokemon[mon.species]
|
||||
local ok, img = pcall(love.graphics.newImage,
|
||||
isPlayer and def.spriteBack or def.spriteFront)
|
||||
return {
|
||||
mon = mon, def = def, isPlayer = isPlayer, stages = {},
|
||||
name = mon.nickname or def.name,
|
||||
curStats = mon.stats, curTypes = def.types, curMoves = mon.moves,
|
||||
sprite = ok and img or nil,
|
||||
}
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
return BattleState.makeBattler(data, mon, isPlayer, nil)
|
||||
end
|
||||
|
||||
-- canonical (host-side-first) state hash, unchanged since v1: it stays on
|
||||
@@ -213,6 +208,8 @@ function LinkBattle.new(game, net, opts)
|
||||
self.player = mkBattler(game.data, myParty[1], true)
|
||||
self.enemy = mkBattler(game.data, theirParty[1], false)
|
||||
self.enemyParty = theirParty
|
||||
self.playerParty = myParty -- intro ball row uses the clamped copies
|
||||
self.opponentName = theirName
|
||||
self.introText = ("%s wants\nto battle!"):format(theirName)
|
||||
self.remoteHashes = {}
|
||||
self.localHashes = {}
|
||||
@@ -235,6 +232,43 @@ function LinkBattle.new(game, net, opts)
|
||||
return nil
|
||||
end
|
||||
|
||||
-- player-side SendOutMon (poof + grow-in + cry); mirrors BattleState
|
||||
local function sendOutPlayer(s, mon)
|
||||
local previous = s.player
|
||||
s.player = mkBattler(game.data, mon, true)
|
||||
s:syncSides()
|
||||
Runtime.emit("battle.battler_switched", {
|
||||
battle = s, side = s.sides[1], battler = s.player, previous = previous,
|
||||
})
|
||||
s.sendingOut = true
|
||||
s:sayNext(s:sendOutText(s.player.name))
|
||||
s:animNext("POOF_ANIM", false)
|
||||
s:actNext(function()
|
||||
s.sendingOut = false
|
||||
s:startGrowIn(s.player)
|
||||
require("src.core.Sound").playCry(s.data, s.player.mon.species)
|
||||
end)
|
||||
end
|
||||
|
||||
-- enemy-side EnemySendOut (grow-in + cry; no poof)
|
||||
local function sendOutEnemy(s, mon)
|
||||
local previous = s.enemy
|
||||
s.enemy = mkBattler(game.data, mon, false)
|
||||
s:syncSides()
|
||||
Runtime.emit("battle.battler_switched", {
|
||||
battle = s, side = s.sides[2], battler = s.enemy, previous = previous,
|
||||
})
|
||||
s.enemySendingOut = true
|
||||
s:sayNext(("%s sent\nout %s!"):format(theirName, s.enemy.name))
|
||||
s:actNext(function()
|
||||
s.enemySendingOut = false
|
||||
s:startGrowIn(s.enemy)
|
||||
s:actNext(function()
|
||||
require("src.core.Sound").playCry(s.data, s.enemy.mon.species)
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
-- decode a remote action message against the enemy battler
|
||||
local function decodeTheirAction(s, msg)
|
||||
if msg.kind == "move" then
|
||||
@@ -306,17 +340,11 @@ function LinkBattle.new(game, net, opts)
|
||||
-- switches happen before attacks (both may switch)
|
||||
if myMsg.kind == "switch" then
|
||||
local idx = myMsg.index
|
||||
s:act(function()
|
||||
s.player = mkBattler(game.data, myParty[idx], true)
|
||||
s:sayNext(("Go! %s!"):format(s.player.name))
|
||||
end)
|
||||
s:act(function() sendOutPlayer(s, myParty[idx]) end)
|
||||
myAction = nil
|
||||
end
|
||||
if theirSwitch then
|
||||
s:act(function()
|
||||
s.enemy = mkBattler(game.data, theirParty[theirSwitch], false)
|
||||
s:sayNext(("%s sent\nout %s!"):format(theirName, s.enemy.name))
|
||||
end)
|
||||
s:act(function() sendOutEnemy(s, theirParty[theirSwitch]) end)
|
||||
end
|
||||
|
||||
s:act(function()
|
||||
@@ -452,10 +480,7 @@ function LinkBattle.new(game, net, opts)
|
||||
self.playerMonFainted = function(s)
|
||||
for _, mon in ipairs(myParty) do
|
||||
if mon.hp > 0 then
|
||||
s:act(function()
|
||||
s.player = mkBattler(game.data, mon, true)
|
||||
s:sayNext(("Go! %s!"):format(s.player.name))
|
||||
end)
|
||||
s:act(function() sendOutPlayer(s, mon) end)
|
||||
return
|
||||
end
|
||||
end
|
||||
@@ -468,10 +493,7 @@ function LinkBattle.new(game, net, opts)
|
||||
self.enemyMonFainted = function(s)
|
||||
for _, mon in ipairs(theirParty) do
|
||||
if mon.hp > 0 then
|
||||
s:act(function()
|
||||
s.enemy = mkBattler(game.data, mon, false)
|
||||
s:sayNext(("%s sent\nout %s!"):format(theirName, s.enemy.name))
|
||||
end)
|
||||
s:act(function() sendOutEnemy(s, mon) end)
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
@@ -18,6 +18,12 @@ ManagerState.isOpaque = true
|
||||
-- Game:keypressed recognizes a directly-pushed instance
|
||||
ManagerState.screenId = "ManagerState"
|
||||
|
||||
-- Same as OptionsMenu: without this, title LOGO zones leak through when
|
||||
-- MODS is opened from the title-screen options path.
|
||||
function ManagerState:sgbPalettes(game)
|
||||
return require("src.render.PaletteFX").wholeNamed(game.data, "MEWMON")
|
||||
end
|
||||
|
||||
-- the charmap has no * ~ + < > glyphs, so the status gutter uses what it
|
||||
-- does have: staged-awaiting-restart, disabled, errored, dep-unhealthy
|
||||
local GLYPH = { staged = ".", disabled = "-", errored = "!", blocked = "?" }
|
||||
|
||||
+49
-2
@@ -484,11 +484,51 @@ function Commands.heal_party(ctx)
|
||||
end
|
||||
end
|
||||
|
||||
-- AskName (engine/menus/naming_screen.asm): yes/no then NamingScreen.
|
||||
-- AddPartyMon only offers this for party mons (box deposits skip it).
|
||||
-- Preserves ctx.lastCheck so GivePokemon's carry is not clobbered by the
|
||||
-- yes/no result (scripts jump_if_false on give failure afterwards).
|
||||
local function askNickname(ctx, mon)
|
||||
local runner = ctx.runner
|
||||
if not runner then return end
|
||||
local success = ctx.lastCheck
|
||||
local name = ctx.game.stringBuffer
|
||||
or (ctx.game.data.pokemon[mon.species]
|
||||
and ctx.game.data.pokemon[mon.species].name)
|
||||
or mon.species
|
||||
-- Prefer the real text label; fall back to the BattleState wording.
|
||||
if ctx.game.data.text and ctx.game.data.text._DoYouWantToNicknameText then
|
||||
Commands.show_text(ctx, "_DoYouWantToNicknameText", { RAM = name })
|
||||
else
|
||||
Commands.show_text(ctx, ("Do you want to\ngive a nickname\nto %s?"):format(name))
|
||||
end
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
ctx.game.stack:push(ChoiceBox.new(ctx.game, function(yes)
|
||||
if not yes then
|
||||
ctx.lastCheck = success
|
||||
runner:resume()
|
||||
return
|
||||
end
|
||||
Screens.push(ctx.game, "NamingScreen", {
|
||||
title = "NICKNAME?", maxLen = 10,
|
||||
onDone = function(nick)
|
||||
if nick and #nick > 0 then mon.nickname = nick end
|
||||
ctx.lastCheck = success
|
||||
runner:resume()
|
||||
end,
|
||||
})
|
||||
end))
|
||||
runner:yield()
|
||||
ctx.lastCheck = success
|
||||
end
|
||||
|
||||
-- give_pokemon <species> <level>: _GivePokemon (engine/events/
|
||||
-- give_pokemon.asm) -- party first, then the box. ctx.lastCheck gets
|
||||
-- the asm's carry: true when the mon was given, false when both the
|
||||
-- party and every box are full (that .boxFull path leaves the giver's
|
||||
-- script able to offer again later, e.g. the Celadon Eevee ball).
|
||||
-- Party adds run AskName (AddPartyMon) when a script runner is present;
|
||||
-- mods that pre-set gift.nickname skip the prompt.
|
||||
function Commands.give_pokemon(ctx, species, level)
|
||||
-- Native mods can transform a gift before the Pokémon object is created.
|
||||
-- This is intentionally an event rather than a special-case starter hook:
|
||||
@@ -505,7 +545,8 @@ function Commands.give_pokemon(ctx, species, level)
|
||||
ctx.game.stringBuffer = ctx.game.data.pokemon[species].name or species
|
||||
ctx.pendingPokemonName = species
|
||||
require("src.battle.BattleState").stampOT(ctx.save, mon)
|
||||
if not Party.add(ctx.save.party, mon) then
|
||||
local addedToParty = Party.add(ctx.save.party, mon)
|
||||
if not addedToParty then
|
||||
if not require("src.pokemon.Boxes").deposit(ctx.save, mon) then
|
||||
ctx.lastCheck = false
|
||||
return
|
||||
@@ -517,6 +558,11 @@ function Commands.give_pokemon(ctx, species, level)
|
||||
dex.owned[species] = true
|
||||
end
|
||||
ctx.lastCheck = true
|
||||
-- AddPartyMon AskName: party only; skip box deposits, mod-set nicknames,
|
||||
-- and callback-style callers that have no script runner to yield on.
|
||||
if addedToParty and not gift.nickname and ctx.runner then
|
||||
askNickname(ctx, mon)
|
||||
end
|
||||
end
|
||||
|
||||
function Commands.give_money(ctx, amount)
|
||||
@@ -1066,7 +1112,8 @@ for _, verb in ipairs({ "show_text", "ask", "choice", "start_battle", "warp",
|
||||
end
|
||||
for _, verb in ipairs({ "show_text", "ask", "choice", "start_battle", "warp",
|
||||
"open_mart", "trade", "push_screen", "record_hall_of_fame",
|
||||
"old_man_demo", "static_battle", "rival_battle", "give_item", "wait",
|
||||
"old_man_demo", "static_battle", "rival_battle", "give_item",
|
||||
"give_pokemon", "wait",
|
||||
"wait_flag", "move_player", "move_npc", "move_npc_to", "walk_npc",
|
||||
"emote", "fade", "pan_camera", "play_once" }) do
|
||||
local meta = Commands.meta[verb] or {}
|
||||
|
||||
@@ -22,6 +22,14 @@ local OptionsMenu = {}
|
||||
OptionsMenu.__index = OptionsMenu
|
||||
OptionsMenu.isOpaque = true
|
||||
|
||||
-- Opaque full-screen menu: own MEWMON so opening OPTION from the title
|
||||
-- (or over the overworld) does not inherit TitleState's LOGO1 band -- that
|
||||
-- zone covers UI rows 8-9, which is the third options box label line
|
||||
-- (pink "MODS" strip when Blue's ROM LOGO1 white is {255,239,255}).
|
||||
function OptionsMenu:sgbPalettes(game)
|
||||
return PaletteFX.wholeNamed(game.data, "MEWMON")
|
||||
end
|
||||
|
||||
-- TextSpeedOptionData frame delays with the original labels
|
||||
local SPEEDS = { { 1, "FAST" }, { 3, "MEDIUM" }, { 5, "SLOW" } }
|
||||
-- no-loader fallback for the ruleset row, same pair BattleState keeps
|
||||
|
||||
+33
-5
@@ -13,13 +13,35 @@ TitleState.isOpaque = true
|
||||
|
||||
-- SGB title zones (PalPacket_Titlescreen): the logo rows get LOGO2,
|
||||
-- the version-ribbon band LOGO1, the rest MEWMON.
|
||||
--
|
||||
-- The CONTINUE / NEW GAME menu and the continue-info box sit inside those
|
||||
-- LOGO bands. pokered's MainMenu clears the title and runs
|
||||
-- RunDefaultPaletteCommand so black UI ink stays black; this port keeps the
|
||||
-- title art visible underneath, so without an overlay those boxes inherit
|
||||
-- LOGO2 blue / LOGO1 red (issue #133). A trailing trueColor zone leaves the
|
||||
-- overlay's DMG black unshaded while the logo and title mon keep title pals.
|
||||
--
|
||||
-- ROM SuperPal whites are often {255,239,255}. Under RED++, LOGO2/MEWMON
|
||||
-- come from the GBC pack (pure white) while Blue's LOGO1 stays on the ROM
|
||||
-- pack (#128), so the version-ribbon row reads as a pink band. Force that
|
||||
-- slot to pure white; ink colors (Blue/Red "Version" text) stay intact.
|
||||
local function withPureWhite(pal)
|
||||
if not pal then return nil end
|
||||
return { { 255, 255, 255 }, pal[2], pal[3], pal[4] }
|
||||
end
|
||||
|
||||
function TitleState:sgbPalettes(game)
|
||||
local P = require("src.render.PaletteFX")
|
||||
local z = {
|
||||
P.zone(P.pal(game.data, "LOGO2"), 0, 0, 19, 7),
|
||||
P.zone(P.pal(game.data, "LOGO1"), 0, 8, 19, 9),
|
||||
P.zone(withPureWhite(P.pal(game.data, "LOGO1")), 0, 8, 19, 9),
|
||||
P.zone(P.pal(game.data, "MEWMON"), 0, 10, 19, 17),
|
||||
}
|
||||
local top = game.stack and game.stack:top()
|
||||
local box = top and top.titleUiBox
|
||||
if box then
|
||||
z[#z + 1] = P.trueColorZone(box[1], box[2], box[3], box[4])
|
||||
end
|
||||
return z[3] and z or nil
|
||||
end
|
||||
|
||||
@@ -119,8 +141,11 @@ local ContinueInfo = {}
|
||||
ContinueInfo.__index = ContinueInfo
|
||||
|
||||
function ContinueInfo.new(title, save)
|
||||
return setmetatable({ title = title, game = title.game, save = save },
|
||||
ContinueInfo)
|
||||
-- box at (4,7), 16x10 tiles -- see ContinueInfo:draw / DisplayContinueGameInfo
|
||||
return setmetatable({
|
||||
title = title, game = title.game, save = save,
|
||||
titleUiBox = { 4, 7, 19, 16 },
|
||||
}, ContinueInfo)
|
||||
end
|
||||
|
||||
function ContinueInfo:update(dt)
|
||||
@@ -184,8 +209,11 @@ function TitleState:openMenu()
|
||||
love.event.quit()
|
||||
end
|
||||
end })
|
||||
game.stack:push(Menu.new(game, items,
|
||||
{ tx = 0, ty = 0, tw = 13, th = #items * 2 + 2 }))
|
||||
local th = #items * 2 + 2
|
||||
local menu = Menu.new(game, items, { tx = 0, ty = 0, tw = 13, th = th })
|
||||
-- full-width title LOGO zones would recolor this box; see sgbPalettes
|
||||
menu.titleUiBox = { 0, 0, 12, th - 1 }
|
||||
game.stack:push(menu)
|
||||
end
|
||||
|
||||
function TitleState:update(dt)
|
||||
|
||||
@@ -120,8 +120,11 @@ FieldDefaults.FIELD = {
|
||||
doorBlock = { bx = 2, by = 2, block = 5 } },
|
||||
},
|
||||
-- IsSurfingAllowed refuses SURF on the B4F stairs square until both
|
||||
-- plug boulders are down (engine/overworld/field_move_messages.asm)
|
||||
-- plug boulders are down (engine/overworld/field_move_messages.asm).
|
||||
-- B3F currents set BIT_FORCED_WARP so the south-edge water warps fire
|
||||
-- without a held d-pad (scripts/SeafoamIslandsB3F.asm).
|
||||
seafoam = {
|
||||
SEAFOAM_ISLANDS_B3F = { setsForcedWarp = true },
|
||||
SEAFOAM_ISLANDS_B4F = {
|
||||
surfBlocked = { { x = 7, y = 11, untilEvents = {
|
||||
"EVENT_SEAFOAM4_BOULDER1_DOWN_HOLE",
|
||||
|
||||
@@ -322,6 +322,12 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
|
||||
-- 16/18 gate exits), and the scripted door-mat walkout that follows
|
||||
-- suppresses onStepComplete, so waiting for a plain step never mounts
|
||||
self:checkForcedMovement()
|
||||
-- Seafoam B4F's map script pushes off the B3F stair warps every frame
|
||||
-- while the upper plugs are out (SeafoamIslandsB4FDefaultScript); the
|
||||
-- B3F/B4F force-surf mouths also arm their MOVE_OBJECT current scripts
|
||||
-- from CheckForceBikeOrSurf. Re-check here so a warp-in does not sit
|
||||
-- idle on those cells waiting for a player step.
|
||||
self:checkSeafoamCurrent()
|
||||
|
||||
-- snap the camera immediately: the overworld doesn't update while a
|
||||
-- Transition is on top, so a stale camera would show the new map at
|
||||
@@ -2669,8 +2675,11 @@ function OverworldState:onStepComplete()
|
||||
elseif entry then
|
||||
-- still standing on the warp we arrived through; do not re-trigger it
|
||||
else
|
||||
-- CheckWarpsNoCollision: door/warp tiles fire immediately; otherwise
|
||||
-- ExtraWarpCheck must pass AND either a d-pad is held or BIT_FORCED_WARP
|
||||
-- is set (Seafoam B3F currents — home/overworld.asm).
|
||||
local w = Warp.onArrive(self.map, p.cellX, p.cellY)
|
||||
if not w and self:dirHeld() then
|
||||
if not w and (self:dirHeld() or self.forcedWarp) then
|
||||
w = Warp.onCollision(self.map, Game.data.field.warpCarpets,
|
||||
p.cellX, p.cellY, p.facing)
|
||||
end
|
||||
@@ -2751,9 +2760,11 @@ function OverworldState:runSpinnerMoves(moves, i)
|
||||
local mv = moves[i]
|
||||
if not mv then
|
||||
self.player.spinning = false
|
||||
if not self:checkSpinner() and self.player.surfing then
|
||||
self:checkSeafoamCurrent()
|
||||
end
|
||||
-- Scripted steps skip onStepComplete while they run; once the RLE
|
||||
-- finishes, re-enter the normal landing pipeline so chained spinners,
|
||||
-- Seafoam currents, and CheckWarpsNoCollision (incl. BIT_FORCED_WARP)
|
||||
-- see the tile we stopped on — same as pokered after simulated joypad.
|
||||
self:onStepComplete()
|
||||
return
|
||||
end
|
||||
self.player.spinning = true -- spin the sprite while sliding
|
||||
@@ -2926,6 +2937,9 @@ function OverworldState:checkSeafoamCurrent()
|
||||
if sf.forcedExit and p.surfing and not allSet(sf.forcedExit.activeUntilEvents) then
|
||||
for _, c in ipairs(sf.forcedExit.coords) do
|
||||
if p.cellX == c.x and p.cellY == c.y then
|
||||
-- SeafoamIslandsB4FDefaultScript: res BIT_FORCED_WARP before the
|
||||
-- push so the B3F stair warps underfoot cannot bounce you back.
|
||||
self.forcedWarp = false
|
||||
require("src.core.Sound").play(Game.data, "Collision")
|
||||
self:scriptMove(p, "up", c.y == 17 and 2 or 1)
|
||||
return true
|
||||
@@ -2947,6 +2961,12 @@ function OverworldState:checkSeafoamCurrent()
|
||||
end
|
||||
for _, c in ipairs(active) do
|
||||
if p.cellX == c.x and p.cellY == c.y then
|
||||
-- SeafoamIslandsB3F.asm sets BIT_FORCED_WARP before DecodeRLEList so
|
||||
-- the south-edge water stairs auto-warp when the current ends.
|
||||
if FieldDefaults.fieldValue(Game.data, "seafoam", self.map.id,
|
||||
"setsForcedWarp") then
|
||||
self.forcedWarp = true
|
||||
end
|
||||
self:runSpinnerMoves(c.moves, 1)
|
||||
return true
|
||||
end
|
||||
|
||||
@@ -215,6 +215,41 @@ do
|
||||
SaveData.runMigrations(tagged)
|
||||
check(tagged.version == "blue", "an existing version tag is left untouched")
|
||||
|
||||
-- #131: format-3 save that beat the Game Corner poster grunt before
|
||||
-- hide_object was wired -- defeatedTrainers only, no objectToggles hide
|
||||
local stuckGrunt = {
|
||||
meta = { format = 3, mods = {} },
|
||||
defeatedTrainers = { GAME_CORNER_obj_11 = true },
|
||||
objectToggles = {},
|
||||
flags = {},
|
||||
player = {},
|
||||
}
|
||||
SaveData.runMigrations(stuckGrunt)
|
||||
check(stuckGrunt.objectToggles.GAME_CORNER
|
||||
and stuckGrunt.objectToggles.GAME_CORNER.GAMECORNER_ROCKET == false,
|
||||
"Game Corner rocket hide backfilled from defeatedTrainers")
|
||||
check(stuckGrunt.meta.format == Version.saveFormat,
|
||||
"format-3 hideout migration stamps to current")
|
||||
-- already-hidden stays hidden; undefeated grunt is left alone
|
||||
local alreadyHidden = {
|
||||
meta = { format = 3, mods = {} },
|
||||
defeatedTrainers = { GAME_CORNER_obj_11 = true },
|
||||
objectToggles = { GAME_CORNER = { GAMECORNER_ROCKET = false } },
|
||||
player = {},
|
||||
}
|
||||
SaveData.runMigrations(alreadyHidden)
|
||||
check(alreadyHidden.objectToggles.GAME_CORNER.GAMECORNER_ROCKET == false,
|
||||
"already-hidden rocket toggle is left false")
|
||||
local neverFought = {
|
||||
meta = { format = 3, mods = {} },
|
||||
defeatedTrainers = {},
|
||||
objectToggles = {},
|
||||
player = {},
|
||||
}
|
||||
SaveData.runMigrations(neverFought)
|
||||
check(not neverFought.objectToggles.GAME_CORNER,
|
||||
"undefeated Game Corner rocket is not auto-hidden")
|
||||
|
||||
love.filesystem = realFS
|
||||
end
|
||||
|
||||
|
||||
+99
-5
@@ -476,7 +476,9 @@ check(title.version and title.version.path
|
||||
"the shipped version ribbon loads")
|
||||
|
||||
-- issue #128: title SGB zones must resolve Blue's LOGO1 (blue ribbon),
|
||||
-- not Red's, when the ROM pack carries Blue SuperPalettes
|
||||
-- not Red's, when the ROM pack carries Blue SuperPalettes. Color 0 is
|
||||
-- forced to pure white so ROM's {255,239,255} does not paint a pink band
|
||||
-- against GBC-pack LOGO2/MEWMON whites.
|
||||
do
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
@@ -496,17 +498,109 @@ do
|
||||
PaletteFX.setMode("redpp")
|
||||
GameVersion.set("blue")
|
||||
local zones = TitleState.sgbPalettes(title, game)
|
||||
check(zones and zones[2] and zones[2].colors == blueLogo1,
|
||||
"Blue title ribbon zone uses ROM LOGO1 under RED++")
|
||||
local ribbon = zones and zones[2] and zones[2].colors
|
||||
check(ribbon and ribbon[4][1] == 115 and ribbon[4][3] == 239,
|
||||
"Blue title ribbon zone keeps ROM LOGO1 blue ink under RED++")
|
||||
check(ribbon and ribbon[1][1] == 255 and ribbon[1][2] == 255
|
||||
and ribbon[1][3] == 255,
|
||||
"Blue title ribbon white is pure (no pink SGB band)")
|
||||
GameVersion.set("red")
|
||||
zones = TitleState.sgbPalettes(title, game)
|
||||
local gbcLogo1 = PaletteFX.gbcPack().palettes.LOGO1
|
||||
check(zones and zones[2] and zones[2].colors == gbcLogo1,
|
||||
"Red title under RED++ keeps gbc-pack LOGO1 even if ROM has Blue's")
|
||||
ribbon = zones and zones[2] and zones[2].colors
|
||||
check(ribbon and ribbon[4][1] == gbcLogo1[4][1]
|
||||
and ribbon[4][2] == gbcLogo1[4][2]
|
||||
and ribbon[4][3] == gbcLogo1[4][3],
|
||||
"Red title under RED++ keeps gbc-pack LOGO1 ink even if ROM has Blue's")
|
||||
GameVersion.set(prevVer)
|
||||
PaletteFX.setMode(prevMode)
|
||||
end
|
||||
|
||||
-- issue #133: title menu / continue overlays must not inherit LOGO2/LOGO1
|
||||
-- (blue/red UI ink). A trailing trueColor zone covers the overlay box.
|
||||
do
|
||||
local logo2 = {
|
||||
{ 255, 255, 255 }, { 230, 197, 0 }, { 148, 156, 148 }, { 41, 99, 181 },
|
||||
}
|
||||
local logo1 = {
|
||||
{ 255, 255, 255 }, { 247, 247, 140 }, { 140, 189, 82 }, { 173, 0, 33 },
|
||||
}
|
||||
local mewmon = {
|
||||
{ 255, 239, 255 }, { 247, 181, 140 }, { 132, 115, 156 }, { 25, 16, 16 },
|
||||
}
|
||||
local game = {
|
||||
data = { palettes = { palettes = {
|
||||
LOGO1 = logo1, LOGO2 = logo2, MEWMON = mewmon,
|
||||
} } },
|
||||
stack = newStack(),
|
||||
}
|
||||
local bare = TitleState.sgbPalettes(title, game)
|
||||
check(bare and #bare == 3 and bare[1].colors == logo2,
|
||||
"bare title keeps three LOGO/MEWMON zones")
|
||||
check(not bare[4], "bare title has no overlay trueColor zone")
|
||||
|
||||
game.stack:push(title)
|
||||
-- openMenu needs SaveData/hasSave; stub a no-save menu via the same stamp
|
||||
local Menu = require("src.ui.Menu")
|
||||
local menu = Menu.new(game, { { label = "NEW GAME" } },
|
||||
{ tx = 0, ty = 0, tw = 13, th = 4 })
|
||||
menu.titleUiBox = { 0, 0, 12, 3 }
|
||||
game.stack:push(menu)
|
||||
local withMenu = TitleState.sgbPalettes(title, game)
|
||||
check(withMenu and #withMenu == 4 and withMenu[4].colors == false,
|
||||
"title menu adds a trueColor overlay zone")
|
||||
check(withMenu[4].x == 0 and withMenu[4].y == 0
|
||||
and withMenu[4].w == 13 * 8 and withMenu[4].h == 4 * 8,
|
||||
"menu overlay covers the CONTINUE/NEW GAME box")
|
||||
|
||||
game.stack:pop()
|
||||
game.stack:push({ titleUiBox = { 4, 7, 19, 16 } })
|
||||
local withCont = TitleState.sgbPalettes(title, game)
|
||||
check(withCont and #withCont == 4 and withCont[4].colors == false,
|
||||
"continue-info overlay adds a trueColor zone")
|
||||
check(withCont[4].x == 4 * 8 and withCont[4].y == 7 * 8
|
||||
and withCont[4].w == 16 * 8 and withCont[4].h == 10 * 8,
|
||||
"continue overlay matches DisplayContinueGameInfo's box")
|
||||
|
||||
-- openMenu itself must stamp titleUiBox on the real Menu it pushes
|
||||
while game.stack:top() do game.stack:pop() end
|
||||
title.game = game
|
||||
title:openMenu()
|
||||
local opened = game.stack:top()
|
||||
check(opened and opened.titleUiBox
|
||||
and opened.titleUiBox[1] == 0 and opened.titleUiBox[3] == 12,
|
||||
"openMenu stamps titleUiBox on the pushed Menu")
|
||||
end
|
||||
|
||||
-- Options / mod manager opened from the title must not inherit LOGO1
|
||||
-- (third options box = rows 8-9 would otherwise tint pink).
|
||||
do
|
||||
local OptionsMenu = require("src.ui.OptionsMenu")
|
||||
local ManagerState = require("src.mods.ManagerState")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local mewmon = {
|
||||
{ 255, 255, 255 }, { 239, 156, 107 }, { 115, 33, 165 }, { 0, 0, 0 },
|
||||
}
|
||||
local game = { data = { palettes = { palettes = { MEWMON = mewmon } } },
|
||||
save = { options = {} } }
|
||||
local optZones = OptionsMenu.sgbPalettes(OptionsMenu, game)
|
||||
check(optZones and #optZones == 1 and optZones[1].colors == mewmon,
|
||||
"OptionsMenu owns a whole-screen MEWMON zone")
|
||||
local modZones = ManagerState.sgbPalettes(ManagerState, game)
|
||||
check(modZones and #modZones == 1 and modZones[1].colors == mewmon,
|
||||
"ManagerState owns a whole-screen MEWMON zone")
|
||||
-- stack walk: with Options on top of Title, Game would pick Options
|
||||
local titleZones = TitleState.sgbPalettes(title, {
|
||||
data = { palettes = { palettes = {
|
||||
LOGO1 = { { 255, 239, 255 }, { 1, 2, 3 }, { 4, 5, 6 }, { 115, 156, 239 } },
|
||||
LOGO2 = mewmon, MEWMON = mewmon,
|
||||
} } },
|
||||
})
|
||||
check(titleZones and titleZones[2].colors[1][2] == 255,
|
||||
"title LOGO1 sanitize still pure-white with pink ROM input")
|
||||
check(PaletteFX.wholeNamed, "PaletteFX.wholeNamed still available for menus")
|
||||
end
|
||||
|
||||
local OakSpeech = require("src.ui.OakSpeech")
|
||||
local ogame = { data = {
|
||||
field = { oakSpeech = { music = "X_Song", demoSpecies = "PIKACHU" } },
|
||||
|
||||
+44
-6
@@ -7,6 +7,7 @@
|
||||
-- over later, at TEXT_OAKSLAB_OAK1's .give_poke_balls beat, gated on
|
||||
-- EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE and the one-shot
|
||||
-- EVENT_GOT_POKEBALLS_FROM_OAK flag (data/scripts/oaks_lab.lua).
|
||||
-- Also #137: starter give_pokemon runs AskName (nickname yes/no).
|
||||
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")
|
||||
@@ -21,6 +22,8 @@ local StateStack = require("src.core.StateStack")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local ScriptRunner = require("src.script.ScriptRunner")
|
||||
local Flags = require("src.script.Flags")
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
local NamingScreen = require("src.ui.NamingScreen")
|
||||
local mapScripts = require("data.scripts.init")
|
||||
|
||||
Game.data = Data
|
||||
@@ -29,16 +32,15 @@ Game.stack = StateStack; StateStack:init()
|
||||
Game.save = SaveData.newGame()
|
||||
require("src.render.Font").load(Data)
|
||||
|
||||
-- pumps a script coroutine to completion, mashing A through any
|
||||
-- show_text/ask boxes along the way (mirrors tests/run_tests.lua's
|
||||
-- runScript helper for the parcel/pokedex chain)
|
||||
local function runScript(script)
|
||||
-- pumps a script coroutine to completion; pressFn returns the Input.pressed
|
||||
-- table for this frame (default: mash A through text/ask/naming)
|
||||
local function runScript(script, pressFn)
|
||||
local r = ScriptRunner.new(Game, nil)
|
||||
r:run(script, {})
|
||||
local guard = 0
|
||||
while r:isRunning() and guard < 2000 do
|
||||
while r:isRunning() and guard < 4000 do
|
||||
guard = guard + 1
|
||||
Input.pressed = { a = true }
|
||||
Input.pressed = pressFn and pressFn() or { a = true }
|
||||
StateStack:update(1 / 60)
|
||||
r:update()
|
||||
end
|
||||
@@ -58,6 +60,11 @@ check(runScript(mapScripts.talkScript("OAKS_LAB", "TEXT_OAKSLAB_BULBASAUR_POKE_B
|
||||
"starter pick script completes")
|
||||
check(Flags.get(Game.save, "EVENT_GOT_STARTER"), "starter flag set")
|
||||
eq(Game.save.inventory.POKE_BALL, nil, "no POKe BALLs yet right after picking a starter")
|
||||
check(Game.save.party[1] and Game.save.party[1].species == "BULBASAUR",
|
||||
"starter joined the party")
|
||||
-- A-mash accepts the nickname prompt and fills NamingScreen with A's
|
||||
check(Game.save.party[1].nickname == "AAAAAAAAAA",
|
||||
"starter nickname prompt accepted (AskName / #137)")
|
||||
|
||||
-- === 2) the parcel/pokedex beat still doesn't grant POKé BALLs ===
|
||||
check(runScript(mapScripts.talkScript("VIRIDIAN_MART", "TEXT_VIRIDIANMART_CLERK")),
|
||||
@@ -107,4 +114,35 @@ check(repaired.OAKSLAB_POKEDEX1 == false
|
||||
and repaired.OAKSLAB_POKEDEX2 == false,
|
||||
"onEnter hides both Pokédex table sprites when EVENT_GOT_POKEDEX is set")
|
||||
|
||||
-- === 6) #137: declining the starter nickname leaves no nickname ===
|
||||
-- Choice boxes in order: (1) "you want X?" YES, (2) nickname YES/NO -> NO
|
||||
Game.save = SaveData.newGame()
|
||||
Flags.set(Game.save, "EVENT_FOLLOWED_OAK_INTO_LAB")
|
||||
local choicesSeen, lastChoice = 0, nil
|
||||
local function declineNickname()
|
||||
local top = StateStack:top()
|
||||
local mt = getmetatable(top)
|
||||
if mt == ChoiceBox and top ~= lastChoice then
|
||||
choicesSeen = choicesSeen + 1
|
||||
lastChoice = top
|
||||
elseif mt ~= ChoiceBox then
|
||||
lastChoice = nil
|
||||
end
|
||||
if mt == ChoiceBox and choicesSeen >= 2 then
|
||||
return { b = true }
|
||||
end
|
||||
if mt == NamingScreen then
|
||||
return { start = true }
|
||||
end
|
||||
return { a = true }
|
||||
end
|
||||
check(runScript(mapScripts.talkScript("OAKS_LAB", "TEXT_OAKSLAB_CHARMANDER_POKE_BALL"),
|
||||
declineNickname),
|
||||
"starter pick with declined nickname completes")
|
||||
check(Flags.get(Game.save, "EVENT_GOT_STARTER"), "declined-nickname path still sets starter flag")
|
||||
eq(Game.save.party[1] and Game.save.party[1].species, "CHARMANDER",
|
||||
"declined-nickname path still gives Charmander")
|
||||
eq(Game.save.party[1] and Game.save.party[1].nickname, nil,
|
||||
"declining nickname leaves the species name")
|
||||
|
||||
S.finish()
|
||||
|
||||
@@ -187,4 +187,55 @@ check(OW.objectVisible(Game.save, "SEAFOAM_ISLANDS_B4F",
|
||||
objOf("SEAFOAM_ISLANDS_B4F", "SEAFOAMISLANDSB4F_BOULDER2")),
|
||||
"SEAFOAMISLANDSB4F_BOULDER2 is visible at the end state")
|
||||
|
||||
-- === issue #129: B3F current auto-warps via BIT_FORCED_WARP ===
|
||||
-- South-edge stairs at (20,17) are water warps (not door/warp tiles), so
|
||||
-- CheckWarpsNoCollision needs BIT_FORCED_WARP (or a held d-pad). B3F
|
||||
-- currents set that bit; after the RLE lands, the warp must fire without
|
||||
-- further player input.
|
||||
do
|
||||
local FieldDefaults = require("src.world.FieldDefaults")
|
||||
check(FieldDefaults.fieldValue(Data, "seafoam", "SEAFOAM_ISLANDS_B3F",
|
||||
"setsForcedWarp") == true,
|
||||
"B3F seafoam setsForcedWarp (BIT_FORCED_WARP) is configured")
|
||||
|
||||
while Game.stack:top() do Game.stack:pop() end
|
||||
Game.save = SaveData.newGame()
|
||||
Game.save.flags = {}
|
||||
-- Landing on the B3F force-surf mouth mounts SURF and arms the current
|
||||
-- (EnterMap CheckForceBikeOrSurf + Seafoam MOVE_OBJECT), including
|
||||
-- BIT_FORCED_WARP for the south-edge stairs.
|
||||
Game.stack:push(OW, "SEAFOAM_ISLANDS_B3F", 18, 7, "down")
|
||||
local cur = Game.stack:top()
|
||||
check(cur.player.surfing == true,
|
||||
"B3F force-surf mouth mounts SURF on map entry")
|
||||
check(cur.forcedWarp == true,
|
||||
"B3F current arms forcedWarp (BIT_FORCED_WARP)")
|
||||
check(#cur.scriptMoves > 0 or cur.player.moving,
|
||||
"unplugged B3F current at (18,7) starts forced surfing RLE")
|
||||
|
||||
-- Fast-forward the scripted current onto the south-edge warp.
|
||||
local guard = 0
|
||||
while guard < 2000 do
|
||||
guard = guard + 1
|
||||
StateStack:update(1 / 60)
|
||||
if cur.map and cur.map.id ~= "SEAFOAM_ISLANDS_B3F" then break end
|
||||
if cur.transitioning then break end
|
||||
end
|
||||
-- Drain the warp Transition onto B4F (and any immediate forcedExit shove).
|
||||
guard = 0
|
||||
while guard < 400 do
|
||||
guard = guard + 1
|
||||
StateStack:update(1 / 60)
|
||||
if Game.stack:top() == cur and cur.map and cur.map.id == "SEAFOAM_ISLANDS_B4F"
|
||||
and not cur.transitioning and #cur.scriptMoves == 0
|
||||
and not cur.player.moving then
|
||||
break
|
||||
end
|
||||
end
|
||||
check(cur.map and cur.map.id == "SEAFOAM_ISLANDS_B4F",
|
||||
"B3F current auto-warps to B4F without a held d-pad (#129)")
|
||||
check(cur.player.cellX == 20 or cur.player.cellX == 21,
|
||||
"forced current lands on the B4F stair-warp column")
|
||||
end
|
||||
|
||||
S.finish()
|
||||
|
||||
@@ -79,6 +79,58 @@ do
|
||||
"the queued trap anim row is attributed to the attacker")
|
||||
end
|
||||
|
||||
-- (2b) Issue #140: being held by Wrap must NOT skip DisplayBattleMenu
|
||||
-- (core.asm:312 then 323-329). FIGHT forces CANNOT_MOVE; AI still
|
||||
-- auto-selects bound. A player switch clears the foe's trap bit
|
||||
-- (SendOutMon core.asm:1761-1762).
|
||||
do
|
||||
local tb = freshBattle()
|
||||
tb.enemy.trappingTurns = 2
|
||||
tb.enemy.trapMove = "WRAP"
|
||||
tb.enemy.trapDamage = 5
|
||||
check(tb:menuLockedAction(tb.player) == nil,
|
||||
"a Wrap victim still gets the battle menu")
|
||||
local fight = tb:fightLockedAction(tb.player)
|
||||
check(fight and fight.special == "bound",
|
||||
"FIGHT while wrapped selects CANNOT_MOVE (bound)")
|
||||
-- wrapper continues only after FIGHT, not by skipping the menu
|
||||
check(tb:menuLockedAction(tb.enemy) == nil,
|
||||
"a Wrap user still gets the battle menu")
|
||||
check(tb:fightLockedAction(tb.enemy) and
|
||||
tb:fightLockedAction(tb.enemy).special == "trapping",
|
||||
"FIGHT while wrapping continues the trap")
|
||||
check(tb:lockedAction(tb.player) and tb:lockedAction(tb.player).special == "bound",
|
||||
"lockedAction still reports bound for AI / callers")
|
||||
end
|
||||
do
|
||||
Game.save.party = {
|
||||
Pokemon.new(Data, "BULBASAUR", 20),
|
||||
Pokemon.new(Data, "SQUIRTLE", 20),
|
||||
}
|
||||
local tb = BattleState.newWild(Game, "EKANS", 10)
|
||||
tb.enemy.trappingTurns = 3
|
||||
tb.enemy.trapMove = "WRAP"
|
||||
tb.enemy.trapDamage = 7
|
||||
local acts = {}
|
||||
function tb:act(fn) acts[#acts + 1] = fn end
|
||||
function tb:actNext(fn) acts[#acts + 1] = fn end
|
||||
function tb:sayNext() end
|
||||
function tb:animNext() end
|
||||
function tb:startGrowIn() end
|
||||
function tb:syncSides() end
|
||||
function tb:markParticipant() end
|
||||
function tb:restoreMimicked() end
|
||||
function tb:executeAction() end
|
||||
function tb:endOfTurn() end
|
||||
function tb:enemyAction() return { special = "bound" } end
|
||||
tb:resolveSwitch(Game.save.party[2])
|
||||
acts[1]() -- send-out clears foe trap
|
||||
eq(tb.enemy.trappingTurns, nil, "player switch clears foe Wrap/Bind/etc.")
|
||||
eq(tb.enemy.trapMove, nil, "player switch clears trapMove")
|
||||
check(tb:fightLockedAction(tb.player) == nil,
|
||||
"switch-in is free to choose a move")
|
||||
end
|
||||
|
||||
-- (3) MIMIC runs MID-move (MimicEffect, effects.asm:1203-1273): the
|
||||
-- move executes, MoveHitTest runs, and only on a hit does the player's
|
||||
-- copy menu open (.letPlayerChooseMove) -- the enemy's Mimic and link
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
-- Parity test: trainer next-mon after an AI switch must skip fainted slots.
|
||||
--
|
||||
-- Agatha (and any switchChance / switch AI) can withdraw mon 1, send mon 2,
|
||||
-- then later return to mon 1. If mon 2 was KO'd in between, enemyMonFainted
|
||||
-- used to do enemyIndex+1 and send the already-fainted Golbat back out.
|
||||
-- The FIGHT menu reappears with an empty enemy HP bar; executeAction then
|
||||
-- returns immediately on target.mon.hp <= 0 -- a softlock.
|
||||
--
|
||||
-- pokered's EnemySendOutFirstMon / AnyEnemyPokemonAliveCheck scan the party
|
||||
-- for the first mon with HP remaining (core.asm), not "current index + 1".
|
||||
--
|
||||
-- Self-contained; run via `luajit tests/parity_ai_switch_faint.lua`.
|
||||
-- Also picked up by tests/run_tests.lua's parity_* glob.
|
||||
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.pokemon and Data.pokemon.RATTATA) then Data:load() end
|
||||
local TypeChart = require("src.battle.TypeChart")
|
||||
TypeChart.load(Data)
|
||||
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local S = require("tests.harness").suite("parity ai switch faint")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
-- Minimal game stub matching what BattleState.newTrainer / enemyMonFainted
|
||||
-- touch. SET style skips the SHIFT "change POKéMON?" ChoiceBox.
|
||||
local function freshGame()
|
||||
return {
|
||||
data = Data,
|
||||
save = {
|
||||
party = { Pokemon.new(Data, "BULBASAUR", 50) },
|
||||
player = { name = "RED" },
|
||||
inventory = {},
|
||||
options = { battleStyle = "set" },
|
||||
pokedex = { seen = {}, owned = {} },
|
||||
flags = {},
|
||||
money = 0,
|
||||
},
|
||||
stack = { push = function() end, pop = function() end, top = function() end },
|
||||
}
|
||||
end
|
||||
|
||||
-- Drain act/say rows until the enemy swap act runs (or the queue empties).
|
||||
local function pumpUntilSwap(b, limit)
|
||||
limit = limit or 200
|
||||
local n = 0
|
||||
while #b.queue > 0 and n < limit do
|
||||
n = n + 1
|
||||
local item = table.remove(b.queue, 1)
|
||||
if item.fn then
|
||||
b.nextInsert = 0
|
||||
item.fn()
|
||||
-- the send-out act replaces self.enemy; stop once a living mon is in
|
||||
if b.enemy and b.enemy.mon and b.enemy.mon.hp > 0 then
|
||||
return true
|
||||
end
|
||||
end
|
||||
end
|
||||
return b.enemy and b.enemy.mon and b.enemy.mon.hp > 0
|
||||
end
|
||||
|
||||
-- #143: after AI-switch reorder, do not send a fainted slot.
|
||||
do
|
||||
local Game = freshGame()
|
||||
local b = BattleState.newTrainer(Game, "OPP_AGATHA", 1)
|
||||
check(#b.enemyParty >= 3, "Agatha has enough party slots for the scenario")
|
||||
|
||||
-- Simulate: switched to slot 2, KO'd it, switched back to slot 1, KO slot 1.
|
||||
b.enemyParty[1].hp = 0
|
||||
b.enemyParty[2].hp = 0
|
||||
-- slots 3+ stay at full HP from construction
|
||||
b.enemyIndex = 1
|
||||
b.enemy.mon = b.enemyParty[1]
|
||||
b.participants = { [Game.save.party[1]] = true }
|
||||
|
||||
b:enemyMonFainted()
|
||||
check(pumpUntilSwap(b), "a living reserve is sent out after the KO")
|
||||
eq(b.enemyIndex, 3, "next index is the first living mon, not fainted slot 2")
|
||||
check(b.enemy.mon.hp > 0, "sent-out mon has HP (no empty-bar softlock)")
|
||||
check(b.enemy.mon == b.enemyParty[3], "sent-out battler is party slot 3")
|
||||
check(b.result ~= "win", "battle continues while reserves remain")
|
||||
end
|
||||
|
||||
-- Sequential KOs (no AI switch) still advance one slot at a time.
|
||||
do
|
||||
local Game = freshGame()
|
||||
local b = BattleState.newTrainer(Game, "OPP_YOUNGSTER", 1)
|
||||
b.enemyParty[1].hp = 0
|
||||
b.enemyIndex = 1
|
||||
b.enemy.mon = b.enemyParty[1]
|
||||
b.participants = { [Game.save.party[1]] = true }
|
||||
b:enemyMonFainted()
|
||||
check(pumpUntilSwap(b), "youngster still sends the second mon")
|
||||
eq(b.enemyIndex, 2, "without AI switch, first living mon is still index 2")
|
||||
check(b.enemy.mon.hp > 0, "second mon is healthy")
|
||||
end
|
||||
|
||||
-- No living reserves: victory even if enemyIndex is mid-party (AI left
|
||||
-- earlier slots dead and the active one was not the last index).
|
||||
do
|
||||
local Game = freshGame()
|
||||
local b = BattleState.newTrainer(Game, "OPP_AGATHA", 1)
|
||||
for _, mon in ipairs(b.enemyParty) do mon.hp = 0 end
|
||||
b.enemyIndex = 2
|
||||
b.enemy.mon = b.enemyParty[2]
|
||||
b.participants = { [Game.save.party[1]] = true }
|
||||
b:enemyMonFainted()
|
||||
eq(b.result, "win", "all-fainted party ends the trainer battle")
|
||||
eq(b.afterQueue, "finish", "victory finishes after the queue")
|
||||
end
|
||||
|
||||
S.finish()
|
||||
@@ -0,0 +1,57 @@
|
||||
-- Parity test: Red's bedroom SNES (#135).
|
||||
--
|
||||
-- pokered hidden_events.asm REDS_HOUSE_2F:
|
||||
-- hidden_event 3, 5, PrintRedSNESText, ANY_FACING
|
||||
-- PrintRedSNESText shows _RedBedroomSNESText ("{PLAYER} is playing the SNES!").
|
||||
--
|
||||
-- Self-contained; run via `luajit tests/parity_red_snes.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.REDS_HOUSE_2F) then Data:load() end
|
||||
local Font = require("src.render.Font")
|
||||
if not pcall(Font.encode, "A") then Font.load(Data) end
|
||||
require("data.scripts.init")
|
||||
local MapScripts = require("src.script.MapScripts")
|
||||
local S = require("tests.harness").suite("parity red snes")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
check(Data.text._RedBedroomSNESText ~= nil, "_RedBedroomSNESText is extracted")
|
||||
check(Data.text._RedBedroomSNESText:find("SNES", 1, true),
|
||||
"_RedBedroomSNESText mentions the SNES")
|
||||
|
||||
local hooks = MapScripts.get("REDS_HOUSE_2F")
|
||||
check(hooks and type(hooks.onInteract) == "function",
|
||||
"REDS_HOUSE_2F registers onInteract for the SNES")
|
||||
|
||||
-- stub just enough of the overworld/game stack to exercise the hook
|
||||
local pushed
|
||||
local game = {
|
||||
data = Data,
|
||||
save = { player = { name = "RED", rival = "BLUE" } },
|
||||
stack = {
|
||||
push = function(_, state) pushed = state end,
|
||||
},
|
||||
}
|
||||
local ow = { player = { facing = "up" } }
|
||||
|
||||
eq(hooks.onInteract(game, ow, 0, 1), false,
|
||||
"bedroom PC tile is not claimed by the SNES hook")
|
||||
eq(hooks.onInteract(game, ow, 3, 6), false,
|
||||
"spawn tile is not claimed by the SNES hook")
|
||||
|
||||
pushed = nil
|
||||
eq(hooks.onInteract(game, ow, 3, 5), true,
|
||||
"SNES at (3,5) consumes the interact")
|
||||
check(pushed ~= nil and pushed.pages ~= nil, "SNES interact pushes a TextBox")
|
||||
local flat = table.concat(pushed.pages[1] or {}, "\n")
|
||||
check(flat:find("SNES", 1, true) and flat:find("RED", 1, true),
|
||||
"SNES TextBox shows the bedroom SNES line with the player name")
|
||||
|
||||
-- ANY_FACING: facing is irrelevant once the faced cell is (3,5)
|
||||
ow.player.facing = "left"
|
||||
pushed = nil
|
||||
eq(hooks.onInteract(game, ow, 3, 5), true,
|
||||
"SNES still fires when the player faces it from the side")
|
||||
|
||||
S.finish()
|
||||
@@ -222,16 +222,21 @@ check(runScript("VERMILION_TRADE_HOUSE", "TEXT_VERMILIONTRADEHOUSE_LITTLE_GIRL")
|
||||
"DUX post-trade script completes")
|
||||
shownIs({ "_AfterTrade3Text" }, "DUX uses the happy dialogset")
|
||||
|
||||
-- === 9) Celadon Eevee: no confirm prompt, GotMonText, ball hidden ===
|
||||
-- === 9) Celadon Eevee: no confirm prompt, AskName then GotMonText,
|
||||
-- ball hidden (GivePokemon -> AddPartyMon AskName; script still
|
||||
-- prints GotMonText after the silent give_pokemon row) ===
|
||||
local EEVEE_MAP, EEVEE_BALL =
|
||||
"CELADON_MANSION_ROOF_HOUSE", "CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL"
|
||||
local EEVEE_TEXT = "TEXT_CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL"
|
||||
Game.save = SaveData.newGame()
|
||||
check(runScript(EEVEE_MAP, EEVEE_TEXT), "Eevee ball script completes")
|
||||
shownIs({ "_GotMonText" }, "Eevee gives immediately (no ask prompt)")
|
||||
shownIs({ "_DoYouWantToNicknameText", "_GotMonText" },
|
||||
"Eevee gives immediately (nickname ask, then GotMonText)")
|
||||
eq(#Game.save.party, 1, "Eevee joins the party")
|
||||
eq(Game.save.party[1].species, "EEVEE", "gift species is EEVEE")
|
||||
eq(Game.save.party[1].level, 25, "Eevee is level 25")
|
||||
check(Game.save.party[1].nickname == "AAAAAAAAAA",
|
||||
"Eevee nickname prompt accepted (A-mash NamingScreen)")
|
||||
check(Flags.get(Game.save, "EVENT_GOT_EEVEE"), "EVENT_GOT_EEVEE bookkeeping set")
|
||||
eq(toggleOf(EEVEE_MAP, EEVEE_BALL), false, "the poke ball object is hidden")
|
||||
check(Game.save.pokedex.owned.EEVEE, "Eevee registered owned")
|
||||
|
||||
@@ -2445,6 +2445,49 @@ do
|
||||
end
|
||||
end
|
||||
|
||||
-- ================= issue #142: player backsprite X =================
|
||||
do
|
||||
-- pret/pokered places the player mon pic at hlcoord 1,5 (screen x=8).
|
||||
-- Drawing at x=16 put every backsprite one tile too far right.
|
||||
do
|
||||
local img = {
|
||||
getWidth = function() return 28 end,
|
||||
getHeight = function() return 28 end,
|
||||
}
|
||||
local battle = setmetatable({
|
||||
showPlayerBack = false,
|
||||
safari = false,
|
||||
demo = false,
|
||||
sendingOut = false,
|
||||
phase = "command",
|
||||
player = { sprite = img, isPlayer = true },
|
||||
}, BattleState)
|
||||
function battle:picImage(i) return i end
|
||||
function battle:growInScale() return nil end
|
||||
function battle:fxHidden() return false end
|
||||
|
||||
local xs = {}
|
||||
local origDraw = love.graphics.draw
|
||||
love.graphics.draw = function(drawn, x, y, r, sx, sy)
|
||||
if drawn == img then xs[#xs + 1] = x end
|
||||
end
|
||||
battle:drawPicsLayer(0, 0, 0)
|
||||
love.graphics.draw = origDraw
|
||||
eq(xs[1], 8, "player backsprite rests at hlcoord 1,5 (x=8)")
|
||||
|
||||
-- trainer/old-man back pic uses the same slot
|
||||
battle.showPlayerBack = true
|
||||
battle.playerBackPic = img
|
||||
xs = {}
|
||||
love.graphics.draw = function(drawn, x, y, r, sx, sy)
|
||||
if drawn == img then xs[#xs + 1] = x end
|
||||
end
|
||||
battle:drawPicsLayer(0, 0, 0)
|
||||
love.graphics.draw = origDraw
|
||||
eq(xs[1], 8, "player/old-man back pic also rests at x=8")
|
||||
end
|
||||
end
|
||||
|
||||
-- ================= BUGS.md batch: ledge-shadow =================
|
||||
do
|
||||
-- == Task 12: ledge-hop shadow is the 2x2 mirrored OAM block ==
|
||||
|
||||
Reference in New Issue
Block a user