mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 16:31:05 +02:00
be90e5b42c
* intro api upgrade * api updates * fix tests * updated templates
359 lines
16 KiB
Lua
359 lines
16 KiB
Lua
-- The shipped example gallery (25-community-and-ecosystem.md 1): every
|
|
-- entry loads clean through the real loader, produces its stated effect,
|
|
-- and carries the metadata the polish checklist requires.
|
|
--
|
|
-- The eight entries load TOGETHER against one dataset, which is the case a
|
|
-- player who enables the whole gallery gets and the only way to catch two
|
|
-- examples fighting over the same id.
|
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
|
love = love or require("tests.love_stub")
|
|
|
|
local Data = require("src.core.Data")
|
|
local FsIo = require("tests.fs_io")
|
|
local Loader = require("src.mods.Loader")
|
|
local Manifest = require("src.mods.Manifest")
|
|
local Runtime = require("src.mods.Runtime")
|
|
|
|
local S = require("tests.harness").suite("example gallery (M15B)")
|
|
local check, eq = S.check, S.eq
|
|
|
|
local GALLERY_ROOT = "mods/examples"
|
|
local IDS = {
|
|
"example_balance_tweaks", "example_shiny_palette", "example_jukebox",
|
|
"example_lost_parcel", "example_weather", "example_dexnav",
|
|
"example_mini_conversion", "example_silly_oak",
|
|
}
|
|
|
|
-- the closed vocabulary from 25 3.1; GAMEPLAY is the accepted v1 alias
|
|
local CATEGORIES = {
|
|
TWEAK = true, BALANCE = true, CONTENT = true, QUEST = true,
|
|
MECHANIC = true, GRAPHICS = true, AUDIO = true, UI = true, TOOL = true,
|
|
TOTAL_CONVERSION = true, OTHER = true, GAMEPLAY = true,
|
|
}
|
|
|
|
local function exists(path)
|
|
local handle = io.open(path, "rb")
|
|
if handle then handle:close() return true end
|
|
return false
|
|
end
|
|
|
|
-- ------- an imported dataset of this suite's own
|
|
-- Data:load() folds into the singleton and require() hands back cached
|
|
-- module tables, so a mod merge through either would leak into every other
|
|
-- suite in this process. loadfile skips the cache, which is the same door
|
|
-- POKEPORT_DATA_DIR uses.
|
|
|
|
-- methods only: inheriting the Data singleton wholesale would let a
|
|
-- namespace an earlier suite merged into it (Data.balls, Data.commands)
|
|
-- show through as this dataset's base, and the engine's own registrations
|
|
-- would then collide with themselves
|
|
local function dataMethods()
|
|
local methods = {}
|
|
for key, value in pairs(Data) do
|
|
if type(value) == "function" then methods[key] = value end
|
|
end
|
|
return methods
|
|
end
|
|
|
|
-- the namespaces are whatever the importer wrote; discovering them beats
|
|
-- restating Data's module list, which would drift the moment one is added
|
|
local function freshImported()
|
|
local set = setmetatable({}, { __index = dataMethods() })
|
|
local pipe = io.popen("ls -1 data/generated/*.lua 2>/dev/null")
|
|
if not pipe then return nil end
|
|
local loaded = 0
|
|
for path in pipe:lines() do
|
|
local name = path:match("([^/]+)%.lua$")
|
|
local chunk = name and loadfile(path)
|
|
if chunk then
|
|
set[name] = chunk()
|
|
loaded = loaded + 1
|
|
end
|
|
end
|
|
pipe:close()
|
|
if set.pokemon == nil or set.maps == nil or loaded < 10 then return nil end
|
|
Data.seedDefaults(set)
|
|
return set
|
|
end
|
|
|
|
-- ------- static metadata: true with or without an imported dataset
|
|
|
|
for _, id in ipairs(IDS) do
|
|
local dir = GALLERY_ROOT .. "/" .. id
|
|
local prefix = id .. ": "
|
|
|
|
local manifestBody = io.open(dir .. "/manifest.json", "rb")
|
|
if not check(manifestBody ~= nil, prefix .. "ships a manifest.json") then
|
|
manifestBody = nil
|
|
end
|
|
if manifestBody then
|
|
local body = manifestBody:read("*a")
|
|
manifestBody:close()
|
|
local manifest = require("src.link.Json").decode(body)
|
|
local ok, parsed = pcall(Manifest.validate, manifest, dir)
|
|
check(ok, prefix .. "the manifest validates (" .. tostring(parsed) .. ")")
|
|
if ok then
|
|
eq(parsed.id, id, prefix .. "the manifest id matches the directory")
|
|
eq(parsed.api, 2, prefix .. "is an api 2 mod")
|
|
check(CATEGORIES[parsed.category],
|
|
prefix .. "category " .. tostring(parsed.category) .. " is in the taxonomy")
|
|
check(parsed.game_version ~= nil and parsed.game_version ~= "",
|
|
prefix .. "declares a game_version range")
|
|
check(parsed.description ~= "", prefix .. "the manifest carries a description")
|
|
end
|
|
end
|
|
|
|
check(exists(dir .. "/README.md"), prefix .. "ships a README.md")
|
|
check(exists(dir .. "/CHANGELOG.md"), prefix .. "ships a CHANGELOG.md")
|
|
check(exists(dir .. "/tests/" .. id .. "_test.lua"),
|
|
prefix .. "ships its own test suite")
|
|
|
|
-- the card is Lua, never read by the merge; it must still parse and meet
|
|
-- the 25 3.2 shape or the manager detail pane has nothing to draw
|
|
local cardChunk = loadfile(dir .. "/mod.card")
|
|
if check(cardChunk ~= nil, prefix .. "ships a parseable mod.card") then
|
|
local okCard, card = pcall(cardChunk)
|
|
if check(okCard and type(card) == "table", prefix .. "mod.card returns a table") then
|
|
check(type(card.summary) == "string" and #card.summary > 0
|
|
and #card.summary <= 100, prefix .. "summary is 1..100 chars")
|
|
check(type(card.author) == "string" and card.author ~= "",
|
|
prefix .. "author is present and non-empty")
|
|
check(type(card.tags) == "table" and #card.tags > 0,
|
|
prefix .. "carries at least one tag")
|
|
for _, tag in ipairs(card.tags or {}) do
|
|
check(tag == tag:lower() and not tag:find("%s"),
|
|
prefix .. "tag " .. tostring(tag) .. " is lowercase kebab")
|
|
end
|
|
check(type(card.differences) == "table"
|
|
and type(card.differences.changed) == "table"
|
|
and type(card.differences.added) == "table"
|
|
and type(card.differences.known) == "table",
|
|
prefix .. "declares a changed/added/known differences ledger")
|
|
check(type(card.credits) == "table" and #card.credits > 0,
|
|
prefix .. "names at least one credit")
|
|
for _, entry in ipairs(card.credits or {}) do
|
|
check(type(entry.who) == "string" and type(entry.for_) == "string",
|
|
prefix .. "every credit says who and what for")
|
|
end
|
|
check(type(card.compat) == "table" and card.compat.modApi == 2,
|
|
prefix .. "compat declares modApi 2")
|
|
end
|
|
end
|
|
end
|
|
|
|
-- the legacy entry keeps its v1 manifest and gains only a card
|
|
do
|
|
local cardChunk = loadfile("mods/example_mew_starter/mod.card")
|
|
if check(cardChunk ~= nil, "example_mew_starter: ships a mod.card") then
|
|
local ok, card = pcall(cardChunk)
|
|
check(ok and type(card) == "table", "example_mew_starter: the card parses")
|
|
check(ok and card.compat and card.compat.modApi == 1,
|
|
"example_mew_starter: the card declares api 1, not 2")
|
|
end
|
|
local body = io.open("mods/example_mew_starter/manifest.json", "rb")
|
|
if check(body ~= nil, "example_mew_starter: manifest is readable") then
|
|
local manifest = require("src.link.Json").decode(body:read("*a"))
|
|
body:close()
|
|
check(manifest.api == nil, "example_mew_starter: stays an api 1 manifest")
|
|
eq(manifest.category, "GAMEPLAY",
|
|
"example_mew_starter: keeps the legacy category value")
|
|
local ok = pcall(Manifest.validate, manifest, "mods/example_mew_starter")
|
|
check(ok, "example_mew_starter: the v1 manifest still validates")
|
|
end
|
|
end
|
|
|
|
-- ------- disabled by default
|
|
-- Loader:_discover walks one level below "mods". The gallery sits a level
|
|
-- deeper, so a fresh install discovers none of it and the merged data of a
|
|
-- mod-free boot is unchanged -- the parity invariant, held by construction.
|
|
|
|
do
|
|
local fs = FsIo.new(".")
|
|
local top = {}
|
|
for _, name in ipairs(fs.getDirectoryItems("mods")) do
|
|
if fs.getInfo("mods/" .. name .. "/manifest.json") then top[name] = true end
|
|
end
|
|
for _, id in ipairs(IDS) do
|
|
check(not top[id], id .. " is not discoverable at the mods/ root")
|
|
end
|
|
check(top.example_mew_starter,
|
|
"the legacy example is still discovered at the mods/ root")
|
|
end
|
|
|
|
-- ------- the gallery loads
|
|
|
|
local data = freshImported()
|
|
if not data then
|
|
print("modkit: example gallery load skipped -- no imported dataset in "
|
|
.. "data/generated/ to fold the examples against")
|
|
return S.finish()
|
|
end
|
|
|
|
-- The transform in example_shiny_palette reads the imported cache and
|
|
-- writes under save/mod-derived/. The io-backed harness filesystem has no
|
|
-- createDirectory, so this run exercises the no-cache path instead: the
|
|
-- transform must degrade to writing nothing, not fail the mod.
|
|
local function galleryFs(ids)
|
|
local inner = FsIo.new(".")
|
|
local overlay = {}
|
|
local hidden = "assets/generated/"
|
|
|
|
local function map(path)
|
|
if path == nil then return path end
|
|
for _, id in ipairs(ids) do
|
|
local mount = "mods/" .. id
|
|
if path == mount then return GALLERY_ROOT .. "/" .. id end
|
|
if path:sub(1, #mount + 1) == mount .. "/" then
|
|
return GALLERY_ROOT .. "/" .. id .. path:sub(#mount + 1)
|
|
end
|
|
end
|
|
return path
|
|
end
|
|
|
|
local fs = { root = inner.root }
|
|
function fs.read(path)
|
|
if path:sub(1, #hidden) == hidden then return nil end
|
|
return overlay[path] or inner.read(map(path))
|
|
end
|
|
function fs.write(path, body) overlay[path] = body return true end
|
|
function fs.createDirectory() return true end
|
|
function fs.load(path) return inner.load(map(path)) end
|
|
function fs.getInfo(path)
|
|
if path == "mods" then return { type = "directory" } end
|
|
if path:sub(1, #hidden) == hidden then return nil end
|
|
if overlay[path] then return { type = "file" } end
|
|
return inner.getInfo(map(path))
|
|
end
|
|
function fs.getDirectoryItems(path)
|
|
if path == "mods" then
|
|
local names = {}
|
|
for i, id in ipairs(ids) do names[i] = id end
|
|
table.sort(names)
|
|
return names
|
|
end
|
|
return inner.getDirectoryItems(map(path))
|
|
end
|
|
return fs
|
|
end
|
|
|
|
local saved = { events = Runtime.events, hooks = Runtime.hooks,
|
|
errors = Runtime.errors }
|
|
local loader = Loader.new({ fs = galleryFs(IDS) })
|
|
local ok, err = pcall(loader.load, loader, data)
|
|
check(ok, "the whole gallery loads without raising (" .. tostring(err) .. ")")
|
|
|
|
for _, message in ipairs(loader.errors) do
|
|
check(false, "loader error: " .. tostring(message))
|
|
end
|
|
eq(#loader.errors, 0, "the gallery loads with zero loader errors")
|
|
|
|
local status = loader:status()
|
|
eq(#status.loaded, #IDS, "every gallery entry reached the loaded state")
|
|
for _, id in ipairs(IDS) do
|
|
local mod = loader.mods[id]
|
|
check(mod ~= nil, id .. " was discovered")
|
|
eq(mod and mod.state, "loaded", id .. " reached the loaded state")
|
|
end
|
|
|
|
-- ------- each entry's stated effect landed in the merged data
|
|
|
|
-- #1 tweaker: patch and each
|
|
eq(data.pokemon.VENUSAUR.baseStats.speed, 100, "#1 patched VENUSAUR speed")
|
|
check(#data.pokemon.VENUSAUR.learnset > 0, "#1 patch left the learnset alone")
|
|
eq(data.items.TM_TOXIC.price, 2000, "#1 halved a TM price through each()")
|
|
eq(data.items.POTION.price, 300, "#1 left non-TM prices alone")
|
|
eq(data.encounters.ROUTE_1.grass.rate, 20, "#1 re-slotted Route 1")
|
|
|
|
-- #2 artist: palette records and the trueColor opt-out
|
|
check(data.palettes.palettes.EXAMPLE_SHINY ~= nil, "#2 registered a palette record")
|
|
eq(#data.palettes.palettes.PALLET, 4, "#2 overrode PALLET with four colors")
|
|
eq(data.sprites.SPRITE_RED.trueColor, true, "#2 opted SPRITE_RED into trueColor")
|
|
check(data.sprites.SPRITE_RED.image ~= nil,
|
|
"#2 patched only the flag; the sheet path survived")
|
|
|
|
-- #3 musician: an authored program, a cry and a hook
|
|
local song = data.audio.songs.Music_ExamplePalletRain
|
|
check(type(song) == "table" and type(song.chip) == "table",
|
|
"#3 registered an authored chip song")
|
|
check(#song.chip.blob > 0, "#3 the song assembled to a non-empty blob")
|
|
check(type(data.audio.cries.MEW) == "table" and data.audio.cries.MEW.chip,
|
|
"#3 replaced the MEW cry with a chip program")
|
|
eq(Runtime.call("music.select", function(chosen) return chosen end,
|
|
"Music_PalletTown", { reason = "map", mapId = "PALLET_TOWN" }),
|
|
"Music_ExamplePalletRain", "#3 music.select swaps the Pallet Town theme")
|
|
eq(Runtime.call("music.select", function(chosen) return chosen end,
|
|
"Music_Routes1", { reason = "map", mapId = "ROUTE_1" }),
|
|
"Music_Routes1", "#3 every other map defers to the vanilla choice")
|
|
|
|
-- #4 quest author: compose semantics, a verb, a token, an item
|
|
check(data.items.EXAMPLE_LOST_PARCEL_PARCEL ~= nil, "#4 registered the parcel item")
|
|
check(data.tokens.EXAMPLE_PARCEL_REWARD ~= nil, "#4 registered its text token")
|
|
check(data.commands["example_lost_parcel:count_ask"] ~= nil, "#4 registered its verb")
|
|
check(data.commands.show_text ~= nil, "#4 the engine's own verbs are untouched")
|
|
local viridian = data.map_scripts and data.map_scripts.VIRIDIAN_CITY
|
|
check(viridian and #viridian > 0, "#4 composed into the VIRIDIAN_CITY chain")
|
|
check(viridian and viridian[1].talk
|
|
and viridian[1].talk.TEXT_VIRIDIANCITY_GAMBLER1 ~= nil,
|
|
"#4 the talk contribution addresses a real TEXT constant")
|
|
-- talk dispatch is single-winner, so the branches the quest does not own
|
|
-- have to replay the base handler rather than re-resolve its TEXT constant
|
|
check(data.commands["example_lost_parcel:base_nerd_chat"] ~= nil,
|
|
"#4 registered the verb that replays the overridden base conversation")
|
|
local pewterTalk = data.map_scripts and data.map_scripts.PEWTER_CITY
|
|
and data.map_scripts.PEWTER_CITY[1].talk.TEXT_PEWTERCITY_SUPER_NERD1
|
|
local fallback = pewterTalk and pewterTalk[#pewterTalk]
|
|
eq(fallback and fallback[1], "example_lost_parcel:base_nerd_chat",
|
|
"#4 the vanilla branch ends in that verb, not a truncating show_text")
|
|
|
|
-- #5 mechanic designer: a ruleset, a status and a gated damage hook
|
|
local weather = data.rulesets.example_weather_battles
|
|
check(weather ~= nil, "#5 registered a selectable ruleset")
|
|
eq(weather.randMax, data.rulesets.gen1_faithful.randMax,
|
|
"#5 the derived ruleset kept the vanilla rules")
|
|
check(data.statuses.EXAMPLE_RAIN ~= nil, "#5 registered the rain status record")
|
|
local function damage(ruleset, moveType)
|
|
return Runtime.call("battle.damage", function() return 100, { crit = false } end,
|
|
{ ruleset = ruleset, move = { type = moveType } })
|
|
end
|
|
Runtime.emit("battle.started", { battle = { ruleset = data.rulesets.gen1_faithful } })
|
|
eq(damage(data.rulesets.gen1_faithful, "WATER"), 100,
|
|
"#5 gen1_faithful is unchanged with the mod installed")
|
|
Runtime.emit("battle.started", { battle = { ruleset = weather } })
|
|
eq(damage(weather, "WATER"), 150, "#5 rain boosts WATER under its own ruleset")
|
|
eq(damage(weather, "FIRE"), 50, "#5 rain dampens FIRE under its own ruleset")
|
|
Runtime.emit("battle.ended", {})
|
|
|
|
-- #6 tool builder: exports, options and the start-menu wrap
|
|
local exports = loader.exports.example_dexnav
|
|
check(type(exports.countSeen) == "function", "#6 published a countSeen export")
|
|
eq(loader.optionSchemas.example_dexnav and #loader.optionSchemas.example_dexnav, 2,
|
|
"#6 defined two option rows")
|
|
local menu = Runtime.call("ui.start_menu.items", function(_, items) return items end,
|
|
{ data = data }, { { label = "POKéDEX" }, { label = "SAVE" } })
|
|
eq(#menu, 3, "#6 added exactly one start-menu row")
|
|
eq(menu[2].label, "DEXNAV", "#6 anchored the row before SAVE")
|
|
|
|
-- #7 total conversion: boot, constants, species, map
|
|
eq(data.field.boot.startMap, "SABLE_COVE", "#7 owns the boot spawn")
|
|
eq(data.field.boot.startFacing, "down", "#7 patch left unnamed boot keys alone")
|
|
eq(data.constants.dexSize, 3, "#7 shrank the dex")
|
|
eq(#data.constants.badges, 1, "#7 override replaced the badge list")
|
|
eq(data.constants.partyMax, 6, "#7 left unpatched constants alone")
|
|
check(data.pokemon.SABLE_EMBERKIT ~= nil, "#7 registered its own species")
|
|
check(data.audio.cries.SABLE_EMBERKIT ~= nil, "#7 gave it a cry")
|
|
check(data.icons.bySpecies.SABLE_EMBERKIT ~= nil, "#7 gave it an icon")
|
|
check(data.maps.SABLE_COVE ~= nil, "#7 registered its map")
|
|
eq(#data.maps.SABLE_COVE.blocks,
|
|
data.maps.SABLE_COVE.width * data.maps.SABLE_COVE.height,
|
|
"#7 the map's block array matches its size")
|
|
|
|
-- co-existence: the gallery does not fight over ids
|
|
check(data.pokemon.MEW ~= nil, "vanilla species survive the whole gallery")
|
|
check(data.maps.PALLET_TOWN ~= nil, "vanilla maps survive the whole gallery")
|
|
|
|
Runtime.events, Runtime.hooks, Runtime.errors =
|
|
saved.events, saved.hooks, saved.errors
|
|
Runtime.currentMod = nil
|
|
|
|
S.finish()
|