Merge pull request #1377 from bryanthaboi/dev

This commit is contained in:
bryanthaboi
2026-08-15 20:22:42 -04:00
committed by GitHub
23 changed files with 901 additions and 50 deletions
+9 -4
View File
@@ -46,7 +46,11 @@ when the runtime schema is absent. The supported row types are `toggle`,
`choice`, `number`, and `text`. Their optional fields retain the meanings
established by the existing in-game option UI: choices are `[label, value]`
pairs, numeric rows may provide `min`, `max`, and `step`, and text rows may
provide `maxLen`.
provide `maxLen`. A row may also use
`visible_if = {key = "mode", equals = "compact"}` or replace `equals` with
`not_equals`. This only hides the in-game menu row; the schema and stored value
remain available, and consumers that do not implement conditions may ignore
the field.
Only mods that are enabled and successfully loaded in the current boot are
included. A disabled or failed mod must not contribute rows. If an older
@@ -69,9 +73,10 @@ it may ignore an unknown row type or optional field.
For compatibility with files produced by the original unversioned prototype,
a missing `schema_version` means version 1. Consumers must ignore documents
with a newer version rather than guessing at their shape. Producers must bump
the version whenever they change the document shape or the meaning of an
existing field. Version 1 is therefore the legacy unversioned format as well
as the explicitly versioned format shown above.
the version whenever they change the document envelope or the meaning of an
existing field. New optional row fields that older consumers can safely ignore
do not require a bump. Version 1 is therefore the legacy unversioned format as
well as the explicitly versioned format shown above.
## Migration note
+29
View File
@@ -226,6 +226,8 @@ exposes `headbutt`, `whirlpool`, `waterfall`, `sweet_scent`, and the
contextual `squirtbottle` key item. Fishing rows include the owned rods that
are valid choices. The list is empty while the world is busy, and omits an
action whenever its item, move, badge, terrain, or engine state forbids it.
The optional second return is `"world is busy"` during transient input locks
or `"no overworld"` before a playable world exists.
Call `mod.world:useFieldAction(id, opts)` to perform a listed action through
the active game's own field-item path. Fishing accepts `{ rod = "OLD_ROD" }`
@@ -235,6 +237,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
@@ -154,6 +154,16 @@ love::image::ImageData *Canvas::newImageData(love::image::Image *module, int sli
return module->newImageData(r.w, r.h, dataformat);
}
bool Canvas::requestImageData()
{
return false;
}
love::image::ImageData *Canvas::pollImageData(love::image::Image *)
{
return nullptr;
}
void Canvas::draw(Graphics *gfx, Quad *q, const Matrix4 &t)
{
if (gfx->isCanvasActive(this))
@@ -232,4 +242,3 @@ StringMap<Canvas::SettingType, Canvas::SETTING_MAX_ENUM> Canvas::settingTypes(Ca
} // graphics
} // love
@@ -81,6 +81,8 @@ public:
int getRequestedMSAA() const;
virtual love::image::ImageData *newImageData(love::image::Image *module, int slice, int mipmap, const Rect &rect);
virtual bool requestImageData();
virtual love::image::ImageData *pollImageData(love::image::Image *module);
virtual void generateMipmaps() = 0;
virtual int getMSAA() const = 0;
@@ -23,6 +23,7 @@
#include "Graphics.h"
#include <algorithm> // For min/max
#include <cstring>
namespace love
{
@@ -197,6 +198,9 @@ Canvas::Canvas(const Settings &settings)
, texture(0)
, renderbuffer(0)
, actualSamples(0)
, readbackBuffer(0)
, readbackFence(nullptr)
, readbackSize(0)
{
format = getSizedFormat(format);
@@ -314,6 +318,14 @@ bool Canvas::loadVolatile()
void Canvas::unloadVolatile()
{
if (readbackFence != nullptr)
glDeleteSync(readbackFence);
if (readbackBuffer != 0)
glDeleteBuffers(1, &readbackBuffer);
readbackFence = nullptr;
readbackBuffer = 0;
readbackSize = 0;
if (fbo != 0 || renderbuffer != 0 || texture != 0)
{
// This is a bit ugly, but we need some way to destroy the cached FBO
@@ -480,6 +492,68 @@ love::image::ImageData *Canvas::newImageData(love::image::Image *module, int sli
return data;
}
bool Canvas::requestImageData()
{
if (readbackFence != nullptr || !isReadable()
|| !(GLAD_ES_VERSION_3_0 || GLAD_VERSION_3_2)
|| texType != TEXTURE_2D
|| (format != PIXELFORMAT_RGBA8 && format != PIXELFORMAT_sRGBA8)
|| actualSamples > 0)
return false;
auto gfx = Module::getInstance<Graphics>(Module::M_GRAPHICS);
if (gfx != nullptr && gfx->isCanvasActive(this))
throw love::Exception("Canvas:requestImageData cannot be called while that Canvas is active.");
const size_t size = (size_t) pixelWidth * (size_t) pixelHeight * 4;
if (readbackBuffer == 0)
glGenBuffers(1, &readbackBuffer);
glBindBuffer(GL_PIXEL_PACK_BUFFER, readbackBuffer);
if (readbackSize != size)
{
glBufferData(GL_PIXEL_PACK_BUFFER, size, nullptr, GL_STREAM_READ);
readbackSize = size;
}
GLuint currentfbo = gl.getFramebuffer(OpenGL::FRAMEBUFFER_ALL);
gl.bindFramebuffer(OpenGL::FRAMEBUFFER_ALL, getFBO());
glReadPixels(0, 0, pixelWidth, pixelHeight, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
readbackFence = glFenceSync(GL_SYNC_GPU_COMMANDS_COMPLETE, 0);
gl.bindFramebuffer(OpenGL::FRAMEBUFFER_ALL, currentfbo);
glBindBuffer(GL_PIXEL_PACK_BUFFER, 0);
return readbackFence != nullptr;
}
love::image::ImageData *Canvas::pollImageData(love::image::Image *module)
{
if (readbackFence == nullptr)
return nullptr;
GLenum status = glClientWaitSync(readbackFence, 0, 0);
if (status == GL_TIMEOUT_EXPIRED)
return nullptr;
glDeleteSync(readbackFence);
readbackFence = nullptr;
if (status == GL_WAIT_FAILED)
return nullptr;
love::image::ImageData *data = module->newImageData(pixelWidth, pixelHeight, PIXELFORMAT_RGBA8);
glBindBuffer(GL_PIXEL_PACK_BUFFER, readbackBuffer);
void *pixels = glMapBufferRange(GL_PIXEL_PACK_BUFFER, 0, readbackSize, GL_MAP_READ_BIT);
if (pixels == nullptr)
{
glBindBuffer(GL_PIXEL_PACK_BUFFER, 0);
data->release();
throw love::Exception("Could not map asynchronous Canvas readback.");
}
memcpy(data->getData(), pixels, readbackSize);
glUnmapBuffer(GL_PIXEL_PACK_BUFFER);
glBindBuffer(GL_PIXEL_PACK_BUFFER, 0);
return data;
}
void Canvas::generateMipmaps()
{
if (getMipmapCount() == 1 || getMipmapMode() == MIPMAPS_NONE)
@@ -54,6 +54,8 @@ public:
ptrdiff_t getHandle() const override;
love::image::ImageData *newImageData(love::image::Image *module, int slice, int mipmap, const Rect &rect) override;
bool requestImageData() override;
love::image::ImageData *pollImageData(love::image::Image *module) override;
void generateMipmaps() override;
int getMSAA() const override
@@ -107,6 +109,9 @@ private:
GLenum status;
int actualSamples;
GLuint readbackBuffer;
GLsync readbackFence;
size_t readbackSize;
static SupportedFormat supportedFormats[PIXELFORMAT_MAX_ENUM];
static SupportedFormat checkedFormats[PIXELFORMAT_MAX_ENUM];
@@ -116,6 +116,31 @@ int w_Canvas_newImageData(lua_State *L)
return 1;
}
int w_Canvas_requestImageData(lua_State *L)
{
Canvas *canvas = luax_checkcanvas(L, 1);
bool requested = false;
luax_catchexcept(L, [&](){ requested = canvas->requestImageData(); });
luax_pushboolean(L, requested);
return 1;
}
int w_Canvas_pollImageData(lua_State *L)
{
Canvas *canvas = luax_checkcanvas(L, 1);
love::image::Image *image = luax_getmodule<love::image::Image>(L, love::image::Image::type);
love::image::ImageData *data = nullptr;
luax_catchexcept(L, [&](){ data = canvas->pollImageData(image); });
if (data == nullptr)
{
lua_pushnil(L);
return 1;
}
luax_pushtype(L, data);
data->release();
return 1;
}
int w_Canvas_generateMipmaps(lua_State *L)
{
Canvas *c = luax_checkcanvas(L, 1);
@@ -139,6 +164,8 @@ static const luaL_Reg w_Canvas_functions[] =
{ "getMSAA", w_Canvas_getMSAA },
{ "renderTo", w_Canvas_renderTo },
{ "newImageData", w_Canvas_newImageData },
{ "requestImageData", w_Canvas_requestImageData },
{ "pollImageData", w_Canvas_pollImageData },
{ "generateMipmaps", w_Canvas_generateMipmaps },
{ "getMipmapMode", w_Canvas_getMipmapMode },
{ 0, 0 }
+200
View File
@@ -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
+21 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
+119
View File
@@ -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
+28 -10
View File
@@ -428,10 +428,29 @@ function HostShell.httpPost(url, body, contentType, userAgent, maxTime)
if type(body) ~= "string" then return nil, "missing body" end
userAgent = userAgent or "gen1recomp"
if HostShell.haveCurl() then
-- --data-binary @- keeps the payload out of argv (command-line length
-- io.popen is one-way on Lua/LuaJIT: its mode is "r" or "w", never
-- "rw". Stage the request body so the response can stay on a read pipe.
local bodyPath = os.tmpname()
local bodyFile, bodyOpenErr = io.open(bodyPath, "wb")
if not bodyFile then
pcall(os.remove, bodyPath)
return nil, "could not create request body: " .. tostring(bodyOpenErr)
end
local bodyOk, bodyErr = pcall(function()
assert(bodyFile:write(body))
assert(bodyFile:close())
end)
if not bodyOk then
pcall(function() bodyFile:close() end)
pcall(os.remove, bodyPath)
return nil, "could not write body: " .. tostring(bodyErr)
end
-- --data-binary @<file> keeps the payload out of argv (command-line length
-- limits on Windows) and preserves every byte including trailing
-- newlines. No -f, matching httpGet: the response body is discarded
-- anyway, and curl's stderr carries the real diagnosis on failure.
-- newlines. The body is staged above because io.popen cannot be opened
-- for both writing and reading. No -f, matching httpGet: the response
-- body is discarded anyway, and curl's stderr carries the diagnosis.
local cmd = ("curl -sSL --proto =http,https --proto-redir =http,https "
.. "--connect-timeout 10 --max-time %d ")
:format(tonumber(maxTime) or 40)
@@ -441,18 +460,17 @@ function HostShell.httpPost(url, body, contentType, userAgent, maxTime)
cmd = cmd .. "-H " .. HostShell.quote("Content-Type: " .. contentType) .. " "
end
cmd = cmd .. "-H " .. HostShell.quote("Content-Length: " .. tostring(#body)) .. " "
.. "--data-binary @- "
.. "--data-binary " .. HostShell.quote("@" .. bodyPath) .. " "
.. "-w " .. HostShell.quote(HTTP_MARK_FMT) .. " "
.. HostShell.quote(url) .. " 2>&1"
local pipe = HostShell.popen(cmd, "rw")
if not pipe then return nil, "could not run curl" end
local writeOk, werr = pcall(pipe.write, pipe, body)
if not writeOk then
HostShell.pclose(pipe)
return nil, "could not write body: " .. tostring(werr)
local pipe = HostShell.popen(cmd)
if not pipe then
pcall(os.remove, bodyPath)
return nil, "could not run curl"
end
local readOk, out = pcall(function() return pipe:read("*a") end)
HostShell.pclose(pipe)
pcall(os.remove, bodyPath)
if not readOk then
return nil, fetchError(url, nil, tostring(out))
end
+6
View File
@@ -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
View File
@@ -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)
+43 -1
View File
@@ -986,12 +986,45 @@ end
function ManagerState:buildOptionRows(m, schema)
local rows = {}
local modId = m.id
local byKey, visibilityKeys = {}, {}
for _, row in ipairs(schema) do
if type(row) == "table" and type(row.key) == "string" then
byKey[row.key] = row
local condition = row.visible_if
if type(condition) == "table" and type(condition.key) == "string" then
visibilityKeys[condition.key] = true
end
end
end
local function visible(row)
local condition = row.visible_if
if condition == nil then return true end
if type(condition) ~= "table" or type(condition.key) ~= "string" then
return false
end
local dependency = byKey[condition.key] or { key = condition.key }
local value = self:optionValue(modId, dependency)
if condition.equals ~= nil then return value == condition.equals end
if condition.not_equals ~= nil then return value ~= condition.not_equals end
return false
end
local function refresh(key)
if not visibilityKeys[key] then return end
local preferred = rows[self.cursor] and rows[self.cursor].id
self.optionRows = self:buildOptionRows(m, schema)
for index, candidate in ipairs(self.optionRows) do
if candidate.id == preferred then self.cursor = index break end
end
self.cursor = clampIndex(self.cursor, #self.optionRows)
end
for _, row in ipairs(schema) do
if type(row) ~= "table" or type(row.key) ~= "string" or row.key == ""
or not OPTION_TYPES[row.type] then
-- malformed rows are skipped, reported where the errors screen reads
Runtime.reportError(modId, "options row skipped: "
.. tostring(type(row) == "table" and (row.key or row.type) or row))
elseif not visible(row) then
-- Keep the row in the schema and stored options, only hide its menu row.
elseif row.type == "toggle" then
rows[#rows + 1] = { id = row.key, label = row.label or row.key,
value = function()
@@ -999,6 +1032,7 @@ function ManagerState:buildOptionRows(m, schema)
end,
step = function()
self:setOption(modId, row.key, not self:optionValue(modId, row))
refresh(row.key)
return true
end }
elseif row.type == "choice" then
@@ -1021,6 +1055,7 @@ function ManagerState:buildOptionRows(m, schema)
end
index = clampIndex(index + dir, #choices)
self:setOption(modId, row.key, choices[index][2])
refresh(row.key)
return true
end }
elseif row.type == "number" then
@@ -1036,6 +1071,7 @@ function ManagerState:buildOptionRows(m, schema)
step = function(_, dir)
local cur = tonumber(self:optionValue(modId, row)) or 0
self:setOption(modId, row.key, clamp(cur + dir * (row.step or 1)))
refresh(row.key)
return true
end,
activate = function()
@@ -1044,7 +1080,10 @@ function ManagerState:buildOptionRows(m, schema)
max = row.max or 99,
start = math.max(1, tonumber(self:optionValue(modId, row)) or 1),
onDone = function(qty)
if qty then self:setOption(modId, row.key, clamp(qty)) end
if qty then
self:setOption(modId, row.key, clamp(qty))
refresh(row.key)
end
end,
}))
end }
@@ -1061,6 +1100,7 @@ function ManagerState:buildOptionRows(m, schema)
default = self:optionValue(modId, row),
onDone = function(name)
self:setOption(modId, row.key, name)
refresh(row.key)
end,
}))
end }
@@ -1075,6 +1115,8 @@ function ManagerState:buildOptionRows(m, schema)
self:setOption(modId, row.key, row.default)
end
end
self.optionRows = self:buildOptionRows(m, schema)
self.cursor = clampIndex(self.cursor, #self.optionRows)
self:notify("DEFAULTS RESTORED")
end }
return rows
+10
View File
@@ -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
+4 -2
View File
@@ -95,8 +95,10 @@ end
-- are listed; callers receive copied labels and never inspect world internals.
function WorldAPI:availableFieldActions()
local game, ow, out = self.game, self:overworld(), {}
if not (game and game.save and ow and ow.map and ow.player)
or not acceptsMenuInput(game, ow) then return out end
if not (game and game.save and ow and ow.map and ow.player) then
return out, NO_OVERWORLD
end
if not acceptsMenuInput(game, ow) then return out, "world is busy" end
local save, inventory = game.save, game.save.inventory or {}
local items = game.data and game.data.items or {}
+4 -2
View File
@@ -78,8 +78,10 @@ end
-- collision and fishing rules.
function WorldAPI:availableFieldActions()
local world, game, out = self:overworld(), self.game, {}
if not (world and game and game.save and world.map and world.player)
or not world:acceptsMenuInput() then return out end
if not (world and game and game.save and world.map and world.player) then
return out, NO_OVERWORLD
end
if not world:acceptsMenuInput() then return out, "world is busy" end
local inventory = game.save.inventory or {}
if (inventory.BICYCLE or 0) > 0 then
+75
View File
@@ -0,0 +1,75 @@
-- HostShell.httpPost must work with Lua/LuaJIT's one-way io.popen.
--
-- io.popen accepts "r" or "w", not "rw". POST needs both a request body
-- and a response status, so the body is staged in a temporary file and curl
-- is opened read-only for its response.
-- luajit tests/engine/host_shell_postlog.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
local HostShell = require("src.core.HostShell")
local MARK = "\n__gen1recomp_http__"
local BODY_PATH = "/tmp/gen1recomp-postlog-body-test"
local URL = "https://logs.example.com/logs"
local BODY = "debug log body\n"
local realOpen = io.open
local realPopen = io.popen
local realTmpname = os.tmpname
local realRemove = os.remove
local realHaveCurl = HostShell.haveCurl
local openedPath, openedMode, writtenBody
local popenCommand, popenMode, removedPath
HostShell.haveCurl = function() return true end
os.tmpname = function() return BODY_PATH end
os.remove = function(path)
removedPath = path
return true
end
io.open = function(path, mode)
openedPath, openedMode = path, mode
return {
write = function(_, value)
writtenBody = value
return true
end,
close = function() return true end,
}
end
io.popen = function(command, mode)
popenCommand, popenMode = command, mode
return {
read = function() return MARK .. "200" end,
close = function() return true end,
}
end
local ok, err = HostShell.httpPost(URL, BODY, "text/plain", "gen1recomp-mod/test", 10)
io.open = realOpen
io.popen = realPopen
os.tmpname = realTmpname
os.remove = realRemove
HostShell.haveCurl = realHaveCurl
eq(ok, true, "a desktop POST succeeds through the read-only response pipe: " .. tostring(err))
eq(openedPath, BODY_PATH, "the request body is written to a temporary file")
eq(openedMode, "wb", "the temporary request body is opened for binary writing")
eq(writtenBody, BODY, "the complete log body is staged")
eq(popenMode, "r", "curl is opened in the supported read-only mode")
check(popenCommand:find("--data-binary", 1, true) ~= nil,
"curl reads the staged body with --data-binary")
check(popenCommand:find(BODY_PATH, 1, true) ~= nil,
"curl receives the temporary body path")
check(popenCommand:find(BODY, 1, true) == nil,
"the log body is not placed directly in the command line")
eq(removedPath, BODY_PATH, "the temporary request body is removed")
T.finish("host shell postlog")
+139
View File
@@ -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()
+21
View File
@@ -1106,6 +1106,27 @@ check(loader.modOptions.okmod.hardcore == false
and loader.modOptions.okmod.startMoney == 3000
and loader.modOptions.okmod.tag == "BLUE",
"RESET DEFAULTS restores every schema default")
-- optional conditions keep mode-specific rows compact and refresh in place
local conditionalSchema = {
{ key = "mode", label = "MODE", type = "choice", default = "one",
choices = { { "ONE", "one" }, { "TWO", "two" } } },
{ key = "oneOnly", label = "ONE ONLY", type = "toggle", default = false,
visible_if = { key = "mode", equals = "one" } },
{ key = "twoOnly", label = "TWO ONLY", type = "toggle", default = false,
visible_if = { key = "mode", equals = "two" } },
{ key = "notOne", label = "NOT ONE", type = "toggle", default = false,
visible_if = { key = "mode", not_equals = "one" } },
}
loader.modOptions.condmod = {}
ms.cursor = 1
ms.optionRows = ms:buildOptionRows({ id = "condmod" }, conditionalSchema)
check(#ms.optionRows == 3 and ms.optionRows[2].id == "oneOnly",
"visible_if uses the controlling row default")
ms.optionRows[1].step(mgame, 1)
check(#ms.optionRows == 4 and ms.optionRows[2].id == "twoOnly"
and ms.optionRows[3].id == "notOne" and ms.cursor == 1,
"editing a controller refreshes conditions without moving the cursor")
press(ms, "b")
check(ms.screen == "list", "B leaves the options screen")
+14 -1
View File
@@ -34,6 +34,9 @@ function redGame.stack:top() return self.states[#self.states] end
local RedAPI = require("src.world.WorldAPI")
local red = RedAPI.new(redGame, "fixture")
local unavailable, reason = RedAPI.new({}, "fixture"):availableFieldActions()
T.eq(#unavailable, 0, "Red lists no actions without an overworld")
T.eq(reason, "no overworld", "Red reports a missing overworld")
local RedWorld = require("src.world.OverworldController")
T.check(type(RedWorld.useBicycle) == "function"
and type(RedWorld.useFishingRod) == "function"
@@ -59,7 +62,9 @@ T.check(not ok and err == "fishing rod unavailable",
T.eq(redWorld.rodUsed, used, "a rejected Red rod changes nothing")
redWorld.player.moving = true
T.eq(#red:availableFieldActions(), 0, "Red hides actions while moving")
actions, err = red:availableFieldActions()
T.eq(#actions, 0, "Red hides actions while moving")
T.eq(err, "world is busy", "Red distinguishes a busy world from no actions")
ok, err = red:useFieldAction("bicycle")
T.check(not ok and err == "world is busy",
"Red refuses a stale action while busy")
@@ -142,6 +147,9 @@ goldWorld.fieldContext = function(_, mon) return {
local GoldAPI = require("src.world.gen2.WorldAPI")
local gold = GoldAPI.new(goldGame, "fixture")
unavailable, reason = GoldAPI.new({}, "fixture"):availableFieldActions()
T.eq(#unavailable, 0, "Gold lists no actions without an overworld")
T.eq(reason, "no overworld", "Gold reports a missing overworld")
actions = gold:availableFieldActions()
byId = {}
for _, action in ipairs(actions) do byId[action.id] = action end
@@ -170,4 +178,9 @@ T.check(gold:useFieldAction("squirtbottle"),
T.eq(goldWorld.itemUsed, "SQUIRTBOTTLE",
"Gold delegates the SquirtBottle to its field-item path")
goldWorld.acceptsMenuInput = function() return false end
actions, err = gold:availableFieldActions()
T.eq(#actions, 0, "Gold hides actions while busy")
T.eq(err, "world is busy", "Gold distinguishes a busy world from no actions")
T.finish()