DramaticShapeVoxelMod archival mirror · read-only
tests/letsgo_empty.lua

letsgo_empty.lua

tests/letsgo_empty.lua14.4 KB1006449d8646b7aa
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367
-- Driver: the two edges the capture mode has to hold on to.
--
--   A  FULL with an EMPTY BAG. The encounter must still be a Let's Go
--      encounter -- head-on seat, no player Pokemon, no classic menu --
--      with the readout saying there is nothing to throw and RUN as the
--      way out. The failure this catches is the old behaviour: falling
--      back to the battle menu, which under FULL offers a FIGHT against a
--      foe that never takes a turn.
--
--   B  FULL, running DRY MID-ENCOUNTER. One ball, thrown weakly enough to
--      fall short: the miss must land in the empty hand rather than
--      tearing the session down.
--
--   C  The SCRIPTED catch tutorial (the VIRIDIAN CITY old man; PROF.OAK
--      and the PIKACHU are the same makeOldManDemo). LET'S GO must not
--      touch it at any rung -- no session, no held camera, no veil -- and
--      it must play its scripted throw through to its own ending.
--
--   SHOT_DIR=.scratchpad/letsgo \
--   POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/letsgo_empty.lua \
--   "/c/Program Files/LOVE/lovec.exe" .
return function(game)
  local U = dofile("tests/drivers/util.lua")
  local DIR = os.getenv("SHOT_DIR") or ".scratchpad"
  local Pokemon = require("src.pokemon.Pokemon")
  local BattleState = require("src.battle.BattleState")
  local Bag = require("src.inventory.Bag")

  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 -- enable it and run again")
    return
  end
  local LetsGo = lib.require("LetsGo")
  local CatchThrow = lib.require("CatchThrow")
  local BattleScene = lib.require("BattleScene")

  game.save.party = {
    Pokemon.new(game.data, "CHARIZARD", 45),
    Pokemon.new(game.data, "PIKACHU", 10),
  }
  game.save.player.name = "RED"
  LetsGo.setting:setValue("full", game)
  if os.getenv("DS_RUNG") == "cards" then
    lib.require("OverworldBattle").setting:setValue(true, game)
    U.log("3D-BTL forced to 2D-3D A")
  end
  U.log("LET'S GO mode: " .. tostring(LetsGo.mode()))

  -- every ball out of the bag, whatever the save arrived with
  local BALLS = { "POKE_BALL", "GREAT_BALL", "ULTRA_BALL", "MASTER_BALL" }
  local function emptyBag()
    for _, id in ipairs(BALLS) do
      local n = game.save.inventory[id] or 0
      if n > 0 then pcall(Bag.remove, game.save, id, n) end
      game.save.inventory[id] = nil
    end
  end
  local function ballCount()
    local n = 0
    for _, id in ipairs(BALLS) do n = n + (game.save.inventory[id] or 0) end
    return n
  end

  -- A REAL key event, not U.tap's synthetic pressQueue inject. The bug
  -- this driver has to be able to see (button edges read on the render
  -- clock instead of the logic step) lives between love.keypressed and
  -- whoever polls the edge, so a driver that writes the queue itself
  -- jumps straight over it.
  --
  -- The B BUTTON's keyboard binding is "x" (or backspace) -- Input's
  -- DEFAULT_BINDINGS. The `b = "b"` next to it is the GAMEPAD table, so a
  -- driver that presses the "b" KEY presses nothing at all and reports a
  -- dead button whatever the code does.
  local B_KEY = "x"
  local function key(name)
    love.keypressed(name, name, false)
    U.wait(2)
    love.keyreleased(name, name)
    U.wait(2)
  end

  U.teleport(game, "ROUTE_1", 5, 8, "down")
  U.wait(90)

  -- drive the intro chatter until the menu would open (which under FULL is
  -- when capture mode takes over instead)
  local function toCapture(battle)
    U.wait(70)
    for _ = 1, 80 do
      if battle.phase == "menu" or CatchThrow.session() then break end
      U.tap(game, "a")
      U.wait(6)
    end
    U.wait(30)
  end

  -- Tap the fight all the way off the stack before the next case starts.
  -- Cases that merely tapped A a fixed number of times left the previous
  -- battle (and its session) alive whenever an outcome ran long, and the
  -- next case then measured the leftover -- which reads as that case
  -- failing, at a spot nowhere near the cause.
  local function closeOut(battle)
    for _ = 1, 400 do
      if not CatchThrow.session() and game.stack:top() ~= battle then break end
      U.tap(game, "a")
      U.wait(4)
    end
    U.wait(40)
  end

  local function describe(tag, battle)
    local s = CatchThrow.session()
    U.log(("%s: session=%s empty=%s phase=%s battle=%s veil=%s hidePlayer=%s")
          :format(tag, s and "yes" or "NO", s and tostring(s.empty) or "-",
                  s and s.phase or "-", tostring(battle.phase),
                  BattleScene.capture and "up" or "down",
                  tostring(BattleScene.capture
                           and BattleScene.capture.hidePlayer)))
    return s
  end

  -- ------- A: an empty bag still gets the capture screen

  emptyBag()
  U.log("== A: FULL with " .. ballCount() .. " balls")
  local a = BattleState.newWild(game, "PIDGEY", 5)
  a.onFinish = function() end
  game.overworld:pushBattle(a)
  toCapture(a)

  local s = describe("A", a)
  U.log(("A: hand is empty -> %s (want yes)"):format(
        (s and s.empty) and "yes" or "NO"))
  U.shot(game, DIR .. "/empty_1_aim.png")

  -- and B is the way out: a Let's Go wild always escapes
  key(B_KEY)
  for _ = 1, 120 do
    U.wait(2)
    if a.result then break end
    U.tap(game, "a")
  end
  U.log(("A: after B -- result=%s (want run)"):format(tostring(a.result)))
  -- the session and the held camera are swept by battle.ended, which is the
  -- teardown BELOW this, not the moment `result` is written -- so the sweep
  -- is only worth asserting once the battle has actually left the stack
  closeOut(a)
  U.log(("A: after teardown -- session=%s veil=%s (want gone/down)")
        :format(CatchThrow.session() and "still up" or "gone",
                BattleScene.capture and "still up" or "down"))

  -- ------- B: the LAST ball, thrown short

  Bag.add(game.save, "POKE_BALL", 1, game.data)
  CatchThrow.lastBall = "POKE_BALL"
  U.log("== B: FULL with " .. ballCount() .. " ball")
  local b = BattleState.newWild(game, "PIDGEY", 5)
  b.onFinish = function() end
  -- The outcome under test is what happens when the LAST ball MISSES, so
  -- the roll must not be left to chance: a run where the Pidgey happened
  -- to be caught used to cascade into every later case (the session lived
  -- on into its epilogue, and C and D then measured that leftover instead
  -- of their own). Forced failure, three shakes -- the roll is the
  -- engine's business and is covered elsewhere.
  b.catchAttempt = function() return false, 3 end
  game.overworld:pushBattle(b)
  toCapture(b)
  describe("B", b)

  -- a deliberately feeble swipe: it must fall short of the Pokemon, so
  -- the miss is the outcome under test rather than a lucky catch
  local aim = CatchThrow._aimInfo()
  if aim then
    local uw, uh = love.graphics.getDimensions()
    local function toWin(gx, gy)
      return (aim.lx + gx * aim.scale) * uw / aim.pw,
             (aim.ly + gy * aim.scale) * uh / aim.ph
    end
    local hx, hy = aim.hand[1], aim.hand[2]
    local dx, dy = aim.ring[1] - hx, aim.ring[2] - hy
    local d = math.sqrt(dx * dx + dy * dy)
    dx, dy = dx / d, dy / d
    local step = 120 / 60
    local px, py = toWin(hx, hy)
    love.mousepressed(px, py, 1, false, 1)
    for i = 1, 8 do
      local wx, wy = toWin(hx + dx * step * i, hy + dy * step * i)
      love.mousemoved(wx, wy, 0, 0, false)
      U.wait(1)
    end
    local wx, wy = toWin(hx + dx * step * 8, hy + dy * step * 8)
    love.mousereleased(wx, wy, 1, false, 1)
  else
    U.log("B: NO AIM INFO -- capture mode did not open")
  end

  -- ride the throw out, then keep tapping through the miss text until the
  -- hand is refilled -- which, with the bag now empty, means the EMPTY hand
  for _ = 1, 400 do
    U.wait(2)
    local q = CatchThrow.session()
    if q and q.phase == "aim" and q.empty then break end
    if not q then break end
    if q.phase ~= "aim" and q.phase ~= "flight" then U.tap(game, "a") end
  end
  local sb = describe("B", b)
  U.log(("B: last ball thrown, balls=%d -> %s (want an empty hand)")
        :format(ballCount(),
                (sb and sb.empty) and "empty hand" or
                (sb and ("still holding " .. tostring(sb.ballId)))
                or "SESSION GONE"))
  U.shot(game, DIR .. "/empty_2_ranout.png")
  key(B_KEY)
  for _ = 1, 120 do
    U.wait(2)
    if b.result then break end
    U.tap(game, "a")
  end
  U.log("B: after B -- result=" .. tostring(b.result) .. " (want run)")
  closeOut(b)

  -- ------- C: the scripted tutorial, untouched

  Bag.add(game.save, "POKE_BALL", 10, game.data)
  U.log("== C: the old man's demo, at FULL, with " .. ballCount() .. " balls")
  local om = game.data.field.oldManBattle or { species = "WEEDLE", level = 5 }
  local c = BattleState.newWild(game, om.species, om.level)
  c:makeOldManDemo()
  c.onFinish = function() end
  U.log("C: LetsGo.scripted -> " .. tostring(LetsGo.scripted(c))
        .. "  fullWild -> " .. tostring(LetsGo.fullWild(c))
        .. "  wantsMinigame -> " .. tostring(LetsGo.wantsMinigame(c)))
  game.overworld:pushBattle(c)

  -- the demo drives ITSELF: the cursor, the bag and the throw are all
  -- scripted, so this only watches. Any session or veil appearing here is
  -- the failure.
  local sawSession, sawVeil = false, false
  local shotDemo = false
  for i = 1, 900 do
    if CatchThrow.session() then sawSession = true end
    if BattleScene.capture then sawVeil = true end
    if not shotDemo and i > 200 then
      shotDemo = true
      U.shot(game, DIR .. "/empty_3_oldman.png")
    end
    if c.result then break end
    U.wait(2)
    -- the scripted beats want A only to page the text along
    if i % 3 == 0 then U.tap(game, "a") end
  end
  U.log(("C: session ever opened=%s (want no)  veil ever up=%s (want no)")
        :format(tostring(sawSession), tostring(sawVeil)))
  U.log(("C: result=%s  balls=%d (want 10 -- the demo consumes none)")
        :format(tostring(c.result), ballCount()))
  U.log(("C: party still %d, first is %s")
        :format(#game.save.party,
                game.data.pokemon[game.save.party[1].species].name))

  -- ------- D: B RUNS, with a ball in hand, on a frame that runs several
  -- logic steps
  --
  -- The reported failure, reproduced rather than reasoned about. Input:step
  -- rebuilds the edge table once per FIXED step; Game:update runs the
  -- frame's steps first and the render-clock hooks after, so any frame
  -- carrying more than one step has already discarded the earlier steps'
  -- edges. Below 60fps -- which is where a 3D battle lives -- that is
  -- every press. speedOverride multiplies the logic clock, so it packs
  -- several steps into each frame on demand and turns "sometimes, on a
  -- slow machine" into "every time, here".
  for _, speed in ipairs({ 1, 4 }) do
    Bag.add(game.save, "POKE_BALL", 5, game.data)
    CatchThrow.lastBall = "POKE_BALL"
    game.speedOverride = speed > 1 and speed or nil
    U.log(("== D: FULL, ball in hand, B to run at %dX logic speed"):format(speed))
    local d = BattleState.newWild(game, "PIDGEY", 5)
    d.onFinish = function() end
    game.overworld:pushBattle(d)
    toCapture(d)
    local sd = describe("D" .. speed, d)
    if sd and not sd.empty then
      -- where does the key actually get to? Each stage of the chain, so a
      -- dead B is attributed rather than guessed at
      local top = game.stack and game.stack:top()
      U.log(("D%dX probe: top=%s onKeyPressed=%s  fullWild=%s declinable=%s")
            :format(speed, tostring(top and top.screenId or "?"),
                    tostring(top and top.onKeyPressed ~= nil),
                    tostring(sd.fullWild), tostring(sd.declinable)))
      -- ------- the counter-factual, measured rather than argued
      --
      -- How many times an edge would have been visible to a poll on the
      -- RENDER clock -- where these reads used to live. Measured on UP
      -- rather than B: the fix TAKES B out of the queue, so B never
      -- reaches `pressed` any more and would read as a false zero. UP is
      -- untouched by everything while the battle is parked, and the
      -- question is about step-vs-frame ordering, not about which button.
      local onFrame, onStep = 0, 0
      local innerU = CatchThrow.update
      CatchThrow.update = function(dt)
        if game.input.wasPressed and game.input:wasPressed("up") then
          onFrame = onFrame + 1
        end
        return innerU(dt)
      end
      local innerB0 = CatchThrow.buttons
      CatchThrow.buttons = function(g)
        local q = g and g.input and g.input.pressQueue
        if q then
          for i = 1, #q do if q[i] == "up" then onStep = onStep + 1 end end
        end
        return innerB0(g)
      end
      love.keypressed("up", "up", false)
      U.wait(2)
      love.keyreleased("up", "up")
      U.wait(2)
      CatchThrow.buttons = innerB0
      CatchThrow.update = innerU
      U.log(("D%dX counter-factual: one UP press -- the logic step saw it "
             .. "%d time(s), the RENDER clock %d time(s)%s")
            :format(speed, onStep, onFrame,
                    onFrame == 0 and "  <-- a frame poll misses it entirely"
                    or ""))

      local seen = 0
      local innerB = CatchThrow.buttons
      CatchThrow.buttons = function(g)
        local q = g and g.input and g.input.pressQueue
        if q and #q > 0 then
          seen = seen + 1
          U.log("D probe: buttons saw queue [" .. table.concat(q, ",") .. "]")
        end
        return innerB(g)
      end
      love.keypressed(B_KEY, B_KEY, false)
      U.log(("D probe: right after keypressed -- queue=[%s] state.b=%s")
            :format(table.concat(game.input.pressQueue, ","),
                    tostring(game.input.state.b)))
      U.wait(2)
      love.keyreleased(B_KEY, B_KEY)
      U.wait(2)
      CatchThrow.buttons = innerB
      U.log("D probe: buttons saw a non-empty queue " .. seen .. " time(s)")
      local ran = false
      for _ = 1, 60 do
        U.wait(2)
        if d.result then ran = true break end
      end
      U.log(("D%dX: after a REAL B -- result=%s (want run)  %s")
            :format(speed, tostring(d.result),
                    ran and "RAN" or "*** B DID NOTHING ***"))
    else
      U.log("D" .. speed .. ": no armed session to test")
    end
    -- escape hatch: a B that did nothing leaves the battle parked in the
    -- capture phase forever, and the rest of the run must still report
    if not d.result then
      pcall(CatchThrow.onBattleEnded)
      d.result, d.phase, d.afterQueue = "run", "messages", "finish"
    end
    closeOut(d)
    game.speedOverride = nil
  end
  U.log("done -- " .. DIR)
end