mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-22 05:26:45 +02:00
Fix transition state pop, Gen 2 Bide/Sleep Talk/exp bugs, pp repair, TM pocket capacity and Bill's PC arrows
CLOSES #1663, CLOSES #1664, CLOSES #1665, CLOSES #1666, CLOSES #1667, CLOSES #1668, CLOSES #1670, CLOSES #1672
This commit is contained in:
+112
-18
@@ -903,8 +903,12 @@ function Battle:movePriority(moveId)
|
|||||||
return (def and Battle.PRIORITY[def.effect]) or 0
|
return (def and Battle.PRIORITY[def.effect]) or 0
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- engine/battle/effect_commands.asm:192-197 (enemy twin :383-390)
|
||||||
|
Battle.SLEEP_BYPASS_MOVES = { SNORE = true, SLEEP_TALK = true }
|
||||||
|
|
||||||
-- Can this mon act? Returns true, or false plus the message the cart prints.
|
-- Can this mon act? Returns true, or false plus the message the cart prints.
|
||||||
function Battle:canAct(mon)
|
-- `moveId` is wCurPlayerMove / wCurEnemyMove (effect_commands.asm:193).
|
||||||
|
function Battle:canAct(mon, moveId)
|
||||||
local name = self:monName(mon)
|
local name = self:monName(mon)
|
||||||
-- SUBSTATUS_RECHARGE, and it is checked BEFORE status: CheckPlayerTurn reads
|
-- SUBSTATUS_RECHARGE, and it is checked BEFORE status: CheckPlayerTurn reads
|
||||||
-- it first, clears it, prints MustRechargeText and jumps to EndTurn, so a mon
|
-- it first, clears it, prints MustRechargeText and jumps to EndTurn, so a mon
|
||||||
@@ -925,7 +929,11 @@ function Battle:canAct(mon)
|
|||||||
local beforeMove = record and record.beforeMove
|
local beforeMove = record and record.beforeMove
|
||||||
if beforeMove
|
if beforeMove
|
||||||
and (record.beforeMovePriority or 0) > Battle.VOLATILE_PRIORITY then
|
and (record.beforeMovePriority or 0) > Battle.VOLATILE_PRIORITY then
|
||||||
return beforeMove(self, mon, name) and true or false
|
-- engine/battle/effect_commands.asm:188-200
|
||||||
|
local bypass = mon.status == "sleep" and Battle.SLEEP_BYPASS_MOVES[moveId]
|
||||||
|
local acted = beforeMove(self, mon, name) and true or false
|
||||||
|
if acted or not bypass then return acted end
|
||||||
|
beforeMove = nil
|
||||||
end
|
end
|
||||||
-- SUBSTATUS_FLINCHED, read and cleared right after the freeze check
|
-- SUBSTATUS_FLINCHED, read and cleared right after the freeze check
|
||||||
-- (CheckPlayerTurn / CheckEnemyTurn `.not_frozen`). Set this turn by the
|
-- (CheckPlayerTurn / CheckEnemyTurn `.not_frozen`). Set this turn by the
|
||||||
@@ -1389,7 +1397,14 @@ function Battle:useMove(attacker, defender, moveId)
|
|||||||
-- free of PP and obedience in exactly the same way.
|
-- free of PP and obedience in exactly the same way.
|
||||||
local rolling = state.rolloutLock == moveId
|
local rolling = state.rolloutLock == moveId
|
||||||
|
|
||||||
if not (charging or rampaging or rolling) then
|
-- engine/battle/effect_commands.asm:977-979, data/moves/effects.asm:795-800,
|
||||||
|
-- engine/battle/move_effects/bide.asm:62-68
|
||||||
|
local biding = def.effect == "EFFECT_BIDE" and state.bideTurns ~= nil
|
||||||
|
|
||||||
|
-- engine/battle/effect_commands.asm:6222-6234, :949-951
|
||||||
|
local called = (self.copyDepth or 0) > 0
|
||||||
|
|
||||||
|
if not (charging or rampaging or rolling or biding or called) then
|
||||||
if move and (move.pp or 0) <= 0 then
|
if move and (move.pp or 0) <= 0 then
|
||||||
self:emit({ kind = "message", text = "No PP left for this move!" })
|
self:emit({ kind = "message", text = "No PP left for this move!" })
|
||||||
return
|
return
|
||||||
@@ -1471,9 +1486,38 @@ function Battle:useMove(attacker, defender, moveId)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- engine/battle/move_effects/sleep_talk.asm:2, :16-19, :61
|
||||||
|
if def.effect == "EFFECT_SLEEP_TALK" then
|
||||||
|
local picked
|
||||||
|
if attacker.status == "sleep" and (self.copyDepth or 0) == 0 then
|
||||||
|
-- engine/battle/move_effects/sleep_talk.asm:40-44, :117-141
|
||||||
|
local pool = {}
|
||||||
|
for _, own in ipairs(attacker.moves or {}) do
|
||||||
|
local ownDef = self:moveDef(own.id)
|
||||||
|
local effect = ownDef and ownDef.effect
|
||||||
|
if own.id ~= moveId and not self:moveDisabled(attacker, own.id)
|
||||||
|
and not Effects.CHARGE[effect] and effect ~= "EFFECT_BIDE" then
|
||||||
|
pool[#pool + 1] = own.id
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if #pool > 0 then picked = pool[rand(self.random, #pool) + 1] end
|
||||||
|
end
|
||||||
|
if not picked then
|
||||||
|
self:markMissed()
|
||||||
|
self:emit({ kind = "message", text = "But it failed!" })
|
||||||
|
return
|
||||||
|
end
|
||||||
|
state.lastMove = nil
|
||||||
|
self.copyDepth = (self.copyDepth or 0) + 1
|
||||||
|
self:useMove(attacker, defender, picked)
|
||||||
|
self.copyDepth = self.copyDepth - 1
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
-- Everything past here counts as "the user's last move" for Mirror Move,
|
-- Everything past here counts as "the user's last move" for Mirror Move,
|
||||||
-- Encore and Disable.
|
-- Encore and Disable. A called move skips the write
|
||||||
state.lastMove = moveId
|
-- (engine/battle/used_move_text.asm:30-36).
|
||||||
|
if (self.copyDepth or 0) == 0 then state.lastMove = moveId end
|
||||||
state.turnsTaken = (state.turnsTaken or 0) + 1
|
state.turnsTaken = (state.turnsTaken or 0) + 1
|
||||||
state.usedMoves = state.usedMoves or {}
|
state.usedMoves = state.usedMoves or {}
|
||||||
local seen = false
|
local seen = false
|
||||||
@@ -1516,6 +1560,13 @@ function Battle:useMove(attacker, defender, moveId)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- BattleCommand_Snore (engine/battle/move_effects/snore.asm:1-9)
|
||||||
|
if def.effect == "EFFECT_SNORE" and attacker.status ~= "sleep" then
|
||||||
|
self:markMissed()
|
||||||
|
self:emit({ kind = "message", text = "But it failed!" })
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
-- Counter and Mirror Coat answer what the user took this turn, at double,
|
-- Counter and Mirror Coat answer what the user took this turn, at double,
|
||||||
-- and fail outright when nothing of the right kind landed.
|
-- and fail outright when nothing of the right kind landed.
|
||||||
local counterKind = Effects.COUNTER[def.effect]
|
local counterKind = Effects.COUNTER[def.effect]
|
||||||
@@ -2133,6 +2184,8 @@ Battle.MOVE_EFFECTS.EFFECT_BIDE = function(self, attacker, defender, def, moveId
|
|||||||
if not state.bideTurns then
|
if not state.bideTurns then
|
||||||
state.bideTurns = Effects.bideTurns(self.random)
|
state.bideTurns = Effects.bideTurns(self.random)
|
||||||
state.bideStored = 0
|
state.bideStored = 0
|
||||||
|
-- engine/battle/core.asm:574-576
|
||||||
|
state.bideMove = moveId
|
||||||
self:emit({ kind = "message",
|
self:emit({ kind = "message",
|
||||||
text = self:monName(attacker) .. " is storing energy!" })
|
text = self:monName(attacker) .. " is storing energy!" })
|
||||||
return
|
return
|
||||||
@@ -2144,7 +2197,7 @@ Battle.MOVE_EFFECTS.EFFECT_BIDE = function(self, attacker, defender, def, moveId
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
local damage = Effects.bideDamage(state.bideStored)
|
local damage = Effects.bideDamage(state.bideStored)
|
||||||
state.bideTurns, state.bideStored = nil, nil
|
state.bideTurns, state.bideStored, state.bideMove = nil, nil, nil
|
||||||
self:emit({ kind = "message",
|
self:emit({ kind = "message",
|
||||||
text = self:monName(attacker) .. " unleashed energy!" })
|
text = self:monName(attacker) .. " unleashed energy!" })
|
||||||
if damage <= 0 then return fail(self) end
|
if damage <= 0 then return fail(self) end
|
||||||
@@ -3092,6 +3145,12 @@ end
|
|||||||
|
|
||||||
-- Faint bookkeeping and experience. Returns true when the battle ended.
|
-- Faint bookkeeping and experience. Returns true when the battle ended.
|
||||||
function Battle:resolveFaints()
|
function Battle:resolveFaints()
|
||||||
|
-- engine/battle/core.asm:2551-2556, :7116-7130, :3033-3037
|
||||||
|
if (self.player.hp or 0) <= 0 and self.participantsCleared ~= self.player then
|
||||||
|
self.participantsCleared = self.player
|
||||||
|
if self.playerIndex then self.participants[self.playerIndex] = nil end
|
||||||
|
end
|
||||||
|
|
||||||
if (self.enemy.hp or 0) <= 0 then
|
if (self.enemy.hp or 0) <= 0 then
|
||||||
self:emit({ kind = "faint", side = "enemy",
|
self:emit({ kind = "faint", side = "enemy",
|
||||||
text = (self.wild and "Wild " or "") .. self:monName(self.enemy)
|
text = (self.wild and "Wild " or "") .. self:monName(self.enemy)
|
||||||
@@ -3547,6 +3606,7 @@ function Battle:switch(index)
|
|||||||
-- A mon that comes back (a REVIVE, or a second battle) has to be able to
|
-- A mon that comes back (a REVIVE, or a second battle) has to be able to
|
||||||
-- announce its own faint again; see resolveFaints.
|
-- announce its own faint again; see resolveFaints.
|
||||||
self.faintAnnounced = nil
|
self.faintAnnounced = nil
|
||||||
|
self.participantsCleared = nil
|
||||||
-- ForcePlayerMonChoice has been answered, so the next faint may ask again.
|
-- ForcePlayerMonChoice has been answered, so the next faint may ask again.
|
||||||
self.pendingSwitch = nil
|
self.pendingSwitch = nil
|
||||||
self.player = mon
|
self.player = mon
|
||||||
@@ -3784,19 +3844,37 @@ function Battle:lockedInMove(mon)
|
|||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- ParsePlayerAction's bide arm (engine/battle/core.asm:569-576), enemy twin
|
||||||
|
-- at :5650
|
||||||
|
function Battle:fightLockedMove(mon)
|
||||||
|
local state = self:volatile(mon)
|
||||||
|
if state.bideTurns then return state.bideMove end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- engine/battle/core.asm:627-629
|
||||||
|
function Battle:cancelBide(mon)
|
||||||
|
local state = self:volatile(mon)
|
||||||
|
state.bideTurns, state.bideStored, state.bideMove = nil, nil, nil
|
||||||
|
end
|
||||||
|
|
||||||
-- Encore forces the move; Disable forbids one. Both are read by the screen
|
-- Encore forces the move; Disable forbids one. Both are read by the screen
|
||||||
-- (to grey out the move list) and by the enemy's own choice below.
|
-- (to grey out the move list) and by the enemy's own choice below.
|
||||||
function Battle:forcedMove(mon)
|
function Battle:forcedMove(mon)
|
||||||
local locked = self:lockedInMove(mon)
|
local locked = self:lockedInMove(mon)
|
||||||
if locked then return locked end
|
if locked then return locked end
|
||||||
local state = self:volatile(mon)
|
local state = self:volatile(mon)
|
||||||
if not state.encore then return nil end
|
-- ParsePlayerAction reads SUBSTATUS_ENCORED ahead of the bide arm
|
||||||
for _, move in ipairs(mon.moves or {}) do
|
-- (engine/battle/core.asm:561-566).
|
||||||
if move.id == state.encore and (move.pp or 0) > 0 then return state.encore end
|
if state.encore then
|
||||||
|
for _, move in ipairs(mon.moves or {}) do
|
||||||
|
if move.id == state.encore and (move.pp or 0) > 0 then
|
||||||
|
return state.encore
|
||||||
|
end
|
||||||
|
end
|
||||||
|
state.encore, state.encoreTurns = nil, nil
|
||||||
end
|
end
|
||||||
-- Encore ends early when the move runs out of PP.
|
return self:fightLockedMove(mon)
|
||||||
state.encore, state.encoreTurns = nil, nil
|
|
||||||
return nil
|
|
||||||
end
|
end
|
||||||
|
|
||||||
function Battle:moveDisabled(mon, moveId)
|
function Battle:moveDisabled(mon, moveId)
|
||||||
@@ -3810,8 +3888,9 @@ function Battle:usableMoves(mon)
|
|||||||
-- .CheckPlayerHasUsableMoves (core.asm:533-556), so a Rollout or a rampage
|
-- .CheckPlayerHasUsableMoves (core.asm:533-556), so a Rollout or a rampage
|
||||||
-- that spent its last PP on the opening turn keeps running: no later turn
|
-- that spent its last PP on the opening turn keeps running: no later turn
|
||||||
-- of the lock spends any. Encore is not in this exemption -- forcedMove
|
-- of the lock spends any. Encore is not in this exemption -- forcedMove
|
||||||
-- ends it the moment the encored move runs dry.
|
-- ends it the moment the encored move runs dry. Bide is exempt too:
|
||||||
local locked = self:lockedInMove(mon)
|
-- .CheckPlayerHasUsableMoves lives inside MoveSelectionScreen (core.asm:5058).
|
||||||
|
local locked = self:lockedInMove(mon) or self:fightLockedMove(mon)
|
||||||
local out = {}
|
local out = {}
|
||||||
for _, move in ipairs(mon.moves or {}) do
|
for _, move in ipairs(mon.moves or {}) do
|
||||||
local ok = (move.pp or 0) > 0 and not self:moveDisabled(mon, move.id)
|
local ok = (move.pp or 0) > 0 and not self:moveDisabled(mon, move.id)
|
||||||
@@ -4105,9 +4184,14 @@ function Battle:vanillaEnemyMove()
|
|||||||
-- move answers "TYPHLOSION's attack missed!", so Hitmonlee cannot be damaged
|
-- move answers "TYPHLOSION's attack missed!", so Hitmonlee cannot be damaged
|
||||||
-- by anything, at any level. Fifteen straight attempts at the Elite Four
|
-- by anything, at any level. Fifteen straight attempts at the Elite Four
|
||||||
-- died there, and no amount of grinding could ever have got past it.
|
-- died there, and no amount of grinding could ever have got past it.
|
||||||
local charged = self:volatile(self.enemy).chargeMove
|
local enemyState = self:volatile(self.enemy)
|
||||||
|
local charged = enemyState.chargeMove
|
||||||
if charged then return charged end
|
if charged then return charged end
|
||||||
|
|
||||||
|
-- engine/battle/core.asm:5650, :5532-5533. Straight off the volatile, the
|
||||||
|
-- way `charged` above is, so the charge-lock test's bare stub still drives it.
|
||||||
|
if enemyState.bideTurns then return enemyState.bideMove end
|
||||||
|
|
||||||
-- Encore and Disable narrow the pool before the AI ever scores it.
|
-- Encore and Disable narrow the pool before the AI ever scores it.
|
||||||
local moves = self:usableMoves(self.enemy)
|
local moves = self:usableMoves(self.enemy)
|
||||||
if #moves == 0 then
|
if #moves == 0 then
|
||||||
@@ -4201,6 +4285,7 @@ local function runTurn(self, action)
|
|||||||
-- (engine/battle/core.asm:5035-5038), which reopens the 2x2 menu with the
|
-- (engine/battle/core.asm:5035-5038), which reopens the 2x2 menu with the
|
||||||
-- turn unspent -- so a refused RUN never bought the enemy a free attack.
|
-- turn unspent -- so a refused RUN never bought the enemy a free attack.
|
||||||
if self.runRefused then return self:takeEvents() end
|
if self.runRefused then return self:takeEvents() end
|
||||||
|
self:cancelBide(self.player)
|
||||||
action = { kind = "skip" }
|
action = { kind = "skip" }
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -4216,6 +4301,9 @@ local function runTurn(self, action)
|
|||||||
if action.kind == "item" and Battle.X_ITEMS[action.item] then
|
if action.kind == "item" and Battle.X_ITEMS[action.item] then
|
||||||
Happiness.change(self.player, "USEDXITEM")
|
Happiness.change(self.player, "USEDXITEM")
|
||||||
end
|
end
|
||||||
|
-- engine/battle/core.asm:572-573 into :627-629; a switch takes :570-571
|
||||||
|
-- instead and keeps the store.
|
||||||
|
if action.kind == "item" then self:cancelBide(self.player) end
|
||||||
|
|
||||||
-- AI_SwitchOrTryItem runs BEFORE the move is chosen: a trainer that decides
|
-- AI_SwitchOrTryItem runs BEFORE the move is chosen: a trainer that decides
|
||||||
-- to rotate or drink a potion spends its whole turn on it.
|
-- to rotate or drink a potion spends its whole turn on it.
|
||||||
@@ -4264,7 +4352,6 @@ local function runTurn(self, action)
|
|||||||
|
|
||||||
local function playerAttack()
|
local function playerAttack()
|
||||||
if action.kind ~= "move" then return end
|
if action.kind ~= "move" then return end
|
||||||
if not self:canAct(self.player) then return end
|
|
||||||
local move = action.move
|
local move = action.move
|
||||||
-- An encored mon has no choice, whatever the menu said.
|
-- An encored mon has no choice, whatever the menu said.
|
||||||
local forced = self:forcedMove(self.player)
|
local forced = self:forcedMove(self.player)
|
||||||
@@ -4284,13 +4371,20 @@ local function runTurn(self, action)
|
|||||||
-- player's own mon spends the rest of the battle underground.
|
-- player's own mon spends the rest of the battle underground.
|
||||||
local stored = self:volatile(self.player).chargeMove
|
local stored = self:volatile(self.player).chargeMove
|
||||||
if stored then move = stored end
|
if stored then move = stored end
|
||||||
|
-- engine/battle/core.asm:558-598 settles wCurPlayerMove before
|
||||||
|
-- engine/battle/effect_commands.asm:193 reads it.
|
||||||
|
if not self:canAct(self.player, move) then return end
|
||||||
-- CheckPlayerLockedIn quits before .CheckPlayerHasUsableMoves and before
|
-- CheckPlayerLockedIn quits before .CheckPlayerHasUsableMoves and before
|
||||||
-- checkobedience, so a locked Rollout or Thrash is exempt from the
|
-- checkobedience, so a locked Rollout or Thrash is exempt from the
|
||||||
-- Struggle substitution and the obedience roll the same way the second
|
-- Struggle substitution and the obedience roll the same way the second
|
||||||
-- half of a charge move is.
|
-- half of a charge move is.
|
||||||
local charging = self:volatile(self.player).chargeMove == move
|
local charging = self:volatile(self.player).chargeMove == move
|
||||||
or self:lockedInMove(self.player) == move
|
or self:lockedInMove(self.player) == move
|
||||||
if not charging and not self:hasUsableMoves(self.player) then
|
-- engine/battle/core.asm:5058, and data/moves/effects.asm:796 keeps
|
||||||
|
-- `checkobedience`.
|
||||||
|
local bideLocked = self:fightLockedMove(self.player) == move
|
||||||
|
if not charging and not bideLocked
|
||||||
|
and not self:hasUsableMoves(self.player) then
|
||||||
self:emit({ kind = "message",
|
self:emit({ kind = "message",
|
||||||
text = self:monName(self.player) .. " has no moves left!" })
|
text = self:monName(self.player) .. " has no moves left!" })
|
||||||
move = Battle.STRUGGLE
|
move = Battle.STRUGGLE
|
||||||
@@ -4328,7 +4422,7 @@ local function runTurn(self, action)
|
|||||||
-- against a trainer, could not be escaped either.
|
-- against a trainer, could not be escaped either.
|
||||||
enemyMoveId = Battle.STRUGGLE
|
enemyMoveId = Battle.STRUGGLE
|
||||||
end
|
end
|
||||||
if not self:canAct(self.enemy) then return end
|
if not self:canAct(self.enemy, enemyMoveId) then return end
|
||||||
-- CheckEnemyTurn's disabled arm (engine/battle/effect_commands.asm:562-574):
|
-- CheckEnemyTurn's disabled arm (engine/battle/effect_commands.asm:562-574):
|
||||||
-- the AI chose before the player's Disable landed, so the turn is spent here.
|
-- the AI chose before the player's Disable landed, so the turn is spent here.
|
||||||
if self:moveDisabled(self.enemy, enemyMoveId) then
|
if self:moveDisabled(self.enemy, enemyMoveId) then
|
||||||
|
|||||||
+21
-1
@@ -1700,6 +1700,14 @@ local function clamp(n, lo, hi, fallback)
|
|||||||
return n
|
return n
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- PP and the PP Up count are unsigned bit fields of one byte
|
||||||
|
-- (constants/pokemon_data_constants.asm:101-102)
|
||||||
|
local function ppInt(v, fallback)
|
||||||
|
local n = tonumber(v)
|
||||||
|
if n == nil or n ~= n or n == math.huge or n == -math.huge then return fallback end
|
||||||
|
return math.max(0, math.floor(n))
|
||||||
|
end
|
||||||
|
|
||||||
local function ensureOrphaned(save)
|
local function ensureOrphaned(save)
|
||||||
if not save.orphaned then
|
if not save.orphaned then
|
||||||
save.orphaned = { mons = {}, items = {} }
|
save.orphaned = { mons = {}, items = {} }
|
||||||
@@ -1763,7 +1771,7 @@ local function scrubKnownMon(mon, data)
|
|||||||
-- (status_screen.asm:66-76, add_mon.asm _MoveMon); deriving once here means
|
-- (status_screen.asm:66-76, add_mon.asm _MoveMon); deriving once here means
|
||||||
-- every later reader (menus, battle, items, SGB bar zones, the link
|
-- every later reader (menus, battle, items, SGB bar zones, the link
|
||||||
-- fingerprint) sees a party-shaped mon. Runs after the level clamp above
|
-- fingerprint) sees a party-shaped mon. Runs after the level clamp above
|
||||||
-- so the derived stats use a sane level. A save that already has stats is
|
-- so the derived stats use a sane level. A complete stat block is
|
||||||
-- untouched.
|
-- untouched.
|
||||||
Stats.ensure(data.pokemon and data.pokemon[mon.species], mon)
|
Stats.ensure(data.pokemon and data.pokemon[mon.species], mon)
|
||||||
local moves = mon.moves
|
local moves = mon.moves
|
||||||
@@ -1783,6 +1791,18 @@ local function scrubKnownMon(mon, data)
|
|||||||
moves[1] = { id = fallback, pp = def.pp }
|
moves[1] = { id = fallback, pp = def.pp }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
-- the replacement PP mirrors AddBonusPP (engine/items/item_effects.asm:2418);
|
||||||
|
-- Mimic rewrites the move id and not the PP (engine/battle/effects.asm:1266)
|
||||||
|
for j = 1, #moves do
|
||||||
|
local slot = moves[j]
|
||||||
|
if type(slot) == "table" then
|
||||||
|
local mdef = data.moves and data.moves[slot.id]
|
||||||
|
local base = ppInt(mdef and mdef.pp, 0)
|
||||||
|
local ppUps = math.min(3, ppInt(slot.ppUps, 0))
|
||||||
|
if slot.ppUps ~= nil then slot.ppUps = ppUps end
|
||||||
|
slot.pp = ppInt(slot.pp, base + ppUps * math.floor(base / 5))
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
local function scrubMonList(list, where, save, data, report)
|
local function scrubMonList(list, where, save, data, report)
|
||||||
|
|||||||
@@ -146,8 +146,7 @@ local VERSION_REQUIRED_FILES_OVERRIDE = {
|
|||||||
-- complete and SlotMachine crashed on its labelled-cell fallback.
|
-- complete and SlotMachine crashed on its labelled-cell fallback.
|
||||||
"assets/generated/slots/gold_slots_1.png",
|
"assets/generated/slots/gold_slots_1.png",
|
||||||
"assets/generated/card_flip/card_flip_1.png",
|
"assets/generated/card_flip/card_flip_1.png",
|
||||||
-- PCMailGFX (engine/pokemon/bills_pc.asm:2170-2173): Bill's PC has no
|
-- PCMailGFX (engine/pokemon/bills_pc.asm:2170-2173)
|
||||||
-- held-item or mail icon in a cache built before the symbol was listed.
|
|
||||||
"assets/generated/pc/mail_item.png",
|
"assets/generated/pc/mail_item.png",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,13 +10,14 @@
|
|||||||
local Bag = {}
|
local Bag = {}
|
||||||
|
|
||||||
-- MAX_ITEMS / MAX_BALLS / MAX_KEY_ITEMS, and the TM/HM pocket holds one of
|
-- MAX_ITEMS / MAX_BALLS / MAX_KEY_ITEMS, and the TM/HM pocket holds one of
|
||||||
-- every TM plus the seven HMs (NUM_TMS + NUM_HMS). A `mods` bagSize override
|
-- every TM plus the seven HMs (NUM_TMS + NUM_HMS -- ram/wram.asm:2421 is
|
||||||
|
-- `wTMsHMs:: ds NUM_TMS + NUM_HMS`, 57 bytes). A `mods` bagSize override
|
||||||
-- replaces the ITEM pocket only, the way the Gen 1 single-bag config did.
|
-- replaces the ITEM pocket only, the way the Gen 1 single-bag config did.
|
||||||
local POCKET_CAPACITY = {
|
local POCKET_CAPACITY = {
|
||||||
ITEM = 20,
|
ITEM = 20,
|
||||||
BALL = 12,
|
BALL = 12,
|
||||||
KEY_ITEM = 25,
|
KEY_ITEM = 25,
|
||||||
TM_HM = 64,
|
TM_HM = 57,
|
||||||
}
|
}
|
||||||
local DEFAULT_CAPACITY = 20
|
local DEFAULT_CAPACITY = 20
|
||||||
|
|
||||||
|
|||||||
+20
-9
@@ -811,19 +811,21 @@ local function buildOverworld()
|
|||||||
local FieldMoves = rawRequire("src.world.gen2.FieldMoves")
|
local FieldMoves = rawRequire("src.world.gen2.FieldMoves")
|
||||||
|
|
||||||
-- home/map.asm:1869
|
-- home/map.asm:1869
|
||||||
local function stepAllowed(world, entity, dir)
|
local function stepAllowed(world, entity, dir, cx, cy)
|
||||||
local d = Map2.DELTA[dir]
|
local d = Map2.DELTA[dir]
|
||||||
if not (world.map and d and entity and entity.cellX) then return true end
|
if not (world.map and d and entity) then return true end
|
||||||
|
cx, cy = cx or entity.cellX, cy or entity.cellY
|
||||||
|
if not (cx and cy) then return true end
|
||||||
if not Permissions.stepPermitted(
|
if not Permissions.stepPermitted(
|
||||||
function(cx, cy) return world:cellCollisionAcross(world.map, cx, cy) end,
|
function(px, py) return world:cellCollisionAcross(world.map, px, py) end,
|
||||||
entity.cellX, entity.cellY, dir) then
|
cx, cy, dir) then
|
||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
local map = world.map
|
local map = world.map
|
||||||
if entity == world.player and FieldMoves.isSurfing(world.playerState) then
|
if entity == world.player and FieldMoves.isSurfing(world.playerState) then
|
||||||
map = world:surfMap(world.map)
|
map = world:surfMap(world.map)
|
||||||
end
|
end
|
||||||
local tx, ty = entity.cellX + d[1], entity.cellY + d[2]
|
local tx, ty = cx + d[1], cy + d[2]
|
||||||
if not map:inBounds(tx, ty) or not map:isWalkable(tx, ty) then return false end
|
if not map:inBounds(tx, ty) or not map:isWalkable(tx, ty) then return false end
|
||||||
for _, e in ipairs(world.entities or {}) do
|
for _, e in ipairs(world.entities or {}) do
|
||||||
if e ~= entity and not e.passable then
|
if e ~= entity and not e.passable then
|
||||||
@@ -1048,9 +1050,17 @@ local function buildOverworld()
|
|||||||
.. "mapped object (def.index) can be moved"
|
.. "mapped object (def.index) can be moved"
|
||||||
end
|
end
|
||||||
local n = math.max(0, tiles or 1)
|
local n = math.max(0, tiles or 1)
|
||||||
if opts and opts.collide and n > 0 and not stepAllowed(world, entity, dir) then
|
if opts and opts.collide and n > 0 then
|
||||||
entity.facing = dir
|
local d = Map2.DELTA[dir]
|
||||||
n = 0
|
local cx, cy = entity.cellX, entity.cellY
|
||||||
|
local allowed = 0
|
||||||
|
for _ = 1, n do
|
||||||
|
if not stepAllowed(world, entity, dir, cx, cy) then break end
|
||||||
|
allowed = allowed + 1
|
||||||
|
if d and cx and cy then cx, cy = cx + d[1], cy + d[2] end
|
||||||
|
end
|
||||||
|
if allowed < n then entity.facing = dir end
|
||||||
|
n = allowed
|
||||||
end
|
end
|
||||||
local bytes = {}
|
local bytes = {}
|
||||||
for _ = 1, n do bytes[#bytes + 1] = step end
|
for _ = 1, n do bytes[#bytes + 1] = step end
|
||||||
@@ -1431,7 +1441,8 @@ COVERAGE[OW] = {
|
|||||||
.. "objectId 1 is wLastTalked, not object zero",
|
.. "objectId 1 is wLastTalked, not object zero",
|
||||||
scriptMove = "the player maps to objectId 0 and a mapped object to "
|
scriptMove = "the player maps to objectId 0 and a mapped object to "
|
||||||
.. "def.index + 1; an entity with neither (a mod's own guest) is "
|
.. "def.index + 1; an entity with neither (a mod's own guest) is "
|
||||||
.. "REFUSED with a reason rather than moving the last-talked NPC",
|
.. "REFUSED with a reason rather than moving the last-talked NPC; "
|
||||||
|
.. "opts.collide truncates the walk at the first blocked step",
|
||||||
connectionLanding = "Gen 1's five values (destDef, tilesetDef, x, y, "
|
connectionLanding = "Gen 1's five values (destDef, tilesetDef, x, y, "
|
||||||
.. "conn); `conn` is Gold's connection record, keyed map/mapId + offset",
|
.. "conn); `conn` is Gold's connection record, keyed map/mapId + offset",
|
||||||
timeOfDay = "recomputed from the clock every call and never cached, so a "
|
timeOfDay = "recomputed from the clock every call and never cached, so a "
|
||||||
|
|||||||
@@ -52,7 +52,9 @@ end
|
|||||||
-- CalcStats) and when one is moved back into the party
|
-- CalcStats) and when one is moved back into the party
|
||||||
-- (engine/pokemon/add_mon.asm _MoveMon tail). The stored current HP is
|
-- (engine/pokemon/add_mon.asm _MoveMon tail). The stored current HP is
|
||||||
-- kept (box_struct does hold it) but clamped to the recalculated maximum.
|
-- kept (box_struct does hold it) but clamped to the recalculated maximum.
|
||||||
-- CalcStats writes all NUM_STATS stats or none (home/move_mon.asm:33-48).
|
-- A complete stat block is returned untouched; an incomplete one is rebuilt,
|
||||||
|
-- because CalcStats writes all NUM_STATS stats or none
|
||||||
|
-- (home/move_mon.asm:33-48). #233, #304, #1517
|
||||||
local function statsComplete(stats)
|
local function statsComplete(stats)
|
||||||
for _, key in ipairs(ORDER) do
|
for _, key in ipairs(ORDER) do
|
||||||
if type(stats[key]) ~= "number" then return false end
|
if type(stats[key]) ~= "number" then return false end
|
||||||
|
|||||||
@@ -85,7 +85,9 @@ function Transition.new(game, onMidpoint, onDone, warp)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function Transition:finish()
|
function Transition:finish()
|
||||||
self.game.stack:pop()
|
if self.done then return end
|
||||||
|
self.done = true
|
||||||
|
if not self.offStack then self.game.stack:pop() end
|
||||||
if self.onDone then self.onDone() end
|
if self.onDone then self.onDone() end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -96,10 +98,16 @@ function Transition:update(dt)
|
|||||||
self.t = 0
|
self.t = 0
|
||||||
if self.phase == "out" then
|
if self.phase == "out" then
|
||||||
self.phase = "in"
|
self.phase = "in"
|
||||||
if self.onMidpoint then self.onMidpoint() end
|
|
||||||
-- LoadGBPal restores the palettes in one write, so with no fade in the
|
-- LoadGBPal restores the palettes in one write, so with no fade in the
|
||||||
-- map is simply there on the next frame
|
-- map is simply there on the next frame
|
||||||
if (self.framesIn or 0) <= 0 then self:finish() end
|
if (self.framesIn or 0) <= 0 then
|
||||||
|
self.offStack = true
|
||||||
|
self.game.stack:pop()
|
||||||
|
if self.onMidpoint then self.onMidpoint() end
|
||||||
|
self:finish()
|
||||||
|
elseif self.onMidpoint then
|
||||||
|
self.onMidpoint()
|
||||||
|
end
|
||||||
else
|
else
|
||||||
self:finish()
|
self:finish()
|
||||||
end
|
end
|
||||||
|
|||||||
+38
-2
@@ -66,7 +66,7 @@ local PIC_X, PIC_Y = 1, 4
|
|||||||
-- 7-size blank tiles per column (engine/gfx/load_pics.asm:342-386).
|
-- 7-size blank tiles per column (engine/gfx/load_pics.asm:342-386).
|
||||||
local PIC_PAD = { [7] = { 0, 0 }, [6] = { 1, 1 }, [5] = { 1, 2 } }
|
local PIC_PAD = { [7] = { 0, 0 }, [6] = { 1, 1 }, [5] = { 1, 2 } }
|
||||||
|
|
||||||
-- gfx/pc/orange.pal, for a cache that predates menu_gfx.billsPc.
|
-- gfx/pc/orange.pal
|
||||||
local BILLS_PC_ORANGE = {
|
local BILLS_PC_ORANGE = {
|
||||||
{ 255, 123, 0 }, { 189, 99, 0 }, { 123, 58, 0 }, { 0, 0, 0 },
|
{ 255, 123, 0 }, { 189, 99, 0 }, { 123, 58, 0 }, { 0, 0, 0 },
|
||||||
}
|
}
|
||||||
@@ -75,6 +75,12 @@ local BILLS_PC_ORANGE = {
|
|||||||
-- (engine/pokemon/bills_pc.asm:1093).
|
-- (engine/pokemon/bills_pc.asm:1093).
|
||||||
local ICON_X, ICON_Y = 7, 12
|
local ICON_X, ICON_Y = 7, 12
|
||||||
|
|
||||||
|
-- $5f at hlcoord 8, 1 and $5e at hlcoord 19, 1, off a PCMailGFX sheet that
|
||||||
|
-- starts at $5c (engine/pokemon/bills_pc.asm:957-963).
|
||||||
|
local ARROW_ROW = 1
|
||||||
|
local ARROW_LEFT = { 3, 8 }
|
||||||
|
local ARROW_RIGHT = { 2, 19 }
|
||||||
|
|
||||||
-- wBillsPC_LoadedBox: 0 is the PARTY, 1..NUM_BOXES are the boxes. Only the
|
-- wBillsPC_LoadedBox: 0 is the PARTY, 1..NUM_BOXES are the boxes. Only the
|
||||||
-- MOVE screen ever loads box 0; the withdraw and deposit lists are one list
|
-- MOVE screen ever loads box 0; the withdraw and deposit lists are one list
|
||||||
-- each (BillsPC_BoxName reads the same byte for all three).
|
-- each (BillsPC_BoxName reads the same byte for all three).
|
||||||
@@ -550,7 +556,7 @@ function BoxMenu:playSfx(name)
|
|||||||
if sfx and sfx[Sound.resolve(data, name)] then Sound.play(data, name) end
|
if sfx and sfx[Sound.resolve(data, name)] then Sound.play(data, name) end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- PlayMonCry: `call GetCryIndex / jr c, .done` (home/pokemon.asm:101)
|
-- PlayMonCry: `call GetCryIndex / jr c, .done` (home/pokemon.asm:113-114)
|
||||||
function BoxMenu:playMonCry(mon)
|
function BoxMenu:playMonCry(mon)
|
||||||
local data = self.game and self.game.data
|
local data = self.game and self.game.data
|
||||||
if not (data and mon and mon.species) or mon.isEgg then return end
|
if not (data and mon and mon.species) or mon.isEgg then return end
|
||||||
@@ -765,6 +771,35 @@ function BoxMenu:drawHeldIcon(mon)
|
|||||||
G.setColor(1, 1, 1, 1)
|
G.setColor(1, 1, 1, 1)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- _MovePKMNWithoutMail only (engine/pokemon/bills_pc.asm:545, :698)
|
||||||
|
function BoxMenu:drawBoxArrows()
|
||||||
|
if self.mode ~= "move" then return end
|
||||||
|
local gfx = (self.menuGfx or {}).billsPc
|
||||||
|
local image = self:image(gfx and gfx.icons)
|
||||||
|
if not image then return end
|
||||||
|
local G = love.graphics
|
||||||
|
local quads = {}
|
||||||
|
for _, arrow in ipairs({ ARROW_LEFT, ARROW_RIGHT }) do
|
||||||
|
local ok, quad = pcall(love.graphics.newQuad, arrow[1] * 8, 0, 8, 8,
|
||||||
|
image:getDimensions())
|
||||||
|
if not ok then return end
|
||||||
|
quads[#quads + 1] = { quad, arrow[2] }
|
||||||
|
end
|
||||||
|
G.setColor(1, 1, 1, 1)
|
||||||
|
local function body()
|
||||||
|
for _, entry in ipairs(quads) do
|
||||||
|
G.draw(image, entry[1], entry[2] * 8, ARROW_ROW * 8)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local colors = gfx and gfx.palette
|
||||||
|
if colors and GbcPalette.available() then
|
||||||
|
GbcPalette.with(colors, body)
|
||||||
|
else
|
||||||
|
body()
|
||||||
|
end
|
||||||
|
G.setColor(1, 1, 1, 1)
|
||||||
|
end
|
||||||
|
|
||||||
-- The PC does not mark the selected row with a ▶: BillsPC_UpdateSelectionCursor
|
-- The PC does not mark the selected row with a ▶: BillsPC_UpdateSelectionCursor
|
||||||
-- lays 20 OBJs as a frame *around* the row -- ten tiles wide by two tall, top
|
-- lays 20 OBJs as a frame *around* the row -- ten tiles wide by two tall, top
|
||||||
-- left at pixel (71, 25), stepping 16 pixels per row. Those cursor tiles are
|
-- left at pixel (71, 25), stepping 16 pixels per row. Those cursor tiles are
|
||||||
@@ -811,6 +846,7 @@ function BoxMenu:drawPanel()
|
|||||||
-- Textbox at (8,0) with a 10x1 interior and the name at (10,1).
|
-- Textbox at (8,0) with a 10x1 interior and the name at (10,1).
|
||||||
Chrome.box(8, 0, 12, 3)
|
Chrome.box(8, 0, 12, 3)
|
||||||
Chrome.print(self:title(), 10, 1)
|
Chrome.print(self:title(), 10, 1)
|
||||||
|
self:drawBoxArrows()
|
||||||
Chrome.box(8, 2, 12, 12)
|
Chrome.box(8, 2, 12, 12)
|
||||||
-- BillsPC_RefreshTextboxes overwrites its own top corners with '└'/'┘'
|
-- BillsPC_RefreshTextboxes overwrites its own top corners with '└'/'┘'
|
||||||
-- (engine/pokemon/bills_pc.asm:1204-1211) so the list reads as hanging
|
-- (engine/pokemon/bills_pc.asm:1204-1211) so the list reads as hanging
|
||||||
|
|||||||
@@ -521,9 +521,7 @@ function ItemPcMenu:drawList()
|
|||||||
local entry = self.rows[i]
|
local entry = self.rows[i]
|
||||||
if i == self.listIndex then Chrome.cursor(5, ty) end
|
if i == self.listIndex then Chrome.cursor(5, ty) end
|
||||||
Chrome.print(entry.name, 6, ty)
|
Chrome.print(entry.name, 6, ty)
|
||||||
-- PlaceMenuItemQuantity (engine/menus/menu_2.asm:24): the xNN is the
|
-- PlaceMenuItemQuantity (engine/menus/menu_2.asm:18, :24)
|
||||||
-- entry's second line, right-aligned in a blank-padded 2-digit field,
|
|
||||||
-- and an item with no quantity draws none at all (menu_2.asm:18).
|
|
||||||
if not self:cantToss(entry.id) then
|
if not self:cantToss(entry.id) then
|
||||||
Chrome.print(TIMES .. Chrome.number(entry.count, 2), 7, ty + 1)
|
Chrome.print(TIMES .. Chrome.number(entry.count, 2), 7, ty + 1)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ local PackGfx = require("src.ui.gen2.PackGfx")
|
|||||||
local Screens = require("src.ui.Screens")
|
local Screens = require("src.ui.Screens")
|
||||||
local Strings = require("src.core.Strings")
|
local Strings = require("src.core.Strings")
|
||||||
|
|
||||||
|
-- constants/sfx_constants.asm:3, :28
|
||||||
|
local SFX_DEX_FANFARE_50_79, SFX_WRONG = 0, 25
|
||||||
|
|
||||||
local PackMenu = {}
|
local PackMenu = {}
|
||||||
PackMenu.__index = PackMenu
|
PackMenu.__index = PackMenu
|
||||||
PackMenu.isOpaque = true
|
PackMenu.isOpaque = true
|
||||||
@@ -247,8 +250,7 @@ function PackMenu:rebuild()
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
-- engine/items/tmhm.asm:341 -- wTMsHMs is walked 1..57, so the TM/HM pocket
|
-- engine/items/tmhm.asm:341
|
||||||
-- has no acquisition order to preserve.
|
|
||||||
if pocket == "TM_HM" then
|
if pocket == "TM_HM" then
|
||||||
table.sort(rows, function(a, b)
|
table.sort(rows, function(a, b)
|
||||||
local ka, kb = self:tmhmKey(a.id), self:tmhmKey(b.id)
|
local ka, kb = self:tmhmKey(a.id), self:tmhmKey(b.id)
|
||||||
@@ -377,7 +379,7 @@ function PackMenu:useSelected()
|
|||||||
-- all four rows at once, the way OAK_THIS_ISNT_THE_TIME's three fit.
|
-- all four rows at once, the way OAK_THIS_ISNT_THE_TIME's three fit.
|
||||||
-- World has already set the decoration's flag and taken the box.
|
-- World has already set the decoration's flag and taken the box.
|
||||||
if world.playSfxNamed then
|
if world.playSfxNamed then
|
||||||
world:playSfxNamed("Sfx_DexFanfare5079")
|
world:playSfxNamed("Sfx_DexFanfare5079", SFX_DEX_FANFARE_50_79)
|
||||||
end
|
end
|
||||||
self.message = { "There was a trophy", "inside!",
|
self.message = { "There was a trophy", "inside!",
|
||||||
"{PLAYER} sent the", "trophy home." }
|
"{PLAYER} sent the", "trophy home." }
|
||||||
@@ -663,7 +665,9 @@ function PackMenu:openTeachParty(row)
|
|||||||
if not allowed then
|
if not allowed then
|
||||||
-- engine/items/tmhm.asm:131
|
-- engine/items/tmhm.asm:131
|
||||||
local world = game.world
|
local world = game.world
|
||||||
if world and world.playSfxNamed then world:playSfxNamed("Sfx_Wrong") end
|
if world and world.playSfxNamed then
|
||||||
|
world:playSfxNamed("Sfx_Wrong", SFX_WRONG)
|
||||||
|
end
|
||||||
if game.say then
|
if game.say then
|
||||||
game:say(("%s can't learn %s!"):format(
|
game:say(("%s can't learn %s!"):format(
|
||||||
require("src.battle.gen2.Mon").displayName(mon), moveName))
|
require("src.battle.gen2.Mon").displayName(mon), moveName))
|
||||||
|
|||||||
@@ -2184,8 +2184,8 @@ function World:playSfxNamed(want, fallbackId)
|
|||||||
self:playSfx(self:sfxIdNamed(want, fallbackId))
|
self:playSfx(self:sfxIdNamed(want, fallbackId))
|
||||||
end
|
end
|
||||||
|
|
||||||
-- .BumpSound (engine/overworld/player_movement.asm:771): `call CheckSFX /
|
-- .BumpSound (engine/overworld/player_movement.asm:771), CheckSFX at
|
||||||
-- ret c` is the whole rate limit (home/audio.asm:477), not a frame counter.
|
-- home/audio.asm:477
|
||||||
function World:bumpSound()
|
function World:bumpSound()
|
||||||
if Sound.sfxBusy() then return end
|
if Sound.sfxBusy() then return end
|
||||||
self:playSfxNamed("Sfx_Bump", SFX.BUMP)
|
self:playSfxNamed("Sfx_Bump", SFX.BUMP)
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
-- BIDE on the real Gold battle screen: the PP counter in the move list, and
|
||||||
|
-- the lock that keeps a storing mon on the move it started (#1664, #1665).
|
||||||
|
--
|
||||||
|
-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_bide_lock_bug1664_1665.lua \
|
||||||
|
-- POKEPORT_SHOT_DIR=/tmp/gold-bide love .
|
||||||
|
--
|
||||||
|
-- data/moves/effects.asm:795-800 puts `storeenergy` ahead of `doturn`, and
|
||||||
|
-- BattleCommand_DoTurn masks SUBSTATUS_BIDE out of the PP spend
|
||||||
|
-- (engine/battle/effect_commands.asm:977-979), so the whole three-turn Bide
|
||||||
|
-- costs the one PP the opening turn paid. The shots are the point: the
|
||||||
|
-- number beside BIDE in the move list must read the same on 01, 02 and 03.
|
||||||
|
--
|
||||||
|
-- The lock is ParsePlayerAction's own arm (engine/battle/core.asm:569-576),
|
||||||
|
-- which sits INSIDE the FIGHT branch and skips MoveSelectionScreen only -- so
|
||||||
|
-- the 2x2 menu still opens, a switch is still legal, and using an item runs
|
||||||
|
-- .reset_bide (:627-629) and CANCELS the store. Block 2 submits TACKLE in
|
||||||
|
-- the middle of a Bide on purpose: the engine has to answer with the Bide.
|
||||||
|
--
|
||||||
|
-- NOT covered here, and deliberately: src/ui/gen2/BattleState.lua still draws
|
||||||
|
-- the move list on a storing turn. The cart jumps past it. That half is a
|
||||||
|
-- screen change and is called out in the fix report rather than faked.
|
||||||
|
local U = require("tests.drivers.util")
|
||||||
|
|
||||||
|
local Mon = require("src.battle.gen2.Mon")
|
||||||
|
|
||||||
|
local function battleScreen(game)
|
||||||
|
for _ = 1, 900 do
|
||||||
|
local top = game.stack:top()
|
||||||
|
if top and top.battle then return top end
|
||||||
|
U.wait(1)
|
||||||
|
end
|
||||||
|
error("battle screen never came up")
|
||||||
|
end
|
||||||
|
|
||||||
|
local function drain(game, screen, frames)
|
||||||
|
for _ = 1, (frames or 400) do
|
||||||
|
if screen.phase == "menu" and #screen.queue == 0 and not screen.anim then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
U.tap(game, "a")
|
||||||
|
U.wait(3)
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local function giveMoves(mon, game, moves)
|
||||||
|
mon.moves = {}
|
||||||
|
for i, id in ipairs(moves) do
|
||||||
|
local def = assert(game.data.moves[id], id .. " is not in moves.lua")
|
||||||
|
mon.moves[i] = { id = id, pp = def.pp, maxPp = def.pp }
|
||||||
|
end
|
||||||
|
return mon
|
||||||
|
end
|
||||||
|
|
||||||
|
local function newWild(game, species, level, moves)
|
||||||
|
local mon = Mon.new(game.data, species, level)
|
||||||
|
if moves then giveMoves(mon, game, moves) end
|
||||||
|
return mon
|
||||||
|
end
|
||||||
|
|
||||||
|
return function(game)
|
||||||
|
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bide"
|
||||||
|
U.wait(45)
|
||||||
|
local world = game.world
|
||||||
|
assert(world and world.map, "gold world did not boot")
|
||||||
|
local failures = {}
|
||||||
|
local function check(ok, what)
|
||||||
|
print((ok and "[ok] " or "[FAIL] ") .. what)
|
||||||
|
if not ok then failures[#failures + 1] = what end
|
||||||
|
end
|
||||||
|
local function pp(mon, slot) return mon.moves[slot].pp end
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------ 1
|
||||||
|
-- One Bide, start to finish, with the move list open between turns. The
|
||||||
|
-- foe is a SPLASH-only RATTATA so nothing interrupts the store.
|
||||||
|
local player = Mon.new(game.data, "SNORLAX", 40)
|
||||||
|
giveMoves(player, game, { "BIDE", "TACKLE" })
|
||||||
|
game.save.party = { player }
|
||||||
|
game.save.inventory = { POTION = 5 }
|
||||||
|
local foe = newWild(game, "RATTATA", 20, { "TACKLE" })
|
||||||
|
assert(world:startBattle({ wild = foe }), "startBattle failed")
|
||||||
|
local screen = battleScreen(game)
|
||||||
|
drain(game, screen, 200)
|
||||||
|
local opening = pp(player, 1)
|
||||||
|
print(("[driver] BIDE opens at %d PP"):format(opening))
|
||||||
|
|
||||||
|
screen:submit({ kind = "move", move = "BIDE" })
|
||||||
|
drain(game, screen, 400)
|
||||||
|
U.shot(game, out .. "/01-selected.png")
|
||||||
|
local afterSelect = pp(player, 1)
|
||||||
|
print(("[driver] after the SELECTION turn: %d PP"):format(afterSelect))
|
||||||
|
check(afterSelect == opening - 1,
|
||||||
|
"the opening turn pays its one PP through doturn")
|
||||||
|
check(screen.battle:forcedMove(player) == "BIDE",
|
||||||
|
"and ParsePlayerAction's bide arm holds the FIGHT choice")
|
||||||
|
|
||||||
|
-- The middle of the store, with TACKLE deliberately submitted: the cart
|
||||||
|
-- never re-reads the move list, so wCurPlayerMove is still the BIDE.
|
||||||
|
local tackleBefore = pp(player, 2)
|
||||||
|
screen:submit({ kind = "move", move = "TACKLE" })
|
||||||
|
drain(game, screen, 400)
|
||||||
|
U.shot(game, out .. "/02-storing.png")
|
||||||
|
print(("[driver] after a STORING turn: BIDE %d PP, TACKLE %d PP")
|
||||||
|
:format(pp(player, 1), pp(player, 2)))
|
||||||
|
check(pp(player, 1) == afterSelect, "a storing turn spends no PP at all")
|
||||||
|
check(pp(player, 2) == tackleBefore,
|
||||||
|
"and the move the menu offered was never run")
|
||||||
|
|
||||||
|
-- The release. Whatever the roll, the store is two or three turns
|
||||||
|
-- (UnleashEnergy's `BattleRandom / and 1 / inc a / inc a`,
|
||||||
|
-- move_effects/bide.asm:88-92), so keep pressing FIGHT until it lets go.
|
||||||
|
local foeBefore = foe.hp
|
||||||
|
for _ = 1, 3 do
|
||||||
|
if not screen.battle:forcedMove(player) then break end
|
||||||
|
screen:submit({ kind = "move", move = "BIDE" })
|
||||||
|
drain(game, screen, 400)
|
||||||
|
end
|
||||||
|
U.shot(game, out .. "/03-unleashed.png")
|
||||||
|
print(("[driver] after the RELEASE: %d PP, foe %d -> %d HP")
|
||||||
|
:format(pp(player, 1), foeBefore, foe.hp))
|
||||||
|
check(pp(player, 1) == afterSelect,
|
||||||
|
"the whole Bide cost exactly one PP, the way the cart charges it")
|
||||||
|
check(screen.battle:forcedMove(player) == nil, "and the lock is released")
|
||||||
|
for _ = 1, 400 do
|
||||||
|
if not game.stack:top() or not game.stack:top().battle then break end
|
||||||
|
U.tap(game, "a")
|
||||||
|
U.wait(3)
|
||||||
|
end
|
||||||
|
U.wait(60)
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------ 2
|
||||||
|
-- .reset_bide: a nonzero wBattlePlayerAction that is not
|
||||||
|
-- BATTLEPLAYERACTION_SWITCH clears SUBSTATUS_BIDE (core.asm:572-573,
|
||||||
|
-- :627-629), so opening the PACK mid-store throws the stored damage away.
|
||||||
|
local bider = Mon.new(game.data, "SNORLAX", 40)
|
||||||
|
giveMoves(bider, game, { "BIDE", "TACKLE" })
|
||||||
|
bider.hp = math.max(1, bider.hp - 30)
|
||||||
|
game.save.party = { bider }
|
||||||
|
game.save.inventory = { POTION = 5 }
|
||||||
|
local foe2 = newWild(game, "RATTATA", 20, { "TACKLE" })
|
||||||
|
assert(world:startBattle({ wild = foe2 }), "startBattle failed")
|
||||||
|
screen = battleScreen(game)
|
||||||
|
drain(game, screen, 200)
|
||||||
|
screen:submit({ kind = "move", move = "BIDE" })
|
||||||
|
drain(game, screen, 400)
|
||||||
|
check(screen.battle:forcedMove(bider) == "BIDE", "the second Bide is up")
|
||||||
|
screen:useItem("POTION")
|
||||||
|
drain(game, screen, 400)
|
||||||
|
U.shot(game, out .. "/04-item-cancelled.png")
|
||||||
|
print("[driver] after the PACK: forcedMove="
|
||||||
|
.. tostring(screen.battle:forcedMove(bider)))
|
||||||
|
check(screen.battle:forcedMove(bider) == nil,
|
||||||
|
"using an item cancels the Bide, as .reset_bide does")
|
||||||
|
check(screen.battle:volatile(bider).bideStored == nil,
|
||||||
|
"and the damage it had banked goes with it")
|
||||||
|
for _ = 1, 400 do
|
||||||
|
if not game.stack:top() or not game.stack:top().battle then break end
|
||||||
|
U.tap(game, "a")
|
||||||
|
U.wait(3)
|
||||||
|
end
|
||||||
|
|
||||||
|
print("[driver] NOTE: the move list is still drawn on a storing turn; the "
|
||||||
|
.. "cart's bide arm skips MoveSelectionScreen and that half lives in "
|
||||||
|
.. "src/ui/gen2/BattleState.lua")
|
||||||
|
if #failures > 0 then
|
||||||
|
for _, what in ipairs(failures) do print("[FAIL] " .. what) end
|
||||||
|
error(#failures .. " bide checks failed")
|
||||||
|
end
|
||||||
|
print("[driver] all bide checks passed")
|
||||||
|
print("[driver] shots in " .. out)
|
||||||
|
end
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
-- #1672: Bill's PC MOVE screen -- the box-name arrows.
|
||||||
|
--
|
||||||
|
-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
|
||||||
|
-- POKEPORT_DRIVER=tests/drivers/gold_billspc_arrows_bug1672.lua \
|
||||||
|
-- perl -e 'alarm 420; exec @ARGV' \
|
||||||
|
-- python3 -c "import pty; pty.spawn(['love','.'])"
|
||||||
|
-- POKEPORT_SHOT_DIR=/tmp/gold-bug1672-arrows (default)
|
||||||
|
--
|
||||||
|
-- BillsPC_MoveMonWOMail_BoxNameAndArrows writes $5f at hlcoord 8, 1 and $5e at
|
||||||
|
-- hlcoord 19, 1 (engine/pokemon/bills_pc.asm:957-963), replacing the box-name
|
||||||
|
-- Textbox's own side borders. Only _MovePKMNWithoutMail calls it: .Init (:545)
|
||||||
|
-- and .PrepInsertCursor (:698). The withdraw (:299) and deposit (:56) inits
|
||||||
|
-- call bare BillsPC_BoxName (:965) and are the negative controls here.
|
||||||
|
--
|
||||||
|
-- What to look for in each shot: a solid LEFT-pointing triangle in the left
|
||||||
|
-- border of the name box and a solid RIGHT-pointing one in the right border,
|
||||||
|
-- both level with the name, on every move-mode shot and on neither of the last
|
||||||
|
-- two. Getting them the wrong way round is the failure this exists to catch.
|
||||||
|
--
|
||||||
|
-- The run ends with the MOVE screen open so a human takes the controls there.
|
||||||
|
local U = require("tests.drivers.util")
|
||||||
|
|
||||||
|
local Boxes = require("src.core.gen2.Boxes")
|
||||||
|
local Mon = require("src.battle.gen2.Mon")
|
||||||
|
local Screens = require("src.ui.Screens")
|
||||||
|
|
||||||
|
return function(game)
|
||||||
|
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1672-arrows"
|
||||||
|
|
||||||
|
local function tap(button, frames)
|
||||||
|
game.input.pressQueue[#game.input.pressQueue + 1] = button
|
||||||
|
game.input.state[button] = true
|
||||||
|
U.wait(2)
|
||||||
|
game.input.state[button] = false
|
||||||
|
U.wait(frames or 6)
|
||||||
|
end
|
||||||
|
|
||||||
|
U.wait(45)
|
||||||
|
assert(game.world and game.world.map, "gold world did not boot")
|
||||||
|
local save, data = game.save, game.data
|
||||||
|
|
||||||
|
local function mon(species, level) return Mon.new(data, species, level) end
|
||||||
|
|
||||||
|
-- IsAnyMonHoldingMail refuses the whole screen (src/ui/gen2/PcMenu.lua:303),
|
||||||
|
-- so the seeded party carries no mail.
|
||||||
|
save.party = {}
|
||||||
|
for _, species in ipairs({ "CYNDAQUIL", "PIDGEY", "SENTRET" }) do
|
||||||
|
save.party[#save.party + 1] = mon(species, 12)
|
||||||
|
end
|
||||||
|
local stored = Boxes.box(save, 1)
|
||||||
|
for i = #stored, 1, -1 do stored[i] = nil end
|
||||||
|
for i, species in ipairs({ "GEODUDE", "ZUBAT", "RATTATA" }) do
|
||||||
|
stored[i] = mon(species, 10 + i)
|
||||||
|
end
|
||||||
|
Boxes.rename(save, 1, "GRASS")
|
||||||
|
save.currentBox = 1
|
||||||
|
|
||||||
|
local function openBox(mode)
|
||||||
|
Screens.push(game, "Gen2BoxMenu", {
|
||||||
|
save = save, mode = mode,
|
||||||
|
onClose = function() game.stack:pop() end,
|
||||||
|
})
|
||||||
|
U.wait(8)
|
||||||
|
return game.stack:top()
|
||||||
|
end
|
||||||
|
|
||||||
|
local function close()
|
||||||
|
while game.stack:top() and game.stack:top().submenuRows do
|
||||||
|
game.stack:pop()
|
||||||
|
end
|
||||||
|
U.wait(4)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ---- 1. the move list on a renamed box -----------------------------------
|
||||||
|
local menu = openBox("move")
|
||||||
|
U.log("01 move, box GRASS:", "want both arrows flanking GRASS on row 1")
|
||||||
|
U.shot(game, out .. "/01-move-box.png")
|
||||||
|
|
||||||
|
-- ---- 2. LEFT onto the PARTY ----------------------------------------------
|
||||||
|
-- BillsPC_BoxName's `.party` arm; only the move screen ever loads box 0.
|
||||||
|
tap("left")
|
||||||
|
U.log("02 move, PARTY:", "boxIndex " .. tostring(menu.boxIndex) ..
|
||||||
|
", want 0 and both arrows still up")
|
||||||
|
U.shot(game, out .. "/02-move-party.png")
|
||||||
|
tap("right")
|
||||||
|
|
||||||
|
-- ---- 3. the MOVE / STATS / CANCEL submenu --------------------------------
|
||||||
|
tap("a")
|
||||||
|
U.log("03 submenu:", "phase " .. tostring(menu.phase) ..
|
||||||
|
", want submenu and both arrows still up")
|
||||||
|
U.shot(game, out .. "/03-move-submenu.png")
|
||||||
|
|
||||||
|
-- ---- 4. the insert cursor (.PrepInsertCursor rewrites them) --------------
|
||||||
|
tap("a", 10)
|
||||||
|
U.log("04 insert:", "phase " .. tostring(menu.phase) ..
|
||||||
|
", want insert and both arrows still up")
|
||||||
|
U.shot(game, out .. "/04-move-insert.png")
|
||||||
|
close()
|
||||||
|
|
||||||
|
-- ---- 5/6. the negative controls ------------------------------------------
|
||||||
|
openBox("withdraw")
|
||||||
|
U.log("05 withdraw:", "want plain border tiles, NO arrows")
|
||||||
|
U.shot(game, out .. "/05-withdraw-none.png")
|
||||||
|
close()
|
||||||
|
|
||||||
|
openBox("deposit")
|
||||||
|
U.log("06 deposit:", "want plain border tiles, NO arrows")
|
||||||
|
U.shot(game, out .. "/06-deposit-none.png")
|
||||||
|
close()
|
||||||
|
|
||||||
|
openBox("move")
|
||||||
|
U.log("done -- the MOVE screen is open; the controls are yours")
|
||||||
|
end
|
||||||
@@ -252,6 +252,11 @@ return function(game)
|
|||||||
dep.index = 2 -- the mon holding FLOWER MAIL
|
dep.index = 2 -- the mon holding FLOWER MAIL
|
||||||
show("23b-pc-deposit-mail", dep)
|
show("23b-pc-deposit-mail", dep)
|
||||||
|
|
||||||
|
-- Only the move list gets the box-name arrows, $5f left and $5e right
|
||||||
|
-- (engine/pokemon/bills_pc.asm:957-963); 22 and 23 above are the controls.
|
||||||
|
local mv = BoxMenu.new(game, { save = save, mode = "move" })
|
||||||
|
show("23c-pc-move-arrows", mv)
|
||||||
|
|
||||||
-- The two clock screens NEW GAME and Mom open (timeset.asm InitClock and
|
-- The two clock screens NEW GAME and Mom open (timeset.asm InitClock and
|
||||||
-- SetDayOfWeek), each at its picker rather than at its opening page.
|
-- SetDayOfWeek), each at its picker rather than at its opening page.
|
||||||
local clock = InitClock.new(game, { save = save })
|
local clock = InitClock.new(game, { save = save })
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
--
|
--
|
||||||
-- Gen 1's party_struct carries one Special word (macros/ram.asm:28-37) and
|
-- Gen 1's party_struct carries one Special word (macros/ram.asm:28-37) and
|
||||||
-- PrintStatsBox reads four fixed cells, wLoadedMonAttack/Defense/Speed/Special
|
-- PrintStatsBox reads four fixed cells, wLoadedMonAttack/Defense/Speed/Special
|
||||||
-- (engine/pokemon/status_screen.asm:255-296). Gen 2 splits that word into
|
-- (engine/pokemon/status_screen.asm:238-287). Gen 2 splits that word into
|
||||||
-- SpclAtk/SpclDef (pokegold macros/ram.asm:29-42), so a mod that wrote a Gen 2
|
-- SpclAtk/SpclDef (pokegold macros/ram.asm:29-42), so a mod that wrote a Gen 2
|
||||||
-- block over a Yellow party leaves the port with no `special` to print.
|
-- block over a Yellow party leaves the port with no `special` to print.
|
||||||
--
|
--
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
-- data/maps/force_bike_surf.asm:5 / engine/overworld/player_state.asm:34-72,
|
||||||
|
-- home/overworld.asm:690-703 (the map-change fade has no fade back in).
|
||||||
|
-- POKEPORT_DRIVER=tests/drivers/warp_midpoint_box_bug1663_test.lua \
|
||||||
|
-- POKEPORT_IDENTITY=bug1663 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||||
|
-- Ride into the Route 16 gate, drop the BICYCLE inside, then walk back out
|
||||||
|
-- the west door. The warp lands on a forced-bike tile, so the refusal box
|
||||||
|
-- opens from inside the fade's midpoint: it must be on screen when the fade
|
||||||
|
-- ends (#1663 ate it), and dismissing it must leave the player on Route 16's
|
||||||
|
-- (17,10), not shoved into the gate wall at (18,10) (#1548).
|
||||||
|
return function(game)
|
||||||
|
local U = dofile("tests/drivers/util.lua")
|
||||||
|
local Pokemon = require("src.pokemon.Pokemon")
|
||||||
|
local TextBox = require("src.render.TextBox")
|
||||||
|
|
||||||
|
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||||
|
local ok = true
|
||||||
|
local function check(label, pass)
|
||||||
|
U.log(pass and "PASS" or "FAIL", label)
|
||||||
|
if not pass then ok = false end
|
||||||
|
return pass
|
||||||
|
end
|
||||||
|
|
||||||
|
local function where()
|
||||||
|
local ow = game.overworld
|
||||||
|
return ow.map.id, ow.player.cellX, ow.player.cellY
|
||||||
|
end
|
||||||
|
local function bikeless()
|
||||||
|
game.save.inventory.BICYCLE = nil
|
||||||
|
game.save.onBike, game.save.forcedBike = false, nil
|
||||||
|
end
|
||||||
|
|
||||||
|
game.save.player.name = "PROBE"
|
||||||
|
game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) }
|
||||||
|
game.save.inventory.BICYCLE = 1
|
||||||
|
game.save.onBike, game.save.forcedBike = false, nil
|
||||||
|
|
||||||
|
-- (16,10) is the open cell west of the gate door; (17,10) is the forced
|
||||||
|
-- tile the door sits on, so the step onto it mounts and then warps
|
||||||
|
U.teleport(game, "ROUTE_16", 16, 10, "right")
|
||||||
|
U.wait(20)
|
||||||
|
U.hold(game, "right", 40)
|
||||||
|
U.wait(40)
|
||||||
|
local map, x, y = where()
|
||||||
|
U.log(("rode east into the gate -> %s (%d,%d)"):format(map, x, y))
|
||||||
|
check("the bike carried the player into the gate",
|
||||||
|
map == "ROUTE_16_GATE_1F")
|
||||||
|
|
||||||
|
bikeless()
|
||||||
|
U.hold(game, "left", 40)
|
||||||
|
-- the map-change fade is 32 frames and hands back with no fade in
|
||||||
|
U.wait(50)
|
||||||
|
|
||||||
|
map, x, y = where()
|
||||||
|
local top = game.stack:top()
|
||||||
|
U.log(("walked back out -> %s (%d,%d), top=%s"):format(map, x, y,
|
||||||
|
tostring(getmetatable(top) == TextBox and "TextBox" or top)))
|
||||||
|
check("the west door lands back on Route 16", map == "ROUTE_16")
|
||||||
|
check("on the forced tile the gate exit warps to", x == 17 and y == 10)
|
||||||
|
check("the refusal box the warp opened is on screen",
|
||||||
|
getmetatable(top) == TextBox)
|
||||||
|
U.shot(game, SHOT_DIR .. "/bug1663_refusal.png")
|
||||||
|
|
||||||
|
-- close it: the shove back east is refused by collision, (18,10) is wall
|
||||||
|
for _ = 1, 12 do
|
||||||
|
if game.stack:top() == game.overworld then break end
|
||||||
|
U.tap(game, "a")
|
||||||
|
U.wait(25)
|
||||||
|
end
|
||||||
|
U.wait(30)
|
||||||
|
map, x, y = where()
|
||||||
|
local walkable = game.overworld.map:isWalkableCell(x, y)
|
||||||
|
U.log(("after the refusal -> %s (%d,%d) walkable=%s")
|
||||||
|
:format(map, x, y, tostring(walkable)))
|
||||||
|
check("dismissing it leaves the player on a walkable cell", walkable)
|
||||||
|
check("and not inside the gate wall at (18,10)", not (x == 18 and y == 10))
|
||||||
|
U.shot(game, SHOT_DIR .. "/bug1663_after.png")
|
||||||
|
|
||||||
|
U.log(ok and "all clear" or "a check failed")
|
||||||
|
while true do coroutine.yield() end
|
||||||
|
end
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
-- A Gen 1 move slot stores current PP in six bits of one byte and the PP Up
|
||||||
|
-- count in the other two (constants/pokemon_data_constants.asm:101-102), and
|
||||||
|
-- the status screen reads it back with `and PP_MASK` before PrintNumber
|
||||||
|
-- (engine/pokemon/status_screen.asm:357-365), so "not a number" is not a state
|
||||||
|
-- the hardware record can hold and the load-time repair must normalize it.
|
||||||
|
-- Max PP follows GetMaxPP/AddBonusPP (engine/items/item_effects.asm:2467,
|
||||||
|
-- 2418). #1668
|
||||||
|
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
|
||||||
|
local T = require("tests.harness")
|
||||||
|
local check, eq = T.check, T.eq
|
||||||
|
love = love or require("tests.love_stub")
|
||||||
|
|
||||||
|
local SaveData = require("src.core.SaveData")
|
||||||
|
|
||||||
|
local data = {
|
||||||
|
pokemon = { FIXMON_A = { id = "FIXMON_A", name = "FIXMON A", types = { "GRASS" },
|
||||||
|
baseStats = { hp = 45, attack = 49, defense = 49, speed = 45, special = 65 } } },
|
||||||
|
moves = {
|
||||||
|
FIX_TACKLE = { id = "FIX_TACKLE", name = "TACKLE", pp = 35 },
|
||||||
|
FIX_GROWL = { id = "FIX_GROWL", name = "GROWL", pp = 40 },
|
||||||
|
},
|
||||||
|
items = {}, maps = { FIXMAP = { id = "FIXMAP" } },
|
||||||
|
constants = { fallbackMove = "FIX_TACKLE" },
|
||||||
|
}
|
||||||
|
|
||||||
|
local function saveWith(moves)
|
||||||
|
return {
|
||||||
|
party = { { species = "FIXMON_A", level = 21, hp = 62, exp = 9000,
|
||||||
|
dvs = { hp = 15, attack = 9, defense = 8, speed = 8, special = 8 },
|
||||||
|
statExp = {}, moves = moves } },
|
||||||
|
boxes = {}, inventory = {}, pcItems = {},
|
||||||
|
player = { map = "FIXMAP", x = 1, y = 1, name = "RED", id = 1 },
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local function scrub(moves)
|
||||||
|
local save = saveWith(moves)
|
||||||
|
SaveData.validate(save, data)
|
||||||
|
return save.party[1].moves
|
||||||
|
end
|
||||||
|
|
||||||
|
-- SummaryMenu page 2 and every battle reader index the save's own slot table
|
||||||
|
local function readers(mv)
|
||||||
|
local mdef = data.moves[mv.id]
|
||||||
|
local maxPP = mdef.pp + (mv.ppUps or 0) * math.floor(mdef.pp / 5)
|
||||||
|
local okFmt = pcall(string.format, "%2d/%2d", mv.pp, maxPP)
|
||||||
|
local okCmp = pcall(function() return mv.pp > 0 end)
|
||||||
|
return okFmt, okCmp, maxPP
|
||||||
|
end
|
||||||
|
|
||||||
|
do -- every non-numeric pp shape the tree can be handed
|
||||||
|
local moves = scrub({
|
||||||
|
{ id = "FIX_TACKLE" },
|
||||||
|
{ id = "FIX_TACKLE", pp = {} },
|
||||||
|
{ id = "FIX_TACKLE", pp = "35" },
|
||||||
|
{ id = "FIX_TACKLE", pp = 0 / 0 },
|
||||||
|
})
|
||||||
|
eq(#moves, 4, "all four slots survive")
|
||||||
|
for i = 1, 4 do
|
||||||
|
local mv = moves[i]
|
||||||
|
check(type(mv.pp) == "number", "slot " .. i .. " carries a numeric pp")
|
||||||
|
local okFmt, okCmp, maxPP = readers(mv)
|
||||||
|
check(okFmt, "slot " .. i .. ": SummaryMenu's ('%2d/%2d'):format no longer raises")
|
||||||
|
check(okCmp, "slot " .. i .. ": playerHasPP's `mv.pp > 0` no longer raises")
|
||||||
|
check(type(mv.pp) == "number" and mv.pp >= 0 and mv.pp <= maxPP,
|
||||||
|
"slot " .. i .. " sits inside 0..maxPP")
|
||||||
|
end
|
||||||
|
eq(moves[1].pp, 35, "a missing pp heals to full, not to Struggle")
|
||||||
|
eq(moves[2].pp, 35, "a table pp heals to full")
|
||||||
|
eq(moves[3].pp, 35, "a numeric string becomes the number it spells")
|
||||||
|
eq(moves[4].pp, 35, "a nan pp heals to full")
|
||||||
|
end
|
||||||
|
|
||||||
|
do -- numeric, but not a value the unsigned byte field can express
|
||||||
|
local moves = scrub({
|
||||||
|
{ id = "FIX_TACKLE", pp = -4 },
|
||||||
|
{ id = "FIX_TACKLE", pp = 1.7 },
|
||||||
|
{ id = "FIX_TACKLE", pp = math.huge },
|
||||||
|
{ id = "FIX_TACKLE", pp = -math.huge },
|
||||||
|
})
|
||||||
|
eq(moves[1].pp, 0, "a negative pp clamps up to zero")
|
||||||
|
eq(moves[2].pp, 1, "a fractional pp floors to an integer")
|
||||||
|
eq(moves[3].pp, 35, "an infinite pp heals to full")
|
||||||
|
eq(moves[4].pp, 35, "so does a negative infinity")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- MimicEffect writes the copied move id into the slot and never touches the
|
||||||
|
-- PP byte (engine/battle/effects.asm:1261-1266), and this port's battler reads
|
||||||
|
-- mon.moves by identity, so a mid-battle save legitimately carries a PP count
|
||||||
|
-- above the slot's current move's max. Clamping it down corrupts a battle
|
||||||
|
-- checkpoint, which is why the repair only replaces values that are not
|
||||||
|
-- numbers at all.
|
||||||
|
do
|
||||||
|
local moves = scrub({
|
||||||
|
{ id = "FIX_TACKLE", pp = 40, mimic = true },
|
||||||
|
{ id = "FIX_TACKLE", pp = 999 },
|
||||||
|
{ id = "FIX_GROWL", pp = 40, ppUps = 0 },
|
||||||
|
})
|
||||||
|
eq(moves[1].pp, 40, "a Mimic'd slot keeps the 40 PP the GROWL it replaced had")
|
||||||
|
check(moves[1].mimic == true, "and the battler's own restore marker survives")
|
||||||
|
eq(moves[2].pp, 999, "an over-max pp is left alone, not clamped")
|
||||||
|
eq(moves[3].pp, 40, "and a full slot is unchanged")
|
||||||
|
end
|
||||||
|
|
||||||
|
do -- the PP Up count is two bits, so 0..3
|
||||||
|
local moves = scrub({
|
||||||
|
{ id = "FIX_TACKLE", pp = 5, ppUps = "x" },
|
||||||
|
{ id = "FIX_TACKLE", pp = 5, ppUps = 9 },
|
||||||
|
{ id = "FIX_TACKLE", pp = 49, ppUps = 2 },
|
||||||
|
{ id = "FIX_TACKLE", pp = 5 },
|
||||||
|
})
|
||||||
|
eq(moves[1].ppUps, 0, "a non-numeric ppUps becomes zero")
|
||||||
|
eq(moves[2].ppUps, 3, "an over-max ppUps clamps to three")
|
||||||
|
eq(moves[3].ppUps, 2, "a legal ppUps is kept")
|
||||||
|
eq(moves[3].pp, 49, "and its PP-Upped count is untouched: 35 + 2 * 7")
|
||||||
|
eq(moves[4].ppUps, nil, "a slot without a ppUps does not grow one")
|
||||||
|
for i = 1, 4 do
|
||||||
|
local okFmt = pcall(string.format, "%2d/%2d", moves[i].pp, 35)
|
||||||
|
check(okFmt, "slot " .. i .. " formats after a ppUps repair")
|
||||||
|
local mdef = data.moves[moves[i].id]
|
||||||
|
local ok = pcall(function()
|
||||||
|
return mdef.pp + (moves[i].ppUps or 0) * math.floor(mdef.pp / 5)
|
||||||
|
end)
|
||||||
|
check(ok, "slot " .. i .. ": SummaryMenu's maxPP arithmetic no longer raises")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- A scalar move slot is a shape the id filter has always tolerated, and
|
||||||
|
-- tests/modkit/cases/checkpoints.lua treats one as valid content that
|
||||||
|
-- SaveData.validate must not rewrite. It stays out of the repair; the
|
||||||
|
-- SummaryMenu.lua:206 crash it causes is a separate defect.
|
||||||
|
do
|
||||||
|
local moves = scrub({ "FIX_TACKLE", { id = "FIX_GROWL" } })
|
||||||
|
eq(#moves, 2, "the scalar slot survives the id filter, as before")
|
||||||
|
eq(moves[1], "FIX_TACKLE", "and is left exactly as the save stored it")
|
||||||
|
eq(moves[2].pp, 40, "while its table-shaped neighbour is still repaired")
|
||||||
|
end
|
||||||
|
|
||||||
|
do -- the moveless-mon fallback slot goes through the same normalization
|
||||||
|
local moves = scrub({ { id = "NOT_A_MOVE", pp = "junk" } })
|
||||||
|
eq(#moves, 1, "the unknown move is replaced by the fallback")
|
||||||
|
eq(moves[1].id, "FIX_TACKLE", "which is data.constants.fallbackMove")
|
||||||
|
check(type(moves[1].pp) == "number", "and carries a numeric pp")
|
||||||
|
eq(moves[1].pp, 35, "at full")
|
||||||
|
end
|
||||||
|
|
||||||
|
do -- a vanilla slot passes through byte-identical
|
||||||
|
local moves = scrub({
|
||||||
|
{ id = "FIX_TACKLE", pp = 20 },
|
||||||
|
{ id = "FIX_GROWL", pp = 0 },
|
||||||
|
{ id = "FIX_GROWL", pp = 64, ppUps = 3 },
|
||||||
|
})
|
||||||
|
eq(moves[1].pp, 20, "a mid-fight pp is left alone")
|
||||||
|
eq(moves[2].pp, 0, "an exhausted move is left on zero, not healed")
|
||||||
|
eq(moves[3].pp, 64, "and a PP-Upped slot agrees with SummaryMenu's own maxPP")
|
||||||
|
eq(moves[3].ppUps, 3, "with its PP Up count intact")
|
||||||
|
end
|
||||||
|
|
||||||
|
do -- box mons are reached by the same pass
|
||||||
|
local save = saveWith({ { id = "FIX_TACKLE", pp = 20 } })
|
||||||
|
save.boxes = { { { species = "FIXMON_A", level = 21, hp = 62,
|
||||||
|
dvs = {}, statExp = {},
|
||||||
|
moves = { { id = "FIX_TACKLE", pp = "nonsense" } } } } }
|
||||||
|
SaveData.validate(save, data)
|
||||||
|
local mv = save.boxes[1][1].moves[1]
|
||||||
|
check(type(mv.pp) == "number", "a box mon's move slot is repaired too")
|
||||||
|
eq(mv.pp, 35, "to full PP")
|
||||||
|
end
|
||||||
|
|
||||||
|
T.finish()
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
-- home/overworld.asm:690-703 (PlayMapChangeSound tail-calls GBFadeOutToBlack)
|
||||||
|
-- and home/fade.asm:43-46: the map-change fade has no matching fade in, so the
|
||||||
|
-- warp shape ends in the same frame its midpoint runs. The midpoint is where
|
||||||
|
-- setMap opens things (the Cycling Road refusal box, a map script's onEnter),
|
||||||
|
-- and a fade that popped the top of the stack after that ate them and then
|
||||||
|
-- finished a second time (#1663).
|
||||||
|
-- luajit tests/engine/transition_identity_pop_bug1663.lua
|
||||||
|
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
|
||||||
|
local T = require("tests.harness")
|
||||||
|
local check, eq = T.check, T.eq
|
||||||
|
love = love or require("tests.love_stub")
|
||||||
|
|
||||||
|
local StateStack = require("src.core.StateStack")
|
||||||
|
local Transition = require("src.render.Transition")
|
||||||
|
local Timing = require("src.core.Timing")
|
||||||
|
|
||||||
|
local function overworld() return { name = "overworld", isOpaque = true } end
|
||||||
|
|
||||||
|
-- ------------------------------------------------ the warp shape (#1663)
|
||||||
|
|
||||||
|
do
|
||||||
|
StateStack:init()
|
||||||
|
local ow = overworld()
|
||||||
|
StateStack:push(ow)
|
||||||
|
|
||||||
|
local box = { name = "refusal" }
|
||||||
|
local entered, depthAtMidpoint, topAtMidpoint = 0, nil, nil
|
||||||
|
function box:enter() entered = entered + 1 end
|
||||||
|
local dones = 0
|
||||||
|
local fade
|
||||||
|
fade = Transition.new({ stack = StateStack }, function()
|
||||||
|
depthAtMidpoint = #StateStack.states
|
||||||
|
topAtMidpoint = StateStack:top()
|
||||||
|
StateStack:push(box)
|
||||||
|
end, function() dones = dones + 1 end, true)
|
||||||
|
StateStack:push(fade)
|
||||||
|
|
||||||
|
local finishedOn
|
||||||
|
for frame = 1, 120 do
|
||||||
|
StateStack:update(1 / 60)
|
||||||
|
if dones > 0 and not finishedOn then finishedOn = frame end
|
||||||
|
end
|
||||||
|
|
||||||
|
eq(finishedOn, Timing.WARP_FADE_OUT, "the warp hands back at the end of the fade")
|
||||||
|
eq(dones, 1, "and hands back exactly once")
|
||||||
|
eq(depthAtMidpoint, 1, "the fade is off the stack before the midpoint runs")
|
||||||
|
check(topAtMidpoint == ow, "so the map switch sees the overworld on top")
|
||||||
|
eq(entered, 1, "the state the midpoint pushed entered once")
|
||||||
|
eq(#StateStack.states, 2, "the fade is gone and the pushed state is not")
|
||||||
|
check(StateStack.states[1] == ow, "the overworld is still the base")
|
||||||
|
check(StateStack:top() == box, "the box the midpoint opened owns the screen")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- a midpoint that pushes nothing still leaves exactly the overworld behind
|
||||||
|
do
|
||||||
|
StateStack:init()
|
||||||
|
local ow = overworld()
|
||||||
|
StateStack:push(ow)
|
||||||
|
local mids, dones = 0, 0
|
||||||
|
local fade = Transition.new({ stack = StateStack },
|
||||||
|
function() mids = mids + 1 end,
|
||||||
|
function() dones = dones + 1 end, true)
|
||||||
|
StateStack:push(fade)
|
||||||
|
for _ = 1, 120 do StateStack:update(1 / 60) end
|
||||||
|
eq(mids, 1, "the map switched once")
|
||||||
|
eq(dones, 1, "the plain warp still hands back exactly once")
|
||||||
|
eq(#StateStack.states, 1, "and pops nothing but itself")
|
||||||
|
check(StateStack:top() == ow, "leaving the overworld on top")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------------------------------------------ the script fade is unchanged
|
||||||
|
|
||||||
|
-- ViridianGym.asm .afterBeat / RocketHideoutB4F BeatGiovanniScript bracket
|
||||||
|
-- their HideObject with GBFadeOutToBlack -> GBFadeInFromBlack, so those keep
|
||||||
|
-- a real fade in and wait under whatever the midpoint opened.
|
||||||
|
do
|
||||||
|
StateStack:init()
|
||||||
|
local ow = overworld()
|
||||||
|
StateStack:push(ow)
|
||||||
|
|
||||||
|
local box = { name = "script box" }
|
||||||
|
local dones = 0
|
||||||
|
local fade = Transition.new({ stack = StateStack },
|
||||||
|
function() StateStack:push(box) end,
|
||||||
|
function() dones = dones + 1 end, false)
|
||||||
|
StateStack:push(fade)
|
||||||
|
|
||||||
|
for _ = 1, Timing.WARP_FADE_OUT + 8 do StateStack:update(1 / 60) end
|
||||||
|
eq(dones, 0, "a fade with a fade-in waits under what its midpoint opened")
|
||||||
|
check(StateStack:top() == box, "the pushed state is on top")
|
||||||
|
check(StateStack.states[2] == fade, "with the fade still underneath it")
|
||||||
|
|
||||||
|
StateStack:pop()
|
||||||
|
for _ = 1, Timing.FADE_IN_FROM_BLACK + 8 do StateStack:update(1 / 60) end
|
||||||
|
eq(dones, 1, "and finishes once the box is gone")
|
||||||
|
eq(#StateStack.states, 1, "leaving the overworld alone on the stack")
|
||||||
|
check(StateStack:top() == ow, "and nothing else came off with it")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------------------------------------------------- finish is idempotent
|
||||||
|
|
||||||
|
do
|
||||||
|
StateStack:init()
|
||||||
|
local ow = overworld()
|
||||||
|
StateStack:push(ow)
|
||||||
|
local dones = 0
|
||||||
|
local fade = Transition.new({ stack = StateStack }, nil,
|
||||||
|
function() dones = dones + 1 end, true)
|
||||||
|
StateStack:push(fade)
|
||||||
|
fade:finish()
|
||||||
|
fade:finish()
|
||||||
|
eq(dones, 1, "a second finish does not hand back a second time")
|
||||||
|
eq(#StateStack.states, 1, "and takes nothing else off the stack")
|
||||||
|
check(StateStack:top() == ow, "the overworld survives the second call")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- a mod record may still ask a warp for a fade in; framesIn 0 is truthy in
|
||||||
|
-- Lua, so the built-in warp keeps its 0 and the retimed one keeps its own
|
||||||
|
do
|
||||||
|
local retimed = { transitions = { warp_fade = { kind = "fade", frames = 32,
|
||||||
|
framesIn = 16 } } }
|
||||||
|
local fade = Transition.new({ data = retimed, stack = StateStack }, nil,
|
||||||
|
nil, true)
|
||||||
|
eq(fade.framesIn, 16, "a retimed warp record keeps its fade in")
|
||||||
|
local vanilla = Transition.new({ stack = StateStack }, nil, nil, true)
|
||||||
|
eq(vanilla.framesIn, Timing.WARP_FADE_IN, "the built-in warp has none")
|
||||||
|
end
|
||||||
|
|
||||||
|
StateStack:clear()
|
||||||
|
|
||||||
|
T.finish("transition_identity_pop_bug1663")
|
||||||
@@ -89,6 +89,15 @@ local MOVES = {
|
|||||||
accuracy = 100, pp = 10, effect = "EFFECT_ENDURE" },
|
accuracy = 100, pp = 10, effect = "EFFECT_ENDURE" },
|
||||||
RAGE = { id = "RAGE", name = "RAGE", power = 20, type = "NORMAL",
|
RAGE = { id = "RAGE", name = "RAGE", power = 20, type = "NORMAL",
|
||||||
accuracy = 100, pp = 20, effect = "EFFECT_RAGE" },
|
accuracy = 100, pp = 20, effect = "EFFECT_RAGE" },
|
||||||
|
-- data/moves/moves.asm:133, :230, :189 and :92 rows, unedited.
|
||||||
|
BIDE = { id = "BIDE", name = "BIDE", power = 0, type = "NORMAL",
|
||||||
|
accuracy = 100, pp = 10, effect = "EFFECT_BIDE" },
|
||||||
|
SLEEP_TALK = { id = "SLEEP_TALK", name = "SLEEP TALK", power = 0,
|
||||||
|
type = "NORMAL", accuracy = 100, pp = 10, effect = "EFFECT_SLEEP_TALK" },
|
||||||
|
SNORE = { id = "SNORE", name = "SNORE", power = 40, type = "NORMAL",
|
||||||
|
accuracy = 100, pp = 15, effect = "EFFECT_SNORE", effectChance = 30 },
|
||||||
|
SOLARBEAM = { id = "SOLARBEAM", name = "SOLARBEAM", power = 120,
|
||||||
|
type = "GRASS", accuracy = 100, pp = 10, effect = "EFFECT_SOLARBEAM" },
|
||||||
}
|
}
|
||||||
|
|
||||||
local GROWTH = {
|
local GROWTH = {
|
||||||
@@ -2790,6 +2799,239 @@ end)()
|
|||||||
check("and its user reappears", b:volatile(player).vanished, nil)
|
check("and its user reappears", b:volatile(player).vanished, nil)
|
||||||
end)()
|
end)()
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------- Bide
|
||||||
|
--
|
||||||
|
-- data/moves/effects.asm:795-800 runs `storeenergy` ahead of `doturn`, and
|
||||||
|
-- BattleCommand_DoTurn's mask drops SUBSTATUS_BIDE outright
|
||||||
|
-- (engine/battle/effect_commands.asm:977-979), so the whole Bide costs the
|
||||||
|
-- one PP its opening turn spent. The lock is ParsePlayerAction's own arm
|
||||||
|
-- (engine/battle/core.asm:569-576) for the player and CheckEnemyLockedIn
|
||||||
|
-- (:5650) for the foe.
|
||||||
|
;(function()
|
||||||
|
local function said(events, text)
|
||||||
|
for _, e in ipairs(events) do
|
||||||
|
if e.kind == "message" and e.text == text then return true end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
local player = Mon.new(DATA, "CYNDAQUIL", 20, { dvs = perfect })
|
||||||
|
player.moves = { { id = "BIDE", pp = 10, maxPp = 10 },
|
||||||
|
{ id = "TACKLE", pp = 35, maxPp = 35 } }
|
||||||
|
player.hp, player.maxHp = 999, 999
|
||||||
|
local wild = Mon.new(DATA, "PIDGEY", 20, { dvs = perfect })
|
||||||
|
wild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
|
||||||
|
wild.hp, wild.maxHp = 9999, 9999
|
||||||
|
local b = Battle.new({ data = DATA, party = { player }, wild = wild,
|
||||||
|
random = zeroRandom })
|
||||||
|
b:takeEvents()
|
||||||
|
|
||||||
|
check("nothing forces the first BIDE", b:forcedMove(player), nil)
|
||||||
|
b:useMove(player, wild, "BIDE")
|
||||||
|
b:takeEvents()
|
||||||
|
check("the opening turn spends one PP", player.moves[1].pp, 9)
|
||||||
|
check("and the FIGHT menu is locked into it", b:forcedMove(player), "BIDE")
|
||||||
|
check("with the move list narrowed to the one",
|
||||||
|
#b:usableMoves(player), 1)
|
||||||
|
|
||||||
|
-- BattleCommand_StoreEnergy banks wPlayerDamageTaken while the bit is up.
|
||||||
|
b:dealDamage(wild, player, 5, {})
|
||||||
|
b:takeEvents()
|
||||||
|
b:useMove(player, wild, "BIDE")
|
||||||
|
check("a storing turn spends no PP", player.moves[1].pp, 9)
|
||||||
|
check("and stays locked", b:forcedMove(player), "BIDE")
|
||||||
|
check("`.still_storing` prints rather than attacking",
|
||||||
|
said(b:takeEvents(), "CYNDAQUIL is storing energy!"), true)
|
||||||
|
|
||||||
|
-- A dry BIDE keeps running: the bide arm jumps past MoveSelectionScreen,
|
||||||
|
-- where .CheckPlayerHasUsableMoves lives (engine/battle/core.asm:5058).
|
||||||
|
player.moves[1].pp = 0
|
||||||
|
check("a spent BIDE is still offered", #b:usableMoves(player), 1)
|
||||||
|
check("...and it is the BIDE", b:usableMoves(player)[1].id, "BIDE")
|
||||||
|
player.moves[1].pp = 9
|
||||||
|
|
||||||
|
local hpBefore = wild.hp
|
||||||
|
b:useMove(player, wild, "BIDE")
|
||||||
|
b:takeEvents()
|
||||||
|
check("the release spends no PP either", player.moves[1].pp, 9)
|
||||||
|
check("UnleashEnergy pays back double", wild.hp, hpBefore - 10)
|
||||||
|
check("and the lock is gone", b:forcedMove(player), nil)
|
||||||
|
|
||||||
|
-- CheckEnemyLockedIn holds SUBSTATUS_BIDE, so the AI is never asked.
|
||||||
|
local es = b:volatile(b.enemy)
|
||||||
|
es.bideTurns, es.bideMove, es.bideStored = 2, "BIDE", 0
|
||||||
|
check("a biding foe re-uses its Bide", b:enemyMove(), "BIDE")
|
||||||
|
es.bideTurns, es.bideMove, es.bideStored = nil, nil, nil
|
||||||
|
|
||||||
|
-- .reset_bide (engine/battle/core.asm:572-573, :627-629): the PACK cancels
|
||||||
|
-- a Bide, a switch does not.
|
||||||
|
b:useMove(player, wild, "BIDE")
|
||||||
|
b:takeEvents()
|
||||||
|
check("locked again", b:forcedMove(player), "BIDE")
|
||||||
|
b:takeTurn({ kind = "item", item = "POTION" })
|
||||||
|
b:takeEvents()
|
||||||
|
check("using an item cancels the Bide", b:forcedMove(player), nil)
|
||||||
|
check("and drops the bank", b:volatile(player).bideStored, nil)
|
||||||
|
end)()
|
||||||
|
|
||||||
|
-- --------------------------------------------------- Snore and Sleep Talk
|
||||||
|
--
|
||||||
|
-- `.fast_asleep` prints FastAsleepText and then falls into `.not_asleep` for
|
||||||
|
-- those two moves instead of `call CantMove / jp EndTurn`
|
||||||
|
-- (engine/battle/effect_commands.asm:188-200). BattleCommand_SleepTalk opens
|
||||||
|
-- on ClearLastMove and ends in ResetTurn (move_effects/sleep_talk.asm:2, :61).
|
||||||
|
;(function()
|
||||||
|
local function said(events, text)
|
||||||
|
for _, e in ipairs(events) do
|
||||||
|
if e.kind == "message" and e.text == text then return true end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
local function sleeper(moves)
|
||||||
|
local player = Mon.new(DATA, "CYNDAQUIL", 20, { dvs = perfect })
|
||||||
|
player.moves = moves
|
||||||
|
player.hp, player.maxHp = 999, 999
|
||||||
|
local wild = Mon.new(DATA, "PIDGEY", 20, { dvs = perfect })
|
||||||
|
wild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
|
||||||
|
wild.hp, wild.maxHp = 9999, 9999
|
||||||
|
local b = Battle.new({ data = DATA, party = { player }, wild = wild,
|
||||||
|
random = zeroRandom })
|
||||||
|
b:takeEvents()
|
||||||
|
return b, player, wild
|
||||||
|
end
|
||||||
|
|
||||||
|
local b, player, wild = sleeper({
|
||||||
|
{ id = "SLEEP_TALK", pp = 10, maxPp = 10 },
|
||||||
|
{ id = "TACKLE", pp = 35, maxPp = 35 } })
|
||||||
|
|
||||||
|
player.status, player.statusTurns = "sleep", 3
|
||||||
|
check("an ordinary move still loses the turn to sleep",
|
||||||
|
b:canAct(player, "TACKLE"), false)
|
||||||
|
check("and the counter was spent", player.statusTurns, 2)
|
||||||
|
check("FastAsleepText still goes up",
|
||||||
|
said(b:takeEvents(), "CYNDAQUIL is fast asleep!"), true)
|
||||||
|
|
||||||
|
player.statusTurns = 3
|
||||||
|
check("SLEEP TALK is let through", b:canAct(player, "SLEEP_TALK"), true)
|
||||||
|
check("...and still spends its sleep turn", player.statusTurns, 2)
|
||||||
|
check("...and still prints the line",
|
||||||
|
said(b:takeEvents(), "CYNDAQUIL is fast asleep!"), true)
|
||||||
|
player.statusTurns = 3
|
||||||
|
check("SNORE is let through too", b:canAct(player, "SNORE"), true)
|
||||||
|
b:takeEvents()
|
||||||
|
|
||||||
|
-- The wake-up arm is not a bypass: it answers for the whole turn.
|
||||||
|
player.statusTurns = 1
|
||||||
|
check("the last sleep turn wakes up", b:canAct(player, "SLEEP_TALK"), true)
|
||||||
|
check("and clears the status", player.status, nil)
|
||||||
|
|
||||||
|
player.status, player.statusTurns = "sleep", 5
|
||||||
|
local hpBefore = wild.hp
|
||||||
|
b:useMove(player, wild, "SLEEP_TALK")
|
||||||
|
b:takeEvents()
|
||||||
|
check("SLEEP TALK pays its own PP through doturn", player.moves[1].pp, 9)
|
||||||
|
check("but the move it calls pays none (ResetTurn)", player.moves[2].pp, 35)
|
||||||
|
check("and that move really landed", wild.hp < hpBefore, true)
|
||||||
|
check("ClearLastMove leaves no last move (used_move_text.asm:30-36)",
|
||||||
|
b:volatile(player).lastMove, nil)
|
||||||
|
|
||||||
|
-- .check_two_turn_move (sleep_talk.asm:117-141) drops the five charge
|
||||||
|
-- effects and EFFECT_BIDE, so a mon with nothing else fails.
|
||||||
|
local b2, player2, wild2 = sleeper({
|
||||||
|
{ id = "SLEEP_TALK", pp = 10, maxPp = 10 },
|
||||||
|
{ id = "SOLARBEAM", pp = 10, maxPp = 10 } })
|
||||||
|
player2.status, player2.statusTurns = "sleep", 5
|
||||||
|
b2:useMove(player2, wild2, "SLEEP_TALK")
|
||||||
|
check("a two-turn move is never sampled",
|
||||||
|
said(b2:takeEvents(), "But it failed!"), true)
|
||||||
|
check("and nothing was called", b2:volatile(player2).chargeMove, nil)
|
||||||
|
|
||||||
|
-- BattleCommand_SleepTalk's own `and SLP_MASK / jr z, .fail` (:16-19).
|
||||||
|
local b3, player3, wild3 = sleeper({
|
||||||
|
{ id = "SLEEP_TALK", pp = 10, maxPp = 10 },
|
||||||
|
{ id = "TACKLE", pp = 35, maxPp = 35 } })
|
||||||
|
b3:useMove(player3, wild3, "SLEEP_TALK")
|
||||||
|
check("an awake SLEEP TALK fails",
|
||||||
|
said(b3:takeEvents(), "But it failed!"), true)
|
||||||
|
|
||||||
|
-- BattleCommand_Snore (move_effects/snore.asm:1-9) is the same refusal.
|
||||||
|
local b4, player4, wild4 = sleeper({ { id = "SNORE", pp = 15, maxPp = 15 } })
|
||||||
|
local snoreBefore = wild4.hp
|
||||||
|
b4:useMove(player4, wild4, "SNORE")
|
||||||
|
check("an awake SNORE fails", said(b4:takeEvents(), "But it failed!"), true)
|
||||||
|
check("and deals nothing", wild4.hp, snoreBefore)
|
||||||
|
player4.status, player4.statusTurns = "sleep", 5
|
||||||
|
b4:useMove(player4, wild4, "SNORE")
|
||||||
|
b4:takeEvents()
|
||||||
|
check("a sleeping SNORE hits", wild4.hp < snoreBefore, true)
|
||||||
|
end)()
|
||||||
|
|
||||||
|
-- ------------------------------------------- fainted mons stop participating
|
||||||
|
--
|
||||||
|
-- UpdateFaintedPlayerMon RESET_FLAGs wBattleParticipantsNotFainted
|
||||||
|
-- (engine/battle/core.asm:2551-2556) and .EvenlyDivideExpAmongParticipants
|
||||||
|
-- divides by the count of set bits (:7118-7130), so the survivor of a lost
|
||||||
|
-- lead collects a whole share, not half of one.
|
||||||
|
;(function()
|
||||||
|
local function twoMonBattle()
|
||||||
|
local one = Mon.new(DATA, "CYNDAQUIL", 20, { dvs = perfect })
|
||||||
|
one.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
|
||||||
|
local two = Mon.new(DATA, "CYNDAQUIL", 20, { dvs = perfect })
|
||||||
|
two.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
|
||||||
|
local wild = Mon.new(DATA, "PIDGEY", 20, { dvs = perfect })
|
||||||
|
wild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
|
||||||
|
local b = Battle.new({ data = DATA, party = { one, two }, wild = wild,
|
||||||
|
random = zeroRandom })
|
||||||
|
b:takeEvents()
|
||||||
|
return b, one, two, wild
|
||||||
|
end
|
||||||
|
|
||||||
|
local b, one, two, wild = twoMonBattle()
|
||||||
|
check("the lead starts as a participant", b.participants[1], true)
|
||||||
|
one.hp = 0
|
||||||
|
b:resolveFaints()
|
||||||
|
b:takeEvents()
|
||||||
|
check("a fainted participant drops out", b.participants[1], nil)
|
||||||
|
|
||||||
|
-- The clear is one-shot: GiveExperiencePoints' `.done` falls through
|
||||||
|
-- ResetBattleParticipants into AddBattleParticipant (:7116, :3033-3037) and
|
||||||
|
-- puts the dead slot's bit back, and nothing takes it off again.
|
||||||
|
local oneShot = twoMonBattle()
|
||||||
|
oneShot.player.hp = 0
|
||||||
|
oneShot:resolveFaints()
|
||||||
|
oneShot:takeEvents()
|
||||||
|
oneShot:resetParticipants()
|
||||||
|
oneShot:resolveFaints()
|
||||||
|
oneShot:takeEvents()
|
||||||
|
check("and the cart's own re-add survives a second pass",
|
||||||
|
oneShot.participants[1], true)
|
||||||
|
|
||||||
|
b:switch(2)
|
||||||
|
b:takeEvents()
|
||||||
|
check("the replacement is a participant", b.participants[2], true)
|
||||||
|
check("...and the fainted lead is not", b.participants[1], nil)
|
||||||
|
local before = two.experience
|
||||||
|
wild.hp = 0
|
||||||
|
b:resolveFaints()
|
||||||
|
b:takeEvents()
|
||||||
|
local shared = two.experience - before
|
||||||
|
|
||||||
|
-- The control: the same KO with the same mon as the only party member.
|
||||||
|
local solo = Mon.new(DATA, "CYNDAQUIL", 20, { dvs = perfect })
|
||||||
|
solo.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
|
||||||
|
local soloWild = Mon.new(DATA, "PIDGEY", 20, { dvs = perfect })
|
||||||
|
soloWild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
|
||||||
|
local soloBattle = Battle.new({ data = DATA, party = { solo },
|
||||||
|
wild = soloWild, random = zeroRandom })
|
||||||
|
soloBattle:takeEvents()
|
||||||
|
local soloBefore = solo.experience
|
||||||
|
soloWild.hp = 0
|
||||||
|
soloBattle:resolveFaints()
|
||||||
|
soloBattle:takeEvents()
|
||||||
|
check("the survivor gets a whole share, not half",
|
||||||
|
shared, solo.experience - soloBefore)
|
||||||
|
check("and the share is a real number", shared > 0, true)
|
||||||
|
end)()
|
||||||
|
|
||||||
print(("gen2 battle: %d checks, %d failures"):format(checks, failures))
|
print(("gen2 battle: %d checks, %d failures"):format(checks, failures))
|
||||||
|
|
||||||
-- Raise rather than os.exit: tests/run_tests.lua dofiles this file, so an
|
-- Raise rather than os.exit: tests/run_tests.lua dofiles this file, so an
|
||||||
|
|||||||
@@ -549,6 +549,33 @@ do
|
|||||||
check("but the ITEM pocket still arms", tmPack.switching, 1)
|
check("but the ITEM pocket still arms", tmPack.switching, 1)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- item_data_constants.asm:47 MAX_ITEMS / MAX_BALLS / MAX_KEY_ITEMS, and the
|
||||||
|
-- TM/HM pocket is wTMsHMs (ram/wram.asm:2421), NUM_TMS + NUM_HMS = 57 bytes:
|
||||||
|
-- 50 add_tm rows and 7 add_hm rows in constants/item_constants.asm:220-293.
|
||||||
|
do
|
||||||
|
local Bag = require("src.inventory.Bag")
|
||||||
|
check("ITEM pocket is MAX_ITEMS", Bag.capacity(packGame.data, "ITEM"), 20)
|
||||||
|
check("BALL pocket is MAX_BALLS", Bag.capacity(packGame.data, "BALL"), 12)
|
||||||
|
check("KEY_ITEM pocket is MAX_KEY_ITEMS",
|
||||||
|
Bag.capacity(packGame.data, "KEY_ITEM"), 25)
|
||||||
|
check("TM/HM pocket is NUM_TMS + NUM_HMS",
|
||||||
|
Bag.capacity(packGame.data, "TM_HM"), 57)
|
||||||
|
|
||||||
|
-- Bag.add tests the cap before inserting, so all 57 cart TM/HMs fit.
|
||||||
|
local tmData = { items = {}, constants = { bagSize = 2 } }
|
||||||
|
for i = 1, 58 do
|
||||||
|
tmData.items["TM_FIX_" .. i] = { id = "TM_FIX_" .. i, name = "TM" .. i,
|
||||||
|
pocket = "TM_HM", index = 200 + i }
|
||||||
|
end
|
||||||
|
check("a mod's bagSize resizes the ITEM pocket only",
|
||||||
|
Bag.capacity(tmData, "TM_HM"), 57)
|
||||||
|
local tmSave = { inventory = {}, bagOrder = {} }
|
||||||
|
for i = 1, 57 do Bag.add(tmSave, "TM_FIX_" .. i, 1, tmData) end
|
||||||
|
check("all 57 of them fit", Bag.slots(tmSave, tmData, "TM_HM"), 57)
|
||||||
|
check("and a 58th TM/HM id has no byte to live in",
|
||||||
|
Bag.add(tmSave, "TM_FIX_58", 1, tmData), false)
|
||||||
|
end
|
||||||
|
|
||||||
-- CANCEL sits one past the last row.
|
-- CANCEL sits one past the last row.
|
||||||
check("cancel is past the end", pack:total(), #pack.rows + 1)
|
check("cancel is past the end", pack:total(), #pack.rows + 1)
|
||||||
pack.index = pack:total()
|
pack.index = pack:total()
|
||||||
|
|||||||
Reference in New Issue
Block a user