-- 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 seven 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", } -- 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()