diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c6630c26..c43af489 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/src/core/game3/battle/init.lua b/src/core/game3/battle/init.lua index b9ac8b1c..045b4d4d 100644 --- a/src/core/game3/battle/init.lua +++ b/src/core/game3/battle/init.lua @@ -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 diff --git a/src/core/game3/runtime.lua b/src/core/game3/runtime.lua index 48a57a1e..ebfdf474 100644 --- a/src/core/game3/runtime.lua +++ b/src/core/game3/runtime.lua @@ -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() diff --git a/src/core/game3/scripting/ops_a.lua b/src/core/game3/scripting/ops_a.lua index 69ad8371..6e23ff62 100644 --- a/src/core/game3/scripting/ops_a.lua +++ b/src/core/game3/scripting/ops_a.lua @@ -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 diff --git a/src/ui/game3/hud.lua b/src/ui/game3/hud.lua index 0e0251e2..3ed6d882 100644 --- a/src/ui/game3/hud.lua +++ b/src/ui/game3/hud.lua @@ -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 diff --git a/tests/drivers/game3_stitchuif_naming_pc.lua b/tests/drivers/game3_stitchuif_naming_pc.lua index 9106758a..a1b3e184 100644 --- a/tests/drivers/game3_stitchuif_naming_pc.lua +++ b/tests/drivers/game3_stitchuif_naming_pc.lua @@ -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") diff --git a/tests/engine/game3_knock_off_item_test.lua b/tests/engine/game3_knock_off_item_test.lua index 6cc78cfd..a24fcf34 100644 --- a/tests/engine/game3_knock_off_item_test.lua +++ b/tests/engine/game3_knock_off_item_test.lua @@ -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") diff --git a/tests/engine/game3_naming_update_contract_test.lua b/tests/engine/game3_naming_update_contract_test.lua index 96710686..7cd4eca8 100644 --- a/tests/engine/game3_naming_update_contract_test.lua +++ b/tests/engine/game3_naming_update_contract_test.lua @@ -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") diff --git a/tests/engine/game3_script_verbs_subset_test.lua b/tests/engine/game3_script_verbs_subset_test.lua index 01e12380..65246579 100644 --- a/tests/engine/game3_script_verbs_subset_test.lua +++ b/tests/engine/game3_script_verbs_subset_test.lua @@ -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)