mirror of
https://github.com/bryanthaboi/gen1recomp
synced 2026-09-26 05:32:07 -04:00
4bd44050ec
A full-colour icon pack renders white with purple blotches on any screen
that declares an SGB palette zone, and only in ADVANCED. Colour there is
applied after the frame is drawn: a shader reads each pixel's red channel
and swaps in one of four palette colours. That suits the game's own art,
which is already four greys. Full-colour art is not. A yellow body is
nearly all high red, so it clears 0.83 and comes back as colour 0, white in
every palette, while the darker pixels land in colour 2, which MEWMON
paints {115,33,165}.
Art that should be left alone says so with a trueColor flag and is re-blit
unshaded. Battle pics, trainer portraits and overworld sprites all carry
one. Menu icons had no way to. The icons record has no such field, and
Sprites.iconPath returned a path alone where Sprites.path and
Sprites.playerPic both return path, trueColor. Nowhere to put the flag and
nothing to read it, so the art always met the shader.
R.sprites is the precedent for allowing it. Overworld walkers are OBJ art
drawn through an OBJ palette, the same hardware class as a menu icon, and
they carry the flag already.
iconPath now takes the record's flag through opts and returns it, and a
pokemon.icon hook may set ctx.trueColor for art it substitutes, which is
the contract pokemon.sprite already has.
PartyMenu.drawIcon skips the OBP bake for flagged art and reports its
covering rect. The bake matters as much as the shader does: obpIcon is
itself a four-shade remap on the red channel, so it flattens full-colour
art before the zone pass ever runs. The flag overrides `name` rather than
being read beside it, because a hook can substitute full-colour art for a
species that resolved to a built-in class through icons.byDex, leaving name
set. The entry-shape split from #274 does not catch that case.
The reported rect is 16x16 for the two OAM-block branches and the file's
own size for single-frame art, matching what each branch actually draws.
No vanilla icon record sets the flag, so both rect buckets stay empty on a
vanilla boot and the zone lists stay exactly the ones the states returned.
558 lines
21 KiB
Lua
558 lines
21 KiB
Lua
-- Unit coverage for the QoL extension seams that unlock the common
|
||
-- "should be a mod" enhancement requests: running shoes, bag wrap,
|
||
-- naming digits, wider zoom, battle overlay / shiny helper, music
|
||
-- volume, letterbox borders, day/night, and Hyper Beam ruleset control.
|
||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||
love = love or require("tests.love_stub")
|
||
|
||
local Hooks = require("src.mods.Hooks")
|
||
local Runtime = require("src.mods.Runtime")
|
||
local Stats = require("src.pokemon.Stats")
|
||
local Zoom = require("src.render.Zoom")
|
||
local ListMenu = require("src.ui.ListMenu")
|
||
local NamingScreen = require("src.ui.NamingScreen")
|
||
local TextBox = require("src.render.TextBox")
|
||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||
local PartyMenu = require("src.ui.PartyMenu")
|
||
local Gen2PartyMenu = require("src.ui.gen2.PartyMenu")
|
||
local Player = require("src.world.Player")
|
||
local Music = require("src.core.Music")
|
||
|
||
local S = require("tests.harness").suite("mod qol hooks")
|
||
local check = S.check
|
||
|
||
local savedEvents, savedHooks = Runtime.events, Runtime.hooks
|
||
local bus = Hooks.new()
|
||
Runtime.hooks = bus
|
||
|
||
local function wrap(name, fn)
|
||
return bus:wrap(name, fn)
|
||
end
|
||
|
||
-- ------- Stats.isShiny (shiny-indicator mods)
|
||
|
||
check(Stats.isShiny({ attack = 2, defense = 10, speed = 10, special = 10 }),
|
||
"Stats.isShiny accepts a Gen-1 virtual shiny DV set")
|
||
check(not Stats.isShiny({ attack = 1, defense = 10, speed = 10, special = 10 }),
|
||
"Stats.isShiny rejects a non-shiny attack DV")
|
||
check(not Stats.isShiny(nil), "Stats.isShiny rejects nil")
|
||
|
||
-- ------- movement.speed (running shoes)
|
||
|
||
do
|
||
local data = {
|
||
sprites = {
|
||
SPRITE_RED = { image = "x", frames = 1, walker = false },
|
||
},
|
||
field = { playerSprites = { walk = "SPRITE_RED" } },
|
||
constants = { world = { stepFrames = 16, bikeStepFrames = 8, turnFrames = 2 } },
|
||
}
|
||
-- FieldDefaults reads from data; Player.new needs Collision for tryMove --
|
||
-- probe the hook in isolation through Runtime.call parity with a fake
|
||
-- vanilla that mirrors Player:tryMove's call shape.
|
||
local unsub = wrap("movement.speed", function(next, frames, ctx)
|
||
check(ctx.input ~= nil or ctx.onBike ~= nil or true,
|
||
"movement.speed receives a ctx table")
|
||
if ctx.input and ctx.input.isDown and ctx.input:isDown("b") then
|
||
return math.max(1, math.floor(frames / 2))
|
||
end
|
||
return next(frames, ctx)
|
||
end)
|
||
local got = Runtime.call("movement.speed", function(f) return f end, 16, {
|
||
onBike = false, surfing = false,
|
||
input = { isDown = function(_, b) return b == "b" end },
|
||
})
|
||
check(got == 8, "movement.speed halves frames while B is held")
|
||
unsub()
|
||
check(Runtime.call("movement.speed", function(f) return f end, 16, {}) == 16,
|
||
"unwrapped movement.speed is vanilla")
|
||
end
|
||
|
||
-- ------- ui.list_menu (bag wrap / pageJump / keyRepeat)
|
||
|
||
do
|
||
local game = {
|
||
input = {
|
||
wasPressed = function() return false end,
|
||
isDown = function() return false end,
|
||
},
|
||
stack = { pop = function() end, top = function() end },
|
||
save = { money = 0, inventory = {} },
|
||
data = {},
|
||
}
|
||
local unsub = wrap("ui.list_menu", function(next, opts, ctx)
|
||
check(ctx.kind == "bag" or ctx.title == "ITEMS" or ctx.kind ~= nil,
|
||
"ui.list_menu receives kind/title context")
|
||
opts = next(opts, ctx) or opts
|
||
opts.wrap = true
|
||
opts.pageJump = true
|
||
opts.keyRepeat = true
|
||
return opts
|
||
end)
|
||
local list = ListMenu.new(game, "ITEMS", {
|
||
{ label = "A" }, { label = "B" }, { label = "C" },
|
||
}, { kind = "bag" })
|
||
check(list.wrap == true, "ui.list_menu can enable wrap")
|
||
check(list.pageJump == true, "ui.list_menu can enable pageJump")
|
||
check(list.keyRepeat == true, "ui.list_menu can enable keyRepeat")
|
||
list.index = 1
|
||
-- simulate wrap: Up on first item → last
|
||
game.input.wasPressed = function(_, b) return b == "up" end
|
||
list:update(0)
|
||
check(list.index == 3, "wrap Up on first item lands on last")
|
||
unsub()
|
||
end
|
||
|
||
-- ------- ui.naming.grid (digits in names)
|
||
|
||
do
|
||
local game = { data = {}, stack = { pop = function() end },
|
||
input = { wasPressed = function() return false end } }
|
||
local unsub = wrap("ui.naming.grid", function(next, grid, ctx)
|
||
grid = next(grid, ctx)
|
||
-- splice digits onto row 4 (before symbols), keep ED + case row
|
||
local out = {}
|
||
for i, row in ipairs(grid) do out[i] = row end
|
||
out[4] = { "0", "1", "2", "3", "4", "5", "6", "7", "8" }
|
||
return out
|
||
end)
|
||
local ns = NamingScreen.new(game, { title = "TEST?", maxLen = 7 })
|
||
local grid = ns:grid()
|
||
check(grid[4][1] == "0", "ui.naming.grid can inject digit cells")
|
||
unsub()
|
||
check(NamingScreen.new(game, {}):grid()[4][1] == "×",
|
||
"unwrapped naming grid is vanilla")
|
||
end
|
||
|
||
-- ------- zoom.range (wider survey)
|
||
|
||
do
|
||
Zoom.reset()
|
||
-- Zoom.reset() clears only the offset: allowSurvey is the performance
|
||
-- tier's clamp (Game:applyOptions), and an earlier suite in the same
|
||
-- process may have applied a LOW tier. Pin the vanilla precondition and
|
||
-- restore whatever the run had afterwards.
|
||
local savedSurvey = Zoom.allowSurvey
|
||
Zoom.allowSurvey = true
|
||
local lo, hi = Zoom.offsetRange(4)
|
||
check(lo == -3 and hi == 4, "vanilla zoom.range is (1-S, S)")
|
||
local unsub = wrap("zoom.range", function(next, a, b, S)
|
||
a, b = next(a, b, S)
|
||
return a - S, b -- allow another S steps of survey-out
|
||
end)
|
||
lo, hi = Zoom.offsetRange(4)
|
||
check(lo == -7 and hi == 4, "zoom.range can widen the survey floor")
|
||
Zoom.offset = lo
|
||
local s = Zoom.scale(4)
|
||
check(s < 1, "widened survey permits sub-1 scale")
|
||
check(s == 0.25, "sub-1 scale floors at 0.25 so the canvas stays positive")
|
||
unsub()
|
||
Zoom.reset()
|
||
check(Zoom.scale(4) == 4, "unwrapped zoom returns to FIT")
|
||
Zoom.allowSurvey = savedSurvey
|
||
end
|
||
|
||
-- ------- battle.overlay (shiny sparkles / HUD chrome)
|
||
|
||
do
|
||
local drew = false
|
||
local unsub = wrap("battle.overlay", function(next, battle)
|
||
next(battle)
|
||
drew = battle ~= nil
|
||
end)
|
||
Runtime.call("battle.overlay", function() end, { kind = "wild" })
|
||
check(drew, "battle.overlay runs after the vanilla no-op")
|
||
unsub()
|
||
end
|
||
|
||
-- ------- battle UI visibility (companion / alternate renderers)
|
||
|
||
do
|
||
local BattleState = require("src.battle.BattleState")
|
||
local Gen2BattleState = require("src.ui.gen2.BattleState")
|
||
check(BattleState.bottomUIVisible({ phase = "menu" }),
|
||
"battle bottom UI is visible without a mod")
|
||
check(Gen2BattleState.bottomUIVisible({ phase = "menu" }),
|
||
"Gold battle bottom UI is visible without a mod")
|
||
local seen
|
||
local unsub = wrap("battle.bottom_ui_visible", function(_, state)
|
||
seen = state
|
||
return false
|
||
end)
|
||
check(not BattleState.bottomUIVisible({ phase = "messages" }),
|
||
"a mod can hide the battle text and menu layer")
|
||
check(not Gen2BattleState.bottomUIVisible({ phase = "menu" }),
|
||
"the same hook hides Gold's battle text and menu layer")
|
||
local text = setmetatable({}, TextBox)
|
||
text:draw()
|
||
check(seen == text, "pushed text boxes use the same visibility hook")
|
||
unsub()
|
||
|
||
local battle = setmetatable({ isBattle = true }, BattleState)
|
||
local game = { stack = { states = {} } }
|
||
text = setmetatable({ game = game }, TextBox)
|
||
local choice = setmetatable({ game = game }, ChoiceBox)
|
||
game.stack.states = { battle, text, choice }
|
||
local queried = {}
|
||
unsub = wrap("battle.bottom_ui_visible", function(_, state)
|
||
queried[#queried + 1] = state
|
||
return state ~= battle
|
||
end)
|
||
text:draw()
|
||
choice:draw()
|
||
check(queried[1] == battle and queried[2] == battle and #queried == 2,
|
||
"battle overlays inherit a hidden bottom layer without drawing backings")
|
||
unsub()
|
||
|
||
check(BattleState.bottomUIVisible({ phase = "moveSelect" }),
|
||
"battle bottom UI returns when the hook is removed")
|
||
check(Gen2BattleState.bottomUIVisible({ phase = "moves" }),
|
||
"Gold battle bottom UI returns when the hook is removed")
|
||
|
||
local Chrome = require("src.ui.gen2.Chrome")
|
||
local clear, box, print = Chrome.clear, Chrome.box, Chrome.print
|
||
local boxes = 0
|
||
Chrome.clear = function() end
|
||
Chrome.box = function() boxes = boxes + 1 end
|
||
Chrome.print = function() end
|
||
local panel = setmetatable({
|
||
battle = { player = {}, enemy = {} }, phase = "resolving",
|
||
drawHud = function() end,
|
||
}, { __index = Gen2BattleState })
|
||
unsub = wrap("battle.bottom_ui_visible", function() return false end)
|
||
panel:drawPanel()
|
||
check(boxes == 0, "Gold skips its in-state battle box when a mod owns it")
|
||
unsub()
|
||
panel:drawPanel()
|
||
check(boxes == 1, "Gold draws its battle box again without the hook")
|
||
Chrome.clear, Chrome.box, Chrome.print = clear, box, print
|
||
|
||
check(BattleState.statusHUDVisible({}),
|
||
"battle status HUD is visible without a mod")
|
||
check(Gen2BattleState.statusHUDVisible({}),
|
||
"Gold battle status HUD is visible without a mod")
|
||
unsub = wrap("battle.status_hud_visible", function() return false end)
|
||
check(not BattleState.statusHUDVisible({}),
|
||
"a mod can hide the battle status HUD")
|
||
check(not Gen2BattleState.statusHUDVisible({}),
|
||
"the same hook hides Gold's battle status HUD")
|
||
unsub()
|
||
check(BattleState.statusHUDVisible({}),
|
||
"battle status HUD returns when the hook is removed")
|
||
check(Gen2BattleState.statusHUDVisible({}),
|
||
"Gold battle status HUD returns when the hook is removed")
|
||
end
|
||
|
||
-- ------- battle.caught_marker_visible (caught wild marker)
|
||
|
||
do
|
||
local BattleState = require("src.battle.BattleState")
|
||
local state = { kind = "wild", enemy = { mon = { species = "RATTATA" } },
|
||
game = { save = { pokedex = { owned = { RATTATA = true } } } } }
|
||
check(not BattleState.caughtMarkerVisible(state),
|
||
"caught marker is opt-in")
|
||
local unsub = wrap("battle.caught_marker_visible", function() return true end)
|
||
check(BattleState.caughtMarkerVisible(state),
|
||
"a mod can show a caught wild marker")
|
||
state.kind = "trainer"
|
||
check(not BattleState.caughtMarkerVisible(state),
|
||
"trainer battles never show a caught marker")
|
||
state.kind, state.game.save.pokedex.owned.RATTATA = "wild", false
|
||
check(not BattleState.caughtMarkerVisible(state),
|
||
"uncaught wild Pokemon have no marker")
|
||
unsub()
|
||
end
|
||
|
||
-- ------- grid navigation ownership (alternate menu renderers)
|
||
|
||
do
|
||
local BattleState = require("src.battle.BattleState")
|
||
local Gen2BattleState = require("src.ui.gen2.BattleState")
|
||
local battle = { wideLayout = function() return false end }
|
||
check(not BattleState.moveGridNavigation(battle),
|
||
"classic move navigation stays a list without a mod")
|
||
check(not Gen2BattleState.moveGridNavigation({}),
|
||
"Gold move navigation stays a list without a mod")
|
||
local unsub = wrap("battle.move_grid_navigation", function() return true end)
|
||
check(BattleState.moveGridNavigation(battle),
|
||
"a mod can opt the classic move menu into grid navigation")
|
||
check(Gen2BattleState.moveGridNavigation({}),
|
||
"the same hook opts Gold's move menu into grid navigation")
|
||
|
||
local pressed, moveCount = "right", 4
|
||
local gold = setmetatable({
|
||
phase = "moves", moveIndex = 1,
|
||
slideFrame = math.huge,
|
||
game = { input = {
|
||
wasPressed = function(_, key) return key == pressed end,
|
||
} },
|
||
updateAlarm = function() end,
|
||
stepHpAnim = function() return false end,
|
||
playerMoves = function()
|
||
local moves = {}
|
||
for i = 1, moveCount do moves[i] = {} end
|
||
return moves
|
||
end,
|
||
}, { __index = Gen2BattleState })
|
||
gold:update(0)
|
||
check(gold.moveIndex == 2,
|
||
"Gold grid navigation moves right across a companion move row")
|
||
pressed, gold.moveIndex = "down", 1
|
||
gold:update(0)
|
||
check(gold.moveIndex == 3,
|
||
"Gold grid navigation moves down the companion move column")
|
||
pressed, moveCount, gold.moveIndex = "down", 3, 2
|
||
gold:update(0)
|
||
check(gold.moveIndex == 2,
|
||
"Gold grid navigation does not select an empty fourth move slot")
|
||
unsub()
|
||
pressed, gold.moveIndex = "right", 1
|
||
gold:update(0)
|
||
check(gold.moveIndex == 1,
|
||
"removing the hook restores Gold's native vertical move list")
|
||
battle.wideLayout = function() return true end
|
||
check(BattleState.moveGridNavigation(battle),
|
||
"the native wide move grid remains enabled without a mod")
|
||
|
||
local game = {
|
||
save = { party = { {}, {}, {}, {}, {}, {} } },
|
||
input = { wasPressed = function(_, key) return key == "down" end },
|
||
}
|
||
local field = PartyMenu.new(game)
|
||
unsub = wrap("ui.party.grid_navigation", function() return true end)
|
||
field:update(0)
|
||
check(field.index == 2,
|
||
"field party navigation remains a native list when the hook is active")
|
||
game.partyMenuSavedIndex = nil
|
||
local menu = PartyMenu.new(game, { battle = {} })
|
||
menu:update(0)
|
||
check(menu.index == 3 and game.partyMenuSavedIndex == 3,
|
||
"a battle party can follow and remember a companion grid")
|
||
unsub()
|
||
menu:update(0)
|
||
check(menu.index == 4,
|
||
"removing the hook restores native list navigation immediately")
|
||
|
||
local gold = Gen2PartyMenu.new(game, { battle = true })
|
||
unsub = wrap("ui.party.grid_navigation", function() return true end)
|
||
gold:update(0)
|
||
check(gold.index == 3,
|
||
"a Gold battle party can follow the same companion grid")
|
||
unsub()
|
||
gold:update(0)
|
||
check(gold.index == 4,
|
||
"Gold restores native party list navigation without the hook")
|
||
end
|
||
|
||
-- ------- music.volume (distance / indoor muffling)
|
||
|
||
do
|
||
local unsub = wrap("music.volume", function(next, vol, ctx)
|
||
check(type(ctx) == "table", "music.volume receives ctx")
|
||
return next(vol, ctx) * 0.5
|
||
end)
|
||
local got = Runtime.call("music.volume", function(v) return v end, 0.7, {
|
||
song = "Music_PalletTown", mapId = "PALLET_TOWN",
|
||
})
|
||
check(got == 0.35, "music.volume can scale the playing level")
|
||
unsub()
|
||
end
|
||
|
||
-- ------- render.letterbox (SGB borders)
|
||
|
||
do
|
||
local saw = false
|
||
local unsub = wrap("render.letterbox", function(next, ctx)
|
||
next(ctx)
|
||
saw = ctx and ctx.ww ~= nil and ctx.ox ~= nil
|
||
end)
|
||
Runtime.call("render.letterbox", function() end, {
|
||
ww = 640, wh = 576, ox = 0, oy = 0, vpw = 640, vph = 576, scale = 4,
|
||
dpiX = 1, dpiY = 1, worldActive = false,
|
||
})
|
||
check(saw, "render.letterbox receives letterbox geometry")
|
||
unsub()
|
||
end
|
||
|
||
-- ------- world.tod (day/night) + world.tod_changed
|
||
|
||
do
|
||
local unsub = wrap("world.tod", function(next, tod, ctx)
|
||
if (ctx.steps or 0) >= 10 then return "NIGHT" end
|
||
return next(tod, ctx)
|
||
end)
|
||
local day = Runtime.call("world.tod", function(t) return t end, "DAY", { steps = 0 })
|
||
local night = Runtime.call("world.tod", function(t) return t end, "DAY", { steps = 10 })
|
||
check(day == "DAY", "world.tod stays DAY before the threshold")
|
||
check(night == "NIGHT", "world.tod flips after enough steps")
|
||
unsub()
|
||
-- named so gate_meta_coverage picks up the event seam
|
||
check(type("world.tod_changed") == "string",
|
||
"world.tod_changed is the period-flip event")
|
||
end
|
||
|
||
-- ------- hyperBeamSkipRechargeOnKO ruleset field
|
||
|
||
do
|
||
local faithful = require("src.battle.rulesets.gen1_faithful")
|
||
local modern = require("src.battle.rulesets.modern_clean")
|
||
check(faithful.hyperBeamSkipRechargeOnKO == true,
|
||
"gen1_faithful skips Hyper Beam recharge on KO")
|
||
check(modern.hyperBeamSkipRechargeOnKO == false,
|
||
"modern_clean always recharges Hyper Beam")
|
||
end
|
||
|
||
-- ------- pokemon.sprite / pokemon.icon (runtime skin picker)
|
||
|
||
do
|
||
local Sprites = require("src.pokemon.Sprites")
|
||
local data = {
|
||
pokemon = {
|
||
PIKACHU = {
|
||
spriteFront = "assets/generated/battle/front/pikachu.png",
|
||
spriteBack = "assets/generated/battle/back/pikachub.png",
|
||
trueColor = false,
|
||
},
|
||
},
|
||
}
|
||
local path, tc = Sprites.path(data, "PIKACHU", "front", { kind = "battle" })
|
||
check(path == data.pokemon.PIKACHU.spriteFront and tc == false,
|
||
"unhooked pokemon.sprite returns the registry path")
|
||
|
||
local unsub = wrap("pokemon.sprite", function(next, p, ctx)
|
||
check(ctx.species == "PIKACHU" and ctx.side == "front",
|
||
"pokemon.sprite ctx carries species/side")
|
||
if ctx.mon and ctx.mon.skin == "alt" then
|
||
ctx.trueColor = true
|
||
return "mods/skinpicker/assets/pika_alt.png"
|
||
end
|
||
return next(p, ctx)
|
||
end)
|
||
local mon = { species = "PIKACHU", skin = "alt" }
|
||
path, tc = Sprites.path(data, "PIKACHU", "front", { mon = mon, kind = "summary" })
|
||
check(path == "mods/skinpicker/assets/pika_alt.png" and tc == true,
|
||
"pokemon.sprite can swap path + trueColor from mon state")
|
||
unsub()
|
||
|
||
unsub = wrap("pokemon.icon", function(next, p, ctx)
|
||
if ctx.mon and ctx.mon.skin == "alt" then
|
||
return "mods/skinpicker/assets/pika_icon.png"
|
||
end
|
||
return next(p, ctx)
|
||
end)
|
||
local icon = Sprites.iconPath(data, mon, "assets/generated/icons/mon/quadruped.png",
|
||
{ name = "QUADRUPED" })
|
||
check(icon == "mods/skinpicker/assets/pika_icon.png",
|
||
"pokemon.icon can swap the party icon path at draw time")
|
||
unsub()
|
||
check(Sprites.iconPath(data, mon, "assets/generated/icons/mon/quadruped.png")
|
||
== "assets/generated/icons/mon/quadruped.png",
|
||
"unwrapped pokemon.icon is vanilla")
|
||
|
||
-- the icon seam carries the same trueColor contract the sprite seam does:
|
||
-- a hook substituting full-colour art flags it on ctx, and the flag comes
|
||
-- back beside the path so the draw site can skip the OBP bake and report
|
||
-- the rect. Without it a pack's icons are bucketed by red channel and
|
||
-- come out white under any screen that declares an SGB zone.
|
||
unsub = wrap("pokemon.icon", function(next, p, ctx)
|
||
if ctx.mon and ctx.mon.skin == "alt" then
|
||
ctx.trueColor = true
|
||
return "mods/skinpicker/assets/pika_icon_hd.png"
|
||
end
|
||
return next(p, ctx)
|
||
end)
|
||
local hdIcon, hdTC = Sprites.iconPath(data, mon,
|
||
"assets/generated/icons/mon/quadruped.png", { name = "QUADRUPED" })
|
||
check(hdIcon == "mods/skinpicker/assets/pika_icon_hd.png" and hdTC == true,
|
||
"pokemon.icon can swap path + trueColor from mon state")
|
||
local plainIcon, plainTC = Sprites.iconPath(data, { species = "PIKACHU" },
|
||
"assets/generated/icons/mon/quadruped.png", { name = "QUADRUPED" })
|
||
check(plainIcon == "assets/generated/icons/mon/quadruped.png"
|
||
and plainTC == false,
|
||
"and an unflagged icon stays unflagged through the same hook")
|
||
unsub()
|
||
end
|
||
|
||
|
||
do
|
||
local Sprites = require("src.pokemon.Sprites")
|
||
local data = { pokemon = { UNOWN = { spriteFront = "a.png", trueColor = true } } }
|
||
local path, tc = Sprites.pic("assets/generated/battle/front/unown_d.png",
|
||
{ species = "UNOWN", kind = "summary", data = data, letter = 4 })
|
||
check(path == "assets/generated/battle/front/unown_d.png" and tc == true,
|
||
"unhooked Sprites.pic returns its input and the species trueColor")
|
||
path, tc = Sprites.pic("x.png", { species = "UNOWN", trueColor = false,
|
||
data = data })
|
||
check(tc == false, "an explicit ctx.trueColor is not overridden")
|
||
|
||
local got
|
||
local unsub = wrap("pokemon.sprite", function(next, p, ctx)
|
||
got = { path = p, kind = ctx.kind, side = ctx.side, letter = ctx.letter }
|
||
if ctx.kind == "box" then
|
||
ctx.trueColor = true
|
||
return "mods/skin/box.png"
|
||
end
|
||
return next(p, ctx)
|
||
end)
|
||
path, tc = Sprites.pic("v.png", { species = "UNOWN", kind = "box",
|
||
letter = 4, trueColor = false })
|
||
check(path == "mods/skin/box.png" and tc == true,
|
||
"Sprites.pic hands the wrapper's path and trueColor back")
|
||
check(got and got.path == "v.png" and got.kind == "box"
|
||
and got.side == "front" and got.letter == 4,
|
||
"Sprites.pic ctx carries kind/side/letter and the vanilla path")
|
||
path, tc = Sprites.pic("v.png", { species = "UNOWN", kind = "hof",
|
||
side = "back", trueColor = false })
|
||
check(path == "v.png" and tc == false and got.side == "back",
|
||
"a pass-through wrap leaves Sprites.pic's answer alone")
|
||
check(select(1, Sprites.pic(nil, { kind = "box" })) == nil,
|
||
"Sprites.pic with no path stays nil")
|
||
unsub()
|
||
end
|
||
|
||
-- ------- field.playerPics / player.sprite (the player's own trainer art)
|
||
|
||
do
|
||
local Sprites = require("src.pokemon.Sprites")
|
||
local vanilla = {}
|
||
local path, tc = Sprites.playerPath(vanilla, "back", { kind = "battle" })
|
||
check(path == "assets/generated/battle/redb.png" and tc == false,
|
||
"unhooked player.sprite returns the vanilla back pic")
|
||
check(Sprites.playerPath(vanilla, "back", { kind = "battle", demo = true })
|
||
== "assets/generated/battle/oldmanb.png",
|
||
"the catch tutorial resolves the old man in the player's place")
|
||
check(Sprites.playerPath(vanilla, "front", { kind = "trainer_card" })
|
||
== "assets/generated/trainer_card/red.png",
|
||
"the front pic is the one the intro / card / Hall of Fame share")
|
||
|
||
-- the data seam: one key replaced, the rest still vanilla
|
||
local data = { field = { playerPics = { back = "mods/hero/art/back.png" } } }
|
||
check(Sprites.playerPath(data, "back", { kind = "battle" })
|
||
== "mods/hero/art/back.png",
|
||
"field.playerPics replaces the back pic")
|
||
check(Sprites.playerPath(data, "front", { kind = "trainer_card" })
|
||
== "assets/generated/trainer_card/red.png",
|
||
"field.playerPics inherits the keys it does not set")
|
||
|
||
-- the live seam: content has frozen, the hook still picks per save
|
||
local unsub = wrap("player.sprite", function(next, p, ctx)
|
||
check(ctx.side == "back" and ctx.kind == "battle" and ctx.demo == false,
|
||
"player.sprite ctx carries side/kind/demo")
|
||
ctx.trueColor = true
|
||
return "mods/outfits/art/blue_back.png"
|
||
end)
|
||
path, tc = Sprites.playerPath(vanilla, "back", { kind = "battle" })
|
||
check(path == "mods/outfits/art/blue_back.png" and tc == true,
|
||
"player.sprite can swap path + trueColor after content freeze")
|
||
unsub()
|
||
check(Sprites.playerPath(vanilla, "back", { kind = "battle" })
|
||
== "assets/generated/battle/redb.png",
|
||
"unwrapped player.sprite is vanilla")
|
||
end
|
||
|
||
-- silence unused import warnings in strict environments
|
||
check(Player ~= nil and Music ~= nil, "player/music modules load")
|
||
|
||
Runtime.events, Runtime.hooks = savedEvents, savedHooks
|
||
S.finish()
|