mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-16 08:11:35 +02:00
Merge pull request #1374 from AverageConsumer/codex/mod-battle-snapshot
This commit is contained in:
@@ -235,6 +235,33 @@ not need generation-specific badge, terrain, bike, fishing, or field-move
|
||||
logic. Action lists are extensible; callers should render the records they
|
||||
understand and ignore unknown ids rather than assuming a fixed list length.
|
||||
|
||||
## Read-only battle snapshots
|
||||
|
||||
`mod.battle:snapshot()` returns `nil` outside a battle and a copied battle
|
||||
record while one is active. Gen 1 (Red, Blue, and Yellow) and Gold expose the
|
||||
same core fields:
|
||||
`revision`, `kind`, `catchable`, `prompt`, `message`, `turn`, `player`,
|
||||
`enemy`, `party`, `moves`, and `items`. Pokémon, moves, messages, and items in
|
||||
the result are detached records; changing them cannot change the battle.
|
||||
`revision` stays stable while the visible battle context is unchanged and
|
||||
advances when it changes, so a UI can skip rebuilding an identical view.
|
||||
|
||||
Pokémon records contain `species`, `name`, `level`, `hp`, `maxHp`, `status`,
|
||||
and `active` (plus `slot` in `party`). Move records contain `slot`, `id`,
|
||||
`name`, `pp`, `maxPp`, `type`, `power`, `accuracy`, and `disabled`. Gen 1 also
|
||||
reports the actual ruleset-aware `displayPower`, `hitChance` percentage, and
|
||||
`effectiveness` multiplier (`10` neutral, `20` super-effective, `5`
|
||||
resisted). Item rows contain `id`, `name`, `count`, `ball`, `needsTarget`, and
|
||||
an optional stock `catchChance` percentage.
|
||||
|
||||
`prompt` describes the currently visible choice (`menu`, `moves`, `party`,
|
||||
`advance`, `safari`, or `mimic`) and is `locked` when another screen or battle
|
||||
phase owns input. Generation-specific features remain optional: Gen 1 includes
|
||||
battle medicine, balls, catch previews, Safari balls, and Mimic choices;
|
||||
Gold currently returns an empty `items` list rather than guessing at its
|
||||
pocketed PACK flow. Callers should ignore unknown fields and tolerate absent
|
||||
optional ones.
|
||||
|
||||
## Rendering pipelines
|
||||
|
||||
Most registries hand the engine *content*. `render_pipelines` hands it
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
-- Read-only Gen 1 battle state for companion UIs and accessibility mods.
|
||||
|
||||
local Damage = require("src.battle.Damage")
|
||||
local ItemEffects = require("src.inventory.ItemEffects")
|
||||
local TypeChart = require("src.battle.TypeChart")
|
||||
|
||||
local BattleAPI = {}
|
||||
BattleAPI.__index = BattleAPI
|
||||
|
||||
function BattleAPI.new(game)
|
||||
return setmetatable({ game = game, revision = 0, signature = nil }, BattleAPI)
|
||||
end
|
||||
|
||||
local function activeBattle(game)
|
||||
local states = game and game.stack and game.stack.states or {}
|
||||
for i = #states, 1, -1 do
|
||||
if states[i].isBattleState then return states[i], states[#states] end
|
||||
end
|
||||
end
|
||||
|
||||
local function monCopy(data, mon, active)
|
||||
if not mon then return nil end
|
||||
local def = data.pokemon[mon.species]
|
||||
return {
|
||||
species = mon.species,
|
||||
name = mon.nickname or (def and def.name) or mon.species,
|
||||
level = mon.level, hp = mon.hp,
|
||||
maxHp = mon.stats and mon.stats.hp or mon.hp,
|
||||
status = mon.status, active = active and true or false,
|
||||
}
|
||||
end
|
||||
|
||||
local function visibleMessage(battle, top)
|
||||
local source = top and top.isTextBox and top or top == battle and battle
|
||||
local lines = source and source.visibleText and source:visibleText()
|
||||
if not lines then return nil end
|
||||
local copy = {}
|
||||
for i, line in ipairs(lines) do copy[i] = tostring(line) end
|
||||
return copy
|
||||
end
|
||||
|
||||
local function signature(game, battle, top)
|
||||
if not battle then return "none" end
|
||||
local parts = { tostring(battle), tostring(top), battle.phase or "",
|
||||
tostring(battle.turnCount or 0), tostring(#(battle.queue or {})),
|
||||
tostring(battle.current), tostring(battle.msgWaiting),
|
||||
tostring(battle.msgPrompt), tostring(battle.menuIndex),
|
||||
tostring(battle.mimicIndex),
|
||||
tostring(battle.safari and battle.safari.balls),
|
||||
tostring(battle.ghost), tostring(battle.noCatch),
|
||||
table.concat(visibleMessage(battle, top) or {}, "\n") }
|
||||
for _, battler in ipairs({ battle.player, battle.enemy }) do
|
||||
local mon = battler and battler.mon
|
||||
parts[#parts + 1] = tostring(mon)
|
||||
parts[#parts + 1] = tostring(mon and mon.hp)
|
||||
parts[#parts + 1] = tostring(mon and mon.status)
|
||||
parts[#parts + 1] = table.concat(battler and battler.curTypes or {}, ",")
|
||||
end
|
||||
for _, mon in ipairs(game.save.party or {}) do
|
||||
parts[#parts + 1] = tostring(mon)
|
||||
parts[#parts + 1] = tostring(mon.hp)
|
||||
parts[#parts + 1] = tostring(mon.status)
|
||||
end
|
||||
local inventory = {}
|
||||
for id, count in pairs(game.save.inventory or {}) do
|
||||
if ItemEffects.isBall(id) or ItemEffects.isBattleMedicine(id) then
|
||||
inventory[#inventory + 1] = id .. "=" .. tostring(count)
|
||||
end
|
||||
end
|
||||
table.sort(inventory)
|
||||
for _, item in ipairs(inventory) do parts[#parts + 1] = item end
|
||||
return table.concat(parts, "|")
|
||||
end
|
||||
|
||||
function BattleAPI:_revision(battle, top)
|
||||
local nextSignature = signature(self.game, battle, top)
|
||||
if nextSignature ~= self.signature then
|
||||
self.signature = nextSignature
|
||||
self.revision = self.revision + 1
|
||||
end
|
||||
return self.revision
|
||||
end
|
||||
|
||||
local IMMUNITY_ONLY = { SPECIAL_DAMAGE_EFFECT = true,
|
||||
SUPER_FANG_EFFECT = true, OHKO_EFFECT = true }
|
||||
|
||||
local function movePreview(battle, move, def)
|
||||
local record = battle.effectRecord and battle:effectRecord(def.effect)
|
||||
local power, typeMult = def.power or 0
|
||||
if power > 0 and move.id ~= "COUNTER" then
|
||||
local raw = TypeChart.effectiveness(def.type, battle.enemy.curTypes or {})
|
||||
if IMMUNITY_ONLY[def.effect] then
|
||||
typeMult = raw == 0 and 0 or 10
|
||||
elseif not (record and record.chooseDamage) then
|
||||
typeMult = raw
|
||||
end
|
||||
end
|
||||
local hitChance
|
||||
if record and record.neverMiss then
|
||||
hitChance = 100
|
||||
elseif not (record and record.gate)
|
||||
and (power > 0 or (record and record.accuracyChecked)) then
|
||||
hitChance = battle.enemy.invulnerable and 0
|
||||
or Damage.accuracyChance(battle.ruleset, def,
|
||||
battle.player, battle.enemy)
|
||||
end
|
||||
local displayPower = power > 0 and move.id ~= "COUNTER"
|
||||
and not IMMUNITY_ONLY[def.effect]
|
||||
and not (record and record.chooseDamage) and power or nil
|
||||
return typeMult, hitChance, displayPower
|
||||
end
|
||||
|
||||
local function moveCopies(game, battle)
|
||||
local out = {}
|
||||
for slot, move in ipairs((battle.player and battle.player.curMoves) or {}) do
|
||||
local def = game.data.moves[move.id] or {}
|
||||
local mult, hitChance, displayPower = movePreview(battle, move, def)
|
||||
out[slot] = { slot = slot, id = move.id, name = def.name or move.id,
|
||||
pp = move.pp,
|
||||
maxPp = (def.pp or move.pp or 0)
|
||||
+ (move.ppUps or 0) * math.floor((def.pp or 0) / 5),
|
||||
type = def.type, power = def.power, accuracy = def.accuracy,
|
||||
displayPower = displayPower, hitChance = hitChance,
|
||||
effectiveness = mult,
|
||||
disabled = battle.player.disabledSlot == slot }
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
local function itemCopies(game, battle, catchable)
|
||||
local out = {}
|
||||
for id, count in pairs(game.save.inventory or {}) do
|
||||
if count > 0
|
||||
and (ItemEffects.isBall(id) or ItemEffects.isBattleMedicine(id)) then
|
||||
local def = game.data.items[id] or {}
|
||||
local ball = ItemEffects.isBall(id)
|
||||
out[#out + 1] = { id = id, name = def.name or id, count = count,
|
||||
ball = ball, needsTarget = not ball,
|
||||
catchChance = ball and catchable and battle.catchChance
|
||||
and battle:catchChance(id) or nil }
|
||||
end
|
||||
end
|
||||
table.sort(out, function(a, b) return a.name < b.name end)
|
||||
return out
|
||||
end
|
||||
|
||||
local function mimicCopies(game, battle)
|
||||
local out = {}
|
||||
for i, move in ipairs(battle.mimicMoves or {}) do
|
||||
local def = game.data.moves[move.id] or {}
|
||||
out[i] = { index = i, slot = move.slot, id = move.id,
|
||||
name = def.name or move.id }
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
function BattleAPI:snapshot()
|
||||
local game = self.game
|
||||
local battle, top = activeBattle(game)
|
||||
if not battle then return nil end
|
||||
local kind = battle:battleKind()
|
||||
local supported = kind ~= "oldman" and kind ~= "link"
|
||||
local catchable = kind == "wild" and not battle.ghost and not battle.noCatch
|
||||
local forcedParty = top and top.isPartyMenu and top.battle == battle
|
||||
and top.forceSwitch
|
||||
local canAdvance = supported and ((top == battle
|
||||
and battle.phase == "messages" and battle.current
|
||||
and (battle.msgWaiting or battle.msgPrompt))
|
||||
or (top and top.isTextBox and not top.choice
|
||||
and (top.waiting or top.done)))
|
||||
local prompt = "locked"
|
||||
if canAdvance then prompt = "advance"
|
||||
elseif supported and forcedParty then prompt = "party"
|
||||
elseif supported and top == battle and kind == "safari"
|
||||
and battle.phase == "menu" then prompt = "safari"
|
||||
elseif supported and top == battle and battle.phase == "mimicSelect" then
|
||||
prompt = "mimic"
|
||||
elseif supported and top == battle and battle.phase == "menu" then
|
||||
prompt = "menu"
|
||||
elseif supported and top == battle and battle.phase == "moveSelect" then
|
||||
prompt = "moves"
|
||||
end
|
||||
local party = {}
|
||||
for i, mon in ipairs(game.save.party or {}) do
|
||||
party[i] = monCopy(game.data, mon,
|
||||
battle.player and battle.player.mon == mon)
|
||||
party[i].slot = i
|
||||
end
|
||||
return { revision = self:_revision(battle, top), kind = kind,
|
||||
catchable = catchable, prompt = prompt,
|
||||
message = visibleMessage(battle, top), turn = battle.turnCount or 0,
|
||||
player = monCopy(game.data, battle.player and battle.player.mon, true),
|
||||
enemy = monCopy(game.data, battle.enemy and battle.enemy.mon, true),
|
||||
party = party, moves = moveCopies(game, battle),
|
||||
items = itemCopies(game, battle, catchable),
|
||||
safariBalls = battle.safari and battle.safari.balls or nil,
|
||||
mimicMoves = mimicCopies(game, battle), mimicIndex = battle.mimicIndex }
|
||||
end
|
||||
|
||||
return BattleAPI
|
||||
@@ -38,6 +38,7 @@ local romText = RomText
|
||||
local BattleState = {}
|
||||
BattleState.__index = BattleState
|
||||
BattleState.isOpaque = true
|
||||
BattleState.isBattleState = true
|
||||
|
||||
-- Category identity for per-category GAME SPEED (RFC 0007), the same
|
||||
-- style OverworldController.isOverworld already uses. Every battle --
|
||||
@@ -156,6 +157,13 @@ function BattleState:caughtMarkerVisible()
|
||||
function() return false end, self) == true
|
||||
end
|
||||
|
||||
function BattleState:catchChance(ball, rateOverride)
|
||||
if Runtime.wantsHook("catch.rate") then return nil end
|
||||
return Catching.chance(ball, self.enemy.mon, self.enemy.def, rateOverride,
|
||||
{ ballDef = self:ballDef(ball), statuses = self.data.statuses,
|
||||
battle = self })
|
||||
end
|
||||
|
||||
function BattleState:moveGridNavigation()
|
||||
if self:wideLayout() then return true end
|
||||
if not Runtime.wantsHook("battle.move_grid_navigation") then return false end
|
||||
@@ -1166,7 +1174,7 @@ function BattleState:startMessage(item)
|
||||
local npos = text:find("[\n\v]", pos)
|
||||
local chunk = npos and text:sub(pos, npos - 1) or text:sub(pos)
|
||||
local codes = Font.encode(chunk)
|
||||
self.lines[#self.lines + 1] = { codes = codes, cont = cont }
|
||||
self.lines[#self.lines + 1] = { codes = codes, cont = cont, text = chunk }
|
||||
self.total = self.total + #codes
|
||||
if not npos then break end
|
||||
cont = text:sub(npos, npos) == "\v"
|
||||
@@ -1199,6 +1207,18 @@ function BattleState:beginMsgLine()
|
||||
self.shown[#self.shown + 1] = {}
|
||||
end
|
||||
|
||||
function BattleState:visibleText()
|
||||
if self.phase ~= "messages" or not (self.current or self.animPlaying) then
|
||||
return nil
|
||||
end
|
||||
local out, count = {}, #(self.shown or {})
|
||||
for i = math.max(1, self.lineIndex - count + 1), self.lineIndex do
|
||||
local line = self.lines and self.lines[i]
|
||||
if line then out[#out + 1] = line.text or "" end
|
||||
end
|
||||
return #out > 0 and out or nil
|
||||
end
|
||||
|
||||
function BattleState:updateQueue()
|
||||
if self.waitingUI then
|
||||
if self.game.stack:top() ~= self then return true end
|
||||
|
||||
+29
-14
@@ -27,6 +27,17 @@ Catching.BALLS = BALLS
|
||||
-- divisor, which is what the old per-field `or` defaults resolved to
|
||||
local DEFAULT_BALL = { randMax = 255, hpFactor = 12, wobbleFactor = 150 }
|
||||
|
||||
local function stockFactors(def, targetMon, targetDef, rateOverride, statuses)
|
||||
local rate = rateOverride or targetDef.catchRate
|
||||
local record = Status.recordFor(statuses, targetMon.status)
|
||||
local statusBonus = record and record.catchBonus or 0
|
||||
local hpQuarter = math.max(1, math.floor(targetMon.hp / 4))
|
||||
local factor = def.hpFactor or DEFAULT_BALL.hpFactor
|
||||
local f = math.min(255, math.floor(math.floor(
|
||||
targetMon.stats.hp * 255 / factor) / hpQuarter))
|
||||
return rate, statusBonus, f, record
|
||||
end
|
||||
|
||||
function Catching.registerInto(registry, _, owner)
|
||||
for id, record in pairs(BALLS) do
|
||||
registry:register(id, record, owner)
|
||||
@@ -41,21 +52,9 @@ end
|
||||
local function stockAttempt(def, targetMon, targetDef, rng, rateOverride, statuses)
|
||||
if def.autoCatch then return true, 3 end
|
||||
local randMax = def.randMax
|
||||
local rate = rateOverride or targetDef.catchRate
|
||||
|
||||
-- the status subtraction and the wobble bonus come off the merged
|
||||
-- status record (SLP/FRZ 25 and +10, the rest 12 and +5)
|
||||
local s = targetMon.status
|
||||
local record = Status.recordFor(statuses, s)
|
||||
local statusBonus = record and record.catchBonus or 0
|
||||
|
||||
-- HP factor (X)
|
||||
local maxhp = targetMon.stats.hp
|
||||
local hpQuarter = math.max(1, math.floor(targetMon.hp / 4))
|
||||
local factor = def.hpFactor or DEFAULT_BALL.hpFactor
|
||||
-- the 255 cap applies only after BOTH divisions (ItemUseBall keeps
|
||||
-- the intermediate in 16 bits); capping early collapses the value
|
||||
local f = math.min(255, math.floor(math.floor(maxhp * 255 / factor) / hpQuarter))
|
||||
local rate, statusBonus, f, record = stockFactors(
|
||||
def, targetMon, targetDef, rateOverride, statuses)
|
||||
|
||||
local function shakes()
|
||||
local ballFactor2 = def.wobbleFactor or DEFAULT_BALL.wobbleFactor
|
||||
@@ -80,6 +79,22 @@ local function stockAttempt(def, targetMon, targetDef, rng, rateOverride, status
|
||||
return false, shakes()
|
||||
end
|
||||
|
||||
-- Exact stock catch probability for read-only previews. A custom attempt
|
||||
-- function may do anything, so nil is safer than presenting a plausible lie.
|
||||
function Catching.chance(ball, targetMon, targetDef, rateOverride, opts)
|
||||
opts = opts or {}
|
||||
local def = opts.ballDef or BALLS[ball] or DEFAULT_BALL
|
||||
if def.attempt then return nil end
|
||||
if def.autoCatch then return 100 end
|
||||
local rate, statusBonus, f = stockFactors(
|
||||
def, targetMon, targetDef, rateOverride, opts.statuses)
|
||||
local outcomes = def.randMax + 1
|
||||
local automatic = math.min(outcomes, math.max(0, statusBonus))
|
||||
local passed = math.min(outcomes, math.max(0, rate + statusBonus + 1))
|
||||
return (automatic + (passed - automatic) * (f + 1) / 256)
|
||||
* 100 / outcomes
|
||||
end
|
||||
|
||||
-- Returns caught, shakes (0-3). rateOverride replaces the species catch
|
||||
-- rate (the Safari game's BAIT/ROCK-modified wEnemyMonActualCatchRate).
|
||||
-- opts (all optional): ballDef = the merged ball record, statuses = the
|
||||
|
||||
+22
-12
@@ -83,25 +83,35 @@ function Damage.critRoll(ruleset, attacker, moveId, rng, highCrit)
|
||||
return rng(0, 255) < b
|
||||
end
|
||||
|
||||
-- Accuracy test: rand(0..255) < floor(accuracy * 255 / 100) adjusted by
|
||||
-- accuracy/evasion stages. With oneIn256Miss a max-accuracy move still
|
||||
-- misses on 255.
|
||||
function Damage.accuracyRoll(ruleset, move, attacker, defender, rng)
|
||||
rng = rng or love.math.random
|
||||
-- Exact number of the 256 RNG outcomes that pass MoveHitTest. Keeping the
|
||||
-- threshold public lets read-only UIs preview the same rules the roll uses.
|
||||
function Damage.accuracyThreshold(ruleset, move, attacker, defender)
|
||||
-- X ACCURACY sets USING_X_ACCURACY: the move simply never misses
|
||||
-- (MoveHitTest returns before any accuracy math, 1/256 included)
|
||||
if attacker.xAccuracy then return true end
|
||||
if attacker.xAccuracy then return 256 end
|
||||
local acc = math.floor(move.accuracy * 255 / 100)
|
||||
local accuracyStage = attacker.stages and attacker.stages.accuracy or 0
|
||||
local evasionStage = defender.stages and defender.stages.evasion or 0
|
||||
-- CalcHitChance scales by the accuracy stage and the evasion stage as
|
||||
-- two separate ratio multiplications, clamping each result
|
||||
acc = math.min(255, Stats.applyStage(acc,
|
||||
attacker.stages and attacker.stages.accuracy or 0))
|
||||
acc = math.min(255, Stats.applyStage(acc,
|
||||
-(defender.stages and defender.stages.evasion or 0)))
|
||||
acc = math.min(255, Stats.applyStage(acc, accuracyStage))
|
||||
acc = math.min(255, Stats.applyStage(acc, -evasionStage))
|
||||
if not ruleset.oneIn256Miss and move.accuracy >= 100
|
||||
and (attacker.stages.accuracy or 0) >= (defender.stages.evasion or 0) then
|
||||
return true
|
||||
and accuracyStage >= evasionStage then
|
||||
return 256
|
||||
end
|
||||
return acc
|
||||
end
|
||||
|
||||
function Damage.accuracyChance(ruleset, move, attacker, defender)
|
||||
return Damage.accuracyThreshold(ruleset, move, attacker, defender) * 100 / 256
|
||||
end
|
||||
|
||||
-- Accuracy test: rand(0..255) < the shared ruleset-aware threshold.
|
||||
function Damage.accuracyRoll(ruleset, move, attacker, defender, rng)
|
||||
rng = rng or love.math.random
|
||||
local acc = Damage.accuracyThreshold(ruleset, move, attacker, defender)
|
||||
if acc == 256 then return true end
|
||||
return rng(0, 255) < acc
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
-- Read-only Gen 2 battle state with the same shape as mod.battle on Gen 1.
|
||||
|
||||
local BattleAPI = {}
|
||||
BattleAPI.__index = BattleAPI
|
||||
|
||||
function BattleAPI.new(game)
|
||||
return setmetatable({ game = game, revision = 0, signature = nil }, BattleAPI)
|
||||
end
|
||||
|
||||
local function activeBattle(game)
|
||||
local states = game and game.stack and game.stack.states or {}
|
||||
local battle
|
||||
for i = #states, 1, -1 do
|
||||
local state = states[i]
|
||||
if state.screenId == "Gen2BattleState" or state.isGen2BattleState then
|
||||
battle = state
|
||||
break
|
||||
end
|
||||
end
|
||||
return battle, states[#states]
|
||||
end
|
||||
|
||||
local function monCopy(data, mon, active)
|
||||
if not mon then return nil end
|
||||
local def = data and data.pokemon and data.pokemon[mon.species]
|
||||
return { species = mon.species,
|
||||
name = mon.nickname or (def and def.name) or mon.species,
|
||||
level = mon.level, hp = mon.hp,
|
||||
maxHp = mon.maxHp or (mon.stats and mon.stats.hp) or mon.hp,
|
||||
status = mon.status, active = active and true or false }
|
||||
end
|
||||
|
||||
local function messageCopy(screen)
|
||||
if not screen.message then return nil end
|
||||
local lines = {}
|
||||
for line in tostring(screen.message):gmatch("[^\n]+") do
|
||||
lines[#lines + 1] = line
|
||||
end
|
||||
return #lines > 0 and lines or nil
|
||||
end
|
||||
|
||||
local function signature(game, screen, top)
|
||||
if not screen then return "none" end
|
||||
local battle = screen.battle or {}
|
||||
local parts = { tostring(screen), tostring(top), tostring(screen.phase),
|
||||
tostring(screen.message), tostring(screen.messageTimer),
|
||||
tostring(screen.menuIndex), tostring(screen.moveIndex),
|
||||
tostring(battle.turn), tostring(battle.over), tostring(battle.outcome) }
|
||||
for _, mon in ipairs({ battle.player, battle.enemy }) do
|
||||
parts[#parts + 1] = tostring(mon)
|
||||
parts[#parts + 1] = tostring(mon and mon.hp)
|
||||
parts[#parts + 1] = tostring(mon and mon.status)
|
||||
end
|
||||
for _, mon in ipairs((game.save and game.save.party) or battle.party or {}) do
|
||||
parts[#parts + 1] = tostring(mon)
|
||||
parts[#parts + 1] = tostring(mon.hp)
|
||||
parts[#parts + 1] = tostring(mon.status)
|
||||
end
|
||||
return table.concat(parts, "|")
|
||||
end
|
||||
|
||||
function BattleAPI:_revision(screen, top)
|
||||
local nextSignature = signature(self.game, screen, top)
|
||||
if nextSignature ~= self.signature then
|
||||
self.signature = nextSignature
|
||||
self.revision = self.revision + 1
|
||||
end
|
||||
return self.revision
|
||||
end
|
||||
|
||||
local function moveCopies(game, battle)
|
||||
local out = {}
|
||||
for slot, move in ipairs((battle.player and battle.player.moves) or {}) do
|
||||
local def = (game.data.moves or {})[move.id] or {}
|
||||
out[slot] = { slot = slot, id = move.id, name = def.name or move.id,
|
||||
pp = move.pp, maxPp = move.maxPp or def.pp or move.pp,
|
||||
type = def.type, power = def.power, accuracy = def.accuracy,
|
||||
disabled = battle:moveDisabled(battle.player, move.id) }
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
local function battleKind(screen)
|
||||
if screen.tutorial then return "oldman" end
|
||||
return screen.battle and screen.battle.wild and "wild" or "trainer"
|
||||
end
|
||||
|
||||
function BattleAPI:snapshot()
|
||||
local game = self.game
|
||||
local screen, top = activeBattle(game)
|
||||
if not screen or not screen.battle then return nil end
|
||||
local battle = screen.battle
|
||||
local prompt = "locked"
|
||||
if top == screen and screen.phase == "menu" then
|
||||
prompt = "menu"
|
||||
elseif top == screen and screen.phase == "moves" then
|
||||
prompt = "moves"
|
||||
elseif top == screen and screen.message then
|
||||
prompt = "advance"
|
||||
elseif top and top.screenId == "Gen2PartyMenu" then
|
||||
prompt = "party"
|
||||
end
|
||||
local party = {}
|
||||
for i, mon in ipairs((game.save and game.save.party) or battle.party or {}) do
|
||||
party[i] = monCopy(game.data, mon, mon == battle.player)
|
||||
party[i].slot = i
|
||||
end
|
||||
return { revision = self:_revision(screen, top), kind = battleKind(screen),
|
||||
catchable = battle.wild and not screen.tutorial, prompt = prompt,
|
||||
message = messageCopy(screen), turn = battle.turn or 0,
|
||||
player = monCopy(game.data, battle.player, true),
|
||||
enemy = monCopy(game.data, battle.enemy, true),
|
||||
party = party, moves = moveCopies(game, battle),
|
||||
-- Gold's PACK is pocketed and target selection is screen-owned. Omit it
|
||||
-- until the engine can expose the same semantic item records as Gen 1.
|
||||
items = {} }
|
||||
end
|
||||
|
||||
return BattleAPI
|
||||
@@ -83,6 +83,12 @@ function ItemEffects.healsHP(id)
|
||||
or id == "REVIVE" or id == "MAX_REVIVE"
|
||||
end
|
||||
|
||||
function ItemEffects.isBattleMedicine(id)
|
||||
return HEAL_AMOUNT[id] ~= nil or STATUS_HEAL[id] ~= nil
|
||||
or id == "MAX_POTION" or id == "FULL_RESTORE"
|
||||
or id == "REVIVE" or id == "MAX_REVIVE"
|
||||
end
|
||||
|
||||
-- Does this item need a party-member target?
|
||||
-- 'data' is optional for compat purposes; targeting falls back to itemDef/vanilla detection
|
||||
function ItemEffects.needsTarget(id, itemDef, data)
|
||||
|
||||
+10
-2
@@ -1308,7 +1308,7 @@ function Loader:_api(mod)
|
||||
-- mod.world materializes on first touch, like the image helper above: a
|
||||
-- headless load must not drag the world stack in, and the Game the facade
|
||||
-- acts on is still being wired when the entry chunk runs
|
||||
local world
|
||||
local world, battle
|
||||
setmetatable(api, { __index = function(_, key)
|
||||
-- mod.game is the live service owner, resolved per generation the way
|
||||
-- mod.world is: src/core/Game.lua's singleton under Gen 1, the Game2
|
||||
@@ -1317,9 +1317,17 @@ function Loader:_api(mod)
|
||||
-- entry chunk runs. This is what a mod should hold instead of requiring
|
||||
-- src.core.Game, which under Gold hands back a table nothing instantiated.
|
||||
if key == "game" then return loader:_game() end
|
||||
local game = loader:_game()
|
||||
if key == "battle" then
|
||||
if battle then return battle end
|
||||
local module = game and engineRequire(loader.generation == 2
|
||||
and "src.battle.gen2.BattleAPI" or "src.battle.BattleAPI")
|
||||
if not module then return nil end
|
||||
battle = module.new(game)
|
||||
return battle
|
||||
end
|
||||
if key ~= "world" then return nil end
|
||||
if world then return world end
|
||||
local game = loader:_game()
|
||||
-- one facade name, one arm per generation: Gold's world is not a stack
|
||||
-- state and its flags are a bitfield, so the resolution differs even
|
||||
-- where the method set does not (src/world/gen2/WorldAPI.lua)
|
||||
|
||||
@@ -253,6 +253,16 @@ function TextBox:beginLine()
|
||||
table.insert(self.shown, {})
|
||||
end
|
||||
|
||||
function TextBox:visibleText()
|
||||
local page = self.pages[self.pageIndex]
|
||||
if not page then return nil end
|
||||
local out, count = {}, #(self.shown or {})
|
||||
for i = math.max(1, self.lineIndex - count + 1), self.lineIndex do
|
||||
if page[i] ~= nil then out[#out + 1] = page[i] end
|
||||
end
|
||||
return #out > 0 and out or nil
|
||||
end
|
||||
|
||||
function TextBox:update(dt)
|
||||
local input = self.game.input
|
||||
self.blink = (self.blink + 1) % 60
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local S = require("tests.harness").suite("mod battle snapshot")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
check(require("src.battle.BattleState").isBattleState == true,
|
||||
"Gen 1 battle states carry the discovery marker")
|
||||
|
||||
local TypeChart = require("src.battle.TypeChart")
|
||||
TypeChart.load({ type_chart = {
|
||||
types = { NORMAL = { name = "NORMAL", category = "physical" } },
|
||||
matchups = {},
|
||||
} })
|
||||
|
||||
local Damage = require("src.battle.Damage")
|
||||
local attacker, defender = { stages = {} }, { stages = {} }
|
||||
eq(Damage.accuracyThreshold({ oneIn256Miss = true }, { accuracy = 100 },
|
||||
attacker, defender), 255, "faithful accuracy keeps the 1-in-256 miss")
|
||||
eq(Damage.accuracyThreshold({ oneIn256Miss = false }, { accuracy = 100 },
|
||||
attacker, defender), 256, "clean accuracy exposes a certain hit")
|
||||
local Catching = require("src.battle.Catching")
|
||||
eq(Catching.chance("MASTER_BALL", { hp = 1, stats = { hp = 1 } },
|
||||
{ catchRate = 1 }), 100, "Master Ball preview is certain")
|
||||
check(Catching.chance("MOD_BALL", { hp = 1, stats = { hp = 1 } },
|
||||
{ catchRate = 1 }, nil, { ballDef = { attempt = function() end } }) == nil,
|
||||
"custom ball logic does not receive a guessed preview")
|
||||
|
||||
local mon = { species = "TESTMON", level = 5, hp = 18,
|
||||
stats = { hp = 20 }, moves = {} }
|
||||
local game = {
|
||||
data = {
|
||||
pokemon = { TESTMON = { name = "TESTMON", catchRate = 255 } },
|
||||
moves = { TACKLE = { name = "TACKLE", type = "NORMAL", power = 35,
|
||||
accuracy = 95, pp = 35 } },
|
||||
items = { POTION = { name = "POTION" },
|
||||
POKE_BALL = { name = "POKE BALL" } },
|
||||
},
|
||||
save = { party = { mon }, inventory = { POTION = 1, POKE_BALL = 1 } },
|
||||
stack = { states = {} },
|
||||
}
|
||||
local battle = {
|
||||
isBattleState = true, phase = "menu", queue = {},
|
||||
ruleset = { oneIn256Miss = true },
|
||||
player = { mon = mon, curTypes = { "NORMAL" }, stages = {},
|
||||
curMoves = { { id = "TACKLE", pp = 35 } } },
|
||||
enemy = { mon = { species = "TESTMON", level = 4, hp = 12,
|
||||
stats = { hp = 12 }, moves = {} }, curTypes = { "NORMAL" }, stages = {} },
|
||||
}
|
||||
function battle:battleKind() return "wild" end
|
||||
function battle:effectRecord() return { accuracyChecked = true } end
|
||||
function battle:visibleText() return { "Wild TESTMON appeared!" } end
|
||||
function battle:catchChance(ball)
|
||||
return require("src.battle.Catching").chance(ball, self.enemy.mon,
|
||||
game.data.pokemon[self.enemy.mon.species])
|
||||
end
|
||||
game.stack.states = { battle }
|
||||
|
||||
local api = require("src.battle.BattleAPI").new(game)
|
||||
local snapshot = api:snapshot()
|
||||
check(snapshot and snapshot.kind == "wild" and snapshot.prompt == "menu",
|
||||
"Gen 1 battle is exposed")
|
||||
eq(snapshot.player.maxHp, 20, "Gen 1 max HP comes from battle stats")
|
||||
eq(snapshot.moves[1].name, "TACKLE", "move records are copied")
|
||||
eq(snapshot.message[1], "Wild TESTMON appeared!", "battle text is copied")
|
||||
eq(#snapshot.items, 2, "medicine and balls are exposed")
|
||||
check(type(snapshot.items[1].catchChance) == "number"
|
||||
or type(snapshot.items[2].catchChance) == "number",
|
||||
"stock catch chance is available")
|
||||
snapshot.player.hp = 0
|
||||
snapshot.moves[1].pp = 0
|
||||
eq(mon.hp, 18, "changing a snapshot cannot change a Pokemon")
|
||||
eq(battle.player.curMoves[1].pp, 35,
|
||||
"changing a snapshot cannot change a move")
|
||||
local same = api:snapshot()
|
||||
eq(same.revision, snapshot.revision, "unchanged battle keeps its revision")
|
||||
battle.enemy.mon.hp = 5
|
||||
check(api:snapshot().revision > same.revision,
|
||||
"observable battle changes advance the revision")
|
||||
game.stack.states = {}
|
||||
check(api:snapshot() == nil, "Gen 1 returns nil outside a battle")
|
||||
game.stack.states = { battle }
|
||||
|
||||
local player2 = { species = "CHIKORITA", level = 5, hp = 20,
|
||||
maxHp = 21, moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } }
|
||||
local enemy2 = { species = "RATTATA", level = 3, hp = 12, maxHp = 12,
|
||||
moves = {} }
|
||||
local battle2 = { player = player2, enemy = enemy2, party = { player2 },
|
||||
wild = true, turn = 0 }
|
||||
function battle2:moveDisabled() return false end
|
||||
local screen2 = { screenId = "Gen2BattleState", battle = battle2,
|
||||
phase = "menu", menuIndex = 1, moveIndex = 1 }
|
||||
local game2 = {
|
||||
data = {
|
||||
pokemon = { CHIKORITA = { name = "CHIKORITA" },
|
||||
RATTATA = { name = "RATTATA" } },
|
||||
moves = { TACKLE = { name = "TACKLE", type = "NORMAL",
|
||||
power = 35, accuracy = 95, pp = 35 } },
|
||||
},
|
||||
save = { party = { player2 } }, stack = { states = { screen2 } },
|
||||
}
|
||||
|
||||
local api2 = require("src.battle.gen2.BattleAPI").new(game2)
|
||||
local snapshot2 = api2:snapshot()
|
||||
check(snapshot2 and snapshot2.kind == "wild" and snapshot2.prompt == "menu",
|
||||
"Gold battle is discovered through its screen id")
|
||||
eq(snapshot2.player.maxHp, 21, "Gold max HP uses the mon field")
|
||||
eq(snapshot2.moves[1].name, "TACKLE", "Gold moves are copied")
|
||||
snapshot2.player.hp = 0
|
||||
snapshot2.moves[1].pp = 0
|
||||
eq(player2.hp, 20, "changing a snapshot cannot change a Gold Pokemon")
|
||||
eq(player2.moves[1].pp, 35,
|
||||
"changing a snapshot cannot change a Gold move")
|
||||
screen2.message = "A wild RATTATA appeared!"
|
||||
screen2.phase = "resolving"
|
||||
local message2 = api2:snapshot()
|
||||
eq(message2.prompt, "advance", "Gold message state is exposed")
|
||||
check(message2.revision > snapshot2.revision,
|
||||
"Gold battle changes advance the revision")
|
||||
game2.stack.states = {}
|
||||
check(api2:snapshot() == nil, "Gold returns nil outside a battle")
|
||||
game2.stack.states = { screen2 }
|
||||
|
||||
local Loader = require("src.mods.Loader")
|
||||
local fs = { read = function() end, getInfo = function() end,
|
||||
getDirectoryItems = function() return {} end }
|
||||
local mod = { path = "mods/snapshot_test", manifest = {
|
||||
id = "snapshot_test", version = "1.0.0", permissionSet = {},
|
||||
} }
|
||||
local loader1 = Loader.new({ fs = fs, generation = 1 })
|
||||
loader1.game = game
|
||||
check(loader1:_api(mod).battle:snapshot().kind == "wild",
|
||||
"mod.battle selects the Gen 1 facade")
|
||||
local loader2 = Loader.new({ fs = fs, generation = 2 })
|
||||
loader2.game = game2
|
||||
check(loader2:_api(mod).battle:snapshot().kind == "wild",
|
||||
"mod.battle selects the Gen 2 facade")
|
||||
|
||||
S.finish()
|
||||
Reference in New Issue
Block a user