diff --git a/tests/shiny_obs.lua b/tests/shiny_obs.lua new file mode 100644 index 0000000..4c11892 --- /dev/null +++ b/tests/shiny_obs.lua @@ -0,0 +1,110 @@ +-- Driver: five shiny encounters, five outdoor places, paced for a capture. +-- +-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/shiny_obs.lua \ +-- "/c/Program Files/LOVE/lovec.exe" . +-- +-- Run from the PROJECT ROOT. Nothing here is a screenshot test -- it is a +-- performance, timed for somebody recording the window. +-- +-- Each beat: teleport, let the ground mesh, open a wild battle, let the +-- Pokemon ARRIVE (the wipe, the send-out, the shiny sparkle), then hold +-- three full seconds on it before closing the battle and moving on. The hold +-- is deliberately silent -- no button taps during it -- so the recording is +-- of the Pokemon standing there and not of a text box being clicked through. +-- +-- Every encounter is shiny, because the odds are pinned to 1 for the run and +-- the roll still happens where it always does, inside Pokemon.new. +return function(game) + local U = dofile("tests/drivers/util.lua") + local BattleState = require("src.battle.BattleState") + local Pokemon = require("src.pokemon.Pokemon") + + local exports = game.mods and game.mods.exports + local lib = exports and exports.DRAMATIC_SHAPE and exports.DRAMATIC_SHAPE.lib + if not lib then + U.log("DRAMATIC_SHAPE is not loaded -- nothing to show") + return + end + local Shiny = lib.require("Shiny") + local OverworldBattle = lib.require("OverworldBattle") + local StadiumInstall = lib.require("StadiumInstall") + + -- ------- timing, in frames at 60fps + local SETTLE = 110 -- after a teleport, for the neighbourhood to mesh + local ARRIVE = 70 -- the wipe, then the Pokemon landing on its tile + local HOLD = 180 -- THE THREE SECONDS + local BETWEEN = 45 -- back on the map before the next one opens + + -- ------- the models + if not StadiumInstall.ready() then + U.log("building stadium models first...") + StadiumInstall.begin() + local guard = 0 + while not StadiumInstall.ready() and guard < 2000 do + for _ = 1, 6 do StadiumInstall.step() end + U.wait(1) + guard = guard + 1 + end + end + U.log("stadium ready: " .. tostring(StadiumInstall.ready())) + + OverworldBattle.setting:setValue("stadium", game) + Shiny.setOdds(1) -- every encounter, shiny + + game.save.player.name = "RED" + game.save.party = { Pokemon.new(game.data, "PIKACHU", 50) } + + -- Five outdoor places, deliberately unalike -- a coast, an open route, a + -- water city, a rocky pass and a wooded shore -- so the recording is five + -- different-looking fights and not the same meadow five times. Each mon is + -- put somewhere its colour has something to sit against. + local RUNS = { + { "BLASTOISE", 45, "PALLET_TOWN", 5, 6 }, + { "PIDGEOTTO", 32, "ROUTE_1", 5, 8 }, + { "GYARADOS", 45, "CERULEAN_CITY", 10, 12 }, + { "CHARIZARD", 50, "ROUTE_4", 10, 5 }, + { "NINETALES", 42, "ROUTE_25", 12, 5 }, + } + + -- a beat before the first one, so a recorder that started with the window + -- is not already mid-encounter by the time it is rolling + U.log("starting in 3...") + U.wait(60) + U.log("2...") + U.wait(60) + U.log("1...") + U.wait(60) + + for i, r in ipairs(RUNS) do + local species, level, map, cx, cy = r[1], r[2], r[3], r[4], r[5] + if not game.data.pokemon[species] then + U.log(("skip %s -- not in this dataset"):format(species)) + elseif not game.data.maps[map] then + U.log(("skip %s -- no map %s"):format(species, map)) + else + U.teleport(game, map, cx, cy, "down") + U.wait(SETTLE) + + local battle = BattleState.newWild(game, species, level) + battle.onFinish = function() end + game.overworld:pushBattle(battle) + + local mon = battle.enemy and battle.enemy.mon + U.log(("%d/5 %-10s %-14s shiny=%s") + :format(i, species, map, tostring(Shiny.isShiny(mon)))) + + -- the arrival, then three seconds of nothing but the Pokemon + U.wait(ARRIVE) + U.wait(HOLD) + + -- close the battle and go back to the map + while game.stack:top() and game.stack:top() ~= game.overworld do + game.stack:pop() + end + U.wait(BETWEEN) + end + end + + Shiny.setOdds(8192) + U.log("done -- five encounters") +end diff --git a/tests/shiny_one.lua b/tests/shiny_one.lua new file mode 100644 index 0000000..7256c02 --- /dev/null +++ b/tests/shiny_one.lua @@ -0,0 +1,133 @@ +-- Driver: ONE shiny encounter, at real 1x speed, held open until you close +-- the window. Meant to be launched once per Pokemon by tests/shiny_run.sh. +-- +-- DS_SPECIES=GYARADOS DS_LEVEL=45 DS_MAP=CERULEAN_CITY DS_CX=10 DS_CY=12 \ +-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/shiny_one.lua \ +-- "/c/Program Files/LOVE/lovec.exe" . +-- +-- ------- why the pacing is done here +-- +-- A driver run is deliberately UNPACED: main.lua's pacingEnabled() returns +-- false whenever POKEPORT_DRIVER is set, so love.run spins as fast as the +-- machine will go and the loop takes one logic step (Game:update(1/60)) per +-- turn of it. That is right for a screenshot script and wrong for a capture: +-- the game runs at whatever multiple of real time the hardware manages, so +-- "wait 180 frames" is three seconds only by coincidence. +-- +-- The fix does not need an engine change. The loop is blocked while this +-- coroutine is running, so sleeping HERE before each yield paces the whole +-- thing -- one 1/60 logic step per 1/60 of real time, which is 1x by +-- construction. Nothing else in the engine has to know. +-- +-- ------- and why it never finishes +-- +-- When a driver coroutine goes dead, main.lua calls love.event.quit(). So +-- holding the battle open forever is exactly how the window stays up until +-- somebody closes it, which is the handshake this run wants: one window, one +-- Pokemon, closed by hand, and only then the next. +return function(game) + local U = dofile("tests/drivers/util.lua") + local BattleState = require("src.battle.BattleState") + local Pokemon = require("src.pokemon.Pokemon") + + local SPECIES = os.getenv("DS_SPECIES") or "GYARADOS" + local LEVEL = tonumber(os.getenv("DS_LEVEL") or "") or 45 + local MAP = os.getenv("DS_MAP") or "ROUTE_1" + local CX = tonumber(os.getenv("DS_CX") or "") or 5 + local CY = tonumber(os.getenv("DS_CY") or "") or 8 + local LEAD = tonumber(os.getenv("DS_LEAD") or "") or 2.0 -- seconds + + -- ------- real-time pacing + -- + -- Each call is one logic step AND one sixtieth of a second of wall clock. + -- The target is carried forward rather than measured from "now" so a slow + -- frame is absorbed by the next one instead of compounding into drift. + local nextAt = nil + local function step() + local now = love.timer.getTime() + nextAt = (nextAt and (nextAt + 1 / 60)) or (now + 1 / 60) + local slack = nextAt - now + if slack > 0 then + love.timer.sleep(slack) + else + nextAt = now -- fell behind; do not try to catch up + end + coroutine.yield() + end + + local function hold(seconds) + for _ = 1, math.floor(seconds * 60) do step() end + end + + -- Unpaced, on purpose: a model rebuild is a loading screen, not part of + -- the capture, and there is no reason to watch it at 1x. + local function fast(n) + for _ = 1, n do coroutine.yield() end + end + + local exports = game.mods and game.mods.exports + local lib = exports and exports.DRAMATIC_SHAPE and exports.DRAMATIC_SHAPE.lib + if not lib then + U.log("DRAMATIC_SHAPE is not loaded") + return + end + local Shiny = lib.require("Shiny") + local OverworldBattle = lib.require("OverworldBattle") + local StadiumInstall = lib.require("StadiumInstall") + + if not StadiumInstall.ready() then + U.log("building stadium models (not part of the capture)...") + StadiumInstall.begin() + local guard = 0 + while not StadiumInstall.ready() and guard < 2000 do + for _ = 1, 6 do StadiumInstall.step() end + fast(1) + guard = guard + 1 + end + end + + OverworldBattle.setting:setValue("stadium", game) + Shiny.setOdds(1) -- this encounter is shiny + + game.save.player.name = "RED" + game.save.party = { Pokemon.new(game.data, "PIKACHU", 50) } + + if not game.data.pokemon[SPECIES] then + U.log("no such species: " .. SPECIES) + return + end + if not game.data.maps[MAP] then + U.log("no such map: " .. MAP) + return + end + + U.teleport(game, MAP, CX, CY, "down") + hold(1.6) -- let the neighbourhood mesh + + hold(LEAD) -- a beat before the fight opens + + local battle = BattleState.newWild(game, SPECIES, LEVEL) + battle.onFinish = function() end + game.overworld:pushBattle(battle) + + local mon = battle.enemy and battle.enemy.mon + U.log(("%s at %s -- shiny=%s (1x; close the window for the next one)") + :format(SPECIES, MAP, tostring(Shiny.isShiny(mon)))) + + -- The battle stays up, at 1x, until the window is closed by hand. No taps: + -- the recording should be the Pokemon standing there, not a text box being + -- clicked through. + -- + -- DS_AUTOCLOSE is for checking the pacing without a person in the loop: set + -- it to a number of seconds and the run should take about that long by the + -- wall clock, which is the only way to prove 1x is actually 1x. + local auto = tonumber(os.getenv("DS_AUTOCLOSE") or "") or 0 + if auto > 0 then + local t0 = love.timer.getTime() + hold(auto) + U.log(("autoclose: asked for %.1fs, took %.2fs") + :format(auto, love.timer.getTime() - t0)) + return + end + while true do step() end +end diff --git a/tests/shiny_run.sh b/tests/shiny_run.sh new file mode 100644 index 0000000..9025851 --- /dev/null +++ b/tests/shiny_run.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# Five shiny encounters, five outdoor places, one window at a time. +# +# bash mods/DramaticShapeVoxelMod/tests/shiny_run.sh +# +# Run from the PROJECT ROOT. +# +# Each Pokemon gets its OWN game window, launched only after the previous one +# has been closed -- the loop blocks on the process, so closing the window is +# what advances it. Nothing is on a timer, so a take can be as long as it +# needs to be, and a bad one can just be closed and re-run. +# +# The battles run at true 1x: tests/shiny_one.lua paces itself against the +# wall clock, because a POKEPORT_DRIVER run is otherwise unpaced and goes as +# fast as the machine can manage. +set -u + +LOVE=${LOVE:-/c/Program Files/LOVE/lovec.exe} +DRIVER=mods/DramaticShapeVoxelMod/tests/shiny_one.lua + +# species | level | map | cell x | cell y +RUNS=( + "BLASTOISE|45|PALLET_TOWN|5|6" + "PIDGEOTTO|32|ROUTE_1|5|8" + "GYARADOS|45|CERULEAN_CITY|10|12" + "CHARIZARD|50|ROUTE_4|10|5" + "NINETALES|42|ROUTE_25|12|5" +) + +OPTS="$APPDATA/LOVE/pokemon-love2d/options.lua" +if [ -f "$OPTS" ]; then + cp "$OPTS" "$OPTS.shiny_run_backup" + echo "backed up options.lua" +fi + +i=0 +for row in "${RUNS[@]}"; do + i=$((i + 1)) + IFS='|' read -r SPECIES LEVEL MAP CX CY <<< "$row" + echo "" + echo "=== $i/5 $SPECIES at $MAP ===" + echo " close the window when you are done recording it" + DS_SPECIES="$SPECIES" DS_LEVEL="$LEVEL" DS_MAP="$MAP" \ + DS_CX="$CX" DS_CY="$CY" \ + POKEPORT_DRIVER="$DRIVER" "$LOVE" . +done + +# The game persists the whole options table mid-run (OverworldBattle.forceOG), +# so a run leaves the display settings it used on disk. Put them back. +if [ -f "$OPTS.shiny_run_backup" ]; then + cp "$OPTS.shiny_run_backup" "$OPTS" + echo "" + echo "options.lua restored" +fi + +echo "done -- five encounters"