Merge remote-tracking branch 'upstream/dev' into nexhas28/gen3-review-v3-fixes

# Conflicts:
#	src/core/game3/battle/anim_seq.lua
#	src/core/game3/battle/effects/secondary.lua
#	src/core/game3/battle/engine.lua
#	src/core/game3/battle/state.lua
#	src/core/game3/battle/switch_seq.lua
#	tests/engine/game3_knock_off_item_test.lua
This commit is contained in:
Shane McGovern
2026-09-22 12:25:30 +01:00
9 changed files with 147 additions and 32 deletions
+22 -3
View File
@@ -825,15 +825,34 @@ jobs:
# Everyone whose commits are in the range: GitHub login when the
# commit is linked to an account, the raw git author name when not;
# CI bots filtered out.
base="${prev_tag:-$(git rev-list --max-parents=0 "$GITHUB_SHA" | tail -1)}"
if [ -n "$prev_tag" ]; then
commits_endpoint="repos/$GITHUB_REPOSITORY/compare/${prev_tag}...${GITHUB_SHA}?per_page=100"
else
# No previous release: include all reachable commits, even the root.
commits_endpoint="repos/$GITHUB_REPOSITORY/commits?sha=${GITHUB_SHA}&per_page=100"
fi
contributors="$(gh api --paginate \
"repos/$GITHUB_REPOSITORY/compare/${base}...${GITHUB_SHA}" \
--jq '.commits[] | if .author and .author.login
"$commits_endpoint" \
--jq '(if type == "array" then . else .commits end)[]
| if .author and .author.login
then "@" + .author.login
else .commit.author.name end' 2>/dev/null \
| grep -viE '\[bot\]$' | sort -uf | sed 's/^/- /' || true)"
# Use the dev -> main promotion PR for this release commit, not an
# individual feature PR whose commits also happen to be in the range.
promotion_body="$(gh api --paginate --slurp \
"repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/pulls?per_page=100" 2>/dev/null \
| jq -r --arg repo "$GITHUB_REPOSITORY" --arg sha "$GITHUB_SHA" '
[.[][] | select(.merged_at != null
and .base.repo.full_name == $repo and .base.ref == "main"
and .head.repo.full_name == $repo and .head.ref == "dev"
and .merge_commit_sha == $sha)]
| sort_by(.merged_at) | last | .body // ""' || true)"
notes="Download the right version for your device below."
if [ -n "$(printf '%s' "$promotion_body" | tr -d '[:space:]')" ]; then
notes="$promotion_body"
fi
if [ -n "$closed" ]; then
notes+=$'\n\n## Issues closed\n\n'"$closed"
fi
+1 -2
View File
@@ -2703,8 +2703,7 @@ function Battle.update(dt, game)
local Naming = package.loaded["src.ui.game3.naming"]
if Naming and Naming.isOpen and Naming.isOpen() then
if input then Naming.handleInput(input) end
Naming.update(dt or (1 / 60))
-- Runtime ticks Hud after Battle; the naming stack entry owns this input.
return
end
+3 -8
View File
@@ -228,6 +228,7 @@ function Runtime.update(dt)
if not Runtime.active then return end
local game = Runtime._game
local Hud = require("src.ui.game3.hud")
local inputTop = require("src.ui.game3.stack").top() or false
local inMenu = Hud.isMenuOpen and Hud.isMenuOpen() or false
Runtime.drainDeferred()
Runtime.noteFieldFocus(Runtime.fieldScreenOpen(inMenu))
@@ -270,21 +271,15 @@ function Runtime.update(dt)
end
local Message = package.loaded["src.ui.game3.message"]
if Message and Message.tick then Message.tick() end
Hud.update(game, dt)
Hud.update(game, dt, inputTop)
return
end
local okN, Naming = pcall(require, "src.ui.game3.naming")
if okN and Naming.isOpen and Naming.isOpen() then
Naming.handleInput(game and game.input)
Naming.update(dt)
end
if not inMenu then
local Field = require("src.core.game3.field")
Field.update(dt)
end
Hud.update(game, dt)
Hud.update(game, dt, inputTop)
end
function Runtime.uiBusy()
+5 -6
View File
@@ -78,13 +78,11 @@ end
-- Script local scratch space: pret's ScriptContext.data[4] (include/script.h:21).
local function local_get(ctx, i)
ctx.locals = ctx.locals or {}
return tonumber(ctx.locals[(tonumber(i) or 0) + 1]) or 0
return ctx.data[tonumber(i) or 0] or 0
end
local function local_set(ctx, i, v)
ctx.locals = ctx.locals or {}
ctx.locals[(tonumber(i) or 0) + 1] = tonumber(v) or 0
ctx.data[tonumber(i) or 0] = v or 0
end
-- The port has no flat address space, so the *ptr family shares a synthetic
@@ -97,12 +95,13 @@ end
local function mem_set(ctx, ptr, v)
ctx.scriptMem = ctx.scriptMem or {}
ctx.scriptMem[tonumber(ptr) or 0] = tonumber(v) or 0
ctx.scriptMem[tonumber(ptr) or 0] = (tonumber(v) or 0) % 256
end
-- pret src/scrcmd.c:358 Compare()
local function cmp(a, b)
a, b = tonumber(a) or 0, tonumber(b) or 0
-- pokefirered/src/scrcmd.c:368: local comparisons read the low byte.
a, b = (tonumber(a) or 0) % 256, (tonumber(b) or 0) % 256
if a < b then return 0 end
if a == b then return 1 end
return 2
+10 -1
View File
@@ -174,11 +174,17 @@ function Hud.sampleFieldInput(game)
}
end
function Hud.update(game, _dt)
function Hud.update(game, _dt, inputTop)
local dt = tonumber(_dt) or (1 / 60)
-- Active stack modal menu tick
local top = Stack.top()
local namingTick = top and top.id == "naming"
if namingTick and top.mod and top.mod.handleInput then
-- Naming consumes input before its page-swap timer can unlock the keyboard.
-- A prompt that opened it during this frame keeps its opening button press.
if inputTop == nil or top == inputTop then top.mod.handleInput(game and game.input) end
end
if top and top.mod and top.mod.update then
pcall(top.mod.update, dt)
end
@@ -213,6 +219,9 @@ function Hud.update(game, _dt)
end
end
-- Do not replay naming input or leak its closing press to the menu underneath.
if namingTick then return end
-- Active stack modal menu input takes top precedence.
-- When battle is active, overlays like EvolutionScene or modal stack menus still receive input.
if Stack.busy() then
@@ -121,6 +121,7 @@ return function(game)
end
U.shot(game, DIR .. "/stitchuif_naming_pc_01_keyboard.png")
typedName = (Naming._state and Naming._state.name) or ""
result(typedName == "AAA", "three A taps type exactly AAA, got " .. typedName)
U.tap(game, "start")
U.wait(20)
U.tap(game, "a")
+43 -6
View File
@@ -13,8 +13,6 @@
-- and it is not what stops the item returning on switch-out; the send-out mask
-- below is.
--
-- persist_item resolves the party mon via State.partyMon(b) (= b._partyMon or
-- b.mon), so this drives the real effect with plain battler tables.
-- luajit tests/engine/game3_knock_off_item_test.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
@@ -24,10 +22,15 @@ local check, eq = T.check, T.eq
love = love or require("tests.love_stub")
local Secondary = require("src.core.game3.battle.effects.secondary")
local State = require("src.core.game3.battle.state")
local function adapter()
local State = require("src.core.game3.battle.state")
local Engine = require("src.core.game3.battle.engine")
local SwitchSeq = require("src.core.game3.battle.switch_seq")
local function adapter(st)
return {
_st = st,
pushEvent = function() end,
abilityOf = function(_, b) return b.ability end,
hp = function(_, b) return b.hp or 100 end,
ownSide = function() return nil end,
@@ -49,9 +52,9 @@ local function battler(side, item, ability)
}
end
local function knock_off(user, target)
local function knock_off(user, target, st)
return Secondary.set({
adapter = adapter(), user = user, target = target,
adapter = adapter(st), user = user, target = target,
move = { moveName = "KNOCK OFF" },
}, "KNOCK_OFF", false, true, false)
end
@@ -93,4 +96,38 @@ eq(mon.item, 13, "the party mon still holds the item after the send-out")
local control = State.makeBattler(mon, "enemy", { partyIndex = 1, st = {} })
eq(control.item, 13, "without the mark the send-out reads the item back")
-- 6. The mark is per party slot and survives every switch path the engine
-- exposes: engine switch-in, presentation send-out, player switch and shift
-- switch -- and it does not leak into the next battle.
local function fresh_mon(item)
return { species = 4, level = 10, hp = 30, maxHp = 30,
item = item, heldItem = item, ability = "NONE", moves = { 10 }, pp = { 35 } }
end
for _, side in ipairs({ "player", "enemy" }) do
local party, foes = { fresh_mon(13), fresh_mon(14) }, { fresh_mon(13), fresh_mon(14) }
local bst = State.new({ playerParty = party, foeParty = foes })
local victim = bst[side]
local foe = side == "player" and bst.enemy or bst.player
local members = side == "player" and party or foes
knock_off(foe, victim, bst)
check(State.isKnockedOff(bst, victim), "the victim's party slot is marked")
check(not State.isKnockedOff(bst, foe), "the opposite side's same slot is unaffected")
Engine.performSwitch(bst, adapter(bst), side, 2)
eq(bst[side].item, 14, "another party slot keeps its active item")
Engine.performSwitch(bst, adapter(bst), side, 1)
eq(bst[side].item, 0, "engine switch-in suppresses the knocked-off item")
eq(members[1].heldItem, 13, "switching preserves the party item")
SwitchSeq.beginSendOut(bst, side, 1, { headless = true })
eq(bst[side].item, 0, "presentation send-out also suppresses the item")
if side == "player" then
SwitchSeq.beginPlayerSwitch(bst, 1, { headless = true })
eq(bst.player.item, 0, "player switch sequence suppresses the item")
end
SwitchSeq.beginShiftSwitch(bst, 1, 1, { headless = true })
eq(bst[side].item, 0, "shift switch sequence suppresses the item")
local fresh = State.new({ playerParty = party, foeParty = foes })
eq(fresh[side].item, 13, "the original held item works in the next battle")
check(not State.isKnockedOff(fresh, fresh[side]), "the new battle has no knock-off mark")
end
T.finish("game3_knock_off_item_test")
@@ -62,4 +62,46 @@ st4.swapT = 0
pcall(Naming.handleInput, input({ down = true }))
eq(st4.row, 1, "input is ignored while the page swap is running")
-- The real runtime must leave stack input and timers to Hud in both branches.
local Runtime = require("src.core.game3.runtime")
local Battle = require("src.core.game3.battle")
Runtime.active = true
Runtime.session = {}
for _, inBattle in ipairs({ false, true }) do
Battle._active = inBattle
local active = open_naming()
Runtime._game = { input = input({ a = true }) }
Runtime.update(1 / 60)
eq(active.name, "A", "one runtime press adds one character, battle=" .. tostring(inBattle))
eq(active.blink, 1 / 60, "runtime advances the naming timer once")
active.pcPages = { "first", "second", "third" }
active.pcPage = 1
Runtime.update(1 / 60)
eq(active.pcPage, 2, "one runtime press advances one result page")
local swapping = open_naming()
swapping.swapT = 124
swapping.swapTo = 2
Runtime.update(1 / 60)
eq(swapping.name, "", "input remains blocked on the swap-completion frame")
eq(swapping.swapT, nil, "the page swap finishes")
Runtime.update(1 / 60)
eq(swapping.name, "a", "the next frame accepts input on the new page")
end
Battle._active = false
Stack.clear()
-- A prompt that opens Naming during the battle update must consume its A.
Battle._active = true
local battleUpdate = Battle.update
local opened
Battle.update = function() opened = open_naming() end
Runtime._game = { input = input({ a = true }) }
Runtime.update(1 / 60)
eq(opened.name, "", "the opening prompt's A does not type a character")
Battle.update = battleUpdate
Runtime.update(1 / 60)
eq(opened.name, "A", "the following frame's A reaches naming")
Battle._active = false
Runtime.active = false
Stack.clear()
T.finish("game3_naming_update_contract_test")
@@ -207,20 +207,22 @@ end
-- 16. Script locals and the synthetic pointer store (src/scrcmd.c:293-375).
vm = new_vm()
vm.ctx.locals = { 10, 20 }
Ops.dispatch(vm, { op = "loadbyte", 0, 10 })
Ops.dispatch(vm, { op = "loadbyte", 1, 20 })
Ops.dispatch(vm, { op = "copylocal", [1] = 0, [2] = 1 })
eq(vm.ctx.locals[1], 20, "copylocal copies a local")
vm.ctx.locals = { 5, 9 }
eq(vm.ctx.data[0], 20, "copylocal copies a local")
Ops.dispatch(vm, { op = "loadbyte", 0, 5 })
Ops.dispatch(vm, { op = "loadbyte", 1, 9 })
Ops.dispatch(vm, { op = "compare_local_to_local", [1] = 0, [2] = 1 })
eq(vm.ctx.comparisonResult, 0, "compare_local_to_local reports LT")
vm.ctx.locals = { 7, 7 }
Ops.dispatch(vm, { op = "loadbyte", 0, 7 })
Ops.dispatch(vm, { op = "compare_local_to_value", [1] = 0, [2] = 7 })
eq(vm.ctx.comparisonResult, 1, "compare_local_to_value reports EQ")
Ops.dispatch(vm, { op = "setptr", [1] = 42, [2] = 0x1234 })
eq(vm.ctx.scriptMem[0x1234], 42, "setptr writes the synthetic byte store")
Ops.dispatch(vm, { op = "loadbytefromptr", [1] = 1, [2] = 0x1234 })
eq(vm.ctx.locals[2], 42, "loadbytefromptr reads it back into a local")
vm.ctx.locals[3] = 9
eq(vm.ctx.data[1], 42, "loadbytefromptr reads it back into a local")
Ops.dispatch(vm, { op = "loadbyte", 2, 9 })
Ops.dispatch(vm, { op = "setptrbyte", [1] = 2, [2] = 0x1235 })
eq(vm.ctx.scriptMem[0x1235], 9, "setptrbyte stores a local")
Ops.dispatch(vm, { op = "copybyte", [1] = 0x1236, [2] = 0x1235 })
@@ -230,6 +232,18 @@ eq(vm.ctx.comparisonResult, 0, "compare_local_to_ptr compares local vs store")
Ops.dispatch(vm, { op = "compare_ptr_to_ptr", [1] = 0x1234, [2] = 0x1236 })
eq(vm.ctx.comparisonResult, 2, "compare_ptr_to_ptr compares two stored bytes")
Ops.dispatch(vm, { op = "loadword", 0, 0x12AB })
Ops.dispatch(vm, { op = "copylocal", 3, 0 })
eq(vm.ctx.data[3], 0x12AB, "copylocal preserves the full loadword register")
Ops.dispatch(vm, { op = "compare_local_to_value", 3, 0xAB })
eq(vm.ctx.comparisonResult, 1, "local comparisons read the low byte")
Ops.dispatch(vm, { op = "setptrbyte", 3, 0x1237 })
Ops.dispatch(vm, { op = "loadbytefromptr", 0, 0x1237 })
eq(vm.ctx.data[0], 0xAB, "pointer writes truncate a word register to a byte")
Ops.dispatch(vm, { op = "loadword", 0, "std:1" })
Ops.dispatch(vm, { op = "copylocal", 1, 0 })
eq(vm.ctx.data[1], "std:1", "copylocal preserves the host's symbolic text pointers")
-- 17. The RAM-script (v*) control flow (src/scrcmd.c:171-209, :1580).
vm = new_vm()
vm:setPc("t_main", 4)