diff --git a/README.md b/README.md index 175e8c90..ed4d9a97 100644 --- a/README.md +++ b/README.md @@ -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? -[![Buy Me a Coffee](https://img.shields.io/badge/Buy_Me_a_Coffee-FFDD00?style=for-the-badge&logo=buy-me-a-coffee&logoColor=black)](https://buymeacoffee.com/bryanthaboi) - diff --git a/data/scripts/flavor/reds_house_2f.lua b/data/scripts/flavor/reds_house_2f.lua new file mode 100644 index 00000000..357f2015 --- /dev/null +++ b/data/scripts/flavor/reds_house_2f.lua @@ -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, + }, +} diff --git a/data/scripts/flavor_all.lua b/data/scripts/flavor_all.lua index efb5d45f..2d03324d 100644 --- a/data/scripts/flavor_all.lua +++ b/data/scripts/flavor_all.lua @@ -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", diff --git a/data/scripts/oaks_lab.lua b/data/scripts/oaks_lab.lua index 20082832..c092467f 100644 --- a/data/scripts/oaks_lab.lua +++ b/data/scripts/oaks_lab.lua @@ -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 diff --git a/data/scripts/story3.lua b/data/scripts/story3.lua index bc77679d..e542c48b 100644 --- a/data/scripts/story3.lua +++ b/data/scripts/story3.lua @@ -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) diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 87edf7e8..2fefd017 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -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 diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index 47b0dd99..9d810c79 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -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. diff --git a/src/core/Version.lua b/src/core/Version.lua index 0465b82a..914fe00b 100644 --- a/src/core/Version.lua +++ b/src/core/Version.lua @@ -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) } diff --git a/src/link/LinkBattle.lua b/src/link/LinkBattle.lua index 2826c2f8..dce7f012 100644 --- a/src/link/LinkBattle.lua +++ b/src/link/LinkBattle.lua @@ -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 diff --git a/src/mods/ManagerState.lua b/src/mods/ManagerState.lua index 53cb81f5..6a438613 100644 --- a/src/mods/ManagerState.lua +++ b/src/mods/ManagerState.lua @@ -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 = "?" } diff --git a/src/script/Commands.lua b/src/script/Commands.lua index 96ed0ebf..9b23ef81 100644 --- a/src/script/Commands.lua +++ b/src/script/Commands.lua @@ -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 : _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 {} diff --git a/src/ui/OptionsMenu.lua b/src/ui/OptionsMenu.lua index 767df6ee..1fa6b396 100644 --- a/src/ui/OptionsMenu.lua +++ b/src/ui/OptionsMenu.lua @@ -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 diff --git a/src/ui/TitleState.lua b/src/ui/TitleState.lua index 20e1138d..bc637b45 100644 --- a/src/ui/TitleState.lua +++ b/src/ui/TitleState.lua @@ -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) diff --git a/src/world/FieldDefaults.lua b/src/world/FieldDefaults.lua index e1b97338..4b1027b6 100644 --- a/src/world/FieldDefaults.lua +++ b/src/world/FieldDefaults.lua @@ -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", diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 82c888f1..7a983f68 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -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 diff --git a/tests/mod_save_tests.lua b/tests/mod_save_tests.lua index 9641c084..041047fb 100644 --- a/tests/mod_save_tests.lua +++ b/tests/mod_save_tests.lua @@ -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 diff --git a/tests/mod_ui_tests.lua b/tests/mod_ui_tests.lua index d994c6f5..7eb4f73b 100644 --- a/tests/mod_ui_tests.lua +++ b/tests/mod_ui_tests.lua @@ -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" } }, diff --git a/tests/parity_F.lua b/tests/parity_F.lua index 7964565c..691375e5 100644 --- a/tests/parity_F.lua +++ b/tests/parity_F.lua @@ -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() diff --git a/tests/parity_H.lua b/tests/parity_H.lua index 1dadc4c6..2a0bda2c 100644 --- a/tests/parity_H.lua +++ b/tests/parity_H.lua @@ -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() diff --git a/tests/parity_J.lua b/tests/parity_J.lua index 1882b527..8113c72b 100644 --- a/tests/parity_J.lua +++ b/tests/parity_J.lua @@ -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 diff --git a/tests/parity_ai_switch_faint.lua b/tests/parity_ai_switch_faint.lua new file mode 100644 index 00000000..db128f57 --- /dev/null +++ b/tests/parity_ai_switch_faint.lua @@ -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() diff --git a/tests/parity_red_snes.lua b/tests/parity_red_snes.lua new file mode 100644 index 00000000..67f409f1 --- /dev/null +++ b/tests/parity_red_snes.lua @@ -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() diff --git a/tests/parity_trade_gift.lua b/tests/parity_trade_gift.lua index 34cc91db..340c4fb7 100644 --- a/tests/parity_trade_gift.lua +++ b/tests/parity_trade_gift.lua @@ -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") diff --git a/tests/run_tests.lua b/tests/run_tests.lua index 19948154..e531096c 100644 --- a/tests/run_tests.lua +++ b/tests/run_tests.lua @@ -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 ==