mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-17 03:02:39 +02:00
new launcher and save converts and pipeline
This commit is contained in:
@@ -0,0 +1,250 @@
|
||||
-- The battle-sprite-scale seam, exercised through the public mod API.
|
||||
--
|
||||
-- Modders scale battle pics per species (pokemon.battleScaleFront /
|
||||
-- battleScaleBack) or per image path (the battle_sprite_scales registry,
|
||||
-- the only handle on non-species pics like the trainer back). The
|
||||
-- properties worth pinning: the schema rejects out-of-range scales and a
|
||||
-- pathless record, image-level beats species-level beats the vanilla
|
||||
-- default, and above all the pic stays GROUNDED -- feet on the text-box
|
||||
-- top, bottom edge in its slot -- at every scale and through the send-out
|
||||
-- grow. The placement math and the scale resolver are pure (no love.*),
|
||||
-- so the grounding contract is asserted directly.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
|
||||
local Loader = require("src.mods.Loader")
|
||||
local Schemas = require("src.mods.Schemas")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
|
||||
local S = require("tests.harness").suite("mod battle scale")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
local function memfs(files)
|
||||
return {
|
||||
read = function(path) return files[path] end,
|
||||
getInfo = function(path)
|
||||
if files[path] then return { type = "file" } end
|
||||
local prefix = path .. "/"
|
||||
for key in pairs(files) do
|
||||
if key:sub(1, #prefix) == prefix then return { type = "directory" } end
|
||||
end
|
||||
return nil
|
||||
end,
|
||||
load = function(path)
|
||||
if not files[path] then return nil, "no file: " .. path end
|
||||
return load(files[path], path)
|
||||
end,
|
||||
getDirectoryItems = function(path)
|
||||
local seen, items = {}, {}
|
||||
local prefix = path .. "/"
|
||||
for key in pairs(files) do
|
||||
if key:sub(1, #prefix) == prefix then
|
||||
local child = key:sub(#prefix + 1):match("^[^/]+")
|
||||
if child and not seen[child] then
|
||||
seen[child] = true
|
||||
items[#items + 1] = child
|
||||
end
|
||||
end
|
||||
end
|
||||
table.sort(items)
|
||||
return items
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
local function manifest(id, extra)
|
||||
return ('{"id":"%s","name":"%s","version":"1.0.0","api":2,' ..
|
||||
'"entry":"main.lua"%s}'):format(id, id, extra or "")
|
||||
end
|
||||
|
||||
-- a minimal, internally consistent base so a pokemon patch has something
|
||||
-- to fold onto; the cross-reference pass skips registries no mod touched,
|
||||
-- so the untouched type/move refs on these records never surface
|
||||
local function baseData()
|
||||
return {
|
||||
pokemon = {
|
||||
PIKACHU = { id = "PIKACHU", name = "PIKACHU", dex = 25,
|
||||
types = { "ELECTRIC" },
|
||||
baseStats = { hp = 35, attack = 55, defense = 30, speed = 90, special = 50 },
|
||||
catchRate = 190, baseExp = 82, level1Moves = { "THUNDERSHOCK" },
|
||||
growthRate = "MEDIUM_FAST", learnset = {}, evolutions = {},
|
||||
spriteFront = "pikachu_front.png", spriteBack = "pikachu_back.png",
|
||||
frontSize = 5 },
|
||||
RAICHU = { id = "RAICHU", name = "RAICHU", dex = 26,
|
||||
types = { "ELECTRIC" },
|
||||
baseStats = { hp = 60, attack = 90, defense = 55, speed = 110, special = 90 },
|
||||
catchRate = 75, baseExp = 122, level1Moves = { "THUNDERSHOCK" },
|
||||
growthRate = "MEDIUM_FAST", learnset = {}, evolutions = {},
|
||||
spriteFront = "raichu_front.png", spriteBack = "raichu_back.png",
|
||||
frontSize = 6 },
|
||||
},
|
||||
moves = {
|
||||
THUNDERSHOCK = { id = "THUNDERSHOCK", name = "THUNDERSHOCK",
|
||||
type = "ELECTRIC", power = 40, accuracy = 100, pp = 30,
|
||||
effect = "PARALYZE_SIDE_EFFECT1" },
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
-- ------- schema: the battle_sprite_scales registry
|
||||
|
||||
do
|
||||
local spec = Schemas.REGISTRIES.battle_sprite_scales
|
||||
check(spec ~= nil, "the battle_sprite_scales registry is in the catalog")
|
||||
eq(spec.semantics, "record", "battle_sprite_scales merges as records")
|
||||
eq(spec.target, "battle_sprite_scales",
|
||||
"battle_sprite_scales writes to its own namespace")
|
||||
|
||||
local ok = Schemas.check(spec, "battle_sprite_scales", "abra_back",
|
||||
{ path = "assets/generated/battle/back/abrab.png", scale = 1.5 }, "register")
|
||||
check(ok, "a path + in-range scale validates")
|
||||
|
||||
-- the boundaries are inclusive
|
||||
check(Schemas.check(spec, "battle_sprite_scales", "lo",
|
||||
{ path = "x.png", scale = 0.25 }, "register"), "scale 0.25 is accepted")
|
||||
check(Schemas.check(spec, "battle_sprite_scales", "hi",
|
||||
{ path = "x.png", scale = 4.0 }, "register"), "scale 4.0 is accepted")
|
||||
|
||||
local tooBig, bigErr = Schemas.check(spec, "battle_sprite_scales", "big",
|
||||
{ path = "x.png", scale = 5 }, "register")
|
||||
check(not tooBig, "a scale above 4.0 is rejected")
|
||||
check(tostring(bigErr):find("0.25", 1, true) ~= nil,
|
||||
"the rejection names the range it wanted: " .. tostring(bigErr))
|
||||
|
||||
check(not Schemas.check(spec, "battle_sprite_scales", "small",
|
||||
{ path = "x.png", scale = 0.1 }, "register"),
|
||||
"a scale below 0.25 is rejected")
|
||||
|
||||
local noPath, pathErr = Schemas.check(spec, "battle_sprite_scales", "nopath",
|
||||
{ scale = 1 }, "register")
|
||||
check(not noPath, "a record with no path is rejected")
|
||||
check(tostring(pathErr):find("path", 1, true) ~= nil,
|
||||
"the rejection names the missing path: " .. tostring(pathErr))
|
||||
|
||||
check(not Schemas.check(spec, "battle_sprite_scales", "emptypath",
|
||||
{ path = "", scale = 1 }, "register"), "an empty path is rejected")
|
||||
end
|
||||
|
||||
-- ------- schema: the per-species scale fields
|
||||
|
||||
do
|
||||
local spec = Schemas.REGISTRIES.pokemon
|
||||
check(Schemas.check(spec, "pokemon", "PIKACHU",
|
||||
{ battleScaleBack = 3, battleScaleFront = 0.5 }, "patch"),
|
||||
"in-range species scale overrides validate as a patch")
|
||||
local bad, err = Schemas.check(spec, "pokemon", "PIKACHU",
|
||||
{ battleScaleBack = 5 }, "patch")
|
||||
check(not bad, "an out-of-range species scale is rejected")
|
||||
check(tostring(err):find("battleScaleBack", 1, true) ~= nil,
|
||||
"the rejection names the field: " .. tostring(err))
|
||||
end
|
||||
|
||||
-- ------- the full merge: a mod patches a species and registers an image
|
||||
|
||||
local FILES = {
|
||||
["mods/biggun/manifest.json"] = manifest("biggun"),
|
||||
["mods/biggun/main.lua"] = [[
|
||||
local mod = ...
|
||||
-- species-level: PIKACHU's back pic at 3x
|
||||
mod.content.pokemon:patch("PIKACHU", { battleScaleBack = 3 })
|
||||
-- image-level, keyed by path: overrides the species scale for this pic
|
||||
mod.content.battle_sprite_scales:register("pika_back", {
|
||||
path = "pikachu_back.png", scale = 1.25,
|
||||
})
|
||||
-- and a bare, non-species pic (a trainer back) reachable only here
|
||||
mod.content.battle_sprite_scales:register("hero_back", {
|
||||
path = "assets/generated/battle/back/redb.png", scale = 1.5,
|
||||
})
|
||||
]],
|
||||
}
|
||||
|
||||
local data = baseData()
|
||||
local loader = Loader.new({ fs = memfs(FILES) })
|
||||
local okLoad = loader:load(data)
|
||||
check(okLoad, "the scale mod loads clean: " .. table.concat(loader.errors, "; "))
|
||||
|
||||
eq(data.pokemon.PIKACHU.battleScaleBack, 3,
|
||||
"the species patch reached the merged data")
|
||||
check(type(data.battle_sprite_scales) == "table",
|
||||
"the merge created the battle_sprite_scales namespace")
|
||||
|
||||
-- image-level beats species-level for the same pic
|
||||
eq(BattleState.resolveBattleScale(data, "back", "pikachu_back.png", "PIKACHU"),
|
||||
1.25, "an image-level entry overrides the species scale for its path")
|
||||
-- a different pic of the same species falls through to the species scale
|
||||
eq(BattleState.resolveBattleScale(data, "back", "raichu_back.png", "PIKACHU"),
|
||||
3, "a species with an override but no image entry uses the species scale")
|
||||
-- the non-species trainer back is reachable only by path
|
||||
eq(BattleState.resolveBattleScale(data, "back",
|
||||
"assets/generated/battle/back/redb.png", nil),
|
||||
1.5, "a bare pic is scaled by its image-level entry with no species")
|
||||
-- an unregistered species, unregistered path: the vanilla side defaults
|
||||
eq(BattleState.resolveBattleScale(data, "front", "raichu_front.png", "RAICHU"),
|
||||
1, "enemy front defaults to 1x when nothing is registered")
|
||||
eq(BattleState.resolveBattleScale(data, "back", "raichu_back.png", "RAICHU"),
|
||||
2, "player back defaults to 2x when nothing is registered")
|
||||
|
||||
-- ------- default unchanged with no registry at all
|
||||
|
||||
do
|
||||
local bare = { pokemon = { PIKACHU = {} } }
|
||||
eq(BattleState.resolveBattleScale(bare, "front", "any.png", "PIKACHU"), 1,
|
||||
"front default holds with no battle_sprite_scales table")
|
||||
eq(BattleState.resolveBattleScale(bare, "back", "any.png", "PIKACHU"), 2,
|
||||
"back default holds with no battle_sprite_scales table")
|
||||
eq(BattleState.resolveBattleScale({}, "back", nil, nil), 2,
|
||||
"back default holds with empty data and no path or species")
|
||||
end
|
||||
|
||||
-- ------- placement math: feet stay pinned at every scale
|
||||
|
||||
local W, H, PAD, PADL = 56, 40, 3, 2
|
||||
|
||||
do
|
||||
for _, s in ipairs({ 0.5, 1, 2, 3 }) do
|
||||
local x, y, sc = BattleState.backPlacement(W, H, PAD, PADL, s)
|
||||
eq(sc, s, "back placement returns the scale (scale " .. s .. ")")
|
||||
eq(y + (H - PAD) * s, 96,
|
||||
"player feet stay on the text-box top at scale " .. s)
|
||||
eq(x, 8 - PADL * s,
|
||||
"player left pad is pulled back proportionally at scale " .. s)
|
||||
end
|
||||
|
||||
local ex, ey = 100, 20
|
||||
for _, s in ipairs({ 0.5, 1, 2, 3 }) do
|
||||
local x, y = BattleState.frontPlacement(ex, ey, W, H, s)
|
||||
eq(y + H * s, ey + H,
|
||||
"enemy bottom edge stays pinned to its slot at scale " .. s)
|
||||
eq(x + W * s / 2, ex + W / 2,
|
||||
"enemy horizontal centre stays pinned at scale " .. s)
|
||||
end
|
||||
|
||||
-- the s=1 case is the vanilla draw exactly: no shift
|
||||
local x1, y1 = BattleState.frontPlacement(ex, ey, W, H, 1)
|
||||
check(x1 == ex and y1 == ey, "scale 1 front placement is the untouched slot")
|
||||
end
|
||||
|
||||
-- ------- composition with the send-out grow
|
||||
|
||||
do
|
||||
-- growInScale returns the AnimateSendingOutMon stages; a mod scale
|
||||
-- composes multiplicatively, and the composed pic is still grounded
|
||||
local moddedBack = BattleState.resolveBattleScale(
|
||||
{ pokemon = { GROWMON = { battleScaleBack = 1.5 } } }, "back", nil, "GROWMON")
|
||||
eq(moddedBack, 1.5, "species back override resolved for the grow test")
|
||||
|
||||
for _, gs in ipairs({ 3 / 7, 5 / 7, 1 }) do
|
||||
local eff = moddedBack * gs
|
||||
local _, y = BattleState.backPlacement(W, H, PAD, PADL, eff)
|
||||
eq(y + (H - PAD) * eff, 96,
|
||||
"player feet stay pinned through grow stage " .. gs)
|
||||
|
||||
local ex, ey = 100, 20
|
||||
local _, ey2 = BattleState.frontPlacement(ex, ey, W, H, eff)
|
||||
eq(ey2 + H * eff, ey + H,
|
||||
"enemy bottom stays pinned through grow stage " .. gs)
|
||||
end
|
||||
end
|
||||
|
||||
S.finish()
|
||||
Reference in New Issue
Block a user