mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 08:21:02 +02:00
big ass modding update
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
-- T4: the link desync suite extended for modded battles
|
||||
-- (21-testing-and-ci "link desync suite").
|
||||
--
|
||||
-- Three classes, all ROM-free against the fixture dataset:
|
||||
--
|
||||
-- symmetric-mod the same mod on both sides changes battle math and
|
||||
-- desyncs nothing -- the two simulations stay mirrored.
|
||||
-- one-sided mod the handshake sees the fingerprint move and refuses
|
||||
-- the battle, instead of letting it desync into a draw
|
||||
-- that explains nothing.
|
||||
-- extra bag a mod field and ppUps survive the wire, and a mon a
|
||||
-- total conversion cannot rebuild is rejected by name
|
||||
-- rather than silently substituted.
|
||||
--
|
||||
-- The primary desync assertion is the mirrored final state (my mon's HP on
|
||||
-- A equals A's mon as seen by B), not the per-turn hash table: LinkBattle
|
||||
-- only records a hash on turns that reach its end-of-turn act, so the hash
|
||||
-- table is sparse enough that "no mismatch" alone would pass vacuously.
|
||||
-- Hashes are still compared where both sides recorded one.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local Protocol = require("src.link.Protocol")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
|
||||
math.randomseed(4242)
|
||||
|
||||
-- a mod that moves battle math: moves are link surface, so installing it
|
||||
-- must move the fingerprint. accuracy is 0..100 and the effect has to
|
||||
-- resolve against the move_effects registry -- a mod registration is
|
||||
-- schema-checked even though the base dataset's own records are not.
|
||||
local BATTLE_MOD = {
|
||||
["mods/fix_battle_mod/manifest.json"] = [[{
|
||||
"id": "fix_battle_mod",
|
||||
"name": "Fixture Battle Mod",
|
||||
"version": "1.0.0",
|
||||
"entry": "main.lua",
|
||||
"api": 2,
|
||||
"affects_link": true
|
||||
}]],
|
||||
["mods/fix_battle_mod/main.lua"] = [[
|
||||
local mod = ...
|
||||
mod.content.moves:register("FIX_MODMOVE", {
|
||||
id = "FIX_MODMOVE", index = 90, name = "FIX MODMOVE",
|
||||
effect = "BURN_SIDE_EFFECT1",
|
||||
power = 60, type = "FIRE", accuracy = 100, pp = 20,
|
||||
anim = { sound = 1, pitch = 0, tempo = 0 },
|
||||
})
|
||||
]],
|
||||
}
|
||||
|
||||
local function loadBattleMod(data)
|
||||
return T.sdk.loadMods({ "mods/fix_battle_mod" },
|
||||
{ data = data, fs = T.sdk.memfs(BATTLE_MOD) })
|
||||
end
|
||||
|
||||
-- ------- symmetric mod: identical installs stay in lockstep
|
||||
|
||||
do
|
||||
local dataA = T.fixtures.fresh()
|
||||
local dataB = T.fixtures.fresh()
|
||||
local runA = loadBattleMod(dataA)
|
||||
T.eq(#runA.errors, 0, "the battle mod loads clean on side A")
|
||||
T.check(dataA.moves.FIX_MODMOVE ~= nil, "the mod's move merged into side A")
|
||||
runA.release()
|
||||
|
||||
local runB = loadBattleMod(dataB)
|
||||
T.eq(#runB.errors, 0, "the battle mod loads clean on side B")
|
||||
T.check(dataB.moves.FIX_MODMOVE ~= nil, "the mod's move merged into side B")
|
||||
runB.release()
|
||||
|
||||
local Fingerprint = require("src.link.Fingerprint")
|
||||
local mods = { { id = "fix_battle_mod", version = "1.0.0", affectsLink = true } }
|
||||
T.eq(Fingerprint.compute(dataA, mods), Fingerprint.compute(dataB, mods),
|
||||
"two identical modded installs agree on the fingerprint")
|
||||
|
||||
-- and the battle really runs to a mirrored finish. dataA already
|
||||
-- carries the engine's built-in records from the merge above: a second
|
||||
-- load into the same table would re-register them and raise, so both
|
||||
-- sides share this one dataset (as two installs of the same mod do).
|
||||
T.link.prepare(dataA)
|
||||
local gameA = T.link.fakeGame(dataA, { "FIXMON_A", "FIXMON_C" }, { name = "RED", level = 20 })
|
||||
local gameB = T.link.fakeGame(dataA, { "FIXMON_B", "FIXMON_A" }, { name = "BLUE", level = 20 })
|
||||
local result = T.link.lockstep(gameA, gameB, { maxFrames = 60000 })
|
||||
|
||||
T.check(result.completed, "the symmetric-mod lockstep battle completes on both sides")
|
||||
T.check((result.resultA == "win" and result.resultB == "lose")
|
||||
or (result.resultA == "lose" and result.resultB == "win")
|
||||
or (result.resultA == "draw" and result.resultB == "draw"),
|
||||
("both simulations agree on the outcome (%s / %s)")
|
||||
:format(tostring(result.resultA), tostring(result.resultB)))
|
||||
|
||||
-- the dense check: each side's view of the same mon must match
|
||||
T.eq(result.battleA.player.mon.hp, result.battleB.enemy.mon.hp,
|
||||
"host mon HP identical on both sides")
|
||||
T.eq(result.battleA.enemy.mon.hp, result.battleB.player.mon.hp,
|
||||
"guest mon HP identical on both sides")
|
||||
T.eq(result.battleA.player.mon.species, result.battleB.enemy.mon.species,
|
||||
"host active species identical on both sides")
|
||||
T.check(result.agreed, "no per-turn hash mismatch across the battle")
|
||||
|
||||
-- the real party is untouched: link battles fight clamped copies
|
||||
T.eq(gameA.save.party[1].hp, gameA.save.party[1].stats.hp,
|
||||
"the real party is untouched by the link battle")
|
||||
end
|
||||
|
||||
-- ------- one-sided mod: the handshake must fail closed
|
||||
|
||||
do
|
||||
local plain = T.fixtures.fresh()
|
||||
local plainRun = T.sdk.loadNone({ data = plain })
|
||||
|
||||
local modded = T.fixtures.fresh()
|
||||
local moddedRun = loadBattleMod(modded)
|
||||
T.eq(#moddedRun.errors, 0, "the one-sided install loads clean")
|
||||
moddedRun.release()
|
||||
|
||||
T.link.prepare(plain)
|
||||
local gameA = T.link.fakeGame(plain, "FIXMON_A", { name = "RED" })
|
||||
local gameB = T.link.fakeGame(modded, "FIXMON_B", { name = "BLUE" })
|
||||
|
||||
-- Handshake.mods reads game.mods, so stand in the loaded set explicitly:
|
||||
-- side B is the one carrying the mod
|
||||
gameB.mods = { status = function()
|
||||
return { loaded = { { id = "fix_battle_mod", version = "1.0.0", affects_link = true } } }
|
||||
end }
|
||||
|
||||
local shake = T.link.handshake(gameA, gameB, "battle", nil)
|
||||
T.check(not shake.match, "a one-sided mod moves the fingerprint")
|
||||
T.eq(shake.verdict, "subset", "the handshake grades a fingerprint mismatch as subset")
|
||||
T.eq(shake.reason, "fingerprint_mismatch", "the mismatch is named, not generic")
|
||||
T.eq(shake.battleAllowed, false,
|
||||
"a subset verdict refuses the lockstep battle rather than desyncing into it")
|
||||
T.eq(shake.tradeAllowed, true, "a subset verdict still permits a negotiated trade")
|
||||
|
||||
-- the positive control: two identical sides must be allowed to battle,
|
||||
-- or the assertion above would pass simply because nothing ever links
|
||||
local twin = T.fixtures.fresh()
|
||||
local twinRun = T.sdk.loadNone({ data = twin })
|
||||
local gameC = T.link.fakeGame(twin, "FIXMON_A", { name = "GREEN" })
|
||||
local clean = T.link.handshake(gameA, gameC, "battle", nil)
|
||||
T.check(clean.match, "two unmodded sides agree on the fingerprint")
|
||||
T.eq(clean.verdict, "full", "two unmodded sides grade as full compatibility")
|
||||
T.eq(clean.battleAllowed, true, "two unmodded sides may battle")
|
||||
twinRun.release()
|
||||
plainRun.release()
|
||||
end
|
||||
|
||||
-- ------- extra bag, ppUps, and strict rejection
|
||||
|
||||
do
|
||||
local data = T.fixtures.fresh()
|
||||
local run = T.sdk.loadNone({ data = data })
|
||||
|
||||
local mon = Pokemon.new(data, "FIXMON_A", 20)
|
||||
mon.extra = { fix_mod_charge = 7, fix_mod_flag = true, fix_mod_name = "SODA" }
|
||||
mon.moves[1].ppUps = 3
|
||||
|
||||
local packed = Protocol.packMon(mon)
|
||||
T.check(packed.extra ~= nil, "the extra bag rides the wire")
|
||||
T.eq(packed.extra.fix_mod_charge, 7, "a numeric mod field is packed")
|
||||
T.eq(packed.extra.fix_mod_flag, true, "a boolean mod field is packed")
|
||||
T.eq(packed.extra.fix_mod_name, "SODA", "a string mod field is packed")
|
||||
T.eq(packed.moves[1].ppUps, 3, "ppUps are packed")
|
||||
|
||||
local received = Protocol.unpackMon(data, packed)
|
||||
T.check(received ~= nil, "the mon rebuilds on the far side")
|
||||
T.eq(received.extra.fix_mod_charge, 7, "the mod field survives the round trip")
|
||||
T.eq(received.extra.fix_mod_flag, true, "the boolean survives the round trip")
|
||||
T.eq(received.extra.fix_mod_name, "SODA", "the string survives the round trip")
|
||||
T.eq(received.moves[1].ppUps, 3, "ppUps survive the round trip")
|
||||
T.eq(received.species, "FIXMON_A", "the species survives")
|
||||
T.eq(received.level, 20, "the level survives")
|
||||
|
||||
-- the extra bag is a copy, not a shared reference: a mod mutating its
|
||||
-- own field must not reach back through the wire
|
||||
received.extra.fix_mod_charge = 99
|
||||
T.eq(mon.extra.fix_mod_charge, 7, "the extra bag is copied, not aliased")
|
||||
|
||||
-- strict rejection: a total conversion that never heard of this species
|
||||
-- must say so rather than substitute a hard-coded fallback
|
||||
local foreign = Protocol.packMon(mon)
|
||||
foreign.species = "NOT_IN_THIS_DATASET"
|
||||
local rebuilt, reason = Protocol.unpackMon(data, foreign, { strict = true })
|
||||
T.eq(rebuilt, nil, "an unknown species is rejected under strict")
|
||||
T.check(reason ~= nil and tostring(reason):find("POK") ~= nil,
|
||||
"the rejection names the species problem (got " .. tostring(reason) .. ")")
|
||||
|
||||
-- and a mon whose moves the dataset does not have
|
||||
local noMoves = Protocol.packMon(mon)
|
||||
noMoves.moves = { { id = "NOT_A_MOVE_HERE", pp = 10 } }
|
||||
local rebuilt2, reason2 = Protocol.unpackMon(data, noMoves, { strict = true })
|
||||
T.eq(rebuilt2, nil, "a mon with no shared moves is rejected under strict")
|
||||
T.eq(reason2, "no shared moves", "the rejection names the move problem")
|
||||
|
||||
-- without strict, the v1 path still substitutes rather than crashing
|
||||
local lenient = Protocol.unpackMon(data, noMoves)
|
||||
T.check(lenient ~= nil, "the non-strict path still rebuilds a mon")
|
||||
T.check(#lenient.moves > 0, "the non-strict path gives the mon a usable move")
|
||||
|
||||
run.release()
|
||||
end
|
||||
|
||||
T.finish("link_desync")
|
||||
@@ -0,0 +1,140 @@
|
||||
-- T4: the mod lifecycle through the public API only
|
||||
-- (21-testing-and-ci "the modkit test harness").
|
||||
--
|
||||
-- This is the case a mod author copies: synthesize (or point at) a mod,
|
||||
-- load it headlessly against the fixture dataset, and assert on what
|
||||
-- reached Data and the buses. Nothing here reaches into loader internals
|
||||
-- that a mod could not reach itself.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
|
||||
-- ------- a mod that exercises content, events, hooks, save and exports
|
||||
|
||||
local GOOD = {
|
||||
["mods/fix_kitchen_sink/manifest.json"] = [[{
|
||||
"id": "fix_kitchen_sink",
|
||||
"name": "Fixture Kitchen Sink",
|
||||
"version": "2.1.0",
|
||||
"entry": "main.lua",
|
||||
"api": 2,
|
||||
"description": "Exercises the public mod surface."
|
||||
}]],
|
||||
["mods/fix_kitchen_sink/main.lua"] = [[
|
||||
local mod = ...
|
||||
mod.content.items:register("FIX_SODA", {
|
||||
id = "FIX_SODA", index = 90, name = "FIX SODA", price = 400,
|
||||
tossable = true,
|
||||
})
|
||||
-- patch an existing record instead of replacing it
|
||||
mod.content.items:patch("FIX_POTION", { price = 250 })
|
||||
mod.events:on("game.ready", function(ev) mod.exports.sawReady = ev ~= nil end)
|
||||
mod.hooks:wrap("catch.rate", function(nextFn, ctx)
|
||||
local base = nextFn(ctx)
|
||||
return base
|
||||
end)
|
||||
mod.save:set("charge", 3)
|
||||
mod.exports.marker = "kitchen-sink"
|
||||
]],
|
||||
}
|
||||
|
||||
do
|
||||
local data = T.fixtures.fresh()
|
||||
local run = T.sdk.loadMods({ "mods/fix_kitchen_sink" },
|
||||
{ data = data, fs = T.sdk.memfs(GOOD) })
|
||||
|
||||
T.eq(#run.errors, 0, "the mod loads with no errors (" .. tostring(run.errors[1]) .. ")")
|
||||
local mod = run.mods.fix_kitchen_sink
|
||||
T.check(mod ~= nil, "the loader discovered the mod by its manifest id")
|
||||
T.eq(mod and mod.state, "loaded", "the mod reached the loaded state")
|
||||
T.eq(mod and mod.manifest.version, "2.1.0", "the manifest version is read")
|
||||
|
||||
-- content reached Data through the merge
|
||||
T.check(data.items.FIX_SODA ~= nil, "a registered item merged into Data")
|
||||
T.eq(data.items.FIX_SODA.price, 400, "the registered item kept its fields")
|
||||
T.eq(data.items.FIX_SODA.tossable, true, "record fields survive the merge")
|
||||
|
||||
-- patch is a deep merge over the base record, not a replacement
|
||||
T.eq(data.items.FIX_POTION.price, 250, "patch overwrote the field it named")
|
||||
T.eq(data.items.FIX_POTION.tossable, true,
|
||||
"patch left the fields it did not name alone")
|
||||
T.eq(data.items.FIX_POTION.name, "FIX POTION", "patch left the record's name alone")
|
||||
|
||||
-- the hook is wrapped, and with exactly one link
|
||||
local hooks = T.record.hooks(run.loader)
|
||||
T.eq(hooks:depth("catch.rate"), 1, "the mod's hook is wrapped once")
|
||||
T.eq(hooks:owners("catch.rate")[1], "fix_kitchen_sink", "the hook link is attributed to the mod")
|
||||
|
||||
-- the event recorder sees what the engine emits
|
||||
local rec = T.record.events(run.loader)
|
||||
run.loader.events:emit("game.ready", { game = { marker = true } })
|
||||
T.eq(rec:count("game.ready"), 1, "the recorder captured the emit")
|
||||
T.check(rec:first("game.ready").game ~= nil, "game.ready carries { game = Game }")
|
||||
rec:stop()
|
||||
|
||||
run.release()
|
||||
end
|
||||
|
||||
-- ------- a mod that throws in its entry chunk rolls back completely
|
||||
|
||||
local BAD = {
|
||||
["mods/fix_broken/manifest.json"] = [[{
|
||||
"id": "fix_broken",
|
||||
"name": "Fixture Broken",
|
||||
"version": "1.0.0",
|
||||
"entry": "main.lua",
|
||||
"api": 2
|
||||
}]],
|
||||
["mods/fix_broken/main.lua"] = [[
|
||||
local mod = ...
|
||||
mod.content.items:register("FIX_GHOST", {
|
||||
id = "FIX_GHOST", index = 91, name = "FIX GHOST", price = 1,
|
||||
})
|
||||
mod.events:on("game.ready", function() end)
|
||||
error("entry chunk exploded")
|
||||
]],
|
||||
}
|
||||
|
||||
do
|
||||
local data = T.fixtures.fresh()
|
||||
local run = T.sdk.loadMods({ "mods/fix_broken" },
|
||||
{ data = data, fs = T.sdk.memfs(BAD) })
|
||||
|
||||
T.check(#run.errors > 0, "a throwing entry chunk is reported as an error")
|
||||
T.check(tostring(run.errors[1]):find("exploded", 1, true) ~= nil,
|
||||
"the error names the failure (" .. tostring(run.errors[1]) .. ")")
|
||||
T.eq(run.mods.fix_broken and run.mods.fix_broken.state, "failed",
|
||||
"the mod is marked failed")
|
||||
|
||||
-- rollback: neither its content nor its subscription survived
|
||||
T.eq(data.items.FIX_GHOST, nil, "a failed mod's content is rolled back out of Data")
|
||||
local hooks = T.record.hooks(run.loader)
|
||||
T.eq(hooks:depth("catch.rate"), 0, "a failed mod leaves no hook links")
|
||||
local listeners = run.loader.events.listeners["game.ready"]
|
||||
T.eq(#(listeners or {}), 0, "a failed mod leaves no event listeners")
|
||||
|
||||
-- and the engine still works: a failed mod is "not installed", not fatal
|
||||
T.check(data.items.FIX_POTION ~= nil, "base content survives a failed mod")
|
||||
|
||||
run.release()
|
||||
end
|
||||
|
||||
-- ------- the no-mod parity baseline for this same path
|
||||
|
||||
do
|
||||
local data = T.fixtures.fresh()
|
||||
local run = T.sdk.loadNone({ data = data })
|
||||
T.eq(#run.errors, 0, "loading no mods produces no errors")
|
||||
T.eq(next(run.mods), nil, "loading no mods discovers no mods")
|
||||
T.eq(data.items.FIX_SODA, nil, "no mod means no mod content in Data")
|
||||
T.eq(data.items.FIX_POTION.price, 300, "the base item keeps its unpatched price")
|
||||
|
||||
local hooks = T.record.hooks(run.loader)
|
||||
for _, name in ipairs(T.catalog.hooks()) do
|
||||
T.eq(hooks:depth(name), 0, "no mod means an empty chain: " .. name)
|
||||
end
|
||||
run.release()
|
||||
end
|
||||
|
||||
T.finish("mod_lifecycle")
|
||||
Reference in New Issue
Block a user