From fcb5d1d348899402c721a169cb6d57970965dabb Mon Sep 17 00:00:00 2001 From: AverageConsumer <35539970+AverageConsumer@users.noreply.github.com> Date: Sun, 16 Aug 2026 01:25:56 +0200 Subject: [PATCH] feat(mods): expose read-only battle snapshots --- docs/modding.md | 27 ++++ src/battle/BattleAPI.lua | 200 +++++++++++++++++++++++++++++ src/battle/BattleState.lua | 22 +++- src/battle/Catching.lua | 43 +++++-- src/battle/Damage.lua | 34 +++-- src/battle/gen2/BattleAPI.lua | 119 +++++++++++++++++ src/inventory/ItemEffects.lua | 6 + src/mods/Loader.lua | 12 +- src/render/TextBox.lua | 10 ++ tests/mod_battle_snapshot_test.lua | 139 ++++++++++++++++++++ 10 files changed, 583 insertions(+), 29 deletions(-) create mode 100644 src/battle/BattleAPI.lua create mode 100644 src/battle/gen2/BattleAPI.lua create mode 100644 tests/mod_battle_snapshot_test.lua diff --git a/docs/modding.md b/docs/modding.md index 4856b6c1..d3028b70 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -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 diff --git a/src/battle/BattleAPI.lua b/src/battle/BattleAPI.lua new file mode 100644 index 00000000..9affe7f9 --- /dev/null +++ b/src/battle/BattleAPI.lua @@ -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 diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 38c1bdd7..6228dc8f 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -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 diff --git a/src/battle/Catching.lua b/src/battle/Catching.lua index d7e6d94f..b4ede2ab 100644 --- a/src/battle/Catching.lua +++ b/src/battle/Catching.lua @@ -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 diff --git a/src/battle/Damage.lua b/src/battle/Damage.lua index 47c0962d..894bfd44 100644 --- a/src/battle/Damage.lua +++ b/src/battle/Damage.lua @@ -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 diff --git a/src/battle/gen2/BattleAPI.lua b/src/battle/gen2/BattleAPI.lua new file mode 100644 index 00000000..23ea585e --- /dev/null +++ b/src/battle/gen2/BattleAPI.lua @@ -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 diff --git a/src/inventory/ItemEffects.lua b/src/inventory/ItemEffects.lua index dfc34125..a900fec9 100644 --- a/src/inventory/ItemEffects.lua +++ b/src/inventory/ItemEffects.lua @@ -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) diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 79fb87c5..5dbad269 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -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) diff --git a/src/render/TextBox.lua b/src/render/TextBox.lua index 3843cbc1..81b46eeb 100644 --- a/src/render/TextBox.lua +++ b/src/render/TextBox.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 diff --git a/tests/mod_battle_snapshot_test.lua b/tests/mod_battle_snapshot_test.lua new file mode 100644 index 00000000..18e9d0b9 --- /dev/null +++ b/tests/mod_battle_snapshot_test.lua @@ -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()