diff --git a/data/scripts/gyms.lua b/data/scripts/gyms.lua index 1050caef..e04356b4 100644 --- a/data/scripts/gyms.lua +++ b/data/scripts/gyms.lua @@ -40,18 +40,32 @@ end -- The badge line + its jingle, armed for the battle screen the way -- SaveEndBattleTextPointers does (PewterGym.asm:117-119) (#1606) +local function pageCount(s) + local n = 0 + for page in (s .. "\f"):gmatch("(.-)\f") do + if page ~= "" then n = n + 1 end + end + return n +end + local function badgeEndBattleText(game, victoryKey) local reward = victoryKey and require("data.scripts.victories")[victoryKey] if not (reward and reward.dialogue) then return nil end local text = game.data.text or {} - local pages = {} + local pages, soundPage = {}, nil for _, label in ipairs(reward.dialogue) do if text[label] and text[label] ~= "" then pages[#pages + 1] = text[label] + -- scripts/PewterGym.asm:156-159 + if not soundPage then + local n = pageCount(text[label]) + if n > 0 then soundPage = n end + end end end if #pages == 0 then return nil end - return table.concat(pages, "\f"), reward.badgeSound + return table.concat(pages, "\f"), reward.badgeSound, + reward.badgeSound and soundPage or nil end -- scripts/PewterGym.asm PewterGymBrockText (text_asm): CheckEvent @@ -74,8 +88,8 @@ M.PEWTER_GYM.talk = { game.data.text._PewterGymBrockPostBattleAdviceText or "Go to the GYM in\nCERULEAN and test\nyour abilities!", done)) else - local text, sound = badgeEndBattleText(game, "OPP_BROCK#1") - ow:engageTrainer(npc, done, text, nil, sound) + local text, sound, soundPage = badgeEndBattleText(game, "OPP_BROCK#1") + ow:engageTrainer(npc, done, text, nil, sound, nil, soundPage) end end, } @@ -108,8 +122,8 @@ local function leaderTalk(beatFlag, adviceLabel, fallback, afterAdvice, victoryK game.stack:push(TextBox.new(game, game.data.text[adviceLabel] or fallback, finish)) else - local text, sound = badgeEndBattleText(game, victoryKey) - ow:engageTrainer(npc, done, text, nil, sound) + local text, sound, soundPage = badgeEndBattleText(game, victoryKey) + ow:engageTrainer(npc, done, text, nil, sound, nil, soundPage) end end end diff --git a/data/scripts/story4.lua b/data/scripts/story4.lua index 8b6f3432..75435f85 100644 --- a/data/scripts/story4.lua +++ b/data/scripts/story4.lua @@ -123,7 +123,7 @@ M.MT_MOON_POKECENTER = { { "check_flag", "EVENT_BOUGHT_MAGIKARP" }, { "jump_if_true", "no_refunds" }, -- MONEY_BOX goes up between the offer and YesNoChoice -- MtMoonPokecenter.asm:31 - { "text_opts", { money = true } }, + { "text_opts", { money = "choice" } }, { "ask", "_MtMoonPokecenterMagikarpSalesmanIGotADealText" }, { "jump_if_false", "declined" }, { "check_money", 500 }, diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 9388a93c..a90d4e66 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -1336,8 +1336,15 @@ function BattleState:updateQueue() -- own clear window instead of letting the next beat play over it if self.waitingSound then local src = self.waitingSound - if src and src.isPlaying and src:isPlaying() then return true end - self.waitingSound = nil + if not self.waitSoundLeft then + local Sound = require("src.core.Sound") + self.waitSoundLeft = Sound.waitFrames and Sound.waitFrames(src) or 180 + end + self.waitSoundLeft = self.waitSoundLeft - 1 + local playing = src and src.isPlaying and src:isPlaying() + if playing and self.waitSoundLeft > 0 then return true end + if playing then pcall(src.stop, src) end + self.waitingSound, self.waitSoundLeft = nil, nil end -- an HP-bar drain holds the queue until the bar catches up if self.draining then @@ -4764,14 +4771,16 @@ function BattleState:enemyMonFainted() -- TrainerNamePointers aims those entries at wTrainerName). The tag -- prints once, so a `para` page carries no second copy (#566). local tag = self.trainer and self.trainer.name - -- the badge jingle (sound_get_item_1 and friends) rides the armed - -- line's first page, as the script's text command would (#1606) + -- scripts/PewterGym.asm:156-159 local sfx = self.endBattleSound + local sfxPage = self.endBattleSoundPage or 1 + local shown = 0 local data = self.data for page in (self.endBattleText .. "\f"):gmatch("(.-)\f") do if page ~= "" then + shown = shown + 1 local line = tag and (tag .. ": " .. page) or page - if sfx then + if sfx and shown >= sfxPage then local id = sfx self:sayNextWaitSfx(line, function() return require("src.core.Sound").play(data, id) diff --git a/src/battle/gen2/Battle.lua b/src/battle/gen2/Battle.lua index 4f570c58..6a05626d 100644 --- a/src/battle/gen2/Battle.lua +++ b/src/battle/gen2/Battle.lua @@ -1697,7 +1697,13 @@ function Battle:useMove(attacker, defender, moveId) if def.effect == "EFFECT_MAGNITUDE" then local rolled, number = Effects.magnitudePower(self.random) powerOverride = rolled - self:emit({ kind = "message", + -- engine/battle/move_effects/magnitude.asm:20-22 + if self.moveEvent then + self.moveEvent.deferAnim = true + self.moveEvent.animDelay = true + end + self.moveEvent = self:emit({ kind = "message", + moveAnim = moveId, side = self:sideOf(attacker), text = ("Magnitude %d!"):format(number) }) end diff --git a/src/battle/gen2/HpBar.lua b/src/battle/gen2/HpBar.lua index a146e4f6..20b7a67f 100644 --- a/src/battle/gen2/HpBar.lua +++ b/src/battle/gen2/HpBar.lua @@ -141,6 +141,18 @@ function HpBar.drawExp(palettes, fraction, px, py) G.setColor(0, 0, 0, 1) end +-- engine/battle/anim_hp_bar.asm:129 +function HpBar.stepToward(shown, target, maxHp) + shown = shown or 0 + target = target or 0 + local step = 1 + if (maxHp or 0) >= HpBar.LENGTH_PX then + step = math.max(1, math.ceil(maxHp / HpBar.LENGTH_PX)) + end + if shown < target then return math.min(target, shown + step) end + return math.max(target, shown - step) +end + -- How far along its current level a mon is, for the exp bar. function HpBar.expFraction(mon, growth, levelFor) if not (mon and growth and levelFor) then return 0 end diff --git a/src/core/BattleCheckpoint.lua b/src/core/BattleCheckpoint.lua index 8a62eb4e..39e6a3b6 100644 --- a/src/core/BattleCheckpoint.lua +++ b/src/core/BattleCheckpoint.lua @@ -43,7 +43,8 @@ local BATTLE_FIELDS = { "moveIndex", "moveSwapIndex", "aiUses", "runAttempts", "payDay", "sideToxic", "isGymLeader", "musicKind", "lastBall", "lockedBall", "lowHealthAlarmDisabled", "lowHealthAlarmOn", "victoryMusicPlayed", - "endBattleText", + "endBattleText", "endBattleSound", "endBattleSoundPage", + "rewardDialogueShown", "playerPartyIndices", } diff --git a/src/core/Game.lua b/src/core/Game.lua index 524735e8..9d8b54ee 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -394,15 +394,13 @@ function Game:logicSpeed() end function Game:update(dt) - -- Fast-forward scales only the logic clock (see src/core/GameSpeed.lua). -- Give the accumulator room for one full frame at the current speed, -- or the anti-spiral clamp quietly caps every level above ~15X. local speed = self:logicSpeed() + local Sound = require("src.core.Sound") + if Sound.setRate then Sound.setRate(speed) end FixedStep.maxAccum = math.max(0.25, speed * FixedStep.STEP * 1.5) FixedStep:update(dt * speed) - -- Audio runs off real time at a fixed 60Hz regardless of game speed or - -- display refresh, so fades and chip synthesis keep their intended tempo - -- whether we are at 1X, 10X, or running with vsync disabled. local step = FixedStep.STEP self.audioAccum = math.min((self.audioAccum or 0) + dt, 0.25) while self.audioAccum >= step do diff --git a/src/core/Game2.lua b/src/core/Game2.lua index 3dc51ad4..592c1c65 100644 --- a/src/core/Game2.lua +++ b/src/core/Game2.lua @@ -207,6 +207,22 @@ function Game2:adoptSave(save, seedBuckets) end function Game2:enterArena(spec) + local version = GameVersion.get() + if spec and spec.slotId then + pcall(SaveData.setActiveSlot, version, spec.slotId) + end + local ok, loaded = pcall(Save.load, version) + if ok and loaded then + local activeMods = self.modStatus and self.modStatus.loaded + SaveData.runMigrations(loaded, self.mods and self.mods.migrations, activeMods) + self.save = loaded + self.save.options = self.options + self:adoptSave(loaded) + require("src.battle.gen2.Mon").syncSaveIdentity(loaded, self.data) + else + require("src.core.Logger").warn("arena2: save slot %s could not be loaded", + tostring(spec and spec.slotId)) + end self.phase = "boot" self.stack:clear() self.stack:push(require("src.ui.gen2.ArenaState").new(self, spec)) @@ -543,12 +559,12 @@ function Game2:learnMoveOn(mon, moveId, onDone) return askForget() end })) end - -- The four-slot list ForgetMove draws under MoveAskForgetText, which is the - -- Blackthorn deleter's own SetUpMoveList box (learn.asm:135-146). + -- engine/pokemon/learn.asm:135-166 local function pushList() Screens.push(self, "Gen2MoveDeleter", { mon = mon, moves = self.data.moves, + layout = "forget", onCancel = function() self.stack:pop() -- the move list self.stack:pop() -- the question it stood on @@ -647,6 +663,12 @@ function Game2:consumeItem(itemId) self.save.inventory[itemId] = left > 0 and left or nil end +-- engine/pokemon/evolve.asm:333 +function Game2:restartMapMusicAfterEvolution() + local world = self.world + if world and world.restoreMapMusic then world:restoreMapMusic() end +end + -- RareCandyEffect's tail (engine/items/item_effects.asm): LearnLevelMoves at -- the new level, then EvolvePokemon. LearnLevelMoves' .learn arm is `predef -- LearnMove` (engine/pokemon/evolve.asm), so a full set gets ForgetMove's ask @@ -683,6 +705,7 @@ function Game2:afterRareCandy(mon, result, onDone) save = self.save, onDone = function() self.stack:pop() + self:restartMapMusicAfterEvolution() if onDone then onDone() end end, }) @@ -716,12 +739,20 @@ function Game2:usePartyItem(itemId) self:say(Strings("You don't have a\n#MON!")) return end - local function finish(result, mon) + -- engine/items/item_effects.asm:1748 + local function openMenu() + local menu = self.stack.top and self.stack:top() + if menu and menu.showItemResult then return menu end + return nil + end + local function finish(result, mon, slot, before) + local menu = openMenu() if not result.used then - self:say(result.text) + self:say(result.text, menu and function() self.stack:pop() end or nil) return end if action == "stone" then + if menu then self.stack:pop() end local party = (self.save and self.save.party) or {} local index for i, member in ipairs(party) do @@ -734,43 +765,61 @@ function Game2:usePartyItem(itemId) onDone = function(evolution) if evolution and evolution.evolved then self:consumeItem(itemId) end self.stack:pop() + self:restartMapMusicAfterEvolution() end, }) - elseif action == "candy" then - self:consumeItem(itemId) - -- data/text/common_1.asm:86 - self:say(result.text, function() self:afterRareCandy(mon, result) end, - result.sfx and TextBox.soundOpts(self, result.sfx) or nil) - else - self:consumeItem(itemId) - self:say(result.text) + return end + self:consumeItem(itemId) + if not menu then + if action == "candy" then + -- data/text/common_1.asm:86 + self:say(result.text, function() self:afterRareCandy(mon, result) end, + result.sfx and TextBox.soundOpts(self, result.sfx) or nil) + else + self:say(result.text) + end + return + end + -- engine/items/item_effects.asm:1663 + local climbs = (action == "heal" or action == "revive") + and before and mon.hp and mon.hp ~= before + menu:showItemResult(slot, { + fromHp = climbs and before or nil, + toHp = climbs and mon.hp or nil, + sfx = climbs and "Sfx_Potion" or result.sfx, + text = result.text, + onDone = function() + self.stack:pop() + if action == "candy" then self:afterRareCandy(mon, result) end + end, + }) end Screens.push(self, "Gen2PartyMenu", { prompt = "useItem", onCancel = function() self.stack:pop() end, - onChoose = function(_, mon) + onChoose = function(slot, mon) + local before = mon and mon.hp if action ~= "pp" then - self.stack:pop() - finish(ItemEffects.useOnMon(itemId, mon, self.data), mon) + finish(ItemEffects.useOnMon(itemId, mon, self.data), mon, slot, before) return end -- RestorePPEffect: the ELIXER pair needs no move pick; an EGG refuses -- before the move list ever opens (UseItem_SelectMon's `cp EGG`). local row = ItemEffects.RESTORE_PP[itemId] or {} if row.each or mon.isEgg then - self.stack:pop() - finish(ItemEffects.usePpItem(itemId, mon, nil, self.data), mon) + finish(ItemEffects.usePpItem(itemId, mon, nil, self.data), mon, slot, + before) return end Screens.push(self, "Gen2MoveDeleter", { mon = mon, moves = self.data.moves, onCancel = function() self.stack:pop() end, - onChoose = function(slot) + onChoose = function(moveSlot) self.stack:pop() -- the move list - self.stack:pop() -- the party list - finish(ItemEffects.usePpItem(itemId, mon, slot, self.data), mon) + finish(ItemEffects.usePpItem(itemId, mon, moveSlot, self.data), mon, + slot, before) end, }) end, @@ -1212,14 +1261,12 @@ function Game2:update(dt) -- they are presentational, so fast-forward must not speed them up. require("src.render.Pipelines").update(dt) pcall(function() require("src.core.DiscordPresence").update(dt) end) - -- GAME SPEED scales the logic clock only, exactly as the Gen 1 path does: - -- audio runs off its own real-time accumulator, so music and sfx keep their - -- tempo at every multiplier. speedOverride is the driver/CLI hook and wins - -- over the saved option. -- pokegold engine/menus/intro_menu.asm:848 IntroSequence: boot cinema runs on the same clock as the overworld local speed = math.max(1, tonumber(self.speedOverride) or tonumber(self.options and self.options.speed) or 1) + local Sound = require("src.core.Sound") + if Sound.setRate then Sound.setRate(speed) end if self.phase == "boot" then FixedStep.maxAccum = math.max(0.25, speed / 60 + 0.05) FixedStep:update(dt * speed) diff --git a/src/core/GameSpeed.lua b/src/core/GameSpeed.lua index 495bd27f..a8681e71 100644 --- a/src/core/GameSpeed.lua +++ b/src/core/GameSpeed.lua @@ -3,10 +3,7 @@ -- Speeding up means running the 1/60 fixed step N times per real frame -- (Game:update), so everything driven by the step -- movement, text, -- battle timing, scripts -- advances N times faster while staying --- deterministic. Audio deliberately does NOT scale: Music.update drives --- fade counters and ChipAudio synthesis off its own real-time 60Hz --- accumulator in Game:update, so music and sfx play at normal pitch and --- tempo at every speed. +-- deterministic. -- -- Vsync still caps how much work a real frame can do, so 10X is a target -- rather than a promise on a slow machine -- the logic simply runs as many diff --git a/src/core/Music.lua b/src/core/Music.lua index c700e591..3144a18d 100644 --- a/src/core/Music.lua +++ b/src/core/Music.lua @@ -476,11 +476,11 @@ function Music.mapSong() return state.mapSong end -function Music.restoreMap(data) +function Music.restoreMap(data, reason) state.current = nil state.pendingRestore = nil local play = effectiveMapSong(data, state.mapSong) - if play then Music.play(data, play, nil, { reason = "map" }) end + if play then Music.play(data, play, nil, { reason = reason or "map" }) end end -- Overwrite the remembered map song without playing anything: the wMapMusic diff --git a/src/core/SessionLifecycle.lua b/src/core/SessionLifecycle.lua index c8e73653..288cf03c 100644 --- a/src/core/SessionLifecycle.lua +++ b/src/core/SessionLifecycle.lua @@ -76,6 +76,10 @@ end function SessionLifecycle.endGameSession(game) pcall(function() require("src.core.Music").stop() end) pcall(function() require("src.core.Sound").stop() end) + pcall(function() + local Sound = require("src.core.Sound") + if Sound.setRate then Sound.setRate(1) end + end) if package.loaded["src.core.ChipAudio"] then pcall(package.loaded["src.core.ChipAudio"].stopMusic) end diff --git a/src/core/Sound.lua b/src/core/Sound.lua index 8bce4c01..da5c5a99 100644 --- a/src/core/Sound.lua +++ b/src/core/Sound.lua @@ -159,6 +159,39 @@ local function deviceSuspended() return ChipAudio ~= nil and ChipAudio.isSuspended() end +-- home/vblank.asm:58-72 +local FF_PITCH_MAX = 4 +local rate = 1 + +function Sound.setRate(n) + n = tonumber(n) + if n and n == n and n > 0 then + rate = math.min(n, FF_PITCH_MAX) + else + rate = 1 + end +end + +function Sound.rate() return rate end + +local function applyRate(src, base) + if not src then return src end + pcall(src.setPitch, src, (base or 1) * rate) + return src +end + +-- home/delay.asm:14 +function Sound.waitFrames(src, fallback) + if not src then return 0 end + local okd, dur = pcall(src.getDuration, src) + if not okd or type(dur) ~= "number" or dur ~= dur or dur <= 0 then + return fallback or 180 + end + local okp, pitch = pcall(src.getPitch, src) + if okp and type(pitch) == "number" and pitch > 0 then dur = dur / pitch end + return math.ceil(dur * 60 * rate) + 2 +end + local function playPath(data, key, def, pitch, tempo, plain) if not love.audio or not def then return nil end if deviceSuspended() then return nil end @@ -176,6 +209,7 @@ local function playPath(data, key, def, pitch, tempo, plain) src = s end pcall(src.stop, src) + applyRate(src, type(def) == "table" and def.pitch or 1) pcall(src.play, src) return src end @@ -674,6 +708,7 @@ function Sound.playPikaCry(data, n) src = s end pcall(src.stop, src) + applyRate(src, 1) pcall(src.play, src) played("cry", "PIKACHU_PCM_" .. n, "PIKACHU") return src @@ -715,6 +750,7 @@ function Sound.playCry(data, species, pikaClip) src = s end pcall(src.stop, src) + applyRate(src, 1) pcall(src.play, src) played("cry", species, species) return src @@ -733,7 +769,7 @@ end function Sound.playMoveCry(data, species, tempoMod) local src = Sound.playCry(data, species) if src and tempoMod and tempoMod ~= 0x80 then - pcall(src.setPitch, src, 256 / (128 + tempoMod)) + applyRate(src, 256 / (128 + tempoMod)) end return src end @@ -748,6 +784,13 @@ function Sound.isPlaying(name) return ok and playing or false end +-- home/delay.asm:14 +function Sound.waitFramesFor(name, fallback) + local src = cached(name) + if not src then return 0 end + return Sound.waitFrames(src, fallback) +end + -- cut a one-shot short (the SFX_STOP_ALL_MUSIC beats around the -- elevator shake stop the last collision thud mid-ring) function Sound.stop(name) diff --git a/src/core/gen2/ItemEffects.lua b/src/core/gen2/ItemEffects.lua index 4aec70ed..3090e980 100644 --- a/src/core/gen2/ItemEffects.lua +++ b/src/core/gen2/ItemEffects.lua @@ -138,8 +138,7 @@ local function bitterHappiness(itemId, mon) end -- ItemRestoreHP: fainted and full-HP targets refuse before anything is spent, --- then RestoreHealth adds the HealingHPAmounts row capped at max HP and --- PARTYMENUTEXT_HEAL_HP prints the delta ("\nrecovered NN HP!"). +-- then RestoreHealth adds the HealingHPAmounts row capped at max HP. local function restoreHp(itemId, mon) local amount = ItemEffects.HEAL_HP[itemId] local maxHp = maxHpOf(mon) @@ -154,7 +153,9 @@ local function restoreHp(itemId, mon) bitterHappiness(itemId, mon) return { used = true, - text = ("%s\nrecovered %d HP!"):format(monName(mon), gained), + -- data/text/common_1.asm:30 + -- home/text.asm:772 + text = ("%s\nrecovered %dHP!"):format(monName(mon), gained), } end diff --git a/src/import/OnlinePanel.lua b/src/import/OnlinePanel.lua index eb139605..9d7568aa 100644 --- a/src/import/OnlinePanel.lua +++ b/src/import/OnlinePanel.lua @@ -132,11 +132,26 @@ function OnlinePanel.readyVersions(imp) return out end +function OnlinePanel.headerVersion(imp) + local scope = type(imp) == "table" and imp.modScope or nil + if scope and imp.ready and imp.ready[scope] then return scope end + return nil +end + function OnlinePanel.selectedVersion(imp) local st = OnlinePanel.state(imp) + local header = OnlinePanel.headerVersion(imp) + if header and header ~= st.version and not st.versionPicked + and not st.roomPicked and not st.wizard and not st.pending + and not st.joinWant and not st.hadRoom then + st.version, st.slotId, st.cartId, st.team = header, nil, nil, {} + st.slotRead, st.ready, st.kind = nil, false, "vanilla" + OnlinePanel.invalidate(imp) + return st.version + end if st.version and imp.ready and imp.ready[st.version] then return st.version end local list = OnlinePanel.readyVersions(imp) - st.version = list[1] + st.version = header or list[1] return st.version end @@ -710,6 +725,19 @@ local function Client() return require("src.online.Client") end +local function nowSeconds() + if love and love.timer and love.timer.getTime then return love.timer.getTime() end + return os.clock() +end + +OnlinePanel.nowSeconds = nowSeconds + +function OnlinePanel.pushProfile(profile) + local client = Client() + if type(client.setProfiles) ~= "function" then return false end + return client.setProfiles(profile and { profile } or {}) +end + local ensureHooks local finishTo @@ -1791,6 +1819,25 @@ function OnlinePanel.pushPresence(room) pcall(Presence.setJoinCode, room.code, "match", #(room.players or {}), 2) end +function OnlinePanel.partyRefusal(imp, packed) + local st = OnlinePanel.state(imp) + if st.kind == "cart" or st.cartId then return nil end + local version = OnlinePanel.engineVersion(imp) + if not version then return nil end + local ok, known = pcall(function() + return require("src.online.ArenaData").speciesIds(version) + end) + if not ok or type(known) ~= "table" then return nil end + for _, mon in ipairs(packed or {}) do + local id = type(mon) == "table" and mon.species or nil + if id and not known[id] then + return Strings("%s is not in the vanilla game, so it cannot go online.", + tostring(id)) + end + end + return nil +end + function OnlinePanel.sendReady(imp) local st = OnlinePanel.state(imp) local pick, reason = OnlinePanel.readTeamSlot(imp) @@ -1822,6 +1869,11 @@ function OnlinePanel.sendReady(imp) st.status, st.statusOk = tostring(why), false return false, why end + local refusal = OnlinePanel.partyRefusal(imp, packed) + if refusal then + st.status, st.statusOk = refusal, false + return false, refusal + end Client().ready(packed, OnlinePanel.partyDigest(packed)) st.ready = true st.status, st.statusOk = Strings("Ready. Waiting for the other trainer."), true @@ -2879,6 +2931,27 @@ function OnlinePanel.update(imp, dt) st.status, st.statusOk = tostring(OnlinePanel._joinError), false OnlinePanel._joinError = nil end + if st.joinWant then + local want = st.joinWant + if OnlinePanel.myProfile(imp) then + st.joinWant = nil + if OnlinePanel.joinByCode(imp, want.code, want.as) then + if want.tourFallback and st.pending then + st.pending.tourFallback = true + end + if st.wizard then finishTo(imp, "room") else OnlinePanel.go(imp, "room") end + end + elseif nowSeconds() > (want.at or 0) then + st.joinWant = nil + st.status, st.statusOk = + Strings("Couldn't read your game, so the join was not sent."), false + end + end + if st.pending and not st.pending.done and st.pending.at + and nowSeconds() - st.pending.at > OnlinePanel.JOIN_WAIT then + st.pending.error = Strings("The relay didn't answer.") + st.pending.done = true + end if st.pending and st.pending.done then local pending = st.pending st.pending = nil @@ -2886,6 +2959,9 @@ function OnlinePanel.update(imp, dt) and pending.reason == "not_found" then OnlinePanel.joinTournamentByCode(imp, pending.code, "spectator") elseif pending.error then + if OnlinePanel.screen(imp) == "room" and not Client().room() then + OnlinePanel.go(imp, "play") + end st.status, st.statusOk = tostring(pending.error), false end end @@ -2899,6 +2975,10 @@ function OnlinePanel.update(imp, dt) OnlinePanel.clearPresence() end st.hadRoom = room ~= nil or tour ~= nil + if st.roomPicked and not st.hadRoom and not st.pending + and not st.joinWant then + st.roomPicked = nil + end local cartKey = room and tostring(room.code) or nil if cartKey ~= st.roomCartKey then st.roomCartKey = cartKey @@ -2948,6 +3028,25 @@ function OnlinePanel.autoReadyTrade(imp, room) return OnlinePanel.sendReady(imp) end +function OnlinePanel.alignToRoom(imp, code) + local st = OnlinePanel.state(imp) + for _, entry in ipairs(Client().lobby() or {}) do + local p = entry.code == code and entry.profile or nil + if p and p.version and imp.ready and imp.ready[p.version] then + local kind = p.kind or "vanilla" + local cartId = p.cart and p.cart.id or nil + st.roomPicked = true + if p.version ~= st.version or kind ~= st.kind or cartId ~= st.cartId then + st.version, st.kind, st.cartId = p.version, kind, cartId + st.slotId, st.team, st.slotRead, st.ready = nil, {}, nil, false + OnlinePanel.invalidate(imp) + end + return true + end + end + return false +end + function OnlinePanel.spectateByCode(imp, code) local st = OnlinePanel.state(imp) code = OnlinePanel.sanitizeCode(code) @@ -2956,21 +3055,38 @@ function OnlinePanel.spectateByCode(imp, code) return OnlinePanel.joinTournamentByCode(imp, code, "spectator") end end - if not OnlinePanel.joinByCode(imp, code, "spectator") then return false end + OnlinePanel.alignToRoom(imp, code) + if not OnlinePanel.joinByCode(imp, code, "spectator") then + if st.joinWant then st.joinWant.tourFallback = true end + return false + end if st.pending then st.pending.tourFallback = true end OnlinePanel.go(imp, "room") return true end +OnlinePanel.JOIN_WAIT = 15 + function OnlinePanel.joinByCode(imp, code, as) local st = OnlinePanel.state(imp) code = OnlinePanel.sanitizeCode(code) if #code ~= OnlinePanel.CODE_LEN then + st.joinWant = nil st.status, st.statusOk = Strings("Room codes are 6 characters."), false return false end ensureHooks() - st.pending = Client().joinRoom(code, as or "player") + local profile, reason = OnlinePanel.myProfile(imp) + if not profile then + st.joinWant = { code = code, as = as or "player", + at = nowSeconds() + OnlinePanel.JOIN_WAIT } + st.status, st.statusOk = reason or Strings("Reading your game..."), false + return false + end + st.joinWant = nil + OnlinePanel.pushProfile(profile) + st.pending = Client().joinRoom(code, as or "player", profile) + if st.pending and st.pending.at == nil then st.pending.at = nowSeconds() end return true end diff --git a/src/import/RomExtractorGen2.lua b/src/import/RomExtractorGen2.lua index 74a43628..3d2c7116 100644 --- a/src/import/RomExtractorGen2.lua +++ b/src/import/RomExtractorGen2.lua @@ -2442,7 +2442,9 @@ function RomExtractorGen2:extractCredits() CREDITS_THEEND_TILES * 16), 64, 16, "credits/theend.png") data.theEnd = "assets/generated/credits/theend.png" -- Credits_TheEnd: hlcoord 6, 8 and 6, 9, eight tiles apiece. - data.theEndX, data.theEndY, data.theEndWidth = 6, 8, 8 + -- ../pokecrystal/engine/movie/credits.asm:595 + data.theEndX, data.theEndWidth = 6, 8 + data.theEndY = (self.edition == "crystal") and 9 or 8 end self:tick("Credits", 2, steps) @@ -2467,11 +2469,15 @@ function RomExtractorGen2:extractCredits() if self.symbols["CreditsPalettes"] then local pal = self:symbol("CreditsPalettes") + -- ../pokecrystal/engine/movie/credits.asm:502 + local crystal = (self.edition == "crystal") + local setCount = crystal and 12 or 6 local palettes = {} - for set = 0, 5 do + for set = 0, setCount - 1 do palettes[set + 1] = self:colors(pal.bank, pal.address + set * 8, 4) end data.palettes = palettes + data.palettesPerScene = crystal and 3 or 1 end self:tick("Credits", steps, steps) diff --git a/src/import/online/Room.lua b/src/import/online/Room.lua index 1addd862..1e6ed257 100644 --- a/src/import/online/Room.lua +++ b/src/import/online/Room.lua @@ -58,14 +58,18 @@ function Room.draw(imp, x, y, w, availH, m) if not room then local pending = st.pending local line = Strings("You are not in a room.") - if pending and not pending.done then + if st.joinWant then + line = Strings("Joining room %s...", tostring(st.joinWant.code)) + elseif pending and not pending.done then line = pending.code and Strings("Joining room %s...", tostring(pending.code)) or Strings("Creating the room...") end local cy = y + Ui.header(imp, x, y, w, m, Strings("Room")) Kit.emptyBox(x, cy, w, rowH * 2, line) - return (cy - y) + rowH * 2 + cy = cy + rowH * 2 + tiny + cy = cy + Ui.statusLine(imp, x, cy, w, m) + return cy - y end local stage = tostring(room.stage or "waiting") diff --git a/src/import/online/Wizard.lua b/src/import/online/Wizard.lua index 40c42a4d..b6a611a6 100644 --- a/src/import/online/Wizard.lua +++ b/src/import/online/Wizard.lua @@ -66,6 +66,7 @@ local function stepGame(imp, x, y, w, m) function(option) st.version, st.slotId, st.cartId, st.team = option.id, nil, nil, {} st.slotRead, st.ready, st.kind = nil, false, "vanilla" + st.versionPicked = true OnlinePanel.invalidate(imp) end) diff --git a/src/online/ArenaData.lua b/src/online/ArenaData.lua index 7179b9b0..8a181ab3 100644 --- a/src/online/ArenaData.lua +++ b/src/online/ArenaData.lua @@ -11,6 +11,7 @@ local Version = require("src.core.Version") local ArenaData = {} local rulesetMemo = {} +local speciesMemo = {} local FIELDS = { { key = "engine", text = "engine differs" }, @@ -113,6 +114,31 @@ function ArenaData.forget(version) end pcall(SaveData.saveOptions, { arenaProfiles = bucket }) if version then rulesetMemo[version] = nil else rulesetMemo = {} end + if version then speciesMemo[version] = nil else speciesMemo = {} end +end + +function ArenaData.speciesIds(version) + if not GameVersion.VERSIONS[version] then return nil end + local hit = speciesMemo[version] + if hit then return hit end + local Trade = require("src.online.Trade") + if Trade.mounted() or Trade.gameIsLive() then return nil end + local prevVersion, prevPrefix = GameVersion.get(), CacheFs.prefix + local ok, rows = pcall(function() + GameVersion.set(version) + CacheFs.prefix = GameVersion.cachePrefix(version) + CacheFs.mountVersion(version) + return CacheFs.loadActive("data/generated/pokemon.lua") + end) + pcall(CacheFs.unmountVersion, version) + GameVersion.set(prevVersion) + CacheFs.prefix = prevPrefix + if not ok or type(rows) ~= "table" then return nil end + local out = {} + for id in pairs(rows) do out[id] = true end + if next(out) == nil then return nil end + speciesMemo[version] = out + return out end local function gen2Dataset() diff --git a/src/online/Client.lua b/src/online/Client.lua index dc1c546b..4705c620 100644 --- a/src/online/Client.lua +++ b/src/online/Client.lua @@ -802,6 +802,16 @@ function Client.configure(opts) return S.relayAddress end +function Client.setProfiles(list) + S.opts = S.opts or {} + S.opts.profiles = list or {} + return S.opts.profiles +end + +function Client.profiles() + return (S.opts and S.opts.profiles) or {} +end + function Client.connect(opts) opts = opts or {} if S.session then Client.disconnect() end @@ -902,7 +912,7 @@ end local function newPending(code, kind) S.pending = { code = code, room = nil, error = nil, done = false, - kind = kind or "room" } + kind = kind or "room", at = now() } return S.pending end diff --git a/src/render/GbcPalette.lua b/src/render/GbcPalette.lua index 9a5e7c67..1cf0f3f0 100644 --- a/src/render/GbcPalette.lua +++ b/src/render/GbcPalette.lua @@ -366,7 +366,7 @@ end -- map whose palettes share nothing at all fills the array and the OBJ list is -- dropped. BG is walked first for that reason too: losing the sprite guard -- costs a few sprite pixels, losing a BG palette would cost the effect. -function GbcPalette.remapTable(bgPalettes, byte, objPalettes) +function GbcPalette.remapTable(bgPalettes, byte, objPalettes, ramp, objRamped) local src, dst = {}, {} local seen = {} local ambiguous = 0 @@ -391,13 +391,21 @@ function GbcPalette.remapTable(bgPalettes, byte, objPalettes) end end + -- ../pokecrystal/engine/battle/battle_transition.asm:592-607 + local forced = ramp and GbcPalette.resolve(ramp) + local forcedBg = forced and GbcPalette.remap(forced, byte) + for _, colors in ipairs(bgPalettes or {}) do local resolved = GbcPalette.resolve(colors) - add(resolved, GbcPalette.remap(resolved, byte)) + add(resolved, forcedBg or GbcPalette.remap(resolved, byte)) end - for _, colors in ipairs(objPalettes or {}) do + for index, colors in ipairs(objPalettes or {}) do local resolved = GbcPalette.resolve(colors) - add(resolved, resolved) + if forced and objRamped and objRamped[index] then + add(resolved, forced) + else + add(resolved, resolved) + end end local count = #src @@ -414,12 +422,16 @@ end -- false when there is no shader or no palette, so a caller can fall back to -- whatever approximation it had before; on success it also returns the -- `ambiguous` count, which is 0 when the pass is exact. -function GbcPalette.useRemap(bgPalettes, byte, objPalettes) - local sh = GbcPalette.remapShader() - if not sh then return false end +function GbcPalette.useRemap(bgPalettes, byte, objPalettes, ramp, objRamped) + if not GbcPalette.remapShader() then return false end + return GbcPalette.useRemapUniforms( + GbcPalette.remapUniforms(bgPalettes, byte, objPalettes, ramp, objRamped)) +end + +function GbcPalette.remapUniforms(bgPalettes, byte, objPalettes, ramp, objRamped) local src, dst, count, ambiguous = - GbcPalette.remapTable(bgPalettes, byte, objPalettes) - if count == 0 then return false end + GbcPalette.remapTable(bgPalettes, byte, objPalettes, ramp, objRamped) + if count == 0 then return nil end local sendSrc, sendDst = {}, {} for index = 1, GbcPalette.REMAP_MAX do sendSrc[index] = { src[index][1] / 255, src[index][2] / 255, @@ -427,13 +439,19 @@ function GbcPalette.useRemap(bgPalettes, byte, objPalettes) sendDst[index] = { dst[index][1] / 255, dst[index][2] / 255, dst[index][3] / 255 } end + return { src = sendSrc, dst = sendDst, count = count, ambiguous = ambiguous } +end + +function GbcPalette.useRemapUniforms(uniforms) + local sh = GbcPalette.remapShader() + if not (sh and uniforms) then return false end -- pcall rather than an assert: a driver that will not take a 32-entry vec3 -- array should drop the effect, not take the battle down with it. local ok = pcall(function() - sh:send("remapCount", count) + sh:send("remapCount", uniforms.count) sh:send("remapTol", GbcPalette.REMAP_TOLERANCE) - sh:send("remapSrc", unpack(sendSrc)) - sh:send("remapDst", unpack(sendDst)) + sh:send("remapSrc", unpack(uniforms.src)) + sh:send("remapDst", unpack(uniforms.dst)) end) if not ok then remapFailed = true @@ -441,7 +459,7 @@ function GbcPalette.useRemap(bgPalettes, byte, objPalettes) return false end love.graphics.setShader(sh) - return true, ambiguous + return true, uniforms.ambiguous end -- Run `body` with `colors` active, restoring whatever shader was set before. diff --git a/src/render/PaletteFX.lua b/src/render/PaletteFX.lua index df922d9f..86fab381 100644 --- a/src/render/PaletteFX.lua +++ b/src/render/PaletteFX.lua @@ -741,6 +741,16 @@ function PaletteFX.spriteObp(spriteDef, seed) return PaletteFX.darkObp(w.spritePalettes[group], group) end +-- engine/overworld/healing_machine.asm:74 +-- color/data/spritepalettes.asm:2 +PaletteFX.HEAL_MACHINE_GROUP = 4 + +function PaletteFX.healMachineObp() + local w = PaletteFX.worldPack() + return w and w.spritePalettes + and w.spritePalettes[PaletteFX.HEAL_MACHINE_GROUP] or nil +end + -- GetHealthBarColor (home/palettes.asm) on the standard 48px bar. It reads -- the bar's own length, so a caller mid-drain passes the animated `pixels` -- rather than let it be re-derived from hp. diff --git a/src/render/TextBox.lua b/src/render/TextBox.lua index 111fb122..a4f161d2 100644 --- a/src/render/TextBox.lua +++ b/src/render/TextBox.lua @@ -21,6 +21,13 @@ local TextBox = {} TextBox.__index = TextBox TextBox.isTextBox = true +-- home/delay.asm:14 +local function sfxWaitFrames(src) + local Sound = require("src.core.Sound") + if not Sound.waitFrames then return 180 end + return Sound.waitFrames(src) +end + -- theme-free fallbacks; geometry resolves against Theme.textBox at -- construction time, so an unthemed boot stays byte-identical local BOX_TX, BOX_TY, BOX_TW, BOX_TH = 0, 12, 20, 6 @@ -108,6 +115,8 @@ function TextBox.new(game, text, onDone, opts) self.choiceLabels = opts and opts.choiceLabels self.choiceBox = opts and opts.choiceBox self.money = opts and opts.money + -- scripts/MtMoonPokecenter.asm:30 + self.moneyWithChoice = opts and opts.moneyWithChoice self.auto = opts and opts.auto self.stay = opts and opts.stay -- engine/events/hidden_events/cinnabar_gym_quiz.asm:119 @@ -349,6 +358,12 @@ function TextBox:arrowVisible() or (self.stay.prompt and not self.stayShown))) end +-- scripts/MtMoonPokecenter.asm:30 +function TextBox:moneyVisible() + if not self.money then return false end + return not self.moneyWithChoice or not not self.choicePushed +end + function TextBox:update(dt) local input = self.game.input self.blink = (self.blink + 1) % 60 @@ -357,11 +372,13 @@ function TextBox:update(dt) if not self.preStarted then self.preStarted = true self.preSrc = self.preSound() + self.preSrcLeft = sfxWaitFrames(self.preSrc) end - if self.preSrc and self.preSrc.isPlaying and self.preSrc:isPlaying() then - return - end - self.preSound, self.preSrc = nil, nil + self.preSrcLeft = (self.preSrcLeft or 0) - 1 + local playing = self.preSrc and self.preSrc.isPlaying and self.preSrc:isPlaying() + if playing and self.preSrcLeft > 0 then return end + if playing then pcall(self.preSrc.stop, self.preSrc) end + self.preSound, self.preSrc, self.preSrcLeft = nil, nil, nil end -- A page or CONT advance blocks the whole box while the original's scroll -- and clear run (src/core/Timing.lua TEXT_SCROLL_PAIR / TEXT_PAGE_CLEAR). @@ -415,6 +432,7 @@ function TextBox:update(dt) if not self.autoStarted then self.autoStarted = true self.autoSrc = self.auto.sound and self.auto.sound() or nil + self.autoSrcLeft = sfxWaitFrames(self.autoSrc) self.autoTimer = 0 end -- auto.tick: one call per frame for as long as the box is held open, @@ -424,8 +442,13 @@ function TextBox:update(dt) -- the overworld underneath is frozen (the Pewter JIGGLYPUFF spin, -- #249). if self.auto.tick then self.auto.tick() end - if self.autoSrc and self.autoSrc.isPlaying and self.autoSrc:isPlaying() then - return -- the cry is still sounding (WaitForSoundToFinish) + -- home/delay.asm:14 + if self.autoSrc then + self.autoSrcLeft = (self.autoSrcLeft or 0) - 1 + if self.autoSrc.isPlaying and self.autoSrc:isPlaying() then + if self.autoSrcLeft > 0 then return end + pcall(self.autoSrc.stop, self.autoSrc) + end end -- auto.wait: the pet-NPC cries (PewterNidoranHouseNidoranText, -- ViridianNicknameHouseSpearowText) have nothing queued behind the @@ -620,7 +643,7 @@ function TextBox:draw() pen = pen + Font.advanceOf(code) end end - if self.money then + if self:moneyVisible() then -- money box (engine/menus/text_box.asm:130): DisplayMoneyBox at -- hlcoord 11,0, the amount right-aligned on its middle row if Chrome then diff --git a/src/script/Commands.lua b/src/script/Commands.lua index ec6de26e..2b1cfde2 100644 --- a/src/script/Commands.lua +++ b/src/script/Commands.lua @@ -135,7 +135,11 @@ function Commands.show_text(ctx, textId, subs, extraOpts) end end -- MONEY_BOX (engine/menus/text_box.asm:133) reads the live wallet - if opts and opts.money == true then + if opts and opts.money == "choice" then + -- scripts/MtMoonPokecenter.asm:30 + opts.money = function() return ctx.save.money end + opts.moneyWithChoice = true + elseif opts and opts.money == true then opts.money = function() return ctx.save.money end end ctx.game.stack:push(TextBox.new(ctx.game, text, function() diff --git a/src/ui/QuantityBox.lua b/src/ui/QuantityBox.lua index b763d4f1..8c080195 100644 --- a/src/ui/QuantityBox.lua +++ b/src/ui/QuantityBox.lua @@ -18,6 +18,13 @@ function QuantityBox.new(game, opts) return self end +-- home/print_bcd.asm:14-50 +local function moneyField(amount) + local digits = ("%06d"):format(math.max(0, math.floor(amount or 0))) + local first = digits:find("[1-9]") or #digits + return "¥" .. digits:sub(first) +end + local function wrap(v, max) if v < 1 then return max end if v > max then return 1 end @@ -48,11 +55,13 @@ function QuantityBox:draw() local ty = 9 Font.drawBox(tx, ty, tw, 3) love.graphics.setColor(0, 0, 0, 1) - local s = ("×%02d"):format(self.qty) -- the multiply glyph tile + local y = (ty + 1) * 8 + Font.draw(("×%02d"):format(self.qty), (tx + 1) * 8, y) if self.unitPrice then - s = s .. (" ¥%d"):format(self.qty * self.unitPrice) + -- home/list_menu.asm:293-299 + local s = moneyField(self.qty * self.unitPrice) + Font.draw(s, (tx + tw - 1) * 8 - Font.width(s), y) end - Font.draw(s, (tx + 1) * 8, (ty + 1) * 8) love.graphics.setColor(1, 1, 1, 1) end diff --git a/src/ui/ShopMenu.lua b/src/ui/ShopMenu.lua index ccbb113b..74ddb403 100644 --- a/src/ui/ShopMenu.lua +++ b/src/ui/ShopMenu.lua @@ -71,13 +71,9 @@ local function buy(game, stock, menu) return end local def = game.data.items[item.value] - if game.save.money < def.price then - list.footer = notEnough - return - end - local affordable = math.min(99, math.floor(game.save.money / math.max(1, def.price))) + -- engine/events/pokemart.asm:152-156 game.stack:push(QuantityBox.new(game, { - max = affordable, + max = 99, unitPrice = def.price, onDone = function(qty) if not qty then diff --git a/src/ui/TradeAnim.lua b/src/ui/TradeAnim.lua index 35353e18..ebba1efa 100644 --- a/src/ui/TradeAnim.lua +++ b/src/ui/TradeAnim.lua @@ -287,8 +287,20 @@ function TradeAnim:advance() end end +local envSkipAllowed = nil + +local function skipAllowed() + if envSkipAllowed == nil then + envSkipAllowed = os.getenv("POKEPORT_DEV") == "1" + or os.getenv("POKEPORT_DRIVER") ~= nil + end + return envSkipAllowed or _G.POKEPORT_DEV_MODE == true +end + +-- engine/movie/trade.asm:20 function TradeAnim:skipHeld() - return self.game.input:wasPressed("a") or self.game.input:wasPressed("b") + if not skipAllowed() then return false end + return self.game.input:wasPressed("start") end function TradeAnim:drawFrameBlock(blockId, x, y) diff --git a/src/ui/gen2/BattleState.lua b/src/ui/gen2/BattleState.lua index 2f538ab9..2147d05f 100644 --- a/src/ui/gen2/BattleState.lua +++ b/src/ui/gen2/BattleState.lua @@ -27,6 +27,7 @@ local Evolution = require("src.core.gen2.Evolution") local GbcPalette = require("src.render.GbcPalette") local Gen2Save = require("src.core.gen2.Save") local Font = require("src.render.Font") +local ForgetMoveList = require("src.ui.gen2.ForgetMoveList") local HpBar = require("src.battle.gen2.HpBar") local ItemEffects = require("src.core.gen2.ItemEffects") local Mon = require("src.battle.gen2.Mon") @@ -136,6 +137,9 @@ local TEXT_ASK_FORGET_SLOT = Strings.source("Which move should\nbe forgotten?") local TEXT_CANT_FORGET_HM = Strings.source("HM moves can't be\nforgotten now.") local TEXT_STOP_LEARNING = Strings.source("Stop learning\n%s?") +-- engine/pokemon/learn.asm:135-166 +BattleState.FORGET_LIST = ForgetMoveList + -- BattleText_EnemyIsAboutToUseWillPlayerChangeMon (data/text/battle.asm:222-231). local TEXT_ENEMY_ABOUT_TO_USE = Strings.source( "%s\nis about to use\v%s.\fWill %s\nchange POKéMON?") @@ -386,9 +390,9 @@ function BattleState.new(game, opts) self.showEnemyHud = false self.showPlayerHud = false - -- BattleStart_TrainerHuds, farcalled from BattleStartMessage before the - -- opening line (engine/battle/trainer_huds.asm:1-9, core.asm:8733). - self.ballRows = { + -- engine/battle/trainer_huds.asm:1-9 + self.ballRows = { player = false, enemy = false } + self.startHuds = { player = true, enemy = not (self.battle and self.battle.wild), } @@ -918,6 +922,8 @@ function BattleState:drawPic(mon, back) colors = Palettes.trainerColors(self.palettes, self.enemyTrainerClass) or colors end + -- ../pokecrystal/engine/gfx/cgb_layouts.asm:67 + if self:introGrayscale() then colors = Palettes.BLACKOUT end if anim and anim.shade then colors = BattleAnimView.shadeColors(colors, anim.shade) end @@ -1063,17 +1069,8 @@ function BattleState:stepHpAnim() -- replacement's rate. local mon = self:activeMon(anim.side) local maxHp = (mon and (mon.maxHp or (mon.stats and mon.stats.hp))) or 0 - local step = 1 - if maxHp >= HpBar.LENGTH_PX then - step = math.max(1, math.ceil(maxHp / HpBar.LENGTH_PX)) - end - local shown = self.shownHp[anim.side] or 0 local target = anim.to or 0 - if shown < target then - shown = math.min(target, shown + step) - else - shown = math.max(target, shown - step) - end + local shown = HpBar.stepToward(self.shownHp[anim.side] or 0, target, maxHp) self.shownHp[anim.side] = shown if shown == target then self.hpAnim = nil end return true @@ -1756,12 +1753,19 @@ function BattleState:advanceQueue() end if event.text then self.message = event.text + -- engine/battle/core.asm:8733 + if self.startHuds then + self.ballRows.player = self.startHuds.player + self.ballRows.enemy = self.startHuds.enemy + self.startHuds = nil + end -- move/level lines do not hold for A/B (battle.asm:336-343); experience -- keeps the wait (common_1.asm:1660-1665). if event.kind == "move" or event.kind == "level" then self.messageTimer = 0 -- engine/battle/effect_commands.asm:1958-1961 - if event.kind == "move" and event.missed then + -- engine/battle/move_effects/magnitude.asm:20 + if event.kind == "move" and (event.missed or event.animDelay) then self.messageDelay = MOVE_DELAY_FRAMES end else @@ -1790,10 +1794,13 @@ function BattleState:advanceQueue() end -- engine/battle/effect_commands.asm:1958: a missed move burns the delay -- and plays nothing; the after-anim chain is animForMove / stepAnim's. - if event.kind == "move" and not event.missed then + -- data/moves/effects.asm:1705-1711 + local animId = event.moveAnim + or (event.kind == "move" and not event.deferAnim and event.move) + if animId and not event.missed then self.afterAnimPlayed = nil self.pendingAfterAnim = nil - if not self:animForMove(event.move, event.side, event.animParam, + if not self:animForMove(animId, event.side, event.animParam, event.effectiveness) then -- BATTLE SCENE off skips the move script but still runs wBattleAfterAnim -- (anim_commands.asm:55-72 .disabled fallthrough). @@ -2159,6 +2166,12 @@ function BattleState:swapMoves(i, j) moves[i], moves[j] = b, a end +-- ../pokecrystal/engine/battle/core.asm:8063 +function BattleState:introGrayscale() + local frame = self.slideFrame + return frame ~= nil and frame < BattleAnimView.SLIDE_FRAMES +end + function BattleState:update(_dt) -- The evolution sweep owns the stack (and the low-HP alarm is long over); -- this state is only still here because ExitBattle has not cleaned up yet. @@ -2239,8 +2252,16 @@ function BattleState:update(_dt) -- Without it the DUDE's auto-input tapped straight through the "Gotcha!" -- line and finishBattle closed the tutorial over the jingle. if self.waitSfx then - if Sound.isPlaying(self.waitSfx) then return end - self.waitSfx = nil + if not self.waitSfxLeft then + self.waitSfxLeft = Sound.waitFramesFor + and Sound.waitFramesFor(self.waitSfx) or 180 + end + self.waitSfxLeft = self.waitSfxLeft - 1 + if Sound.isPlaying(self.waitSfx) then + if self.waitSfxLeft > 0 then return end + if Sound.stop then Sound.stop(self.waitSfx) end + end + self.waitSfx, self.waitSfxLeft = nil, nil end -- AnimateExpBar sits right after PrintText Text_MonGainedExpPoint -- (core.asm:6881-6888), with that line still on screen. Run the crawl @@ -3496,16 +3517,15 @@ function BattleState:useOnPartyMon(itemId, action) stack:pop() self:openPack() end, - onChoose = function(_, mon) + onChoose = function(partySlot, mon) -- RestorePPEffect: the ETHER pair needs the move pick first, the ELIXER -- pair walks every slot without one, and an EGG refuses before the move -- list ever opens (UseItem_SelectMon's `cp EGG`). local row = (action == "pp") and ItemEffects.RESTORE_PP[itemId] or nil if row and not row.each and mon and not mon.isEgg then - return self:pickMoveForItem(itemId, mon) + return self:pickMoveForItem(itemId, mon, partySlot) end - stack:pop() - self:applyPartyItem(itemId, action, mon) + self:applyPartyItem(itemId, action, mon, nil, partySlot) end, }) end @@ -3515,7 +3535,7 @@ end -- port serves both with src/ui/gen2/MoveDeleter.lua. Backing out drops only -- the move list and leaves the party list standing, which is the routine's own -- `jr nz, .loop`. -function BattleState:pickMoveForItem(itemId, mon) +function BattleState:pickMoveForItem(itemId, mon, partySlot) local stack = self.game.stack Screens.push(self.game, "Gen2MoveDeleter", { mon = mon, @@ -3523,8 +3543,7 @@ function BattleState:pickMoveForItem(itemId, mon) onCancel = function() stack:pop() end, onChoose = function(slot) stack:pop() -- the move list - stack:pop() -- the party list - self:applyPartyItem(itemId, "pp", mon, slot) + self:applyPartyItem(itemId, "pp", mon, slot, partySlot) end, }) end @@ -3567,8 +3586,11 @@ end -- revive target, a PP slot already full); this side adds the arm that only -- exists with a battle up, spends the item where UseDisposableItem sits, and -- pays the turn the pack costs. -function BattleState:applyPartyItem(itemId, action, mon, slot) +function BattleState:applyPartyItem(itemId, action, mon, slot, partySlot) local data = (self.game and self.game.data) or {} + local stack = self.game and self.game.stack + local menu = stack and stack.top and stack:top() + if not (menu and menu.showItemResult) then menu = nil end local before = (mon and mon.hp) or 0 local result if action == "pp" then @@ -3590,12 +3612,34 @@ function BattleState:applyPartyItem(itemId, action, mon, slot) end end if not result.used then + if menu then stack:pop() end self.message = oneLine(result.text) self.messageTimer = MESSAGE_FRAMES self.phase = "resolving" return end self:consumeItem(itemId) + if menu then + -- engine/items/item_effects.asm:1671 + local climbs = (action == "heal" or action == "revive") + and mon and (mon.hp or 0) ~= before + if climbs then + -- engine/items/item_effects.asm:1659 + self:stopAlarm() + self.healSilence = true + end + menu:showItemResult(partySlot, { + fromHp = climbs and before or nil, + toHp = climbs and mon.hp or nil, + sfx = climbs and "Sfx_Potion" or nil, + text = result.text, + onDone = function() + stack:pop() + self:finishPartyItemTurn(itemId, mon) + end, + }) + return + end self.queue = {} if mon == self.battle.player and (mon.hp or 0) ~= before then -- Every HP-restoring effect zeroes wLowHealthAlarm BEFORE it touches the @@ -3619,6 +3663,18 @@ function BattleState:applyPartyItem(itemId, action, mon, slot) self:advanceQueue() end +-- engine/items/item_effects.asm:1671 +function BattleState:finishPartyItemTurn(itemId, mon) + self.queue = {} + if mon and mon == self.battle.player and self.shownHp then + self.shownHp.player = mon.hp or 0 + if self.hpAnim and self.hpAnim.side == "player" then self.hpAnim = nil end + end + self:pushAll(self.battle:takeTurn({ kind = "item", item = itemId })) + self.phase = "resolving" + self:advanceQueue() +end + -- The HUD is not a box: engine/battle/core.asm draws an L-shaped frame out of -- four tiles (DrawEnemyHUDBorder / DrawPlayerHUDBorder) -- a horizontal rule -- under the whole thing with a short vertical stub at one end, opening left for @@ -3856,6 +3912,8 @@ function BattleState:drawPanel() -- engine/battle_anims/anim_commands.asm:1302 local previousBgp = GbcPalette.setBgp(nil) local moveMenu = self.phase == "moves" + local forgetting = self.phase == "choose-forget" + and (self.messageTimer or 0) <= 0 Chrome.box(0, 12, 20, 6) if moveMenu then -- List box first (core.asm:5074-5084), MoveInfoBox on top (:5157). @@ -3877,45 +3935,39 @@ function BattleState:drawPanel() end Chrome.printThrough(label, tx, ty, Chrome.DEFAULT_BOX_PALETTE) end - elseif self.phase == "moves" - or (self.phase == "choose-forget" and (self.messageTimer or 0) <= 0) then - -- The forget picker shows the SAME four moves, cursored by forgetIndex -- - -- pick one to drop for the pending move. - local forgetting = self.phase == "choose-forget" + elseif forgetting then + -- engine/pokemon/learn.asm:136-146 + self:printMessage() + local learn = self.pendingLearn + local mon = learn and self.battle.party[learn.index] + local moves = (mon and mon.moves) or self:playerMoves() + ForgetMoveList.draw(moves, self.forgetIndex, + self.game and self.game.data and self.game.data.moves, + Chrome.DEFAULT_BOX_PALETTE) + elseif moveMenu then local moves = self:playerMoves() - if forgetting then - local learn = self.pendingLearn - local mon = learn and self.battle.party[learn.index] - moves = (mon and mon.moves) or moves - end - local cursorRow = forgetting and self.forgetIndex or self.moveIndex + local cursorRow = self.moveIndex -- w2DMenuCursorInitX 5 with the names at hlcoord 6 (core.asm:5086-5107). - local cursorCol = moveMenu and 5 or 1 - local nameCol = moveMenu and 6 or 2 for i, move in ipairs(moves) do local ty = 13 + (i - 1) -- Cursor in the box's own gutter, not clipped against the border. if i == cursorRow then - Chrome.cursorThrough(cursorCol, ty, Chrome.DEFAULT_BOX_PALETTE) + Chrome.cursorThrough(5, ty, Chrome.DEFAULT_BOX_PALETTE) end -- The held slot's marker. `.battle_player_moves` writes '▷' into the -- row wSwappingMove names (engine/battle/core.asm:5157-5165) so a move -- picked up for a swap is visible while the cursor moves off it. It -- hlcoord 5, 13 is the cursor's own gutter, so PlaceMenuCursor covers -- the marker on the cursor's row. - if not forgetting and self.moveSwapIndex == i and i ~= cursorRow then - Chrome.printThrough("\u{25B7}", cursorCol, ty, Chrome.DEFAULT_BOX_PALETTE) + if self.moveSwapIndex == i and i ~= cursorRow then + Chrome.printThrough("\u{25B7}", 5, ty, Chrome.DEFAULT_BOX_PALETTE) end local def = self.game and self.game.data and self.game.data.moves and self.game.data.moves[move.id] - Chrome.printThrough((def and def.name) or move.id, nameCol, ty, + Chrome.printThrough((def and def.name) or move.id, 6, ty, Chrome.DEFAULT_BOX_PALETTE) - if not moveMenu then - Chrome.printRightThrough(("%d/%d"):format(move.pp or 0, move.maxPp or 0), - 19, ty, Chrome.DEFAULT_BOX_PALETTE) - end end - if moveMenu then self:drawMoveInfoBox(moves[cursorRow]) end + self:drawMoveInfoBox(moves[cursorRow]) else -- Battle messages wrap inside the box rather than running off the frame. self:printMessage() diff --git a/src/ui/gen2/BattleTransition.lua b/src/ui/gen2/BattleTransition.lua index 06cc7e16..6be58bc0 100644 --- a/src/ui/gen2/BattleTransition.lua +++ b/src/ui/gen2/BattleTransition.lua @@ -55,7 +55,7 @@ local COLS, ROWS = 20, 18 -- SCREEN_WIDTH x SCREEN_HEIGHT, in tiles -- flash runs there is no four-entry palette left in the frame to permute. -- GbcPalette's remap shader puts one back: it matches each pixel to the BG -- palette entry that produced it and substitutes what the byte sends that entry --- to, which is CopyPals exactly. BattleTransition:drawFlash does that and +-- to, which is CopyPals exactly. BattleTransition:drawMap does that and -- falls back to flashVeil below -- the entry's mean shade against the identity -- %11100100 (3,2,1,0), normalised so 3,3,3,3 is solid black and 0,0,0,0 is -- solid white -- only when the exact pass cannot run. @@ -106,6 +106,18 @@ BattleTransition.FLASH_FRAMES = -- The Poke Ball overlay (trainer battles only) -------------------------------------------------------------------------- +BattleTransition.TRAINER_PAL = { + { 255, 148, 239 }, { 255, 90, 123 }, { 255, 41, 41 }, { 58, 58, 58 }, +} +BattleTransition.TRAINER_PAL_DARK = { + { 255, 148, 239 }, { 255, 41, 41 }, { 255, 41, 41 }, { 255, 41, 41 }, +} +-- ../pokecrystal/engine/battle/battle_transition.asm:689-701 +BattleTransition.RAMPED_OBJ = { + [Palettes.OW_PALETTE_ID.PAL_OW_TREE] = true, + [Palettes.OW_PALETTE_ID.PAL_OW_ROCK] = true, +} + -- `.PokeBallTransition`, 16 bigdw rows of 16 bits, stamped from hlcoord 2, 1. -- A set bit becomes BATTLETRANSITION_SQUARE; the drawing loop stops early on a -- byte that has shifted itself empty, which is why the trailing zero columns of @@ -357,6 +369,15 @@ end function BattleTransition:drawsWidescreen() return true end function BattleTransition:wantsFillScale() return true end +-- ../pokecrystal/engine/battle/battle_transition.asm:272-275 +local function darknessFor(world) + if not world then return false end + if world.daytime then return world.daytime == "DARK" end + local def = world.map and world.map.def + local hour = world.hour and world:hour() or nil + return Palettes.isDarkness(def, hour, world.flashUsed) and true or false +end + -- opts: world, trainer (bool), environment, playerLevel, enemyLevel, -- random(n), onDone function BattleTransition.new(game, opts) @@ -389,14 +410,48 @@ function BattleTransition.new(game, opts) if not BattleTransition.STYLES[style] then style = vanillaStyle(ctx) end self.style = style self.trainer = opts.trainer and true or false + -- ../pokecrystal/engine/battle/battle_transition.asm:585-587 + self.recolor = self.trainer + if opts.dark ~= nil then + self.dark = opts.dark and true or false + else + self.dark = darknessFor(self.world) + end self.black = {} self.frame = 0 self.step = 0 - self.phase = self.trainer and "pokeball" or "flash" self.sine = nil + if self.trainer then + self.phase = "pokeball" + elseif self.dark then + self:beginOutro() + else + self.phase = "flash" + end return self end +function BattleTransition:trainerRamp() + if not self.recolor then return nil end + return self.dark and BattleTransition.TRAINER_PAL_DARK + or BattleTransition.TRAINER_PAL +end + +-- ../pokecrystal/engine/battle/battle_transition.asm:272-275 +function BattleTransition:flashFrames() + if self.dark then return 0 end + return BattleTransition.FLASH_FRAMES +end + +function BattleTransition:beginOutro() + self.phase = "outro" + self.frame = 0 + self.step = 0 + if self.style == "sine" then + self.sine = BattleTransition.sineFrames() + end +end + -- One logic frame. The phases run in the jumptable's order and the state pops -- itself when the last one is done, so the battle screen comes up on the black -- screen the wipe left behind. @@ -406,20 +461,17 @@ function BattleTransition:update(_dt) -- Two DelayFrames on the DMG path, one CGBOnly_CopyTilemapAtOnce on the -- other; either way the ball is on screen for a moment before the flash. if self.frame >= 2 then - self.phase = "flash" - self.frame = 0 + if self.dark then + self:beginOutro() + else + self.phase = "flash" + self.frame = 0 + end end return end if self.phase == "flash" then - if self.frame >= BattleTransition.FLASH_FRAMES then - self.phase = "outro" - self.frame = 0 - self.step = 0 - if self.style == "sine" then - self.sine = BattleTransition.sineFrames() - end - end + if self.frame >= self:flashFrames() then self:beginOutro() end return end if self.phase == "outro" then @@ -536,12 +588,38 @@ function BattleTransition:remapPalettes() return bg, Palettes.objectSet(world.palettes, world.daytime) end +-- ../pokecrystal/engine/battle/battle_transition.asm:657-683 +function BattleTransition:bindRemap(byte) + if Tilt.active and Tilt.active() then return false end + if not GbcPalette.remapShader() then return false end + byte = byte or GbcPalette.BGP_IDENTITY + local ramp = self:trainerRamp() + local world = self.world + local def = world and world.map and world.map.def + local daytime = world and world.daytime + local palettes = world and world.palettes + local cache = self.remapCache + if not (cache and cache.byte == byte and cache.ramp == ramp + and cache.def == def and cache.daytime == daytime + and cache.palettes == palettes) then + local bg, obj = self:remapPalettes() + if not bg then return false end + local uniforms = GbcPalette.remapUniforms(bg, byte, obj, ramp, + ramp and BattleTransition.RAMPED_OBJ or nil) + if not uniforms then return false end + cache = { byte = byte, ramp = ramp, def = def, daytime = daytime, + palettes = palettes, uniforms = uniforms } + self.remapCache = cache + end + return GbcPalette.useRemapUniforms(cache.uniforms) and true or false +end + -- Draw the map through this frame's rBGP byte, exactly. Returns false when the -- exact pass cannot run, which is the caller's cue to draw the world plainly -- and lay the brightness veil over it instead. -function BattleTransition:drawFlash(w, h, pal) - local byte = BattleTransition.flashByte(pal) - if byte == GbcPalette.BGP_IDENTITY then +function BattleTransition:drawMap(w, h, byte) + local ramp = self:trainerRamp() + if not ramp and (not byte or byte == GbcPalette.BGP_IDENTITY) then -- `dc 3, 2, 1, 0` twice in the table: the picture is simply itself. self.world:draw() return true @@ -549,19 +627,30 @@ function BattleTransition:drawFlash(w, h, pal) -- TILT projects the finished frame through a linear-filtered canvas, so its -- pixels are blends of palette colours rather than palette colours; matching -- them back would posterise the warp instead of flashing it. - if Tilt.active and Tilt.active() then return false end - local bg, obj = self:remapPalettes() - if not bg then return false end local canvas = self:capture(w, h) if not canvas then return false end - local applied = GbcPalette.useRemap(bg, byte, obj) - if not applied then return false end + if not self:bindRemap(byte) then return false end love.graphics.setColor(1, 1, 1, 1) love.graphics.draw(canvas, 0, 0) GbcPalette.clear() return true end +BattleTransition.TINT_ALPHA = 0.55 + +function BattleTransition:drawTint(w, h) + local ramp = self:trainerRamp() + if not ramp then return false end + local color = GbcPalette.resolve(ramp) + local c = color and color[3] + if not c then return false end + local G = love.graphics + G.setColor(c[1] / 255, c[2] / 255, c[3] / 255, BattleTransition.TINT_ALPHA) + G.rectangle("fill", 0, 0, w, h) + G.setColor(1, 1, 1, 1) + return true +end + function BattleTransition:drawWidescreen(w, h) local G = love.graphics local world = self.world @@ -577,14 +666,16 @@ function BattleTransition:drawWidescreen(w, h) -- Cleared once the flash has been drawn exactly, so the veil below is only -- ever the fallback and the two can never both land on one frame. local pal = self:flashPal() + local byte = pal and BattleTransition.flashByte(pal) or nil if world and world.map then if ly then self:drawWavy(w, h, ly) - elseif pal and self:drawFlash(w, h, pal) then + elseif self:drawMap(w, h, byte) then pal = nil else world:draw() + self:drawTint(w, h) end else G.setColor(0, 0, 0, 1) @@ -649,7 +740,8 @@ function BattleTransition:drawCells(w, h, cells) local size, ox, oy = self:grid(w, h) -- Shade 3 of the text palette, through the COLOR mode like every other -- direct colour read. - local color = GbcPalette.color(nil, 4) + local ramp = self:trainerRamp() + local color = ramp and GbcPalette.color(ramp, 4) or GbcPalette.color(nil, 4) if color then G.setColor(color[1] / 255, color[2] / 255, color[3] / 255, 1) else @@ -678,6 +770,7 @@ function BattleTransition:drawWavy(w, h, ly) if not self.quad then self.quad = love.graphics.newQuad(0, 0, w, scale, w, h) end + local shaded = self:trainerRamp() and self:bindRemap(nil) for y = 0, rows - 1 do -- 144 overrides for however many screen rows the window has; a row past -- the end of the array holds the last value, the way the LCD keeps the @@ -696,6 +789,11 @@ function BattleTransition:drawWavy(w, h, ly) G.draw(canvas, self.quad, -shift - w, y * scale) end end + if shaded then + GbcPalette.clear() + else + self:drawTint(w, h) + end end function BattleTransition:capture(w, h) diff --git a/src/ui/gen2/Credits.lua b/src/ui/gen2/Credits.lua index 13dc2ad6..5428203b 100644 --- a/src/ui/gen2/Credits.lua +++ b/src/ui/gen2/Credits.lua @@ -54,6 +54,7 @@ local Chrome = require("src.ui.gen2.Chrome") local Font = require("src.render.Font") +local GameVersion = require("src.core.GameVersion") local GbcPalette = require("src.render.GbcPalette") local Logger = require("src.core.Logger") local Music = require("src.core.Music") @@ -89,17 +90,30 @@ local STEP_LY = 7 -- ConstructCreditsTilemap's rows. The banner is rows 0-3 and 14-17, the two -- border strips rows 4 and 13, and rows 5-12 are the blank $7f text field. -local BANNER_ROWS = { 0, 14 } local BANNER_TILES = 4 -- a mon graphic is 4x4 tiles local BANNER_REPEATS = 5 -- .InitTopPortion's `ld b, 5` -local BORDER_ROWS = { 4, 13 } local TEXT_TOP_ROW = 5 -- ParseCredits clears from hlcoord 0, 5 -local TEXT_ROWS = 8 -- ...for SCREEN_WIDTH * 8 local TEXT_FIRST_ROW = 6 -- .print's hlcoord 0, 6 local LINE_SPACING = 2 -- `ld bc, SCREEN_WIDTH * 2` -- Credits_TheEnd: tiles $40.. at hlcoord 6, 8 and 6, 9, eight apiece. -local THEEND_X, THEEND_Y, THEEND_W = 6, 8, 8 +local THEEND_X, THEEND_W = 6, 8 + +-- ../pokecrystal/engine/movie/credits.asm:400-451 +local LAYOUT = { + gs = { bannerRows = { 0, 14 }, borderRows = { 4, 13 }, textRows = 8, + theEndY = 8, lyStep = 2 }, + crystal = { bannerRows = { 0 }, borderRows = { 4, 17 }, textRows = 12, + theEndY = 9, lyStep = -2 }, +} + +local function isCrystal() + return GameVersion.engine() == "crystal" +end + +local function layout() + return isCrystal() and LAYOUT.crystal or LAYOUT.gs +end -- Credits_HandleBButton: the fast-forward only unlocks once wCreditsPos has -- passed $d, i.e. thirteen script bytes in. @@ -137,6 +151,22 @@ Credits.PALETTES = { [3] = pal555(31, 31, 31, 22, 15, 10, 31, 19, 09, 07, 07, 07), -- Sentret } +Credits.PALETTES_CRYSTAL = { + pal555(31, 00, 31, 31, 25, 00, 11, 14, 31, 07, 07, 07), + pal555(31, 05, 05, 11, 14, 31, 11, 14, 31, 31, 31, 31), + pal555(31, 05, 05, 00, 00, 00, 31, 31, 31, 31, 31, 31), + pal555(31, 31, 31, 31, 27, 00, 26, 06, 31, 07, 07, 07), + pal555(03, 13, 31, 20, 00, 24, 26, 06, 31, 31, 31, 31), + pal555(03, 13, 31, 00, 00, 00, 31, 31, 31, 31, 31, 31), + pal555(31, 31, 31, 23, 12, 28, 31, 22, 00, 07, 07, 07), + pal555(03, 20, 00, 31, 22, 00, 31, 22, 00, 31, 31, 31), + pal555(03, 20, 00, 00, 00, 00, 31, 31, 31, 31, 31, 31), + pal555(31, 31, 31, 31, 10, 31, 31, 00, 09, 07, 07, 07), + pal555(31, 14, 00, 31, 00, 09, 31, 00, 09, 31, 31, 31), + pal555(31, 14, 00, 31, 31, 31, 31, 31, 31, 31, 31, 31), +} +Credits.CRYSTAL_PALETTES_PER_SCENE = 3 + -- Credits_LoadBorderGFX's .Frames, as which of a mon's graphics each of the -- four animation frames uses. Three of the four mons repeat their first -- graphic on frame 2; Sentret is the one with four distinct frames. @@ -147,8 +177,19 @@ Credits.BORDER_FRAMES = { [3] = { 1, 2, 3, 4 }, -- Sentret } +-- ../pokecrystal/engine/movie/credits.asm:572-591 +Credits.BORDER_FRAMES_CRYSTAL = { + [0] = { 1, 2, 3, 4 }, + [1] = { 1, 2, 3, 4 }, + [2] = { 1, 2, 3, 4 }, + [3] = { 1, 2, 3, 4 }, +} + -- The four species the banner parades, in scene order. Credits.SCENE_SPECIES = { [0] = "BELLOSSOM", "TOGEPI", "ELEKID", "SENTRET" } +-- ../pokecrystal/engine/movie/credits.asm:610-613 +Credits.SCENE_SPECIES_CRYSTAL = + { [0] = "PICHU", "SMOOCHUM", "DITTO", "IGGLYBUFF" } -------------------------------------------------------------------------- -- The strings @@ -163,7 +204,15 @@ Credits.SCENE_SPECIES = { [0] = "BELLOSSOM", "TOGEPI", "ELEKID", "SENTRET" } -- changes anything, because .staff and the default arm really do land on the -- same hlcoord. -local STRINGS = {} +local RAW_STRINGS = {} +local STRINGS = setmetatable({}, { + __index = function(_, key) + local value = RAW_STRINGS[key] + if type(value) == "function" then return value() end + return value + end, + __newindex = function(_, key, value) RAW_STRINGS[key] = value end, +}) local ID = {} local nextId = 0 @@ -191,16 +240,28 @@ defineString("CREDIT_END", "END") -- person as far as ParseCredits is concerned. -- Credits_Staff differs per edition, including the centring spaces -- (data/credits_strings.asm:54-60). -Credits.STAFF = defineString("STAFF", - require("src.core.GameVersion").get() == "silver" and { - " #MON", - " SILVER VERSION", - " PORT STAFF", - } or { +-- ../pokecrystal/data/credits_strings.asm:183-185 +local STAFF_LINES = { + gold = { " #MON", " GOLD VERSION", " PORT STAFF", - }) + }, + silver = { + " #MON", + " SILVER VERSION", + " PORT STAFF", + }, + crystal = { + " #MON", + " CRYSTAL VERSION", + " PORT STAFF", + }, +} + +Credits.STAFF = defineString("STAFF", function() + return STAFF_LINES[GameVersion.get()] or STAFF_LINES.gold +end) defineString("DIRECTOR", " DIRECTOR") defineString("PROGRAMMING", " PROGRAMMING") defineString("ENGINE_DESIGN", " ENGINE DESIGN") @@ -422,7 +483,7 @@ end -- gfx/credits/theend.2bpp the same eight columns carry the words instead. local function theEnd(self) self.pending[#self.pending + 1] = - { theEnd = true, x = THEEND_X, y = THEEND_Y, width = THEEND_W } + { theEnd = true, x = THEEND_X, y = layout().theEndY, width = THEEND_W } end -- The `.wait` arm's hBGMapMode: everything written since the last parse @@ -490,7 +551,9 @@ end -- banner is cleared. function Credits:borderGraphic() if self.borderFrame == 0xff then return nil end - local frames = Credits.BORDER_FRAMES[self.scene] or Credits.BORDER_FRAMES[0] + local sets = isCrystal() and Credits.BORDER_FRAMES_CRYSTAL + or Credits.BORDER_FRAMES + local frames = sets[self.scene] or sets[0] return frames[self.borderFrame + 1] end @@ -498,7 +561,7 @@ end -- scanlines at $1f and the eight at $67 -- which, with hLCDCPointer pointing -- at rSCX, is a horizontal slide of the two border strips and nothing else. function Credits:advanceLY() - self.lyOverride = (self.lyOverride + 2) % 256 + self.lyOverride = (self.lyOverride + layout().lyStep) % 256 end -------------------------------------------------------------------------- @@ -603,12 +666,25 @@ end -- index. The extracted table is 1-based, so scene 0 is set 1; the transcribed -- PALETTES above stay as the fallback for a cache without it. function Credits:palette() + if isCrystal() then return (self:palettes()) end local sets = self.gfx and self.gfx.palettes local extracted = sets and sets[self.scene % 4 + 1] if extracted then return extracted end return Credits.PALETTES[self.scene] or Credits.PALETTES[0] end +-- ../pokecrystal/engine/movie/credits.asm:501-514 +function Credits:palettes() + if not isCrystal() then + local pal = self:palette() + return pal, pal, pal + end + local sets = self.gfx and self.gfx.palettes + if not (sets and sets[12]) then sets = Credits.PALETTES_CRYSTAL end + local base = (self.scene % 4) * Credits.CRYSTAL_PALETTES_PER_SCENE + return sets[base + 1], sets[base + 2], sets[base + 3] +end + function Credits:image(path) if not path then return nil end local cached = self.images[path] @@ -625,7 +701,9 @@ end -- The icon standing in for this scene's 4x4 mon graphic, and which of its two -- 16x16 frames the border frame maps onto. function Credits:sceneIcon() - local species = Credits.SCENE_SPECIES[self.scene] + local names = isCrystal() and Credits.SCENE_SPECIES_CRYSTAL + or Credits.SCENE_SPECIES + local species = names[self.scene] local icons = self.icons if not (species and icons and icons.species and icons.icons) then return nil end local entry = icons.icons[icons.species[species]] @@ -704,10 +782,11 @@ end -- Rows 0-3 and 14-17: five copies of a 4x4-tile block across the screen. function Credits:drawBanner() - local pal = GbcPalette.resolve(self:palette()) + local banner = self:palettes() + local pal = GbcPalette.resolve(banner) local graphic = self:borderGraphic() local cell = BANNER_TILES * 8 - for _, row in ipairs(BANNER_ROWS) do + for _, row in ipairs(layout().bannerRows) do -- The cleared banner really is one flat colour: wCreditsBlankFrame2bpp is -- sixteen tiles of solid colour 2. local backdrop = graphic and (GbcPalette.color(pal, 1) or pal[1]) @@ -755,14 +834,16 @@ end -- `lyOverride % 32` is the whole of the slide. Row 4 starts at $24 and row 13 -- at $20: the two rows are different quarters of the 9-tile strip. function Credits:drawBorderStrips() - local pal = GbcPalette.resolve(self:palette()) + local _, strips = self:palettes() + local pal = GbcPalette.resolve(strips) local color = GbcPalette.color(pal, 3) or pal[3] local shift = self.lyOverride % 32 local gfx = self.gfx local image = gfx and gfx.border and self:image(gfx.border) - local starts = { [BORDER_ROWS[1]] = (gfx and gfx.borderTopTile) or 5, - [BORDER_ROWS[2]] = (gfx and gfx.borderBottomTile) or 1 } - for _, row in ipairs(BORDER_ROWS) do + local rows = layout().borderRows + local starts = { [rows[1]] = (gfx and gfx.borderTopTile) or 5, + [rows[2]] = (gfx and gfx.borderBottomTile) or 1 } + for _, row in ipairs(rows) do fill(color, 0, row * 8, SCREEN_W, 8) if image then local first = starts[row] @@ -795,7 +876,7 @@ function Credits:drawBorderStrips() end function Credits:drawText() - local pal = self:palette() + local _, _, pal = self:palettes() for _, entry in ipairs(self.shown) do if entry.theEnd then local image = self:theEndImage() @@ -825,7 +906,8 @@ function Credits:drawText() end function Credits:drawPanel() - local pal = GbcPalette.resolve(self:palette()) + local _, _, field = self:palettes() + local pal = GbcPalette.resolve(field) -- Rows 4-13 are $7f, the blank tile, which reads as colour 0. local paper = GbcPalette.color(pal, 1) or pal[1] fill(paper, 0, 0, SCREEN_W, SCREEN_H) @@ -845,11 +927,10 @@ end Credits.TILES_W = TILES_W Credits.TEXT_TOP_ROW = TEXT_TOP_ROW -Credits.TEXT_ROWS = TEXT_ROWS Credits.TEXT_FIRST_ROW = TEXT_FIRST_ROW Credits.LINE_SPACING = LINE_SPACING -Credits.BANNER_ROWS = BANNER_ROWS -Credits.BORDER_ROWS = BORDER_ROWS +Credits.LAYOUT = LAYOUT +Credits.layout = layout Credits.SKIP_AFTER_POS = SKIP_AFTER_POS Credits.ALLOW_SKIPPING_CREDITS_F = ALLOW_SKIPPING_CREDITS_F Credits.JUMPTABLE_EXIT_F = JUMPTABLE_EXIT_F diff --git a/src/ui/gen2/EvolutionAnim.lua b/src/ui/gen2/EvolutionAnim.lua index 80ad94be..eca843c5 100644 --- a/src/ui/gen2/EvolutionAnim.lua +++ b/src/ui/gen2/EvolutionAnim.lua @@ -60,9 +60,7 @@ local PARAGRAPH_FRAMES = 20 -- gfx/sgb/predef.pal PREDEFPAL_BLACKOUT, through the extractor's own 5-bit to -- 8-bit scale (floor(v * 255 / 31 + 0.5)). -local BLACKOUT = { - { 255, 255, 255 }, { 58, 58, 58 }, { 16, 25, 25 }, { 0, 0, 0 }, -} +local BLACKOUT = Palettes.BLACKOUT -- NUM_SPRITE_ANIM_STRUCTS. InitSpriteAnimStruct refuses once the pool is -- full, so the balls of light cap out at ten on screen no matter how many diff --git a/src/ui/gen2/ForgetMoveList.lua b/src/ui/gen2/ForgetMoveList.lua new file mode 100644 index 00000000..075bc800 --- /dev/null +++ b/src/ui/gen2/ForgetMoveList.lua @@ -0,0 +1,41 @@ +-- engine/pokemon/learn.asm:135-166 + +local Chrome = require("src.ui.gen2.Chrome") + +local ForgetMoveList = { + x = 5, y = 2, interiorW = 13, interiorH = 8, + cursorX = 6, nameX = 7, y0 = 4, step = 2, rows = 4, +} + +function ForgetMoveList.rowY(slot) + return ForgetMoveList.y0 + (slot - 1) * ForgetMoveList.step +end + +function ForgetMoveList.label(entry, moveData) + if not entry then return "-" end + local def = moveData and moveData[entry.id] + return (def and def.name) or entry.id +end + +function ForgetMoveList.draw(moves, cursor, moveData, palette) + Chrome.textbox(ForgetMoveList.x, ForgetMoveList.y, + ForgetMoveList.interiorW, ForgetMoveList.interiorH) + for slot = 1, ForgetMoveList.rows do + local ty = ForgetMoveList.rowY(slot) + local label = ForgetMoveList.label(moves and moves[slot], moveData) + if palette then + Chrome.printThrough(label, ForgetMoveList.nameX, ty, palette) + else + Chrome.print(label, ForgetMoveList.nameX, ty) + end + if slot == cursor then + if palette then + Chrome.cursorThrough(ForgetMoveList.cursorX, ty, palette) + else + Chrome.cursor(ForgetMoveList.cursorX, ty) + end + end + end +end + +return ForgetMoveList diff --git a/src/ui/gen2/MoveDeleter.lua b/src/ui/gen2/MoveDeleter.lua index e429a4ae..0a3ec7d1 100644 --- a/src/ui/gen2/MoveDeleter.lua +++ b/src/ui/gen2/MoveDeleter.lua @@ -14,6 +14,7 @@ -- answer a row. local Chrome = require("src.ui.gen2.Chrome") +local ForgetMoveList = require("src.ui.gen2.ForgetMoveList") local Sound = require("src.core.Sound") local MoveDeleter = {} @@ -29,6 +30,7 @@ function MoveDeleter.new(game, opts) local self = setmetatable({}, MoveDeleter) self.game = game local data = (game and game.data) or {} + self.forget = opts.layout == "forget" self.mon = opts.mon self.moves = opts.moves or data.moves self.onChoose = opts.onChoose @@ -74,10 +76,19 @@ function MoveDeleter:update(_dt) if input:wasPressed("a") or input:wasPressed("b") then self:finish(nil) end return end - if input:wasPressed("up") and self.row > 1 then - self.row = self.row - 1 - elseif input:wasPressed("down") and self.row < n then - self.row = self.row + 1 + -- engine/pokemon/learn.asm:160 + if input:wasPressed("up") then + if self.row > 1 then + self.row = self.row - 1 + elseif self.forget then + self.row = n + end + elseif input:wasPressed("down") then + if self.row < n then + self.row = self.row + 1 + elseif self.forget then + self.row = 1 + end elseif input:wasPressed("a") then self:playSfx("Sfx_ReadText2") self:finish(self.row) @@ -88,6 +99,11 @@ function MoveDeleter:update(_dt) end function MoveDeleter:draw() + if self.forget then + ForgetMoveList.draw(self.list, self.row, self.moves) + love.graphics.setColor(1, 1, 1, 1) + return + end Chrome.textbox(0, 1, 18, 9) for slot = 1, 4 do local nameY = 3 + (slot - 1) * 2 diff --git a/src/ui/gen2/PartyMenu.lua b/src/ui/gen2/PartyMenu.lua index 1caa3151..2ae1610f 100644 --- a/src/ui/gen2/PartyMenu.lua +++ b/src/ui/gen2/PartyMenu.lua @@ -26,6 +26,7 @@ local Mail = require("src.core.gen2.Mail") local Mon = require("src.battle.gen2.Mon") local Runtime = require("src.mods.Runtime") local Screens = require("src.ui.Screens") +local Sound = require("src.core.Sound") local PartyMenu = {} PartyMenu.__index = PartyMenu @@ -49,6 +50,9 @@ PartyMenu.PROMPTS = { -- SPRITE_ANIM cadence. local ICON_FRAME_STEPS = 16 +-- engine/items/item_effects.asm:1748 +PartyMenu.ACTION_TEXT_DELAY = 50 + -- data/mon_menu.asm MonMenuOptions' MONMENU_FIELD_MOVE rows, in table order: -- a move the mon knows that appears here gets a row of its own above the -- fixed options, named with GetMoveName rather than a menu string. @@ -489,10 +493,64 @@ function PartyMenu:updateSubmenu(input) end end +function PartyMenu:playSfx(name) + local data = self.game and self.game.data + local sfx = data and data.audio and data.audio.sfx + if sfx and sfx[Sound.resolve(data, name)] then Sound.play(data, name) end +end + +-- engine/items/item_effects.asm:1671 +function PartyMenu:showItemResult(slot, opts) + opts = opts or {} + self.itemResult = { + slot = slot, + shown = opts.fromHp, + target = opts.toHp, + text = opts.text, + delay = PartyMenu.ACTION_TEXT_DELAY, + onDone = opts.onDone, + } + if opts.sfx then self:playSfx(opts.sfx) end +end + +function PartyMenu:itemResultClimbing() + local r = self.itemResult + return r ~= nil and r.shown ~= nil and r.target ~= nil and r.shown ~= r.target +end + +-- engine/battle/anim_hp_bar.asm:246 +function PartyMenu:shownHpFor(slot, mon) + local r = self.itemResult + if r and r.slot == slot and r.shown then return r.shown end + return mon and mon.hp +end + +function PartyMenu:updateItemResult(input) + local r = self.itemResult + if self:itemResultClimbing() then + local mon = self.party[r.slot] + local maxHp = mon and (mon.maxHp or (mon.stats and mon.stats.hp)) or 0 + r.shown = HpBar.stepToward(r.shown, r.target, maxHp) + return + end + if r.delay > 0 then + r.delay = r.delay - 1 + return + end + if input:wasPressed("a") or input:wasPressed("b") then + self.itemResult = nil + if r.onDone then r.onDone() end + end +end + function PartyMenu:update(_dt) self.clock = self.clock + 1 local input = self.game and self.game.input if not input then return end + if self.itemResult then + self:updateItemResult(input) + return + end if self.submenu then self:updateSubmenu(input) return @@ -685,14 +743,15 @@ end -- the coordinate it is given, then six $62 bar cells, then the $6b end cap. So -- a party row's bar is the battle HUD's bar, tile for tile -- which is why this -- goes through BattleHud instead of drawing a rectangle. -function PartyMenu:drawHpBar(mon, tx, ty) +function PartyMenu:drawHpBar(mon, tx, ty, hp) local maxHp = mon.maxHp or (mon.stats and mon.stats.hp) + if hp == nil then hp = mon.hp end if self.hud and self.hud:available() then - return self.hud:drawHpBar(mon.hp, maxHp, tx, ty) + return self.hud:drawHpBar(hp, maxHp, tx, ty) end -- No battle-HUD sheet in the cache: the plain bar, two tiles in, so the -- fallback still lands where the cells would. - HpBar.draw(self.palettes, mon.hp, maxHp, (tx + 2) * 8, ty * 8 + 2) + HpBar.draw(self.palettes, hp, maxHp, (tx + 2) * 8, ty * 8 + 2) return tx + 2 + HpBar.LENGTH_TILES end @@ -705,8 +764,9 @@ end -- PlaceStatusString (engine/pokemon/mon_stats.asm): three letters, and a mon -- with no HP reads FNT whatever its status byte says. -local function statusString(mon) - if (mon.hp or 0) <= 0 then return "FNT" end +local function statusString(mon, hp) + if hp == nil then hp = mon.hp end + if (hp or 0) <= 0 then return "FNT" end local status = mon.status if not status then return nil end local class = ItemEffects.STATUS_CLASS[tostring(status):lower()] @@ -719,13 +779,14 @@ end -- name and an icon alone: no HP digits, no bar, no level, no FNT. The name -- itself is String_Egg -- GiveEgg writes "EGG" over the nickname slot -- so -- it never reads as the species hiding inside. -function PartyMenu.rowFor(mon) +function PartyMenu.rowFor(mon, hp) if mon.isEgg then return { name = "EGG" } end local maxHp = mon.maxHp or (mon.stats and mon.stats.hp) or 0 + if hp == nil then hp = mon.hp end return { name = mon.nickname or mon.name or mon.species or "?", - hp = num3(mon.hp) .. "/" .. num3(maxHp), - status = statusString(mon), + hp = num3(hp) .. "/" .. num3(maxHp), + status = statusString(mon, hp), -- is one font glyph ($6e), not the two characters ":L". level = "" .. tostring(mon.level or 1), } @@ -779,14 +840,15 @@ function PartyMenu:drawPanel() Chrome.cursor(0, nameY, true) end self:drawIcon(mon, self:iconX(i), 4 + (i - 1) * 16 + self:iconBob(i)) - local row = PartyMenu.rowFor(mon) + local hp = self:shownHpFor(i, mon) + local row = PartyMenu.rowFor(mon, hp) Chrome.print(row.name, 3, nameY) if self.tmhm then local able = self:tmhmAble(mon) if able then Chrome.print(able, 12, dataY) end else if row.hp then Chrome.print(row.hp, 13, nameY) end - if row.hp then self:drawHpBar(mon, 11, dataY) end + if row.hp then self:drawHpBar(mon, 11, dataY, hp) end end if row.status then Chrome.print(row.status, 5, dataY) end if row.level then Chrome.print(row.level, 8, dataY) end @@ -802,11 +864,19 @@ function PartyMenu:drawPanel() -- ReturnToMapWithSpeechTextbox restores the normal font afterwards, and so -- does this: the prompt is ordinary text. Font.useBattleExtra(wasBattle) - Chrome.box(0, 14, 20, 4) - -- SwitchPartyMons swaps the prompt for PARTYMENUACTION_MOVE's string while - -- the second pick is open, then puts the caller's own back. - local prompt = self.switchFrom and PartyMenu.PROMPTS.moveTo or self.prompt - Chrome.print(#self.party == 0 and PartyMenu.PROMPTS.none or prompt, 1, 16) + -- engine/pokemon/party_menu.asm:698 + if self.itemResult and self.itemResult.text and not self:itemResultClimbing() then + Chrome.textbox(0, 12, 18, 4) + local line = 14 + for part in tostring(self.itemResult.text):gmatch("[^\n]+") do + if line <= 16 then Chrome.print(part, 1, line) end + line = line + 2 + end + else + Chrome.box(0, 14, 20, 4) + local prompt = self.switchFrom and PartyMenu.PROMPTS.moveTo or self.prompt + Chrome.print(#self.party == 0 and PartyMenu.PROMPTS.none or prompt, 1, 16) + end -- PokemonActionSubmenu clears (1,15) 2x18 before MonSubmenu draws, so the -- prompt is gone behind the box rather than showing through it. if self.submenu then self:drawSubmenu() end diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 57605044..48de5d87 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -85,6 +85,25 @@ local HEAL_BALL_XY = { -- swaps the two middle shades of the monitor/ball art in place local HEAL_FLASH_MAP = { [0] = 0, [1] = 2, [2] = 1, [3] = 3 } +-- engine/overworld/healing_machine.asm:54 +local HEAL_FLASH_MAP_GBC = { [0] = 0, [1] = 0, [2] = 1, [3] = 2 } + +-- engine/overworld/healing_machine.asm:74 +local function healMachineShader(visible) + local base = PaletteFX.usesGbcPack() and PaletteFX.healMachineObp() or nil + if not base and visible then return nil end + local shader = PaletteFX.shader() + if not shader then return nil end + local colors = base or PaletteFX.GRAYS + if not visible then + colors = PaletteFX.permute(colors, + base and HEAL_FLASH_MAP_GBC or HEAL_FLASH_MAP) + end + PaletteFX.sendColors(shader, colors) + love.graphics.setShader(shader) + return shader +end + -- scripts/VermilionDock.asm:39 VermilionDockSSAnneLeavesScript: her hull is -- the four blocks (5..8, 1..2) of VermilionDock.blk local SS_ANNE_BLOCK = { x = 5, y = 1, w = 4, h = 2 } @@ -3600,7 +3619,8 @@ end -- caller already showed the box, so the battle starts without a second -- one (#869). function OverworldState:engageTrainer(npc, onDone, endBattleText, skipBattleText, - endBattleSound, endBattleIsReward) + endBattleSound, endBattleIsReward, + endBattleSoundPage) local d = npc.def Runtime.emit("world.trainer_engaged", { npc = npc, trainerClass = d.trainerClass, partyIndex = d.trainerParty }) @@ -3652,9 +3672,9 @@ function OverworldState:engageTrainer(npc, onDone, endBattleText, skipBattleText -- cuts (#282). Substituted here because BattleState:say takes finished -- text, while TextBox expanded the {PLAYER}/{RIVAL} tokens itself. battle.endBattleText = wonText and TextBox.substitute(Game, wonText) or nil - -- the badge jingle rides the armed line's first page on the battle - -- screen (sound_get_item_1 in _TX_PRE dialogue; see gyms.lua) (#1606) + -- scripts/PewterGym.asm:156-159 battle.endBattleSound = endBattleText ~= nil and endBattleSound or nil + battle.endBattleSoundPage = endBattleText ~= nil and endBattleSoundPage or nil -- one truth for both checkVictoryRewards call sites; endBattleIsReward -- = false marks an armed line that is NOT the victories dialogue (#1606) battle.rewardDialogueShown = endBattleText ~= nil @@ -5396,19 +5416,7 @@ function OverworldState:drawWorld() love.graphics.newQuad(0, 8, 8, 8, w, h), -- ball ($7d) } end - -- the jingle flash recolors the machine sprites in place - -- (FlashSprite8Times XORs rOBP1; the sprites never disappear): - -- ha.visible == false is the flashed half of each beat, drawn with - -- the light/dark shades swapped instead of skipped - local shader - if not ha.visible then - shader = PaletteFX.shader() - if shader then - PaletteFX.sendColors(shader, - PaletteFX.permute(PaletteFX.GRAYS, HEAL_FLASH_MAP)) - love.graphics.setShader(shader) - end - end + local shader = healMachineShader(ha.visible) -- TileRenderer windows with -floor(cam), so the overlay must use the -- same snap or a fractional camera (odd fill/tilt view sizes) parks -- the balls a pixel off the machine tiles diff --git a/src/world/gen2/Palettes.lua b/src/world/gen2/Palettes.lua index d7dfbed7..e6cf84e8 100644 --- a/src/world/gen2/Palettes.lua +++ b/src/world/gen2/Palettes.lua @@ -39,6 +39,10 @@ Palettes.OW_PALETTE_ID = { PAL_OW_PINK = 5, PAL_OW_EMOTE = 6, PAL_OW_TREE = 7, PAL_OW_ROCK = 8, } +Palettes.BLACKOUT = { + { 255, 255, 255 }, { 58, 58, 58 }, { 16, 25, 25 }, { 0, 0, 0 }, +} + -- Roofs are only recolored outdoors; LoadMapPals returns early for anything -- that is not TOWN or ROUTE, so an indoor map keeps the pool's roof palette. local ROOF_ENVIRONMENTS = { TOWN = true, ROUTE = true } diff --git a/src/world/gen2/Player.lua b/src/world/gen2/Player.lua index 94bc9057..eb62ae49 100644 --- a/src/world/gen2/Player.lua +++ b/src/world/gen2/Player.lua @@ -103,6 +103,7 @@ function Player:tryMove(dir, map, entities) if self.moving then return nil end if self.facing ~= dir then self.facing = dir + self.bumpFrames = nil if self.turnArmed then self.turnArmed = false self.turnTimer = TURN_FRAMES @@ -126,10 +127,14 @@ function Player:tryMove(dir, map, entities) if not allowed then -- World:movePlayer tells the two refusals apart: "edge" is what asks the -- connection table for the neighbouring map, "blocked" is a bump. + -- (engine/overworld/player_movement.asm:93-106, :525-531). + -- engine/overworld/movement.asm:315 + self.bumpFrames = 1 return why == "bounds" and "edge" or "blocked" end self.targetX, self.targetY = tx, ty self.moving = true + self.bumpFrames = nil self.progress = 0 return "moved" end @@ -149,6 +154,7 @@ function Player:scriptStep(dir) if not d then return false end self.targetX, self.targetY = self.cellX + d[1], self.cellY + d[2] self.moving = true + self.bumpFrames = nil self.progress = 0 return true end @@ -175,11 +181,22 @@ function Player:walkPhase() -- pokegold engine/overworld/map_objects.asm StepFunction_Turn: forces the -- walking leg frame for the whole 4-frame turn-in-place. if self.turnTimer > 0 then return 1 end - if not self.moving then return 0 end + if not self.moving then + -- map_object_action.asm:45-69 + if (self.bumpFrames or 0) <= 0 then return 0 end + return (math.floor(self.animClock / 8) % 2 == 1) and 1 or 0 + end local p = self.animClock % STEP_FRAMES return (p >= 4 and p < 12) and 1 or 0 end +-- map_object_action.asm:71-94 +function Player:drawFlip() + if self.moving or (self.bumpFrames or 0) <= 0 then return self.stepFlip end + local mirrored = math.floor(self.animClock / 16) % 2 == 1 + return self.stepFlip ~= mirrored +end + function Player:update() if self.turnTimer > 0 then self.turnTimer = self.turnTimer - 1 @@ -190,6 +207,11 @@ function Player:update() if self.spinFrames <= 0 then self.spinFrames = nil end end if not self.moving then + -- map_objects.asm:1517-1525 + if (self.bumpFrames or 0) > 0 then + self.bumpFrames = self.bumpFrames - 1 + self.animClock = self.animClock + 1 + end -- Re-arm turn-in-place once a poll finds no held direction (caller -- clears this while a dir is held; we only set it from idle). return false @@ -277,13 +299,14 @@ function Player:draw(ox, oy, scale) self:drawFishing(yOffset) else local facing, phase = self.facing, self:walkPhase() + local flip = self:drawFlip() -- OBJECT_ACTION_SPIN (map_object_action.asm:96-152), for step_dig. if self.spinFrames then facing = SPIN_FACINGS[math.floor(self.spinTimer / 4) % 4 + 1] phase = 0 end self.sprite:draw( - self.px, self.py + yOffset, 0, 0, facing, phase, self.stepFlip) + self.px, self.py + yOffset, 0, 0, facing, phase, flip) end G.pop() return diff --git a/src/world/gen2/World.lua b/src/world/gen2/World.lua index 107b51a8..74155b0b 100644 --- a/src/world/gen2/World.lua +++ b/src/world/gen2/World.lua @@ -2593,17 +2593,31 @@ function World:playMapMusic() end end --- RestartMapMusic, which replays wMapMusic -- and wMapMusic is MUSIC_BICYCLE --- for as long as the player is riding (.GetOnBike writes it there). So a --- battle, a hatch or a menu that owned the sound gives the BIKE theme back, --- not the map's own song. +-- data/maps/setup_scripts.asm:48 +-- home/audio.asm:335 +-- home/audio.asm:281 +function World:setMapMusic(mapId, seamless) + local data = self.game and self.game.data + local audio = data and data.audio + if not (audio and audio.runtime) then return end + local bike = not seamless + and FieldMoves.isBiking(self.playerState) + and self:playBikeMusic() + if bike then return end + Music.playMap(data, mapId, nil, + FieldMoves.isSurfing(self.playerState), + seamless and Music.MAP_FADE or nil, + self:mapMusicSong(mapId)) +end + +-- home/audio.asm:379 +-- home/audio.asm:281 function World:restoreMapMusic() local data = self.game and self.game.data if not data then return end - if FieldMoves.isBiking(self.playerState) and self:playBikeMusic() then - return - end - Music.restoreMap(data) + -- engine/events/overworld.asm:1627 + local reason = FieldMoves.isBiking(self.playerState) and "bike" or nil + Music.restoreMap(data, reason) end -- ForceMapMusic (engine/overworld/map_setup.asm:201), the music row every @@ -5006,6 +5020,7 @@ function World:playBikeMusic() if not (audio.songs and audio.songs[Bike.MUSIC_BICYCLE]) then return false end -- engine/events/overworld.asm:1621-1630 Music.stop() + Music.setMapSong(Bike.MUSIC_BICYCLE) Music.play(data, Bike.MUSIC_BICYCLE, true, { reason = "bike" }) return true end @@ -9327,16 +9342,7 @@ function World:setMap(mapId, cx, cy, facing, opts) self:rebuildPeople({ seamless = opts.seamless }) -- rebuildPeople may have pooled fresh NPCs; give them their colors too. self:applyPalettes() - local audio = self.game and self.game.data and self.game.data.audio - -- PlayMapMusicBike / SpecialMapMusic (home/audio.asm:335, :397): a biking - -- player keeps the bike theme; Music.play dedupes seamless edge crossings. - if audio and audio.runtime then - if not (FieldMoves.isBiking(self.playerState) and self:playBikeMusic()) then - Music.playMap(self.game.data, mapId, nil, - FieldMoves.isSurfing(self.playerState), nil, - self:mapMusicSong(mapId)) - end - end + self:setMapMusic(mapId, opts.seamless) -- Fires with the map fully built and BEFORE the map's own scene script, so a -- listener sees exactly the state that script does -- the position Gen 1's -- emit takes ahead of its onEnter chain. `via` carries the same four words @@ -9624,6 +9630,7 @@ function World:tryConnection(dir) p.facing = dir p.targetX, p.targetY = x, y p.moving = true + p.bumpFrames = nil p.progress = 0 FixedStep:discardCatchup() return true @@ -9687,6 +9694,7 @@ function World:tryLedgeJump(dir) p.targetX, p.targetY = tx, ty p.moving = true p.jumping = true + p.bumpFrames = nil -- JumpStep res IN_GRASS_F and calls neither UpdateTallGrassFlags nor -- ShakeGrass (engine/overworld/movement.asm:741-770). p.inGrass, p.grassShake = false, nil @@ -10496,6 +10504,8 @@ function World:stepBody() end if not dir then p.turnArmed = true + -- engine/overworld/player_movement.asm:108 + p.bumpFrames = nil return end if p.moving then return end diff --git a/tests/drivers/arena_boot_gen2_loopback.lua b/tests/drivers/arena_boot_gen2_loopback.lua index 524aeee6..d802631a 100644 --- a/tests/drivers/arena_boot_gen2_loopback.lua +++ b/tests/drivers/arena_boot_gen2_loopback.lua @@ -129,7 +129,7 @@ return function(game) } local guest, why = LinkBattle2.newGuest(guestGame, ctx.guestNet, { myParty = ctx.guestParty, - theirParty = ctx.hostParty, + theirParty = ctx.hostParty or (arena.spec and arena.spec.myParty), theirName = "GOLD", seed = ctx.seed, verdict = "full", diff --git a/tests/drivers/arena_boot_gen2_slot_spec.lua b/tests/drivers/arena_boot_gen2_slot_spec.lua new file mode 100644 index 00000000..52fdaf4b --- /dev/null +++ b/tests/drivers/arena_boot_gen2_slot_spec.lua @@ -0,0 +1,92 @@ +local Net = require("src.link.Net") +local ArenaBoot = require("src.online.ArenaBoot") +local Save = require("src.core.gen2.Save") +local SaveData = require("src.core.SaveData") + +local VERSION = os.getenv("POKEPORT_VERSION") or "gold" + +local hostNet, guestNet = Net.loopbackPair() + +local function mon(species, item) + return { + species = species, + level = 20, + experience = 8000, + hp = 60, + maxHp = 60, + stats = { hp = 60, attack = 30, defense = 30, speed = 30, + spAttack = 30, spDefense = 30 }, + moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }, + dvs = { attack = 15, defense = 15, speed = 15, special = 15 }, + statExp = { hp = 0, attack = 0, defense = 0, speed = 0, special = 0 }, + item = item, + happiness = 70, + ot = "GOLD", + otId = 1234, + } +end + +local SLOT_LABEL = "arena-slot-spec" + +local slotId +for _, row in ipairs(SaveData.listSlots(VERSION) or {}) do + if row.label == SLOT_LABEL then slotId = row.id break end +end +if not slotId then + slotId = SaveData.createSlot(VERSION) + if slotId then SaveData.renameSlot(VERSION, slotId, SLOT_LABEL) end +end +if slotId then SaveData.setActiveSlot(VERSION, slotId) end +local save = Save.newGame({ playerName = "GOLD" }) +save.version = VERSION +save.party = { mon("CYNDAQUIL", "LEFTOVERS") } +local wrote, writeErr = Save.save(save) +if not wrote then + error("arena slot spec could not write a save: " .. tostring(writeErr)) +end + +local guestParty = { { + species = "TOTODILE", + level = 20, + experience = 8000, + hp = 60, + moves = { { id = "TACKLE", pp = 35 } }, + dvs = { attack = 15, defense = 15, speed = 15, special = 15 }, + statExp = { hp = 0, attack = 0, defense = 0, speed = 0, special = 0 }, + happiness = 70, +} } +local seed = 515151 + +local ctx = { + hostNet = hostNet, + guestNet = guestNet, + hostParty = nil, + guestParty = guestParty, + seed = seed, + result = nil, +} +_G.POKEPORT_ARENA_TEST = ctx + +local spec, err = ArenaBoot.spec({ + profile = { + engine = 2, + version = VERSION, + engineVersion = "test", + apiVersion = 2, + fingerprint = "loopback", + rulesetId = "gen2", + kind = "vanilla", + rule = { partySize = 1 }, + }, + role = "host", + slotId = slotId or "slot1", + team = { 1 }, + seed = seed, + peerName = "SILVER", + theirParty = guestParty, + session = hostNet, + onDone = function(result) ctx.result = result or "ended" end, +}) + +if not spec then error("gen 2 arena spec invalid: " .. tostring(err)) end +return spec diff --git a/tests/drivers/battle_fanfare_speed_bug1952_test.lua b/tests/drivers/battle_fanfare_speed_bug1952_test.lua new file mode 100644 index 00000000..b0490966 --- /dev/null +++ b/tests/drivers/battle_fanfare_speed_bug1952_test.lua @@ -0,0 +1,83 @@ +-- home/vblank.asm:58-72 +-- home/delay.asm:14 +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or os.getenv("POKEPORT_SHOT_DIR") or "/tmp/shots" + local Pokemon = require("src.pokemon.Pokemon") + local Growth = require("src.pokemon.Growth") + local BattleState = require("src.battle.BattleState") + local Sound = require("src.core.Sound") + + game.speedOverride = 4 + + local squirtle = Pokemon.new(game.data, "SQUIRTLE", 5, function(_, b) return b end) + local def = game.data.pokemon.SQUIRTLE + squirtle.exp = Growth.expForLevel(def.growthRate, 6, game.data.growth_rates) - 1 + game.save.party = { squirtle } + + U.teleport(game, "ROUTE_1", 5, 5, "down") + local ow = game.overworld + + local battle = BattleState.newWild(game, "SLOWPOKE", 2) + battle.onFinish = function() end + battle.rng = function(a, _) return a end + battle.enemy.mon.hp = 1 + battle.enemy.mon.stats.speed = 1 + ow:pushBattle(battle) + + U.log("logic speed", game:logicSpeed(), "sfx rate", Sound.rate()) + + for _ = 1, 240 do + if battle.phase == "menu" then break end + U.tap(game, "a") + U.wait(3) + end + if battle.phase ~= "menu" then error("bug1952: never reached the FIGHT menu") end + + U.tap(game, "a") + for _ = 1, 60 do + if battle.phase == "moveSelect" then break end + U.wait(1) + end + if battle.phase ~= "moveSelect" then error("bug1952: never reached move select") end + U.tap(game, "a") + + local t0, dur, pitch, held + local shot = false + for _ = 1, 1200 do + U.wait(1) + if battle.waitingSound and not t0 then + local src = battle.waitingSound + t0 = love.timer.getTime() + local okd, d = pcall(src.getDuration, src) + local okp, p = pcall(src.getPitch, src) + dur = okd and d or nil + pitch = okp and p or nil + if not shot then + shot = U.shot(game, DIR .. "/bug1952_fanfare.png") + end + elseif t0 and not battle.waitingSound and not held then + held = love.timer.getTime() - t0 + break + end + U.tap(game, "a") + end + + if not t0 then error("bug1952: the level-up fanfare never armed the gate") end + if not held then error("bug1952: the fanfare gate never released") end + + U.log(("fanfare duration %.3fs pitch %s held %.3fs"):format( + dur or -1, tostring(pitch), held)) + U.shot(game, DIR .. "/bug1952_after.png") + + if dur and held > dur * 0.75 then + error(("bug1952: the 4X battle still held the full fanfare (%.3fs of %.3fs)") + :format(held, dur)) + end + if dur and pitch and pitch > 0 and held < (dur / pitch) * 0.8 then + error(("bug1952: the fanfare was cut short (%.3fs of a %.3fs pitched jingle)") + :format(held, dur / pitch)) + end + U.log("PASS the fanfare hold scaled with the logic clock") + love.event.quit() +end diff --git a/tests/drivers/crystal_credits_shots.lua b/tests/drivers/crystal_credits_shots.lua new file mode 100644 index 00000000..6c61cea8 --- /dev/null +++ b/tests/drivers/crystal_credits_shots.lua @@ -0,0 +1,53 @@ +-- ../pokecrystal/engine/movie/credits.asm:400-451 +local U = require("tests.drivers.util") + +local Credits = require("src.ui.gen2.Credits") + +local INTERVAL = 60 +local LIMIT = 8000 + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/crystal-credits" + + U.wait(45) + assert(game.world and game.world.map, "crystal world did not boot") + + local rolled = false + local credits = Credits.new(game, { + allowSkip = true, + onDone = function() rolled = true end, + }) + game.stack:clear() + game.stack:push(credits) + + local banner, strips, field = credits:palettes() + U.log(("palettes: banner %s strips %s field %s"):format( + table.concat(banner[1], ","), table.concat(strips[1], ","), + table.concat(field[1], ","))) + U.log(("layout: banners %d, strips %s, theEnd row %d"):format( + #Credits.layout().bannerRows, + table.concat(Credits.layout().borderRows, "/"), + Credits.layout().theEndY)) + + local shots, scene = 0, -1 + while not credits.exiting and credits.frames < LIMIT do + U.wait(INTERVAL) + if credits.scene ~= scene then + scene = credits.scene + U.log(("credits scene %d at frame %d (pass %d, pos %d)") + :format(scene, credits.frames, credits.passes, credits.pos)) + end + U.shot(game, ("%s/credits-%04d-scene%d.png") + :format(out, credits.frames, scene)) + shots = shots + 1 + end + assert(credits.exiting, "the credits script never reached CREDITS_END") + U.wait(30) + U.shot(game, ("%s/credits-%04d-theend.png"):format(out, credits.frames)) + U.log(("%d credits shots over %d frames, %d passes") + :format(shots + 1, credits.frames, credits.passes)) + U.tap(game, "a") + U.wait(10) + assert(rolled, "A did not leave the credits once the exit flag was up") + game.stack:clear() +end diff --git a/tests/drivers/crystal_evolution_music_bug1976.lua b/tests/drivers/crystal_evolution_music_bug1976.lua new file mode 100644 index 00000000..9c72fc1a --- /dev/null +++ b/tests/drivers/crystal_evolution_music_bug1976.lua @@ -0,0 +1,76 @@ +local U = require("tests.drivers.util") + +local Mon = require("src.battle.gen2.Mon") +local Music = require("src.core.Music") + +-- ../pokecrystal/engine/pokemon/evolve.asm:331 + +return function(game) + local fails = 0 + local function say(line) print("[1976] " .. line) end + local function ok(cond, line) + if not cond then fails = fails + 1 end + say((cond and "PASS " or "FAIL ") .. line) + end + + U.wait(60) + local world, save = game.world, game.save + if not (world and world.map and save) then + say("FAIL the crystal world did not boot") + love.event.quit(1) + return + end + + world:warpToMapId("ROUTE_29", 20, 8, "down") + U.wait(60) + local want = Music.current() + ok(want ~= nil, "the map theme is sounding (" .. tostring(want) .. ")") + + local function runEvolution(label, species, kick) + save.party = { Mon.new(game.data, species, 20) } + kick() + U.wait(10) + local menu = game.stack:top() + if menu and menu.showItemResult then + U.tap(game, "a") + U.wait(10) + end + local screen + for _ = 1, 240 do + local top = game.stack:top() + if top and top.phase and top.onDone then + screen = top + break + end + U.wait(1) + end + if not screen then + ok(false, label .. ": the evolution screen never opened") + return + end + local left = false + for _ = 1, 900 do + if game.stack:top() ~= screen then break end + if Music.current() ~= want then left = true end + U.tap(game, "a") + U.wait(4) + end + ok(left, label .. ": the map theme went down under the screen") + U.wait(30) + ok(Music.current() == want, label .. ": map music back (got " + .. tostring(Music.current()) .. ", want " .. tostring(want) .. ")") + end + + runEvolution("FIRE STONE", "EEVEE", function() + save.inventory = save.inventory or {} + save.inventory.FIRE_STONE = 1 + game:usePartyItem("FIRE_STONE") + end) + + runEvolution("RARE CANDY", "CYNDAQUIL", function() + game:afterRareCandy(save.party[1], { learned = {} }) + end) + + say(fails == 0 and "all claims passed" or (fails .. " claims failed")) + love.event.quit(fails == 0 and 0 or 1) +end diff --git a/tests/drivers/crystal_heal_item_bug1977.lua b/tests/drivers/crystal_heal_item_bug1977.lua new file mode 100644 index 00000000..19159cde --- /dev/null +++ b/tests/drivers/crystal_heal_item_bug1977.lua @@ -0,0 +1,63 @@ +local U = require("tests.drivers.util") + +local Mon = require("src.battle.gen2.Mon") + +-- ../pokecrystal/engine/items/item_effects.asm:1654 + +return function(game) + local shots = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/pokeport-shots" + local fails = 0 + local function say(line) print("[1977] " .. line) end + local function ok(cond, line) + if not cond then fails = fails + 1 end + say((cond and "PASS " or "FAIL ") .. line) + end + + U.wait(60) + local world, save = game.world, game.save + if not (world and world.map and save) then + say("FAIL the crystal world did not boot") + love.event.quit(1) + return + end + save.party = { Mon.new(game.data, "CYNDAQUIL", 30) } + world:warpToMapId("NEW_BARK_TOWN", 4, 6, "down") + U.wait(30) + + local mon = save.party[1] + local maxHp = mon.maxHp or (mon.stats and mon.stats.hp) + mon.hp = 1 + save.inventory = save.inventory or {} + save.inventory.HYPER_POTION = 5 + + game:usePartyItem("HYPER_POTION") + U.wait(10) + local menu = game.stack:top() + ok(menu ~= nil and menu.showItemResult ~= nil, "the party list opened") + if not (menu and menu.showItemResult) then + love.event.quit(1) + return + end + + U.tap(game, "a") + U.wait(4) + ok(menu.itemResult ~= nil, "the pick stayed inside the list") + ok(game.stack:top() == menu, "with the list still the top state") + U.shot(game, shots .. "/1977_bar_mid.png") + + U.wait(40) + ok(menu.itemResult ~= nil, "the list is still up after the climb") + ok(menu.itemResult == nil or not menu:itemResultClimbing(), + "the bar reached the healed value") + U.shot(game, shots .. "/1977_text.png") + + U.wait(60) + U.tap(game, "a") + U.wait(10) + ok(game.stack:top() ~= menu, "the button dropped the list") + ok(mon.hp == maxHp, "and the mon came out at full HP") + ok(save.inventory.HYPER_POTION == 4, "with one HYPER POTION spent") + + say(fails == 0 and "all claims passed" or (fails .. " claims failed")) + love.event.quit(fails == 0 and 0 or 1) +end diff --git a/tests/drivers/gen2_battle_intro_shots.lua b/tests/drivers/gen2_battle_intro_shots.lua new file mode 100644 index 00000000..d78ff720 --- /dev/null +++ b/tests/drivers/gen2_battle_intro_shots.lua @@ -0,0 +1,82 @@ +-- POKEPORT_VERSION=crystal POKEPORT_IDENTITY=crystal-aug29b POKEPORT_TOUCH=0 \ +-- POKEPORT_SHOT_DIR=/tmp/gen2-intro \ +-- POKEPORT_DRIVER=tests/drivers/gen2_battle_intro_shots.lua love . +-- ../pokecrystal/engine/battle/core.asm:8063 +-- engine/battle/core.asm:8733 +local U = require("tests.drivers.util") + +local Mon = require("src.battle.gen2.Mon") +local Trainers = require("src.world.gen2.Trainers") + +local OUT = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gen2-intro" + +local function rowState(screen) + local rows = screen.ballRows or {} + return ("slideFrame=%s gray=%s player=%s enemy=%s"):format( + tostring(screen.slideFrame), tostring(screen:introGrayscale()), + tostring(rows.player), tostring(rows.enemy)) +end + +local function seedParty(game) + local party = {} + for i = 1, 4 do party[i] = Mon.new(game.data, "CYNDAQUIL", 18 + i) end + party[3].status = "poison" + party[4].hp = 0 + return party +end + +local function openBattle(game, opts) + assert(game.world:startBattle(opts), "startBattle failed") + for _ = 1, 900 do + local top = game.stack:top() + if top and top.battle then return top end + U.wait(1) + end + error("battle screen never came up") +end + +local function shootIntro(game, screen, tag) + for i = 0, 6 do + U.shot(game, ("%s/%s-slide-%d.png"):format(OUT, tag, i)) + U.log(("[driver] %s slide %d: %s"):format(tag, i, rowState(screen))) + U.wait(10) + end + for _ = 1, 300 do + if screen.message then break end + U.wait(1) + end + U.wait(4) + U.shot(game, ("%s/%s-startline.png"):format(OUT, tag)) + U.log(("[driver] %s start line %q: %s"):format(tag, + tostring(screen.message), rowState(screen))) +end + +local function drainBattle(game) + for _ = 1, 900 do + if not (game.stack:top() or {}).battle then return end + U.tap(game, "a") + end +end + +return function(game) + U.wait(45) + assert(game.world and game.world.map, "gen2 world did not boot") + game.save.inventory = {} + + game.save.party = seedParty(game) + local screen = openBattle(game, { wild = Mon.new(game.data, "PIDGEY", 5) }) + shootIntro(game, screen, "wild") + drainBattle(game) + + local entry = game.world:trainerParty(36, 1) + if entry then + entry.party = Trainers.party(game.data, entry) + game.save.party = seedParty(game) + screen = openBattle(game, { trainer = entry }) + shootIntro(game, screen, "trainer") + else + U.log("[driver] no trainer 36/1 in this cache; wild shots only") + end + + U.log("[driver] shots in " .. OUT .. " -- the battle is yours") +end diff --git a/tests/drivers/gen2_bike_connection_music_bug1975_test.lua b/tests/drivers/gen2_bike_connection_music_bug1975_test.lua new file mode 100644 index 00000000..b1f58be4 --- /dev/null +++ b/tests/drivers/gen2_bike_connection_music_bug1975_test.lua @@ -0,0 +1,84 @@ +-- data/maps/setup_scripts.asm:89 +local U = require("tests.drivers.util") + +local FieldMoves = require("src.world.gen2.FieldMoves") +local Music = require("src.core.Music") +local Permissions = require("src.world.gen2.Permissions") + +return function(game) + local DIR = os.getenv("SHOT_DIR") or os.getenv("POKEPORT_SHOT_DIR") + or "/tmp/bike-connection-1975" + local fails = 0 + local function say(line) print("[1975] " .. line) end + local function ok(cond, line) + if not cond then fails = fails + 1 end + say((cond and "PASS " or "FAIL ") .. line) + end + + U.wait(60) + local world = game.world + if not (world and world.map) then + say("FAIL the gen2 world did not boot") + love.event.quit(1) + return + end + + world:warpToMapId("ROUTE_29", 20, 8, "left") + U.wait(45) + ok(world.map.id == "ROUTE_29", "landed on Route 29 (got " + .. tostring(world.map.id) .. ")") + + local startX, startY = 7, nil + local rows = (world.map.height or 9) * 2 + for y = 0, rows - 1 do + local clear = true + for x = 0, startX do + if not Permissions.isWalkable(world.map:cellCollision(x, y)) then + clear = false + break + end + end + if clear and not startY then startY = y end + end + if not startY then + say("FAIL no walkable row runs to Route 29's west edge") + love.event.quit(1) + return + end + world:warpToMapId("ROUTE_29", startX, startY, "left") + U.wait(45) + say("standing at " .. startX .. "," .. startY .. " on " .. tostring(world.map.id)) + + world:applyPlayerState(FieldMoves.PLAYER_BIKE) + world:playBikeMusic() + U.wait(10) + ok(Music.current() == "Music_Bicycle", "riding to the bike theme (got " + .. tostring(Music.current()) .. ")") + U.shot(game, DIR .. "/1975-before.png") + + local from = world.map.id + local crossed = false + for _ = 1, 60 do + U.hold(game, "left", 16) + if world.map.id ~= from then crossed = true break end + end + ok(crossed, "crossed the west connection (now on " + .. tostring(world.map.id) .. ")") + + U.wait(8 * Music.MAP_FADE) + local mapSongs = game.data.audio and game.data.audio.mapSongs + local want = world:mapMusicSong(world.map.id) + or (mapSongs and mapSongs[world.map.id]) + say("destination song is " .. tostring(want) + .. ", playing " .. tostring(Music.current())) + ok(Music.current() ~= "Music_Bicycle", + "the bike theme did not survive the crossing") + ok(Music.current() == want, + "the destination map's own theme is playing") + ok(Music.mapSong() == Music.current(), + "FadeToMapMusic left wMapMusic on the new song") + U.shot(game, DIR .. "/1975-after.png") + + say(fails == 0 and "ALL PASS" or (fails .. " FAILURES")) + love.event.quit(fails == 0 and 0 or 1) +end diff --git a/tests/drivers/gen2_bump_walk_in_place_bug1978_test.lua b/tests/drivers/gen2_bump_walk_in_place_bug1978_test.lua new file mode 100644 index 00000000..003fb64e --- /dev/null +++ b/tests/drivers/gen2_bump_walk_in_place_bug1978_test.lua @@ -0,0 +1,77 @@ +-- engine/overworld/player_movement.asm:93-110 +local U = require("tests.drivers.util") + +local Permissions = require("src.world.gen2.Permissions") + +return function(game) + local DIR = os.getenv("SHOT_DIR") or os.getenv("POKEPORT_SHOT_DIR") + or "/tmp/bump-anim-1978" + local fails = 0 + local function say(line) print("[1978] " .. line) end + local function ok(cond, line) + if not cond then fails = fails + 1 end + say((cond and "PASS " or "FAIL ") .. line) + end + + U.wait(60) + local world = game.world + if not (world and world.map) then + say("FAIL the gen2 world did not boot") + love.event.quit(1) + return + end + + world:warpToMapId("CHERRYGROVE_CITY", 20, 10, "down") + U.wait(45) + local p = world.player + local map = world.map + + local px, py + for y = 0, (map.height or 9) * 2 - 1 do + for x = 0, (map.width or 20) * 2 - 2 do + if Permissions.isWalkable(map:cellCollision(x, y)) + and not Permissions.isWalkable(map:cellCollision(x + 1, y)) then + px, py = x, y + break + end + end + if px then break end + end + if not px then + say("FAIL no cell with a wall to its east") + love.event.quit(1) + return + end + world:warpToMapId("CHERRYGROVE_CITY", px, py, "right") + U.wait(45) + say("bumping east from " .. px .. "," .. py) + + local phases, moved = {}, false + local shots = {} + for f = 1, 40 do + table.insert(game.input.pressQueue, "right") + game.input.state.right = true + coroutine.yield() + if p.moving then moved = true end + phases[p:walkPhase()] = true + if f == 4 or f == 11 then + shots[#shots + 1] = DIR .. "/1978-frame" .. f .. ".png" + U.shot(game, shots[#shots]) + end + end + game.input.state.right = false + ok(not moved, "the wall never let the step start") + ok(phases[0] and phases[1], + "both the standing and the walking pose were drawn while stuck") + ok(#shots == 2, "captured the two poses") + + U.wait(2) + -- engine/overworld/player_movement.asm:108 + ok(p:walkPhase() == 0, "the pose stands on the frame after the release") + ok((p.bumpFrames or 0) == 0, "with no bump left to run out") + U.wait(40) + ok(p:walkPhase() == 0, "and stays standing") + + say(fails == 0 and "ALL PASS" or (fails .. " FAILURES")) + love.event.quit(fails == 0 and 0 or 1) +end diff --git a/tests/drivers/gold_fanfare_speed_bug1952_test.lua b/tests/drivers/gold_fanfare_speed_bug1952_test.lua new file mode 100644 index 00000000..93948288 --- /dev/null +++ b/tests/drivers/gold_fanfare_speed_bug1952_test.lua @@ -0,0 +1,81 @@ +-- ../pokecrystal/home/vblank.asm:58-72 +-- ../pokecrystal/home/delay.asm:14 +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or os.getenv("POKEPORT_SHOT_DIR") or "/tmp/shots" + local Sound = require("src.core.Sound") + + U.wait(45) + if not (game.world and game.world.map) then + error("bug1952: the gen 2 world never booted") + end + + local vol = game.options and game.options.sfxVol + if vol == 0 then error("bug1952: SFX VOL is 0, nothing to measure") end + + U.wait(2) + if Sound.rate() ~= 1 then + error(("bug1952: the rate was %s before any fast-forward"):format( + tostring(Sound.rate()))) + end + + game.speedOverride = 4 + U.wait(2) + U.log("gen2 speed override", 4, "sfx rate", Sound.rate()) + if Sound.rate() ~= 4 then + error(("bug1952: Game2:update never drove Sound.setRate (rate %s at 4X)") + :format(tostring(Sound.rate()))) + end + + local name = "Sfx_CaughtMon" + local src = Sound.play(game.data, name) + if not src then error("bug1952: " .. name .. " did not play") end + local okd, dur = pcall(src.getDuration, src) + local okp, pitch = pcall(src.getPitch, src) + if not (okd and dur) then error("bug1952: no duration for " .. name) end + U.log(("%s duration %.3fs pitch %s"):format(name, dur, tostring(pitch))) + if not (okp and pitch == 4) then + error(("bug1952: the gen 2 jingle played at natural pitch (%s)") + :format(tostring(pitch))) + end + + local budget = Sound.waitFramesFor(name) + local want = math.ceil(dur * 60) + 2 + U.log(("gate budget %d logic frames, hardware frames %d"):format(budget, want)) + if budget ~= want then + error(("bug1952: the gen 2 gate budgets %d frames for a %d frame jingle") + :format(budget, want)) + end + + U.shot(game, DIR .. "/bug1952_gold_fanfare.png") + + local t0, held = love.timer.getTime(), nil + for _ = 1, 1200 do + U.wait(1) + if not Sound.isPlaying(name) then + held = love.timer.getTime() - t0 + break + end + end + if not held then error("bug1952: the gen 2 jingle never ended") end + U.log(("held %.3fs of a %.3fs jingle pitched to %s"):format(held, dur, pitch)) + if held > dur * 0.75 then + error(("bug1952: the 4X jingle still ran the full length (%.3fs of %.3fs)") + :format(held, dur)) + end + if held < (dur / pitch) * 0.7 then + error(("bug1952: the jingle was cut short (%.3fs of a %.3fs pitched jingle)") + :format(held, dur / pitch)) + end + + game.speedOverride = nil + U.wait(2) + if Sound.rate() ~= 1 then + error(("bug1952: dropping back to 1X left the rate at %s") + :format(tostring(Sound.rate()))) + end + + U.shot(game, DIR .. "/bug1952_gold_after.png") + U.log("PASS the gen 2 jingle scaled with the logic clock") + love.event.quit() +end diff --git a/tests/drivers/gold_forget_move_bug1980.lua b/tests/drivers/gold_forget_move_bug1980.lua new file mode 100644 index 00000000..f7e0979f --- /dev/null +++ b/tests/drivers/gold_forget_move_bug1980.lua @@ -0,0 +1,80 @@ +local U = require("tests.drivers.util") + +local Mon = require("src.battle.gen2.Mon") +local Screens = require("src.ui.Screens") + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-forget" + + U.wait(45) + local world = game.world + assert(world and world.map, "gold world did not boot") + + local player = Mon.new(game.data, "TOTODILE", 30) + assert(player, "could not build a TOTODILE") + player.moves = { + { id = "SURF", pp = 15, maxPp = 15 }, + { id = "SCRATCH", pp = 35, maxPp = 35 }, + { id = "LEER", pp = 30, maxPp = 30 }, + { id = "RAGE", pp = 20, maxPp = 20 }, + } + game.save.party = { player } + + Screens.push(game, "Gen2MoveDeleter", { + mon = player, + moves = game.data.moves, + layout = "forget", + onChoose = function() game.stack:pop() end, + onCancel = function() game.stack:pop() end, + }) + U.wait(6) + U.shot(game, out .. "/1980-overworld-list.png") + U.tap(game, "down") + U.wait(4) + U.shot(game, out .. "/1980-overworld-list-row2.png") + U.tap(game, "b") + U.wait(6) + + assert(world:startBattle({ wild = Mon.new(game.data, "RATTATA", 5) }), + "startBattle failed") + local st + for _ = 1, 900 do + local top = game.stack:top() + if top and top.battle then st = top break end + U.wait(1) + end + assert(st and st.battle, "battle screen is not on the stack") + for _ = 1, 1800 do + if st.phase == "menu" then break end + if (st.messageTimer or 0) > 0 and not st.anim then + U.tap(game, "a") + else + U.wait(1) + end + end + assert(st.phase == "menu", "battle never reached the command menu") + + st.pendingLearn = { index = 1, move = "ICE_PUNCH", moveName = "ICE PUNCH" } + st.phase = "choose-forget" + st.forgetIndex = 1 + st.messageTimer = 0 + st.message = "Which move should\nbe forgotten?" + U.wait(6) + U.shot(game, out .. "/1980-battle-list.png") + U.tap(game, "down") + U.wait(4) + U.shot(game, out .. "/1980-battle-list-row2.png") + + st.forgetIndex = 1 + U.tap(game, "a") + U.wait(6) + U.shot(game, out .. "/1980-battle-hm-refusal.png") + print("[driver] message after picking the HM slot: " + .. tostring(st.message):gsub("\n", " / ")) + U.tap(game, "a") + U.wait(6) + U.shot(game, out .. "/1980-battle-list-reprint.png") + + print("[driver] PASS forget-move shots in " .. out) + love.event.quit() +end diff --git a/tests/drivers/gold_magnitude_anim_bug1979.lua b/tests/drivers/gold_magnitude_anim_bug1979.lua new file mode 100644 index 00000000..89acf522 --- /dev/null +++ b/tests/drivers/gold_magnitude_anim_bug1979.lua @@ -0,0 +1,97 @@ +local U = require("tests.drivers.util") + +local Mon = require("src.battle.gen2.Mon") + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-magnitude" + + U.wait(45) + local world = game.world + assert(world and world.map, "gold world did not boot") + + local player = Mon.new(game.data, "CYNDAQUIL", 40) + assert(player, "could not build a CYNDAQUIL") + player.moves = { { id = "MAGNITUDE", pp = 30, maxPp = 30 } } + game.save.party = { player } + local wild = Mon.new(game.data, "RATTATA", 30) + assert(world:startBattle({ wild = wild }), "startBattle failed") + + local st + for _ = 1, 900 do + local top = game.stack:top() + if top and top.battle then st = top break end + U.wait(1) + end + assert(st and st.battle, "battle screen is not on the stack") + + for _ = 1, 1800 do + if st.phase == "menu" then break end + if (st.messageTimer or 0) > 0 and not st.anim then + U.tap(game, "a") + else + U.wait(1) + end + end + assert(st.phase == "menu", "battle never reached the command menu") + U.tap(game, "a") + for _ = 1, 120 do + if st.phase == "moves" then break end + U.wait(1) + end + assert(st.phase == "moves", "FIGHT never opened the move list") + U.tap(game, "a") + + local badFrames, animOnNumber, sawUsedLine, sawNumberLine = 0, false, false, false + local shotUsed, shotNumber = false, false + local lastMsg + for _ = 1, 1200 do + local msg = st.message or "" + local anim = st.anim ~= nil + if msg ~= lastMsg then + lastMsg = msg + print("[driver] msg: " .. msg:gsub("\n", " / ") .. " anim=" .. tostring(anim)) + end + if msg:match("used MAGNITUDE") then + sawUsedLine = true + if anim then badFrames = badFrames + 1 end + if not shotUsed then + shotUsed = U.shot(game, out .. "/1979-used-line.png") + end + elseif msg:match("^Magnitude %d") then + sawNumberLine = true + if anim then + animOnNumber = true + if not shotNumber then + shotNumber = U.shot(game, out .. "/1979-magnitude-line.png") + end + end + end + if st.battle.over then break end + if (st.messageTimer or 0) > 0 and not anim then + U.tap(game, "a") + else + U.wait(1) + end + end + + local ok = true + if not sawUsedLine then + print("[driver] FAIL never saw the used-move line") ok = false + end + if not sawNumberLine then + print("[driver] FAIL never saw the Magnitude N! line") ok = false + end + if badFrames > 0 then + print(("[driver] FAIL animation ran on the used-move line for %d frames") + :format(badFrames)) + ok = false + end + if not animOnNumber then + print("[driver] FAIL no animation while Magnitude N! was on screen") + ok = false + end + if ok then + print("[driver] PASS magnitude animates on its own line, shots in " .. out) + end + love.event.quit() +end diff --git a/tests/drivers/gold_transition_shots.lua b/tests/drivers/gold_transition_shots.lua index f4589131..04ff7d94 100644 --- a/tests/drivers/gold_transition_shots.lua +++ b/tests/drivers/gold_transition_shots.lua @@ -22,6 +22,7 @@ return function(game) U.wait(20) local function run(style, trainer) + local tag = style .. (trainer and "-trainer" or "-wild") local done = false game.stack:push(Transition.new(game, { world = world, @@ -40,14 +41,14 @@ return function(game) if want then shots = shots + 1 U.shot(game, ("%s/%s/%s-%02d.png") - :format(out, style, outro and "outro" or "flash", shots)) + :format(out, tag, outro and "outro" or "flash", shots)) end if outro then outroShots = outroShots + 1 end if done then break end U.wait(1) end - print(("[driver] %-8s %d shots, finished=%s") - :format(style, shots, tostring(done))) + print(("[driver] %-16s %d shots, finished=%s") + :format(tag, shots, tostring(done))) -- The state pops itself; if it did not, take it off so the next run starts -- from a clean stack. if not done then game.stack:pop() end @@ -55,9 +56,10 @@ return function(game) end run("spin", true) - run("speckle", false) - run("sine", false) - run("zoom", false) + run("spin", false) + run("speckle", true) + run("sine", true) + run("zoom", true) print("[driver] shots in " .. out) love.event.quit() diff --git a/tests/drivers/gym_leader_victory_test.lua b/tests/drivers/gym_leader_victory_test.lua index 075adb8e..34f94d99 100644 --- a/tests/drivers/gym_leader_victory_test.lua +++ b/tests/drivers/gym_leader_victory_test.lua @@ -97,6 +97,8 @@ return function(game) "the battle carries the armed badge line (#1606)") assert(battle.endBattleSound == "Get_Item1", "and the badge jingle beside it (sound_level_up, PewterGym.asm)") + assert(battle.endBattleSoundPage == 3, + "the jingle is armed for the BOULDERBADGE page (PewterGym.asm:156)") U.shot(game, DIR .. "/brock_real_battle.png") U.log("force-finishing Brock's battle to run the reward chain") battle.onFinish("win") diff --git a/tests/drivers/heal_test.lua b/tests/drivers/heal_test.lua index 19158252..00419114 100644 --- a/tests/drivers/heal_test.lua +++ b/tests/drivers/heal_test.lua @@ -3,6 +3,11 @@ return function(game) local U = dofile("tests/drivers/util.lua") local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local COLORS = os.getenv("HEAL_COLORS") + if COLORS then + require("src.render.PaletteFX").setMode(COLORS) + U.log("COLORS mode:", COLORS) + end local Pokemon = require("src.pokemon.Pokemon") local ChoiceBox = require("src.ui.ChoiceBox") local TextBox = require("src.render.TextBox") diff --git a/tests/drivers/magikarp_money_bug1983_test.lua b/tests/drivers/magikarp_money_bug1983_test.lua new file mode 100644 index 00000000..d0c163ec --- /dev/null +++ b/tests/drivers/magikarp_money_bug1983_test.lua @@ -0,0 +1,58 @@ +-- scripts/MtMoonPokecenter.asm:30-34 +-- SHOT_DIR=/tmp/karp_shots POKEPORT_DRIVER=tests/drivers/magikarp_money_bug1983_test.lua love . + +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or os.getenv("POKEPORT_SHOT_DIR") + or "/tmp/karp_shots" + local TextBox = require("src.render.TextBox") + local ChoiceBox = require("src.ui.ChoiceBox") + + local function topIs(cls) + return getmetatable(game.stack:top()) == cls + end + + local function findBox() + for i = #game.stack.states, 1, -1 do + if getmetatable(game.stack.states[i]) == TextBox then + return game.stack.states[i] + end + end + end + + game.save.money = 5326 + game.save.flags.EVENT_BOUGHT_MAGIKARP = nil + U.teleport(game, "MT_MOON_POKECENTER", 10, 7, "up") + U.wait(10) + U.shot(game, DIR .. "/karp_00_center.png") + + local box + for _ = 1, 40 do + U.tap(game, "a") + U.wait(4) + box = findBox() + if box then break end + U.hold(game, "up", 2) + end + if not box then + U.log("FAIL: salesman never opened a text box") + return + end + + U.log("page1 moneyVisible:", box:moneyVisible()) + U.shot(game, DIR .. "/karp_01_page1.png") + + for _ = 1, 400 do + if topIs(ChoiceBox) then break end + if box.done and not box.choicePushed then U.wait(1) end + U.tap(game, "a") + U.wait(2) + end + U.log("choice up:", topIs(ChoiceBox), "moneyVisible:", box:moneyVisible()) + U.shot(game, DIR .. "/karp_02_choice.png") + + U.tap(game, "b") + U.wait(30) + U.shot(game, DIR .. "/karp_03_declined.png") + U.log("done") +end diff --git a/tests/drivers/online_relay_smoke.lua b/tests/drivers/online_relay_smoke.lua index 7ab13149..99e4d8cb 100644 --- a/tests/drivers/online_relay_smoke.lua +++ b/tests/drivers/online_relay_smoke.lua @@ -183,6 +183,33 @@ return function(game) end check(watching.error == nil, "a matching spectator is admitted: " .. tostring(watching.error)) + + local function rejoin(profile) + Watcher.leaveRoom() + pump3(10) + local pending = profile and Watcher.joinRoom(room.code, "spectator", profile) + or Watcher.joinRoom(room.code, "spectator") + for _ = 1, 600 do + if pending.done then break end + pump3(1) + end + return pending + end + + Watcher.setProfiles({ badProfile }) + local stale = rejoin(nil) + check(stale.reason == "profile_mismatch", + "a join with no profile argument falls back to the stale snapshot " .. + "and is refused: " .. tostring(stale.reason)) + local fresh = rejoin(PROFILE) + check(fresh.error == nil, + "the same session joins once the picked profile is passed: " .. + tostring(fresh.error)) + Watcher.setProfiles({}) + local blind = rejoin(nil) + check(blind.reason == "bad_profile", + "a join with no profile at all is refused as bad_profile: " .. + tostring(blind.reason)) Watcher.disconnect() pump(5) diff --git a/tests/drivers/online_shot.lua b/tests/drivers/online_shot.lua index 185c9bef..fe73ad9c 100644 --- a/tests/drivers/online_shot.lua +++ b/tests/drivers/online_shot.lua @@ -397,6 +397,18 @@ STATES["room-result"] = function(OnlinePanel, imp) OnlinePanel.lastResult = "win" end +STATES["room-refused"] = function(OnlinePanel, imp) + local client = goOnline(OnlinePanel, imp, 4) + client.room = function() return nil end + ready(OnlinePanel, imp, 3) + OnlinePanel.home(imp) + OnlinePanel.go(imp, "play") + OnlinePanel.go(imp, "room") + local st = OnlinePanel.state(imp) + st.status, st.statusOk = "Your game doesn't match the room. " .. + "(game version differs: the room has crystal, you have red)", false +end + STATES["room-trade"] = function(OnlinePanel, imp) local client = goOnline(OnlinePanel, imp, 4) client.room = function() diff --git a/tests/drivers/route.lua b/tests/drivers/route.lua index f0761dff..e68b340e 100644 --- a/tests/drivers/route.lua +++ b/tests/drivers/route.lua @@ -2348,7 +2348,7 @@ local DEFAULT_STOCK = { { "POTION", 10 } } -- -- It does NOT follow the list convention cursorTo implements. On a list, -- up moves toward index 1; on a QuantityBox, up *increases* the count and --- down decreases it, both wrapping around 1..max (QuantityBox.lua:29-33). +-- down decreases it, both wrapping around 1..max (QuantityBox.lua:36-39). -- Handing it cursorTo therefore walked the wrong way and then oscillated -- between 1 and max forever, so buyItem gave up and bought nothing -- the -- exception being a target exactly one wrap below max, which is why the @@ -2475,6 +2475,13 @@ local function buyItem(id, qty, where) say(("shop: could not reach the %s row"):format(id)) return false end + local price = ((G.data.items or {})[id] or {}).price or 0 + local afford = price > 0 and math.floor((G.save.money or 0) / price) or qty + if afford < 1 then + say(("shop: cannot afford one %s (¥%d of ¥%d)") + :format(id, G.save.money or 0, price)) + return false + end press("a") U.wait(6) if not isQty() then @@ -2482,8 +2489,7 @@ local function buyItem(id, qty, where) say(("shop: no quantity box opened for %s"):format(id)) return false end - -- QuantityBox caps at .max (what we can afford), so never ask for more - local want = math.min(qty, top().max or qty) + local want = math.max(1, math.min(qty, top().max or qty, afford)) if not qtyTo(want) then note("shop: could not set the quantity for " .. id, where) say(("shop: stuck setting %s quantity to %d on %s"):format(id, want, diff --git a/tests/drivers/shop_test.lua b/tests/drivers/shop_test.lua index e8ad8a40..f94ccb74 100644 --- a/tests/drivers/shop_test.lua +++ b/tests/drivers/shop_test.lua @@ -39,6 +39,12 @@ return function(game) U.tap(game, "a") -- first item U.wait(8) U.log(tag, "qty:", topIs(QuantityBox)) + U.tap(game, "down") -- engine/events/pokemart.asm:152-153 + U.wait(6) + U.log(tag, "qty max:", game.stack:top().qty) + U.shot(game, ("%s/%s_0b_qty99.png"):format(DIR, tag)) + U.tap(game, "up") + U.wait(6) U.tap(game, "a") -- x01 U.wait(8) U.log(tag, "confirm:", topIs(ChoiceBox)) diff --git a/tests/engine/arena_gen2_slot_bug1944.lua b/tests/engine/arena_gen2_slot_bug1944.lua new file mode 100644 index 00000000..f05f4569 --- /dev/null +++ b/tests/engine/arena_gen2_slot_bug1944.lua @@ -0,0 +1,71 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") + +local T = require("tests.harness") +local Game2 = require("src.core.Game2") +local Save = require("src.core.gen2.Save") +local ArenaBoot = require("src.online.ArenaBoot") + +package.loaded["src.ui.gen2.ArenaState"] = { + new = function(game, spec) return { game = game, spec = spec } end, +} + +local function mon(species) + return { + species = species, level = 50, hp = 100, + moves = { { id = "TACKLE", pp = 35, ppUps = 0 } }, + dvs = { attack = 15, defense = 15, speed = 15, special = 15, hp = 15 }, + statExp = {}, experience = 125000, + } +end + +local function fakeStack() + return { + pushed = {}, + clear = function(self) self.pushed = {} end, + push = function(self, state) self.pushed[#self.pushed + 1] = state end, + } +end + +local function host(loaded) + local savedLoad = Save.load + Save.load = function() return loaded end + local game = setmetatable({ + stack = fakeStack(), + options = { text = 3 }, + data = { pokemon = {} }, + save = Save.newGame({ playerName = "GOLD" }), + }, { __index = Game2 }) + game:enterArena({ slotId = "slot1", role = "host", team = {}, + profile = { engine = 2, version = "gold", + rule = { partySize = 3, forceLevel = 50 } } }) + Save.load = savedLoad + return game +end + +local slot = Save.newGame({ playerName = "GOLD" }) +slot.party = { mon("CHIKORITA"), mon("TOTODILE") } + +local game = host(slot) +T.eq(game.save, slot, "entering a Gen 2 arena adopts the slot it was given") +T.eq(#(game.save.party or {}), 2, "so the party is the save's, not the skeleton's") +T.eq(game.save.options, game.options, "with the launcher's option block kept") +T.eq(#game.stack.pushed, 1, "and the arena state is on a cleared stack") + +local spec = { role = "host", team = {}, + profile = { engine = 2, version = "gold", + rule = { partySize = 3, forceLevel = 50 } } } +local packed, err = ArenaBoot.packOwnParty(game, spec) +T.check(packed ~= nil, "packOwnParty finds a party to send: " .. tostring(err)) +T.eq(packed and #packed, 2, "both party members go out") +T.eq(packed and packed[1].species, "CHIKORITA", "led by the first slot") + +local empty = host(nil) +T.check(empty.save ~= nil, "an unreadable slot leaves the boot skeleton alone") +local none, why = ArenaBoot.packOwnParty(empty, + { role = "host", team = {}, + profile = { engine = 2, version = "gold", rule = { partySize = 3 } } }) +T.eq(none, nil, "an empty party still refuses to send") +T.eq(why, "no party to send", "with the message the arena screen draws") + +T.finish("arena gen2 slot bug1944") diff --git a/tests/engine/crystal_credits_bug1967.lua b/tests/engine/crystal_credits_bug1967.lua new file mode 100644 index 00000000..2f5bb439 --- /dev/null +++ b/tests/engine/crystal_credits_bug1967.lua @@ -0,0 +1,116 @@ +-- ../pokecrystal/engine/movie/credits.asm:400-451 +-- ../pokecrystal/data/credits_strings.asm:183-185 + +package.path = "./?.lua;./?/init.lua;" .. package.path + +love = require("tests.love_stub") + +local T = require("tests.harness") +local GameVersion = require("src.core.GameVersion") +local Credits = require("src.ui.gen2.Credits") + +local prior = GameVersion.get() + +local function rgb(color, r, g, b, label) + T.eq(("%d,%d,%d"):format(color[1], color[2], color[3]), + ("%d,%d,%d"):format(r, g, b), label) +end + + +GameVersion.set("crystal") + +local staff = Credits.STRINGS[Credits.STAFF] +T.eq(type(staff), "table", "the crystal STAFF heading is three lines") +T.eq(staff[2], " CRYSTAL VERSION", + "and names the edition the cart's own string does") + +local crystal = Credits.layout() +T.eq(#crystal.bannerRows, 1, "ConstructCreditsTilemap calls .InitTopPortion once") +T.eq(crystal.bannerRows[1], 0, "so the banner is rows 0-3 only") +T.eq(crystal.borderRows[1], 4, "the first border strip is drawn at hlcoord 0, 4") +T.eq(crystal.borderRows[2], 17, "the second at hlcoord 0, 17, not Gold's 13") +T.eq(crystal.textRows, 12, "ParseCredits clears SCREEN_WIDTH * 12") +T.eq(crystal.theEndY, 9, "Credits_TheEnd is hlcoord 6, 9") +T.eq(crystal.lyStep, -2, "Credits_LYOverride is `dec a / dec a`") + +T.eq(#Credits.PALETTES_CRYSTAL, 12, "credits.pal is four blocks of three") +T.eq(Credits.SCENE_SPECIES_CRYSTAL[0], "PICHU", "scene 0 is Pichu") +T.eq(Credits.SCENE_SPECIES_CRYSTAL[3], "IGGLYBUFF", "scene 3 Igglybuff") + +local movie = Credits.new({ data = {} }, {}) +movie.scene = 0 +local banner, strips, field = movie:palettes() +T.check(banner ~= strips and strips ~= field and banner ~= field, + "GetCreditsPalette hands rows 0-3, the strips and the field three palettes") +rgb(banner[1], 255, 0, 255, "the banner keeps the block's first set") +rgb(field[1], 255, 41, 41, "the field's paper is RGB 31,05,05") +rgb(field[4], 255, 255, 255, "and its ink RGB 31,31,31, so the text is white") + +movie.scene = 3 +local _, _, last = movie:palettes() +rgb(last[1], 255, 115, 0, "scene 3 reads Igglybuff's third set") + +movie.scene = 0 +movie.borderFrame = 2 +T.eq(movie:borderGraphic(), 3, ".Frames gives every crystal mon four graphics") + +movie.lyOverride = 0 +movie:advanceLY() +T.eq(movie.lyOverride, 254, "the override counts down two a pass") + +local ending = Credits.new({ data = {} }, { + script = { Credits.THEEND, Credits.WAIT, 1, Credits.END }, +}) +ending:parse() +T.eq(ending.shown[1].y, 9, "THE END lands on row 9") + +local stale = Credits.new({ data = {} }, + { gfx = { palettes = { Credits.PALETTES[0], Credits.PALETTES[1], + Credits.PALETTES[2], Credits.PALETTES[3], Credits.PALETTES[0], + Credits.PALETTES[1] } } }) +stale.scene = 0 +local _, _, staleField = stale:palettes() +rgb(staleField[4], 255, 255, 255, + "an older crystal cache uses the transcribed block") + +local fresh = { palettes = {} } +for index = 1, 12 do fresh.palettes[index] = { { index, 0, 0 } } end +fresh.palettesPerScene = 3 +local imported = Credits.new({ data = {} }, { gfx = fresh }) +imported.scene = 1 +local a, b, c = imported:palettes() +T.eq(a[1][1], 4, "scene 1 starts at the fourth extracted set") +T.eq(b[1][1], 5, "its strips at the fifth") +T.eq(c[1][1], 6, "and its field at the sixth") + + +GameVersion.set("gold") + +T.eq(Credits.STRINGS[Credits.STAFF][2], " GOLD VERSION", + "gold still reads GOLD VERSION") + +local gs = Credits.layout() +T.eq(#gs.bannerRows, 2, "gold calls .InitTopPortion twice") +T.eq(gs.bannerRows[2], 14, "the second banner at hlcoord 0, 14") +T.eq(gs.borderRows[2], 13, "its lower strip on row 13") +T.eq(gs.textRows, 8, "and eight rows of text field") +T.eq(gs.theEndY, 8, "THE END on row 8") +T.eq(gs.lyStep, 2, "with the override counting up") + +local gold = Credits.new({ data = {} }, {}) +gold.scene = 0 +local one, two, three = gold:palettes() +T.check(one == two and two == three, + "gold copies one 8-byte set to every palette slot") +rgb(one[1], 255, 255, 255, "which is credits.pal's first set") + +gold.borderFrame = 2 +T.eq(gold:borderGraphic(), 1, "and Bellossom repeats its first graphic on frame 2") + +GameVersion.set("silver") +T.eq(Credits.STRINGS[Credits.STAFF][2], " SILVER VERSION", + "silver keeps its own heading") + +GameVersion.set(prior) + +T.finish("crystal credits bug 1967") diff --git a/tests/engine/fanfare_speed_bug1952_test.lua b/tests/engine/fanfare_speed_bug1952_test.lua new file mode 100644 index 00000000..a49b9247 --- /dev/null +++ b/tests/engine/fanfare_speed_bug1952_test.lua @@ -0,0 +1,115 @@ +-- home/vblank.asm:58-72 +-- home/delay.asm:14 + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local check, eq = T.check, T.eq +local Data = T.fixtures.fresh() +require("src.render.Font").load(Data) + +local Sound = require("src.core.Sound") +local BattleState = require("src.battle.BattleState") + + +eq(Sound.rate(), 1, "the rate starts at 1") +Sound.setRate(2) +eq(Sound.rate(), 2, "2X sets the one-shot playback rate to 2") +Sound.setRate(100) +eq(Sound.rate(), 4, "a huge speed clamps to FF_PITCH_MAX") +Sound.setRate(nil) +eq(Sound.rate(), 1, "nil falls back to 1") +Sound.setRate(-1) +eq(Sound.rate(), 1, "a negative speed falls back to 1") +Sound.setRate(0) +eq(Sound.rate(), 1, "zero falls back to 1") +Sound.setRate(0 / 0) +eq(Sound.rate(), 1, "NaN falls back to 1") +Sound.setRate(1) + + +local function stub(dur, pitch, playing) + return { + playing = playing ~= false, + getDuration = function() return dur end, + getPitch = function() return pitch end, + isPlaying = function(self) return self.playing end, + stop = function(self) self.playing = false end, + } +end + +eq(Sound.waitFrames(nil), 0, "no source is no wait") +eq(Sound.waitFrames(stub(2, 1)), 122, "a 2s sfx at pitch 1 is 120 frames plus margin") +eq(Sound.waitFrames(stub(2, 2)), 62, "pitched to 2X it takes half as many frames") +eq(Sound.waitFrames(stub(2, 4)), 32, "and a quarter at the 4X clamp") + +Sound.setRate(4) +eq(Sound.waitFrames(stub(2, 4)), 122, + "at 4X a 4X-pitched jingle still spans the same 120 logic frames") +eq(Sound.waitFrames(stub(2, 1)), 482, + "a source fast-forward could not pitch costs proportionally more frames") +Sound.setRate(1) + +local broken = { getDuration = function() error("no duration") end } +eq(Sound.waitFrames(broken), 180, "a source with no usable duration gets the fallback") +eq(Sound.waitFrames(broken, 90), 90, "and the caller may pick the fallback") +eq(Sound.waitFrames({}), 180, "so does a source with no getDuration at all") + + +local function gate(src) + local st = setmetatable({ + waitingSound = src, + queue = {}, + game = { stack = { top = function() return nil end } }, + }, BattleState) + return st +end + +local stuck = stub(1, 1) +local st = gate(stuck) +local budget = Sound.waitFrames(stuck) +local held = 0 +for _ = 1, budget + 200 do + if not st:updateQueue() then break end + held = held + 1 +end +check(stuck.playing == false, "the budget stops a source that never ends on its own") +eq(st.waitingSound, nil, "and releases the queue") +eq(held, budget - 1, "after exactly the frame budget, not forever") + +local short = stub(1, 1) +local st2 = gate(short) +check(st2:updateQueue(), "the gate holds while the sfx sounds") +short.playing = false +check(st2:updateQueue() == false, "and releases the frame the sfx goes quiet") +eq(st2.waitSoundLeft, nil, "the budget is cleared with the source") + + +local Game2 = require("src.core.Game2") +require("src.core.FixedStep"):init(function() end) + +local function gen2(speed) + return setmetatable({ + phase = "boot", + speedOverride = speed, + audioAccum = 0, + data = {}, + stack = { top = function() return nil end }, + }, { __index = Game2 }) +end + +Sound.setRate(1) +gen2(4):update(0) +eq(Sound.rate(), 4, "a Gen 2 frame drives the one-shot rate off its own speed") +gen2(2):update(0) +eq(Sound.rate(), 2, "and follows it down") +gen2(100):update(0) +eq(Sound.rate(), 4, "the Gen 2 path clamps through the same setRate") +gen2(1):update(0) +eq(Sound.rate(), 1, "1X is rate 1 in Gen 2 too") + +Sound.setRate(4) +require("src.core.SessionLifecycle").endGameSession(nil) +eq(Sound.rate(), 1, "session teardown resets the rate for the next boot") + +T.finish("fanfare_speed_bug1952") diff --git a/tests/engine/gen2_bike_connection_music_bug1975.lua b/tests/engine/gen2_bike_connection_music_bug1975.lua new file mode 100644 index 00000000..017cd057 --- /dev/null +++ b/tests/engine/gen2_bike_connection_music_bug1975.lua @@ -0,0 +1,111 @@ +-- data/maps/setup_scripts.asm:48 + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local eq = T.eq + +love = require("tests.love_stub") + +local Source = {} +Source.__index = Source +function Source:play() self.playing = true end +function Source:stop() self.playing = false end +function Source:pause() self.playing = false end +function Source:isPlaying() return self.playing end +function Source:setLooping() end +function Source:setVolume(v) self.volume = v end +function Source:setPitch() end +function Source:setFilter() end +function Source:getDuration() return 1 end + +love.audio = { + newSource = function(file, mode) + return setmetatable({ file = file, mode = mode }, Source) + end, +} + +local Music = require("src.core.Music") +local World = require("src.world.gen2.World") + +local DATA = { audio = { + runtime = true, + generation = 2, + songs = { + Music_Bicycle = { file = "bicycle.wav" }, + Music_Route30 = { file = "route30.wav" }, + Music_CherrygroveCity = { file = "cherrygrove.wav" }, + }, + musicOrder = { "Music_Route30", "Music_CherrygroveCity" }, +} } + +local MAPS = { + ROUTE_30 = { music = 0x80, environment = "ROUTE" }, + CHERRYGROVE_CITY = { music = 0x81, environment = "TOWN" }, +} + +local function newWorld() + local world = World.new({ data = DATA, save = { engineFlags = {} } }) + world.maps = MAPS + world.playerState = "bike" + Music.stop() + return world +end + +local world = newWorld() +eq(world:mapMusicSong("ROUTE_30"), "Music_Route30", "route song resolves") + +world:setMapMusic("ROUTE_30", false) +eq(Music.current(), "Music_Bicycle", "a warp-class load re-asserts the bike theme") +eq(Music.mapSong(), "Music_Bicycle", "PlayMapMusicBike writes wMapMusic") + +world:setMapMusic("CHERRYGROVE_CITY", true) +for _ = 1, 8 * Music.MAP_FADE do Music.update(DATA) end +eq(Music.current(), "Music_CherrygroveCity", + "a connection crossing fades to the destination's own song while biking") +eq(Music.mapSong(), "Music_CherrygroveCity", + "FadeToMapMusic overwrites wMapMusic, so a battle restores the map song") + +Music.stop() +world:restoreMapMusic() +eq(Music.current(), "Music_CherrygroveCity", + "RestartMapMusic replays wMapMusic, not the bike theme") + +world = newWorld() +world:setMapMusic("ROUTE_30", false) +world:setMapMusic("ROUTE_30", true) +for _ = 1, 8 * Music.MAP_FADE do Music.update(DATA) end +eq(Music.current(), "Music_Route30", + "crossing off the bike-theme map still lands on the route song") + +world = newWorld() +world.playerState = "normal" +world:setMapMusic("ROUTE_30", false) +eq(Music.current(), "Music_Route30", "on foot a warp load plays the map song") +world:setMapMusic("ROUTE_30", true) +eq(Music.current(), "Music_Route30", + "crossing into a map with the same song is a no-op (cp e / jr z, .done)") + +local Runtime = require("src.mods.Runtime") +local hooks = require("src.mods.Hooks").new() +Runtime.install(require("src.mods.Events").new(), hooks) +local reasons = {} +hooks:wrap("music.select", function(nextLink, song, ctx) + reasons[#reasons + 1] = ctx.reason + return nextLink(song, ctx) +end, nil, "bug1975") + +world = newWorld() +world:setMapMusic("ROUTE_30", false) +Music.stop() +world:restoreMapMusic() +eq(Music.current(), "Music_Bicycle", "a restore while biking replays the theme") +eq(reasons[#reasons], "bike", "and a mod still reads it as the bike reason") + +world.playerState = "normal" +world:setMapMusic("ROUTE_30", false) +Music.stop() +world:restoreMapMusic() +eq(reasons[#reasons], "map", "on foot the restore is a plain map restore") + +T.finish("gen2_bike_connection_music_bug1975") diff --git a/tests/engine/gen2_bump_walk_in_place_bug1978.lua b/tests/engine/gen2_bump_walk_in_place_bug1978.lua new file mode 100644 index 00000000..5986ce8c --- /dev/null +++ b/tests/engine/gen2_bump_walk_in_place_bug1978.lua @@ -0,0 +1,89 @@ +-- engine/overworld/player_movement.asm:93-110 +-- engine/overworld/movement.asm:315-327 +-- map_object_action.asm:71-94 + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq + +love = require("tests.love_stub") + +local Player = require("src.world.gen2.Player") + +local MAP = { + inBounds = function(_, x, y) return x >= 0 and y >= 0 and x < 20 and y < 20 end, + isWalkable = function(_, x, y) return x == 5 and y == 5 end, +} + +local OPEN = { + inBounds = function() return true end, + isWalkable = function() return true end, +} + +local function newPlayer() + local p = Player.new(5, 5, "right", nil) + p.turnArmed = false + return p +end + +local p = newPlayer() +eq(p:tryMove("right", MAP, nil), "blocked", "the wall refuses the step") +eq(p.moving, false, "a bump never starts a step") +check((p.bumpFrames or 0) > 0, "the refusal armed the in-place bump") + +local seen = {} +for _ = 1, 40 do + p:tryMove("right", MAP, nil) + seen[p:walkPhase()] = true + p:update() + eq(p.moving, false, "still standing on the same cell") +end +check(seen[0] and seen[1], "the legs cycle in place while held into the wall") +eq(p.cellX, 5, "and the player never left the cell") + +p = newPlayer() +p.animClock = 0 +local phases, flips = {}, {} +for i = 0, 31 do + p:tryMove("right", MAP, nil) + phases[i] = p:walkPhase() + flips[i] = p:drawFlip() + p:update() +end +eq(phases[0], 0, "the first pose is the standing one") +eq(phases[6], 0, "still standing seven frames in") +eq(phases[8], 1, "the walking pose lands on the eighth frame") +eq(phases[14], 1, "and holds for eight frames") +eq(phases[16], 0, "back to standing") +eq(phases[24], 1, "the second walking pose is the mirrored one") +check(flips[24] ~= flips[8], "which draws with the other leg forward") + +p = newPlayer() +p.animClock = 8 +p:tryMove("right", MAP, nil) +-- engine/overworld/movement.asm:315 +eq(p.bumpFrames, 1, "the bump lasts one OBJECT_STEP_DURATION frame") +eq(p:walkPhase(), 1, "which draws the walking pose") +p:update() +-- engine/overworld/player_movement.asm:108 +eq(p:walkPhase(), 0, "and .Standing drops to the standing pose on release") +eq(p:drawFlip(), p.stepFlip, "with the sprite unmirrored again") + +p = newPlayer() +p:tryMove("right", MAP, nil) +eq(p:tryMove("right", OPEN, nil), "moved", "an open tile steps") +eq(p.bumpFrames, nil, "a step clears the bump") + +p = newPlayer() +p.turnArmed = true +p:tryMove("right", MAP, nil) +eq(p:tryMove("down", MAP, nil), "turned", "a new direction turns first") +eq(p.bumpFrames, nil, "and the turn frames are not a bump") + +p = newPlayer() +p:tryMove("right", MAP, nil) +p:scriptStep("right") +eq(p.bumpFrames, nil, "a cutscene step clears the bump") + +T.finish("gen2_bump_walk_in_place_bug1978") diff --git a/tests/engine/gen2_evolution_map_music_bug1976.lua b/tests/engine/gen2_evolution_map_music_bug1976.lua new file mode 100644 index 00000000..3eaa1eec --- /dev/null +++ b/tests/engine/gen2_evolution_map_music_bug1976.lua @@ -0,0 +1,96 @@ +-- ../pokecrystal/engine/pokemon/evolve.asm:331 +-- ../pokecrystal/engine/pokemon/evolve.asm:194 +-- ../pokecrystal/engine/items/item_effects.asm:1141 + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") + +love = require("tests.love_stub") +require("src.core.Logger").warn = function() end + +local Game2 = require("src.core.Game2") +local Screens = require("src.ui.Screens") + +local DATA_POKEMON = { + SUNKERN = { + id = "SUNKERN", name = "SUNKERN", + evolutions = { + { method = "EVOLVE_ITEM", item = "SUN_STONE", into = "SUNFLORA" }, + }, + }, + SUNFLORA = { id = "SUNFLORA", name = "SUNFLORA", evolutions = {} }, + POLIWAG = { + id = "POLIWAG", name = "POLIWAG", + evolutions = { { method = "EVOLVE_LEVEL", level = 5, into = "POLIWHIRL" } }, + }, + POLIWHIRL = { id = "POLIWHIRL", name = "POLIWHIRL", evolutions = {} }, +} + +local function newGame(species, inventory) + local seen = { restored = 0 } + local mon = { species = species, level = 10, moves = {} } + local game = setmetatable({ + data = { + pokemon = DATA_POKEMON, + screens = { + Gen2PartyMenu = function(_, options) + seen.party = options + return {} + end, + Gen2EvolutionAnim = function(_, options) + seen.evolution = options + return {} + end, + }, + }, + save = { party = { mon }, inventory = inventory or {} }, + stack = { pop = function() end, push = function() end }, + world = { + restoreMapMusic = function() seen.restored = seen.restored + 1 end, + }, + }, Game2) + Screens.invalidate() + return game, mon, seen +end + + +do + local game, mon, seen = newGame("SUNKERN", { SUN_STONE = 1 }) + game:usePartyItem("SUN_STONE") + T.check(seen.party ~= nil, "the SUN STONE opened the party list") + seen.party.onChoose(1, mon) + T.check(seen.evolution ~= nil, "the pick pushed the evolution screen") + T.eq(seen.restored, 0, "with the map theme still down under the screen") + seen.evolution.onDone({ evolved = true }) + T.eq(seen.restored, 1, "and RestartMapMusic once the screen is gone") +end + +do + local game, mon, seen = newGame("SUNKERN", { SUN_STONE = 1 }) + game:usePartyItem("SUN_STONE") + seen.party.onChoose(1, mon) + seen.evolution.onDone({ canceled = true }) + T.eq(seen.restored, 1, "a cancelled stone evolution restarts it as well") +end + + +do + local game, mon, seen = newGame("POLIWAG") + game:afterRareCandy(mon, { learned = {} }) + T.check(seen.evolution ~= nil, "the candy's level-up pushed the screen") + T.eq(seen.restored, 0, "nothing restarted while it is up") + seen.evolution.onDone({ evolved = true }) + T.eq(seen.restored, 1, "and the map theme comes back after it") +end + +do + local game, mon, seen = newGame("SUNFLORA") + local ran = false + game:afterRareCandy(mon, { learned = {} }, function() ran = true end) + T.check(ran, "the caller's continuation still runs") + T.eq(seen.evolution, nil, "with no evolution screen") + T.eq(seen.restored, 0, "and no restart") +end + +T.finish("gen2 evolution map music bug 1976") diff --git a/tests/engine/gen2_forget_move_window_bug1980.lua b/tests/engine/gen2_forget_move_window_bug1980.lua new file mode 100644 index 00000000..5c61425c --- /dev/null +++ b/tests/engine/gen2_forget_move_window_bug1980.lua @@ -0,0 +1,94 @@ +-- engine/pokemon/learn.asm:135-166 +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") + +love = require("tests.love_stub") + +local Chrome = require("src.ui.gen2.Chrome") +local ForgetMoveList = require("src.ui.gen2.ForgetMoveList") +local MoveDeleter = require("src.ui.gen2.MoveDeleter") + +local L = ForgetMoveList +T.same({ L.x, L.y, L.interiorW, L.interiorH }, { 5, 2, 13, 8 }, + "ForgetMove's Textbox is (5,2) with NUM_MOVES*2 x MOVE_NAME_LENGTH inside") +T.same({ L.nameX, L.y0, L.step, L.cursorX }, { 7, 4, 2, 6 }, + "names run from (7,4) every two rows with the cursor in column 6") + +local boxes, texts, cursors +local function spy() + boxes, texts, cursors = {}, {}, {} + Chrome.box = function(tx, ty, tw, th) + boxes[#boxes + 1] = { tx, ty, tw, th } + end + Chrome.print = function(text, tx, ty) + texts[#texts + 1] = { text, tx, ty } + end + Chrome.printThrough = function(text, tx, ty) + texts[#texts + 1] = { text, tx, ty } + end + Chrome.printRightThrough = function(text, tx, ty) + texts[#texts + 1] = { text, tx, ty } + end + Chrome.cursor = function(tx, ty) cursors[#cursors + 1] = { tx, ty } end + Chrome.cursorThrough = function(tx, ty) cursors[#cursors + 1] = { tx, ty } end +end + +local MOVE_DATA = { + TACKLE = { name = "TACKLE" }, + SURF = { name = "SURF" }, +} +local function twoMoves() + return { { id = "TACKLE", pp = 30, maxPp = 35 }, + { id = "SURF", pp = 1, maxPp = 15 } } +end + +spy() +ForgetMoveList.draw(twoMoves(), 2, MOVE_DATA) + +T.same(boxes[1], { 5, 2, 15, 10 }, + "the list opens its own 15x10 box over the player HUD") +T.eq(#boxes, 1, "nothing else is boxed") +T.same(texts[1], { "TACKLE", 7, 4 }, "first name at (7,4)") +T.same(texts[2], { "SURF", 7, 6 }, "second name two rows down") +T.same(texts[3], { "-", 7, 8 }, "ListMoves fills an empty slot with '-'") +T.same(texts[4], { "-", 7, 10 }, "fourth row is drawn too") +T.eq(#texts, 4, "names only -- ListMoves prints no PP") +T.same(cursors, { { 6, 6 } }, "the cursor sits on its row in column 6") + +spy() +local forget = MoveDeleter.new(nil, { + mon = { moves = twoMoves() }, moves = MOVE_DATA, layout = "forget", +}) +forget:draw() +T.same(boxes[1], { 5, 2, 15, 10 }, "the learn-move list uses ForgetMove's box") +T.eq(#texts, 4, "the learn-move list prints names only") + +spy() +local deleter = MoveDeleter.new(nil, { + mon = { moves = twoMoves() }, moves = MOVE_DATA, +}) +deleter:draw() +T.same(boxes[1], { 0, 1, 20, 11 }, "the Blackthorn deleter keeps its own box") +T.check(#texts > 4, "the Blackthorn deleter keeps its PP column") + +local pressed +local function stubInput(state) + state.game = { input = { wasPressed = function(_, btn) + return btn == pressed + end } } +end + +stubInput(forget) +forget.row = 1 +pressed = "up" +forget:update(0) +T.eq(forget.row, 2, "ForgetMove's list wraps up to the last move") + +stubInput(deleter) +deleter.row = 1 +pressed = "up" +deleter:update(0) +T.eq(deleter.row, 1, "the Blackthorn deleter still stops at the top") + +T.finish() diff --git a/tests/engine/gen2_heal_item_party_anim_bug1977.lua b/tests/engine/gen2_heal_item_party_anim_bug1977.lua new file mode 100644 index 00000000..56450119 --- /dev/null +++ b/tests/engine/gen2_heal_item_party_anim_bug1977.lua @@ -0,0 +1,84 @@ +-- engine/items/item_effects.asm:1644 +-- engine/items/item_effects.asm:1671 +-- engine/items/item_effects.asm:1748 +-- data/text/common_1.asm:30 +-- home/text.asm:772 + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") + +love = require("tests.love_stub") +require("src.core.Logger").warn = function() end + +local HpBar = require("src.battle.gen2.HpBar") +local ItemEffects = require("src.core.gen2.ItemEffects") +local PartyMenu = require("src.ui.gen2.PartyMenu") + + +T.eq(HpBar.stepToward(10, 30, 39), 11, "under 48 max HP the bar steps one HP") +T.eq(HpBar.stepToward(30, 10, 39), 29, "and one hit point back down") +T.eq(HpBar.stepToward(0, 200, 96), 2, + "from 48 up a step is ceil(maxHp / 48) hit points") +T.eq(HpBar.stepToward(28, 30, 200), 30, "and it never overshoots the target") + + +local mon = { species = "SUICUNE", nickname = "SUICUNE", hp = 197, + maxHp = 199, stats = { hp = 199 } } +local result = ItemEffects.useOnMon("POTION", mon, {}) +T.eq(result.text, "SUICUNE\nrecovered 2HP!", + "TextCommand_DECIMAL is left-aligned, so no padding and no space before HP!") + + +local input = { pressed = {} } +function input:press(button) self.pressed[button] = true end +function input:wasPressed(button) + if self.pressed[button] then + self.pressed[button] = nil + return true + end + return false +end +function input:isDown() return false end + +local party = { { species = "SENTRET", nickname = "SENTRET", level = 5, + hp = 1, maxHp = 20, stats = { hp = 20 }, moves = {} } } +local game = { data = { gen2MenuGfx = {} }, input = input, + save = { party = party } } +local menu = PartyMenu.new(game, { party = party, prompt = "useItem" }) + +local done = false +menu:showItemResult(1, { fromHp = 1, toHp = 11, sfx = "Sfx_Potion", + text = "SENTRET\nrecovered 10HP!", onDone = function() done = true end }) +T.check(menu:itemResultClimbing(), "the pick arms the bar climb") +T.eq(menu:shownHpFor(1, party[1]), 1, "the row reads the pre-heal HP first") +T.eq(PartyMenu.rowFor(party[1], menu:shownHpFor(1, party[1])).hp, " 1/ 20", + "and the digits follow the bar, not the mon") + +for _ = 1, 9 do menu:update(1 / 60) end +T.eq(menu.itemResult.shown, 10, "one hit point a step under 48 max HP") +menu:update(1 / 60) +T.eq(menu.itemResult.shown, 11, "landing on the healed value") +T.check(not menu:itemResultClimbing(), "with the climb finished") + +for _ = 1, PartyMenu.ACTION_TEXT_DELAY do + input:press("a") + menu:update(1 / 60) +end +T.check(not done, "the button is dead through DelayFrames' 50 frames") +input:press("a") +menu:update(1 / 60) +T.check(done, "and WaitPressAorB_BlinkCursor answers after them") +T.eq(menu.itemResult, nil, "clearing the result with it") + +menu:showItemResult(1, { text = "SENTRET\nis cured of poison." }) +T.check(not menu:itemResultClimbing(), "a status cure runs no HealHP_SFX_GFX") +T.eq(menu:shownHpFor(1, party[1]), party[1].hp, + "and the row reads the mon straight") + +local before = menu.index +input:press("down") +menu:update(1 / 60) +T.eq(menu.index, before, "the list is frozen behind the action text") + +T.finish("gen2 heal item party anim bug 1977") diff --git a/tests/engine/gen2_trainer_battle_pal_bug1970.lua b/tests/engine/gen2_trainer_battle_pal_bug1970.lua new file mode 100644 index 00000000..08fb18e0 --- /dev/null +++ b/tests/engine/gen2_trainer_battle_pal_bug1970.lua @@ -0,0 +1,102 @@ +-- ../pokecrystal/engine/battle/battle_transition.asm:584 + +package.path = "./?.lua;./?/init.lua;" .. package.path + +love = require("tests.love_stub") + +local T = require("tests.harness") +local GbcPalette = require("src.render.GbcPalette") +local Transition = require("src.ui.gen2.BattleTransition") + +local function scale5(value) + return math.floor(value * 255 / 31 + 0.5) +end + +local function ramps(pal, rows) + for index, row in ipairs(rows) do + local color = pal[index] + T.eq(color[1], scale5(row[1]), "colour " .. index .. " red") + T.eq(color[2], scale5(row[2]), "colour " .. index .. " green") + T.eq(color[3], scale5(row[3]), "colour " .. index .. " blue") + end +end + +do + ramps(Transition.TRAINER_PAL, { + { 31, 18, 29 }, { 31, 11, 15 }, { 31, 5, 5 }, { 7, 7, 7 }, + }) + ramps(Transition.TRAINER_PAL_DARK, { + { 31, 18, 29 }, { 31, 5, 5 }, { 31, 5, 5 }, { 31, 5, 5 }, + }) +end + +local function make(opts) + opts.environment = opts.environment or "TOWN" + return Transition.new(nil, opts) +end + +do + local wild = make({ trainer = false }) + T.eq(wild:trainerRamp(), nil, "a wild transition keeps the map's colours") + local trainer = make({ trainer = true }) + T.check(trainer:trainerRamp() == Transition.TRAINER_PAL, + "a trainer transition repaints the background red") + local dark = make({ trainer = true, dark = true }) + T.check(dark:trainerRamp() == Transition.TRAINER_PAL_DARK, + "a DARKNESS map takes .darkpals") +end + +do + local lit = make({ trainer = true }) + T.eq(lit:flashFrames(), Transition.FLASH_FRAMES, "the flash runs in full") + local dark = make({ trainer = true, dark = true }) + T.eq(dark:flashFrames(), 0, "DoFlashAnimation bails out on DARKNESS_PALSET") + T.eq(dark.phase, "pokeball", "a trainer battle still stamps the ball") + local darkWild = make({ trainer = false, dark = true }) + T.eq(darkWild.phase, "outro", "and a wild one goes straight to the outro") +end + +do + local bgA = { { 10, 10, 10 }, { 20, 20, 20 }, { 30, 30, 30 }, { 40, 40, 40 } } + local bgB = { { 50, 50, 50 }, { 60, 60, 60 }, { 70, 70, 70 }, { 80, 80, 80 } } + local tree = { { 90, 90, 90 }, { 91, 91, 91 }, { 92, 92, 92 }, { 93, 93, 93 } } + local other = { { 1, 2, 3 }, { 4, 5, 6 }, { 7, 8, 9 }, { 11, 12, 13 } } + local objs = { other, other, other, other, other, other, tree, other } + local src, dst, count = GbcPalette.remapTable({ bgA, bgB }, + GbcPalette.BGP_IDENTITY, objs, Transition.TRAINER_PAL, + Transition.RAMPED_OBJ) + local function mapped(from) + for i = 1, count do + if src[i][1] == from[1] and src[i][2] == from[2] + and src[i][3] == from[3] then + return dst[i] + end + end + end + for index = 1, 4 do + local want = Transition.TRAINER_PAL[index] + for _, palette in ipairs({ bgA, bgB }) do + local got = mapped(palette[index]) + T.check(got and got[1] == want[1] and got[2] == want[2] + and got[3] == want[3], + "every BG palette's colour " .. index .. " takes the ramp") + end + local treeGot = mapped(tree[index]) + T.check(treeGot and treeGot[1] == want[1] and treeGot[2] == want[2] + and treeGot[3] == want[3], + "PAL_OW_TREE takes it too") + local otherGot = mapped(other[index]) + T.check(otherGot and otherGot[1] == other[index][1], + "an OBJ palette the cart leaves alone maps to itself") + end +end + +do + local bgA = { { 10, 10, 10 }, { 20, 20, 20 }, { 30, 30, 30 }, { 40, 40, 40 } } + local src, dst, count = GbcPalette.remapTable({ bgA }, 0xff) + T.eq(count, 4, "no ramp: the table is the palette itself") + T.check(src[1][1] == 10 and dst[1][1] == 40, + "and $ff still sends every colour to shade 3") +end + +T.finish("gen2 trainer battle palette bug 1970") diff --git a/tests/engine/gym_badge_jingle_page_bug1982.lua b/tests/engine/gym_badge_jingle_page_bug1982.lua new file mode 100644 index 00000000..20b4037f --- /dev/null +++ b/tests/engine/gym_badge_jingle_page_bug1982.lua @@ -0,0 +1,113 @@ +-- scripts/PewterGym.asm:156-159 +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local check, eq = T.check, T.eq +local gyms = require("data.scripts.gyms") +local victories = require("data.scripts.victories") + +local text = {} +local armed, armedSound, armedSoundPage +local fakeOw = { + engageTrainer = function(_, _, _, endBattleText, _, endBattleSound, _, + endBattleSoundPage) + armed, armedSound, armedSoundPage = + endBattleText, endBattleSound, endBattleSoundPage + end, +} +local fakeGame = { data = { text = text }, save = { flags = {} } } + +local function pages(n, tag) + local out = {} + for i = 1, n do out[i] = tag .. i end + return table.concat(out, "\f") +end + +local function armFor(mapId, textId, victoryKey, firstLabelPages) + local reward = victories[victoryKey] + for i, label in ipairs(reward.dialogue or {}) do + text[label] = pages(i == 1 and firstLabelPages or 2, label .. "#") + end + armed, armedSound, armedSoundPage = nil, nil, nil + gyms[mapId].talk[textId](fakeGame, fakeOw, { id = "npc#1" }, function() end) + return armed, armedSound, armedSoundPage +end + +local leaders = { + { "PEWTER_GYM", "TEXT_PEWTERGYM_BROCK", "OPP_BROCK#1", 3 }, + { "CERULEAN_GYM", "TEXT_CERULEANGYM_MISTY", "OPP_MISTY#1", 3 }, + { "SAFFRON_GYM", "TEXT_SAFFRONGYM_SABRINA", "OPP_SABRINA#1", 3 }, + { "CINNABAR_GYM", "TEXT_CINNABARGYM_BLAINE", "OPP_BLAINE#1", 2 }, + { "VIRIDIAN_GYM", "TEXT_VIRIDIANGYM_GIOVANNI", "OPP_GIOVANNI#3", 1 }, +} +for _, entry in ipairs(leaders) do + local _, sound, page = armFor(entry[1], entry[2], entry[3], entry[4]) + eq(sound, victories[entry[3]].badgeSound, + entry[3] .. " still arms its badge jingle") + eq(page, entry[4], entry[3] .. " fires the jingle on its badge page") +end + +local _, surgeSound, surgePage = armFor("VERMILION_GYM", + "TEXT_VERMILIONGYM_LT_SURGE", "OPP_LT_SURGE#1", 3) +eq(surgeSound, nil, "LT.SURGE arms no badge jingle") +eq(surgePage, nil, "and no jingle page") + +local ok, real = pcall(dofile, "data/generated/text.lua") +if ok and type(real) == "table" + and real._PewterGymBrockReceivedBoulderBadgeText then + local n = 0 + for page in (real._PewterGymBrockReceivedBoulderBadgeText .. "\f") + :gmatch("(.-)\f") do + if page ~= "" then n = n + 1 end + end + eq(n, 3, "Brock's badge label really is three pages") +end + +local Data = T.fixtures.fresh() +require("src.render.Font").load(Data) +local TypeChart = require("src.battle.TypeChart") +TypeChart.load(Data) +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") +local BattleState = require("src.battle.BattleState") + +local function queueFor(soundPage) + local save = SaveData.newGame() + save.player.name = "RED" + save.party = { Pokemon.new(Data, "FIXMON_A", 60) } + local game = { data = Data, save = save } + game.stack = { + states = {}, + push = function(self, s) table.insert(self.states, s) end, + pop = function(self) return table.remove(self.states) end, + top = function(self) return self.states[#self.states] end, + } + local battle = BattleState.newTrainer(game, "OPP_FIX_YOUNGSTER", 1) + battle.participants = {} + battle.playVictoryMusic = function() end + battle.slidePic = function() end + battle.endBattleText = "one\ftwo\fthree" + battle.endBattleSound = "Get_Item1" + battle.endBattleSoundPage = soundPage + for _, mon in ipairs(battle.enemyParty) do mon.hp = 0 end + battle.queue, battle.nextInsert = {}, 0 + battle:enemyMonFainted() + return battle.queue +end + +local function sfxRow(queue) + for _, row in ipairs(queue) do + if row.waitForLearningSfx then return row end + end +end + +local row = sfxRow(queueFor(3)) +check(row ~= nil, "the badge jingle is queued with a page") +check(row and row.text and row.text:find("three", 1, true) ~= nil, + "the jingle rides the third page, not the first") + +local first = sfxRow(queueFor(nil)) +check(first and first.text and first.text:find("one", 1, true) ~= nil, + "an unpaged end-battle sound still rides the first page") + +T.finish("gym badge jingle page (#1982)") diff --git a/tests/engine/gym_leader_end_battle_text_bug1606.lua b/tests/engine/gym_leader_end_battle_text_bug1606.lua index 5be0ac84..7cc28fa0 100644 --- a/tests/engine/gym_leader_end_battle_text_bug1606.lua +++ b/tests/engine/gym_leader_end_battle_text_bug1606.lua @@ -35,15 +35,17 @@ end local fakeGame = { data = { text = text }, save = { flags = {} } } -local armed, armedSound +local armed, armedSound, armedSoundPage local fakeOw = { - engageTrainer = function(_, _, _, endBattleText, _, endBattleSound) - armed, armedSound = endBattleText, endBattleSound + engageTrainer = function(_, _, _, endBattleText, _, endBattleSound, _, + endBattleSoundPage) + armed, armedSound, armedSoundPage = + endBattleText, endBattleSound, endBattleSoundPage end, } local function armedFor(mapId, textId, victoryKey) - armed, armedSound = nil, nil + armed, armedSound, armedSoundPage = nil, nil, nil gyms[mapId].talk[textId](fakeGame, fakeOw, { id = "npc#1" }, function() end) local labels = victories[victoryKey].dialogue local want = {} diff --git a/tests/engine/heal_machine_palette_bug1986.lua b/tests/engine/heal_machine_palette_bug1986.lua new file mode 100644 index 00000000..a65cfc60 --- /dev/null +++ b/tests/engine/heal_machine_palette_bug1986.lua @@ -0,0 +1,118 @@ +-- engine/overworld/healing_machine.asm:74 +-- color/data/spritepalettes.asm:2 + +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local S = require("tests.harness").suite("heal machine palette #1986") +local check, eq = S.check, S.eq + +local GameVersion = require("src.core.GameVersion") +local PaletteFX = require("src.render.PaletteFX") +local OW = require("src.world.OverworldController") + +local function upvalue(fn, want) + local i = 1 + while true do + local n, v = debug.getupvalue(fn, i) + if not n then return nil end + if n == want then return v end + i = i + 1 + end +end + +local prevVer = GameVersion.get() +local prevMode = PaletteFX.mode + +GameVersion.set("red") +PaletteFX.setMode("redpp") + +eq(PaletteFX.HEAL_MACHINE_GROUP, 4, "the heal machine wears OBJ palette 4") + +local obp = PaletteFX.healMachineObp and PaletteFX.healMachineObp() +check(type(obp) == "table" and #obp == 4, + "Advanced resolves a 4-color heal-machine palette") +obp = obp or { { 0, 0, 0 }, { 0, 0, 0 }, { 0, 0, 0 }, { 0, 0, 0 } } +eq(obp[1][1], 222, "shade 0 is the pack's light green (R)") +eq(obp[2][1], 255, "shade 1 is orange (R)") +eq(obp[2][2], 156, "shade 1 is orange (G)") +eq(obp[2][3], 82, "shade 1 is orange (B)") +eq(obp[3][1], 247, "shade 2 is red (R)") +eq(obp[3][2], 82, "shade 2 is red (G)") +eq(obp[3][3], 49, "shade 2 is red (B)") +eq(obp[4][1], 0, "shade 3 stays black") +check(obp[2][1] ~= obp[3][1] or obp[2][2] ~= obp[3][2], + "the two ball shades are not the same color") + +local grays = PaletteFX.GRAYS +for i = 1, 4 do + check(obp[i][1] ~= grays[i][1] or obp[i][2] ~= grays[i][2] + or obp[i][3] ~= grays[i][3] or i == 4, + "shade " .. (i - 1) .. " is not a DMG gray") +end + +local healShader = upvalue(OW.drawWorld, "healMachineShader") +check(type(healShader) == "function", + "the heal overlay picks its palette through healMachineShader") + +local realShader, realSend = PaletteFX.shader, PaletteFX.sendColors +local realSetShader = love.graphics.setShader +local STUB = { stub = true } +local sent, bound +PaletteFX.shader = function() return STUB end +PaletteFX.sendColors = function(_, colors) sent = colors end +love.graphics.setShader = function(s) bound = s end + +local function beat(visible) + sent, bound = nil, nil + local s = healShader(visible) + return s, sent, bound +end + +local ok, err = pcall(function() + PaletteFX.setMode("redpp") + + local s, lit, bnd = beat(true) + eq(s, STUB, "Advanced shades the machine on a lit beat") + eq(bnd, STUB, "and binds that shader before the sprites are drawn") + check(type(lit) == "table", "a lit beat sends a palette") + eq(lit[2][1], obp[2][1], "the lit ball keeps the pack's orange (R)") + eq(lit[2][2], obp[2][2], "the lit ball keeps the pack's orange (G)") + eq(lit[3][1], obp[3][1], "the lit ball keeps the pack's red (R)") + eq(lit[3][2], obp[3][2], "the lit ball keeps the pack's red (G)") + check(lit[2][1] ~= PaletteFX.GRAYS[2][1], + "a lit beat is not the DMG gray ramp") + + -- engine/overworld/healing_machine.asm:54 + local _, flashed = beat(false) + check(type(flashed) == "table", "a flashed half-beat sends a palette") + eq(flashed[2][1], obp[1][1], "the flash beat lifts shade 1 to shade 0") + eq(flashed[3][1], obp[2][1], "the flash beat lifts shade 2 to shade 1") + eq(flashed[4][1], obp[3][1], "the flash beat lifts shade 3 to shade 2") + check(flashed[2][1] ~= obp[3][1], + "the Advanced flash lifts brightness instead of swapping the middle shades") + + for _, mode in ipairs({ "gbc", "og", "classic" }) do + PaletteFX.setMode(mode) + check(not PaletteFX.usesGbcPack(), mode .. " is not the Advanced pack") + local ds, dlit, dbnd = beat(true) + eq(ds, nil, mode .. " leaves a lit beat unshaded") + eq(dlit, nil, mode .. " sends no palette on a lit beat") + eq(dbnd, nil, mode .. " binds no shader on a lit beat") + local _, dflash = beat(false) + check(type(dflash) == "table", mode .. " still shades the flashed half-beat") + eq(dflash[2][1], PaletteFX.GRAYS[3][1], + mode .. " swaps the two middle DMG grays on the flash beat") + eq(dflash[3][1], PaletteFX.GRAYS[2][1], + mode .. " swaps them back for shade 2") + end +end) + +PaletteFX.shader, PaletteFX.sendColors = realShader, realSend +love.graphics.setShader = realSetShader +check(ok, "the heal palette pass ran: " .. tostring(err)) + +PaletteFX.setMode(prevMode) +GameVersion.set(prevVer) + +S.finish() diff --git a/tests/engine/magikarp_money_box_bug1983.lua b/tests/engine/magikarp_money_box_bug1983.lua new file mode 100644 index 00000000..dfa6783b --- /dev/null +++ b/tests/engine/magikarp_money_box_bug1983.lua @@ -0,0 +1,66 @@ +-- scripts/MtMoonPokecenter.asm:30-34 +-- Museum1F.asm:72-79 + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local Commands = require("src.script.Commands") +local TextBox = require("src.render.TextBox") + +local function newGame() + local game = { save = { player = {}, money = 5326 }, data = { text = {} } } + game.stack = { + states = {}, + push = function(self, s) table.insert(self.states, s) end, + pop = function(self) return table.remove(self.states) end, + top = function(self) return self.states[#self.states] end, + } + game.input = { + wasPressed = function() return false end, + isDown = function() return false end, + } + return game +end + +local function askWith(moneyOpt) + local game = newGame() + game.data.text.TEST_DEAL = "MAN: Hello, there!\nHave I got a deal" + local runner = { yield = function() end, resume = function() end } + local ctx = { game = game, runner = runner, save = game.save } + Commands.text_opts(ctx, { money = moneyOpt }) + Commands.ask(ctx, "TEST_DEAL") + return game, game.stack:top() +end + +local game, box = askWith("choice") +check(getmetatable(box) == TextBox, "the pitch is a TextBox") +check(type(box.money) == "function", "money = \"choice\" still arms the live wallet reader") +eq(box.money(), 5326, "the reader returns the save's money") +check(box.moneyWithChoice, "money = \"choice\" defers the box to the YES/NO menu") +check(not box:moneyVisible(), "no money box on the first frame of the offer") + +for _ = 1, 600 do + if box.done then break end + box:update(1 / 60) + check(not box:moneyVisible(), "no money box while the offer is typing") +end +check(box.done, "the offer finished typing") +check(not box:moneyVisible(), "still no money box on the frame typing ends") + +box:update(1 / 60) +eq(#game.stack.states, 2, "the YES/NO box is up") +check(box:moneyVisible(), "the money box appears on the same frame as the menu") + +local game2, box2 = askWith(true) +check(not box2.moneyWithChoice, "money = true is unchanged") +check(box2:moneyVisible(), "Museum-style money = true draws from frame 1") +eq(box2.money(), 5326, "money = true still reads the live wallet") +eq(#game2.stack.states, 1, "only the text box is up before the menu") + +local plain = TextBox.new(newGame(), "hi", nil, {}) +check(not plain:moneyVisible(), "a box with no money opt never draws one") + +T.finish("magikarp_money_box_bug1983") diff --git a/tests/engine/mart_quantity_cap_bug1981.lua b/tests/engine/mart_quantity_cap_bug1981.lua new file mode 100644 index 00000000..72ab711f --- /dev/null +++ b/tests/engine/mart_quantity_cap_bug1981.lua @@ -0,0 +1,158 @@ +-- engine/events/pokemart.asm:151-155 +-- home/print_bcd.asm:14-50 + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local function glyphs(text) + local n = 0 + for _ in tostring(text):gmatch("[%z\1-\127\194-\244][\128-\191]*") do + n = n + 1 + end + return n +end + +local realFont = package.loaded["src.render.Font"] +local calls = {} +package.loaded["src.render.Font"] = { + BORDER = { tl = 1, tr = 2, bl = 3, br = 4, h = 5, v = 6 }, + draw = function(text, x, y) calls[#calls + 1] = { "draw", text, x, y } end, + drawCode = function(code, x, y) calls[#calls + 1] = { "code", code, x, y } end, + drawBox = function(tx, ty, tw, th) calls[#calls + 1] = { "box", tx, ty, tw, th } end, + width = function(text) return glyphs(text) * 8 end, + split = function(text) + local out = {} + for s in tostring(text):gmatch("[%z\1-\127\194-\244][\128-\191]*") do + out[#out + 1] = s + end + return out + end, + spansFitting = function(spans, pixels) return math.min(#spans, math.floor(pixels / 8)) end, + encode = function(text) return { text } end, + advanceOf = function() return 8 end, +} + +local RELOAD = { + "src.ui.QuantityBox", "src.ui.ShopMenu", "src.ui.ListMenu", + "src.ui.Menu", "src.ui.Theme", +} +for _, m in ipairs(RELOAD) do package.loaded[m] = nil end + +local QuantityBox = require("src.ui.QuantityBox") +local ShopMenu = require("src.ui.ShopMenu") + +local function found(kind, pred) + for _, c in ipairs(calls) do + if c[1] == kind and pred(c) then return c end + end + return nil +end + +local function presser(button) + return { input = { wasPressed = function(_, b) return b == button end }, + stack = { pop = function() end } } +end + +do + local box = QuantityBox.new({}, { max = 99, unitPrice = 200 }) + eq(box.qty, 1, "the box opens on ×01 (InitialQuantityText)") + box.game = presser("down") + box:update(1 / 60) + eq(box.qty, 99, "DOWN from 1 wraps to wMaxItemQuantity, not to a money cap") + box.game = presser("up") + box:update(1 / 60) + eq(box.qty, 1, "UP from the max wraps back to 1") +end + +do + local box = QuantityBox.new({}, { max = 99, unitPrice = 200, start = 99 }) + eq(box.qty, 99, "start clamps inside 1..max") + calls = {} + box:draw() + + local frame = found("box", function() return true end) + if check(frame ~= nil, "the priced box is drawn") then + eq(frame[2], 7, "hlcoord 7, 9") + eq(frame[3], 9, "row 9") + eq(frame[4], 13, "c = 11 plus the border") + end + + check(found("draw", function(c) + return c[2] == "×99" and c[3] == 64 and c[4] == 80 + end) ~= nil, "the quantity prints at column 8 on row 10") + + local price = found("draw", function(c) + return tostring(c[2]):find("¥") ~= nil + end) + if check(price ~= nil, "the total prints as its own field") then + eq(price[2], "¥19800", "leading zeroes blank out, ¥ leads the digits") + eq(price[3], 104, "right-aligned so the last digit lands in column 18") + eq(price[4], 80, "on the same row as the quantity") + end + + check(found("draw", function(c) + return tostring(c[2]):find("×") and tostring(c[2]):find("¥") + end) == nil, "quantity and price are separate fields, not one string") +end + +do + local box = QuantityBox.new({}, { max = 99, unitPrice = 200 }) + calls = {} + box:draw() + local price = found("draw", function(c) + return tostring(c[2]):find("¥") ~= nil + end) + if check(price ~= nil, "×01 still prints a total") then + eq(price[2], "¥200", "no zero padding ahead of the ¥") + eq(price[3], 120, "still right-aligned to column 18") + end +end + +do + local box = QuantityBox.new({}, { max = 5 }) + calls = {} + box:draw() + local frame = found("box", function() return true end) + if check(frame ~= nil, "the plain box is drawn") then + eq(frame[2], 15, "hlcoord 15, 9") + eq(frame[4], 5, "c = 3 plus the border") + end + check(found("draw", function(c) return tostring(c[2]):find("¥") end) == nil, + "no money field without a unitPrice") +end + +do + local pushed = {} + local game = { + data = { + items = { POKE_BALL = { name = "POKe BALL", price = 200 } }, + text = {}, + }, + save = { money = 100, inventory = {}, bagOrder = {} }, + stack = { push = function(_, s) pushed[#pushed + 1] = s end, + pop = function() end }, + } + local menu = ShopMenu.new(game, { "POKE_BALL" }, function() end) + menu.items[1].onSelect() + local list = pushed[1] + if check(list ~= nil and list.onChoose ~= nil, "BUY pushes the item list") then + local before = list.footer + list.onChoose({ value = "POKE_BALL" }) + local qty = pushed[2] + if check(qty ~= nil, "choosing an item opens the quantity menu") then + eq(qty.max, 99, "wMaxItemQuantity is 99, not floor(money / price)") + eq(qty.unitPrice, 200, "the unit price rides along for the total") + end + eq(list.footer, before, + "no pre-confirm refusal: .isThereEnoughMoney runs after YES") + end +end + +package.loaded["src.render.Font"] = realFont +for _, m in ipairs(RELOAD) do package.loaded[m] = nil end +require("src.ui.Screens").invalidate() + +T.finish() diff --git a/tests/engine/trade_anim_unskippable_bug1985.lua b/tests/engine/trade_anim_unskippable_bug1985.lua new file mode 100644 index 00000000..fb21c07f --- /dev/null +++ b/tests/engine/trade_anim_unskippable_bug1985.lua @@ -0,0 +1,66 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Data = T.fixtures.load() +local ids = T.fixtures.ids + +local S = require("tests.harness").suite("trade anim unskippable") +local check = S.check + +local Game = require("src.core.Game") +local Input = require("src.core.Input") +local StateStack = require("src.core.StateStack") +local SaveData = require("src.core.SaveData") +local Pokemon = require("src.pokemon.Pokemon") +local TradeAnim = require("src.ui.TradeAnim") + +Game.data = Data +Game.input = Input; Input:init() +Game.stack = StateStack; StateStack:init() +Game.save = SaveData.newGame() +require("src.render.Font").load(Data) + +local sent = Pokemon.new(Data, ids.species[1], 10) +local recv = Pokemon.new(Data, ids.species[2], 10) +recv.nickname = "DUX" +recv.ot = "TRAINER" +recv.otId = 8193 + +local done = false +local anim = TradeAnim.new(Game, { + sent = sent, received = recv, enemyName = "TRAINER", + onDone = function() done = true end, +}) +Game.stack:push(anim) +if anim.enter then anim:enter() end + +check(anim:skipHeld() == false, "skipHeld is false with no button held") + +local frames = 0 +local function mash(n) + for _ = 1, n do + if done then return end + frames = frames + 1 + Input.pressed = { a = true, b = true } + StateStack:update(1 / 60) + Input.pressed = {} + end +end + +mash(60) +check(not done, "60 frames of A+B does not finish the cinematic") +check(anim.phase == "show_player", + "still in show_player after 60 frames of mashing, got " .. tostring(anim.phase)) +check(anim.sub == "slide", + "Trade_ShowPlayerMon's 63-frame slide is still running, got " .. tostring(anim.sub)) + +local guard = 0 +while not done and guard < 20000 do + guard = guard + 1 + mash(1) +end +check(done, "the cinematic still reaches onDone on its own timers") +check(frames >= 1000, + "the full sequence runs on cart-length delays, took " .. frames .. " frames") + +S.finish() diff --git a/tests/gen2_battle_items_test.lua b/tests/gen2_battle_items_test.lua index 186e4596..954733ba 100644 --- a/tests/gen2_battle_items_test.lua +++ b/tests/gen2_battle_items_test.lua @@ -195,6 +195,25 @@ local function runToMenu(screen, cap) return false end +-- engine/items/item_effects.asm:1748 +local function drainItemResult(pushed) + for _ = 1, 400 do + local top = pushed[#pushed] + if not (top and top.itemResult) then return end + Input:overlayPressed("a") + Input:step() + top:update(1 / 60) + Input:overlayReleased("a") + end +end + +local function pick(pushed, slot, mon) + local picker = pushed[#pushed] + picker.onChoose(slot, mon) + drainItemResult(pushed) + return picker +end + -- ---- StatusHealingEffect on a BENCHED mon --------------------------------- do local lead = Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect }) @@ -215,7 +234,7 @@ do local picker = pushed[#pushed] eq(getmetatable(picker), PartyMenu, "and the pick is the party screen") eq(picker.prompt, PartyMenu.PROMPTS.useItem, "under the cart's own question") - picker.onChoose(2, bench) + pick(pushed, 2, bench) eq(bench.status, nil, "the ANTIDOTE cured the BENCHED mon") eq(bench.toxicCounter, nil, "and the toxic ramp went with the status byte") eq(save.inventory.ANTIDOTE, 1, "one ANTIDOTE left the bag") @@ -244,7 +263,7 @@ do -- The berries carry the same StatusHealingActions rows as the shop cures. screen:useItem("PSNCUREBERRY") - pushed[#pushed].onChoose(2, bench) + pick(pushed, 2, bench) eq(bench.status, nil, "PSNCUREBERRY cures poison like an ANTIDOTE") eq(save.inventory.PSNCUREBERRY, nil, "and the berry is eaten") check(runToMenu(screen), "back to the menu") @@ -296,7 +315,7 @@ do battle:volatile(player).confuseCount = 3 local turn0 = battle.turn screen:useItem("FULL_HEAL") - pushed[#pushed].onChoose(1, player) + pick(pushed, 1, player) eq(battle:volatile(player).confuseCount, nil, "a $ff-mask item clears SUBSTATUS_CONFUSED on whoever is out") eq(save.inventory.FULL_HEAL, 1, "and it costs the FULL HEAL") @@ -308,7 +327,7 @@ do player.status = "poison" battle:volatile(player).confuseCount = 3 screen:useItem("ANTIDOTE") - pushed[#pushed].onChoose(1, player) + pick(pushed, 1, player) eq(player.status, nil, "the ANTIDOTE still cures the poison") eq(battle:volatile(player).confuseCount, 3, "but a masked cure leaves the confusion alone") @@ -355,7 +374,7 @@ do local maxHp = bench.maxHp or bench.stats.hp local turn0 = battle.turn screen:useItem("REVIVE") - pushed[#pushed].onChoose(2, bench) + pick(pushed, 2, bench) eq(bench.hp, math.max(1, math.floor(maxHp / 2)), "REVIVE stands the BENCHED mon up at half max HP (ReviveHalfHP)") eq(bench.status, nil, "with its status byte cleared") @@ -365,7 +384,7 @@ do bench.hp = 0 screen:useItem("MAX_REVIVE") - pushed[#pushed].onChoose(2, bench) + pick(pushed, 2, bench) eq(bench.hp, maxHp, "MAX REVIVE takes ReviveFullHP instead") eq(save.inventory.MAX_REVIVE, nil, "and is spent") check(runToMenu(screen), "the turn drains") @@ -406,7 +425,7 @@ do pushed[#pushed].onChoose(1, player) local turn0 = battle.turn - pushed[#pushed].onChoose(2) + pick(pushed, 2) eq(player.moves[2].pp, 14, "the ETHER put 10 PP back into the chosen slot") eq(player.moves[1].pp, 35, "and left the other slot alone") eq(save.inventory.ETHER, 1, "one ETHER spent") @@ -427,7 +446,7 @@ do player.moves[2].pp = 14 local depth = #pushed screen:useItem("ELIXER") - pushed[#pushed].onChoose(1, player) + pick(pushed, 1, player) eq(#pushed, depth, "the ELIXER opened no move list") eq(player.moves[1].pp, 35, "it filled the first slot (capped at max)") eq(player.moves[2].pp, 24, "and walked on to the second") @@ -435,7 +454,7 @@ do check(runToMenu(screen), "the turn drains") screen:useItem("MAX_ELIXER") - pushed[#pushed].onChoose(1, player) + pick(pushed, 1, player) eq(player.moves[2].pp, 35, "MAX ELIXER takes the .restore_all arm") eq(save.inventory.MAX_ELIXER, nil, "and is spent") end diff --git a/tests/gen2_battle_pack_test.lua b/tests/gen2_battle_pack_test.lua index 9b3e3889..2595a408 100644 --- a/tests/gen2_battle_pack_test.lua +++ b/tests/gen2_battle_pack_test.lua @@ -172,6 +172,21 @@ local function runToMenu(screen, cap) return false end +-- engine/items/item_effects.asm:1748 +local function pick(pushed, slot, mon) + local picker = pushed[#pushed] + picker.onChoose(slot, mon) + for _ = 1, 400 do + local top = pushed[#pushed] + if not (top and top.itemResult) then break end + Input:overlayPressed("a") + Input:step() + top:update(1 / 60) + Input:overlayReleased("a") + end + return picker +end + -- The real menu press: the 2x2 grid's DOWN puts the cursor on PACK. local function openPackFromMenu(screen, pushed) Input:overlayPressed("down") @@ -421,7 +436,7 @@ do local turn0 = battle.turn screen:useItem("PARLYZ_HEAL") - pushed[#pushed].onChoose(1, player) + pick(pushed, 1, player) eq(player.status, nil, "a PARLYZ HEAL cures it in battle") eq(save.inventory.PARLYZ_HEAL, nil, "the item is spent") eq(battle.turn, turn0 + 1, "and the turn with it") diff --git a/tests/gen2_battle_test.lua b/tests/gen2_battle_test.lua index e086328a..c401f3c7 100644 --- a/tests/gen2_battle_test.lua +++ b/tests/gen2_battle_test.lua @@ -1270,15 +1270,28 @@ do magWild.hp = 200 magWild.maxHp = 200 local magEvents = magBattle:takeTurn({ kind = "move", move = "MAGNITUDE" }) - local announced + local announced, announceEvent, usedEvent for _, ev in ipairs(magEvents) do + if ev.kind == "move" and ev.move == "MAGNITUDE" and not usedEvent then + usedEvent = ev + end if ev.kind == "message" and type(ev.text) == "string" - and ev.text:match("^Magnitude %d+") then + and ev.text:match("^Magnitude %d+") and not announced then announced = ev.text - break + announceEvent = ev end end check("magnitude announces rolled number", announced, "Magnitude 8!") + check("magnitude used-move line defers the animation", + usedEvent and usedEvent.deferAnim, true) + check("magnitude used-move line burns the move delay", + usedEvent and usedEvent.animDelay, true) + check("magnitude used-move line owns no animation", + usedEvent and usedEvent.moveAnim, nil) + check("magnitude line carries the animation", + announceEvent and announceEvent.moveAnim, "MAGNITUDE") + check("magnitude line carries the attacking side", + announceEvent and announceEvent.side, "player") check("magnitude deals more than power-1 would", magWild.hp < 200, true) end diff --git a/tests/gen2_battle_ui_test.lua b/tests/gen2_battle_ui_test.lua index 549e84e7..37434776 100644 --- a/tests/gen2_battle_ui_test.lua +++ b/tests/gen2_battle_ui_test.lua @@ -282,6 +282,18 @@ local function runToMenu(screen, cap) return false end +-- engine/items/item_effects.asm:1748 +local function drainItemResult(picker) + for _ = 1, 400 do + if not picker.itemResult then return true end + Input:overlayPressed("a") + Input:step() + picker:update(1 / 60) + Input:overlayReleased("a") + end + return not picker.itemResult +end + do local screen = newScreen({ inventory = { MASTER_BALL = 1, POKE_BALL = 1, @@ -493,6 +505,9 @@ do local benchBefore = bench.hp picker.onChoose(2, bench) + eq(picker.itemResult and picker.itemResult.shown, benchBefore, + "the picked row's bar starts at the pre-heal HP (wWhichHPBar = $2)") + check(drainItemResult(picker), "and the button drops the list") eq(bench.hp, math.min(bench.maxHp or bench.stats.hp, benchBefore + 20), "the POTION landed on the BENCHED mon") eq(save.inventory.POTION, 1, "and one POTION left the bag") @@ -700,11 +715,16 @@ do screenSave.inventory.POTION = 1 screen:useItem("POTION") local picker = screen.game.stack.top() + local hurt = player.hp picker.onChoose(1, player) - check(screen.hpAnim ~= nil and screen.hpAnim.side == "player", - "healing the ACTIVE mon arms the bar chase (HealHP_SFX_GFX's AnimateHPBar)") + -- engine/items/item_effects.asm:1671 + check(picker.itemResult ~= nil and picker.itemResult.shown == hurt + and picker.itemResult.target == player.hp, + "healing the ACTIVE mon climbs the party row's bar") + eq(screen.hpAnim, nil, "with no HUD chase armed behind the list") + check(drainItemResult(picker), "the button drops the list") check(runToMenu(screen), "the heal turn drains") - eq(screen.shownHp.player, player.hp, "and the bar refilled to the new HP") + eq(screen.shownHp.player, player.hp, "and the HUD came back at the new HP") end -- ---- GROWL's sound comes from its own anim script (cache-fed) ------------- @@ -862,6 +882,36 @@ do eq(screen.showPlayerHud, true, "and UpdatePlayerHUD after the send-out") end +-- ../pokecrystal/engine/battle/core.asm:8063 +do + local screen = newScreen() + check(screen:introGrayscale(), "PREDEFPAL_BLACKOUT covers frame 0") + run(screen, BattleAnimView.SLIDE_FRAMES - 1) + check(screen:introGrayscale(), "and the last frame of the slide") + run(screen, 1) + check(not screen:introGrayscale(), + "SCGB_BATTLE_COLORS at the tail of InitBattleDisplay") +end + +-- engine/battle/core.asm:8733 +do + local screen = newScreen() + eq(screen.ballRows.player, false, "no player ball row at construction") + eq(screen.ballRows.enemy, false, "nor an OT row") + run(screen, BattleAnimView.SLIDE_FRAMES) + eq(screen.ballRows.player, false, "and neither rode in with the bands") + eq(screen.ballRows.enemy, false, "on either side") + for _ = 1, 3000 do + Input:step() + screen:update(1 / 60) + if screen.message == "Wild PIDGEY appeared!" then break end + end + eq(screen.message, "Wild PIDGEY appeared!", "BattleStartMessage's own line") + eq(screen.ballRows.player, true, "ShowPlayerMonsRemaining rides that line") + eq(screen.ballRows.enemy, false, + "and wBattleMode skips ShowOTTrainerMonsRemaining in the wild") +end + -- ---- the wild mon cries with BattleStartMessage ---------------------------- -- -- `.not_shiny`: `ld a, [wTempEnemyMonSpecies] / call PlayStereoCry` runs before @@ -1132,15 +1182,23 @@ do run(screen2, 1) check(siren, "the second battle's siren is up too") screen2:useItem("POTION") - screen2.game.stack.top().onChoose(1, player2) + local picker2 = screen2.game.stack.top() + picker2.onChoose(1, player2) check(not siren, "the POTION silences it where UseDisposableItem sits") - check(screen2.hpAnim and screen2.hpAnim.side == "player", - "with the bar only starting to climb") + check(picker2.itemResult and picker2.itemResult.shown == 2, + "with the party row's bar only starting to climb") local rangDuringClimb = false + for _ = 1, 600 do + if not picker2.itemResult then break end + Input:overlayPressed("a") + Input:step() + picker2:update(1 / 60) + Input:overlayReleased("a") + if picker2.itemResult and siren then rangDuringClimb = true end + end for _ = 1, 600 do Input:step() screen2:update(1 / 60) - if screen2.hpAnim and siren then rangDuringClimb = true end if screen2.phase == "menu" then break end end check(not rangDuringClimb, "and it stays silent for the whole climb") diff --git a/tests/gen2_field_items_test.lua b/tests/gen2_field_items_test.lua index 63e36568..c28c8358 100644 --- a/tests/gen2_field_items_test.lua +++ b/tests/gen2_field_items_test.lua @@ -188,13 +188,13 @@ do local result = ItemEffects.useOnMon("POTION", mon, DATA) eq(result.used, true, "a POTION on a hurt mon is spent") eq(mon.hp, 30, "and heals HealingHPAmounts' 20") - eq(result.text, "CYNDAQUIL\nrecovered 20 HP!", "with the cart's line") + eq(result.text, "CYNDAQUIL\nrecovered 20HP!", "with the cart's line") local capped = fixtureMon(12, { hp = mon.maxHp - 5 }) local capResult = ItemEffects.useOnMon("HYPER_POTION", capped, DATA) eq(capResult.used, true, "a heal near full HP still lands") eq(capped.hp, capped.maxHp, "capped at max HP") - eq(capResult.text, "CYNDAQUIL\nrecovered 5 HP!", "printing the real delta") + eq(capResult.text, "CYNDAQUIL\nrecovered 5HP!", "printing the real delta") local full = fixtureMon(12) eq(ItemEffects.useOnMon("POTION", full, DATA).used, false, @@ -444,16 +444,18 @@ do eq(party.prompt, "Use on which ?", "under UseOnWhichPKMNString") -- Pick the first mon: the real PartyMenu update loop takes the A press. - drive(host, function() - return host.stack:top() ~= party - end) - local box = host.stack:top() - check(box ~= nil and box.pages ~= nil, "the pick lands a TextBox") + drive(host, function() return party.itemResult ~= nil end) + -- engine/items/item_effects.asm:1748 + check(party.itemResult ~= nil, "the pick stays in the party list") + eq(party.itemResult.text, "CYNDAQUIL\nrecovered 20HP!", + "with PARTYMENUTEXT_HEAL_HP in the list's box") + eq(party.itemResult.shown, 10, "the bar starts at the pre-heal HP") + eq(party.itemResult.target, 30, "and climbs to the healed value") eq(host.save.party[1].hp, 30, "the POTION healed through the real menu") eq(host.save.inventory.POTION, 1, "and one POTION left the pack") drive(host, function() return host.stack:top() == packSentinel end) eq(host.stack:top(), packSentinel, - "dismissing the message returns to the pack, not past it") + "the button returns to the pack, not past it") end do diff --git a/tests/online_panel.lua b/tests/online_panel.lua index c8466d05..0c13f69c 100644 --- a/tests/online_panel.lua +++ b/tests/online_panel.lua @@ -283,11 +283,15 @@ Client.you = savedYou local joined = nil local savedJoin = Client.joinRoom -Client.joinRoom = function(code, as) joined = { code = code, as = as } end +Client.joinRoom = function(code, as, profile) + joined = { code = code, as = as, profile = profile } +end T.check(OnlinePanel.joinByCode(imp, "ab2cd3", "player"), "a six-character code joins") T.eq(joined and joined.code, "AB2CD3", "the code is upper-cased on the way out") T.eq(joined and joined.as, "player", "the join role is passed through") +T.eq(joined and joined.profile and joined.profile.version, "red", + "and the join carries the profile of the game that is picked") joined = nil T.check(not OnlinePanel.joinByCode(imp, "AB", "player"), "a short code is refused") @@ -1704,12 +1708,14 @@ do _pages = {}, _uiActions = {}, _actAt = {} } local sst = OnlinePanel.state(simp) sst.version, sst.ready = "red", true + sst.profiles["red|vanilla|-"] = { profile = { engine = 1, version = "red", + kind = "vanilla", fingerprint = "f1" } } local savedJoin, savedTour, savedLobby = Client.joinRoom, Client.joinTournament, Client.lobby local roomJoins, tourJoins = {}, {} local pendingRoom - Client.joinRoom = function(code, as) - roomJoins[#roomJoins + 1] = { code = code, as = as } + Client.joinRoom = function(code, as, profile) + roomJoins[#roomJoins + 1] = { code = code, as = as, profile = profile } pendingRoom = { code = code, done = false } return pendingRoom end @@ -1829,4 +1835,141 @@ do T.eq(remote:update(), "done", "but a finished trade stays finished") end +do + local himp = { ready = { red = true, crystal = true }, modScope = "crystal", + activeSlot = {}, slots = {}, pulse = 0, + _pages = {}, _uiActions = {}, _actAt = {} } + T.eq(OnlinePanel.selectedVersion(himp), "crystal", + "the ONLINE tab follows the launcher's cartridge, not ORDER[1]") + local hst = OnlinePanel.state(himp) + hst.versionPicked = true + himp.modScope = "red" + T.eq(OnlinePanel.selectedVersion(himp), "crystal", + "a game picked in the wizard is not yanked by the header") +end + +do + local wimp = { ready = { red = true, crystal = true }, modScope = "crystal", + activeSlot = {}, slots = {}, pulse = 0, + _pages = {}, _uiActions = {}, _actAt = {} } + T.eq(OnlinePanel.selectedVersion(wimp), "crystal", "the tab starts on the header") + local wst = OnlinePanel.state(wimp) + wst.joinWant = { code = "AB2CD3", as = "player", at = 1e9 } + wimp.modScope = "red" + T.eq(OnlinePanel.selectedVersion(wimp), "crystal", + "a parked join is not re-aimed at another game by the header") +end + +do + local aimp = { ready = { red = true, crystal = true }, modScope = "crystal", + activeSlot = {}, slots = {}, pulse = 0, + _pages = {}, _uiActions = {}, _actAt = {} } + local ast = OnlinePanel.state(aimp) + T.eq(OnlinePanel.selectedVersion(aimp), "crystal", "the tab starts on the header") + local savedLobby = Client.lobby + Client.lobby = function() + return { { code = "RM1234", intent = "battle", + profile = { version = "red", kind = "vanilla" } } } + end + T.check(OnlinePanel.alignToRoom(aimp, "RM1234"), "aligning to a listed room") + T.eq(ast.version, "red", "takes the room's game") + T.eq(OnlinePanel.selectedVersion(aimp), "red", + "and the header does not yank the tab while the room is live") + Client.lobby = savedLobby + OnlinePanel.update(aimp, 1 / 60) + T.eq(ast.roomPicked, nil, "leaving the room releases the room's pin") + T.eq(OnlinePanel.selectedVersion(aimp), "crystal", + "so the header drives the tab again") +end + +do + local pimp = { ready = { red = true, crystal = true }, modScope = "crystal", + activeSlot = {}, slots = {}, pulse = 0, + _pages = {}, _uiActions = {}, _actAt = {} } + local pst = OnlinePanel.state(pimp) + pst.ready = true + local sent, profiles = {}, nil + local savedJoin, savedSet = Client.joinRoom, Client.setProfiles + Client.joinRoom = function(code, as, profile) + sent[#sent + 1] = { code = code, as = as, profile = profile } + return { code = code, done = false } + end + Client.setProfiles = function(list) profiles = list return list end + + T.check(not OnlinePanel.joinByCode(pimp, "ab2cd3", "player"), + "a join with no computed profile yet is not sent") + T.eq(#sent, 0, "nothing goes on the wire") + T.eq(pst.joinWant and pst.joinWant.code, "AB2CD3", "the join is parked") + T.eq(pst.profileWant, "crystal|vanilla|-", + "and the profile for the picked game is queued") + + pst.profiles["crystal|vanilla|-"] = { profile = { engine = 2, + version = "crystal", kind = "vanilla", fingerprint = "c1" } } + OnlinePanel.update(pimp, 1 / 60) + T.eq(#sent, 1, "the parked join fires once the profile lands") + T.eq(sent[1] and sent[1].profile and sent[1].profile.version, "crystal", + "carrying the game the player picked") + T.eq(profiles and profiles[1] and profiles[1].version, "crystal", + "and the client's own snapshot is refreshed with it") + T.eq(pst.joinWant, nil, "the parked join is cleared") + T.eq(OnlinePanel.screen(pimp), "room", "landing on the Room screen") + + local savedTime = love.timer.getTime + local clock = 0 + love.timer.getTime = function() return clock end + pst.pending = { code = "AB2CD3", done = false, at = 0 } + clock = OnlinePanel.JOIN_WAIT + 1 + OnlinePanel.update(pimp, 1 / 60) + T.eq(pst.pending, nil, "a join the relay never answers stops pending") + T.eq(pst.status, "The relay didn't answer.", "and says so") + T.eq(OnlinePanel.screen(pimp), "play", "off the dead Room screen") + + pst.status = nil + pst.profiles["crystal|vanilla|-"] = { profile = nil, reason = "no cache" } + clock = 100 + T.check(not OnlinePanel.joinByCode(pimp, "cd3ab2", "player"), + "an unreadable profile refuses the join") + T.eq(pst.status, "no cache", "with the reason") + clock = 100 + OnlinePanel.JOIN_WAIT + 1 + OnlinePanel.update(pimp, 1 / 60) + T.eq(pst.joinWant, nil, "and the parked join gives up on its own deadline") + T.eq(pst.status, "Couldn't read your game, so the join was not sent.", + "saying why instead of waiting forever") + love.timer.getTime = savedTime + Client.joinRoom, Client.setProfiles = savedJoin, savedSet + OnlinePanel.home(pimp) +end + +do + local savedProfiles = Client.profiles() + Client.setProfiles({ { version = "red" } }) + T.eq(Client.profiles()[1].version, "red", + "setProfiles replaces the client's profile snapshot") + Client.setProfiles(savedProfiles) +end + +do + local mimp = { ready = { red = true }, activeSlot = {}, slots = {}, pulse = 0, + _pages = {}, _uiActions = {}, _actAt = {} } + local mst = OnlinePanel.state(mimp) + mst.version, mst.kind = "red", "vanilla" + local ArenaData = require("src.online.ArenaData") + local savedSpecies = ArenaData.speciesIds + ArenaData.speciesIds = function() return { PIKACHU = true } end + T.eq(OnlinePanel.partyRefusal(mimp, { { species = "PIKACHU" } }), nil, + "a vanilla party is sent as it is") + T.eq(OnlinePanel.partyRefusal(mimp, { { species = "PIKACHU" }, + { species = "CELEBI" } }), + "CELEBI is not in the vanilla game, so it cannot go online.", + "a mod species is named and refused before the handshake") + mst.kind, mst.cartId = "cart", "kanto" + T.eq(OnlinePanel.partyRefusal(mimp, { { species = "CELEBI" } }), nil, + "a cart arena is the cart's business, not the vanilla list's") + mst.kind, mst.cartId = "vanilla", nil + ArenaData.speciesIds = function() return nil end + T.eq(OnlinePanel.partyRefusal(mimp, { { species = "CELEBI" } }), nil, + "and an unreadable dataset keeps its opinion to itself") + ArenaData.speciesIds = savedSpecies +end + T.finish("online panel")