mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 08:21:02 +02:00
370 lines
16 KiB
Lua
370 lines
16 KiB
Lua
-- The six Gen 2-only content registries, end to end: held_items,
|
|
-- phone_contacts, decorations, apricorns, landmarks and radio_channels.
|
|
--
|
|
-- Three claims, and the third is the one that makes the other two worth
|
|
-- anything:
|
|
--
|
|
-- 1. the routing is symmetrical. Schemas.GEN2 says where a shared registry
|
|
-- lands on Gold; Schemas.GEN1 is its mirror, and these six are gated
|
|
-- under GEN ONE -- Red has no phone, no radio and no held items, so a
|
|
-- write there is taken, dropped and reported rather than merged into a
|
|
-- namespace nothing on Red would read.
|
|
-- 2. the vanilla records are the literals they replaced, byte for byte. A
|
|
-- registry that seeds a different record than the module used to hold is
|
|
-- a behaviour change wearing a refactor's clothes.
|
|
-- 3. the CONSUMER reads through the registry. A registry nothing reads is
|
|
-- the silent no-op this whole design exists to prevent, so each one is
|
|
-- driven from a mod's own registration through to the routine the game
|
|
-- calls: Decorations.attributes, Phone.CONTACTS, Apricorns.ballFor,
|
|
-- Nests.landmarkId, MapRadio.channelRecord and, for held_items, the
|
|
-- write-back onto data.items that src/battle/gen2/Battle.lua:heldEffect
|
|
-- reads.
|
|
--
|
|
-- ROM-free: the generation is injected through the loader (T.sdk.loadMods
|
|
-- opts.generation), the way tests/engine/gate_gen2_mod_api.lua does.
|
|
|
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
|
|
|
local T = require("tests.modkit")
|
|
local Schemas = require("src.mods.Schemas")
|
|
local ItemEffects = require("src.core.gen2.ItemEffects")
|
|
local Phone = require("src.core.gen2.Phone")
|
|
local Decorations = require("src.core.gen2.Decorations")
|
|
local Apricorns = require("src.core.gen2.Apricorns")
|
|
local Nests = require("src.core.gen2.Nests")
|
|
local MapRadio = require("src.ui.gen2.MapRadio")
|
|
|
|
local NAMES = { "held_items", "phone_contacts", "decorations", "apricorns",
|
|
"landmarks", "radio_channels" }
|
|
|
|
local PATHS = {
|
|
held_items = "gen2HeldItems",
|
|
phone_contacts = "gen2PhoneContacts",
|
|
decorations = "gen2Decorations",
|
|
apricorns = "gen2Apricorns",
|
|
landmarks = "gen2Landmarks.landmarks",
|
|
radio_channels = "gen2RadioChannels",
|
|
}
|
|
|
|
-- ------- 1. the mirror of Schemas.GEN2
|
|
|
|
for _, name in ipairs(NAMES) do
|
|
local spec = Schemas.REGISTRIES[name]
|
|
T.check(spec ~= nil, "the catalog declares it: " .. name)
|
|
T.eq(spec.target, nil,
|
|
"a Gen 2-only registry carries no Gen 1 target: " .. name)
|
|
T.eq(Schemas.targetFor(name, spec, 1), nil,
|
|
"and none is invented for Gen 1: " .. name)
|
|
T.eq(Schemas.gatedFor(name, 1), true, "gated under Gen 1: " .. name)
|
|
T.eq(Schemas.targetFor(name, spec, 2), PATHS[name],
|
|
"routed to its Gen 2 path: " .. name)
|
|
T.eq(Schemas.gatedFor(name, 2), false, "not gated under Gen 2: " .. name)
|
|
T.check(spec.example ~= nil, "and documents a call: " .. name)
|
|
end
|
|
|
|
-- the mirror holds in the other direction too: nothing shared is gated under
|
|
-- Gen 1, which is what would break if a routing row were put in the wrong
|
|
-- table
|
|
for name, spec in pairs(Schemas.REGISTRIES) do
|
|
if Schemas.GEN1[name] == nil then
|
|
T.eq(Schemas.targetFor(name, spec, 1), spec.target,
|
|
"a registry with no Gen 1 routing row keeps its target: " .. name)
|
|
T.eq(Schemas.gatedFor(name, 1), false,
|
|
"and is not gated under Gen 1: " .. name)
|
|
end
|
|
end
|
|
|
|
for name in pairs(Schemas.GEN1) do
|
|
T.check(Schemas.REGISTRIES[name] ~= nil,
|
|
"Schemas.GEN1 names a real registry: " .. name)
|
|
end
|
|
|
|
-- ------- the Gen 2 dataset these merge into
|
|
--
|
|
-- data.gen2Constants.phoneContactOrder is the phone id space, verbatim from
|
|
-- the ROM manifest (the four PHONE_UNUSED rows are the const_skip holes).
|
|
local PHONE_ORDER = {
|
|
"PHONE_00", "PHONE_MOM", "PHONE_OAK", "PHONE_BILL", "PHONE_ELM",
|
|
"PHONE_SCHOOLBOY_JACK", "PHONE_POKEFAN_BEVERLY", "PHONE_SAILOR_HUEY",
|
|
"PHONE_UNUSED", "PHONE_UNUSED", "PHONE_UNUSED",
|
|
"PHONE_COOLTRAINERM_GAVEN", "PHONE_COOLTRAINERF_BETH",
|
|
"PHONE_BIRDKEEPER_JOSE", "PHONE_COOLTRAINERF_REENA", "PHONE_YOUNGSTER_JOEY",
|
|
"PHONE_BUG_CATCHER_WADE", "PHONE_FISHER_RALPH", "PHONE_PICNICKER_LIZ",
|
|
"PHONE_HIKER_ANTHONY", "PHONE_CAMPER_TODD", "PHONE_PICNICKER_GINA",
|
|
"PHONE_JUGGLER_IRWIN", "PHONE_BUG_CATCHER_ARNIE", "PHONE_SCHOOLBOY_ALAN",
|
|
"PHONE_UNUSED", "PHONE_LASS_DANA", "PHONE_SCHOOLBOY_CHAD",
|
|
"PHONE_POKEFANM_DEREK", "PHONE_FISHER_CHRIS", "PHONE_POKEMANIAC_BRENT",
|
|
"PHONE_PICNICKER_TIFFANY", "PHONE_BIRDKEEPER_VANCE", "PHONE_FISHER_WILTON",
|
|
"PHONE_BLACKBELT_KENJI", "PHONE_HIKER_PARRY", "PHONE_PICNICKER_ERIN",
|
|
}
|
|
|
|
-- two landmark records in the cache's own shape (index = the map header byte)
|
|
local function landmarkTable()
|
|
return {
|
|
order = { "LANDMARK_SPECIAL", "LANDMARK_FIX_TOWN" },
|
|
landmarks = {
|
|
LANDMARK_SPECIAL = { id = "LANDMARK_SPECIAL", name = "SPECIAL",
|
|
x = 0, y = 0, index = 0 },
|
|
LANDMARK_FIX_TOWN = { id = "LANDMARK_FIX_TOWN", name = "FIX\nTOWN",
|
|
x = 4, y = 5, index = 1 },
|
|
},
|
|
}
|
|
end
|
|
|
|
local function goldData()
|
|
local data = T.fixtures.fresh()
|
|
-- an item that holds something, so held_items has a row to seed from
|
|
data.items.FIX_LEFTOVERS = {
|
|
id = "FIX_LEFTOVERS", index = 90, name = "FIX LEFTOVERS", price = 0,
|
|
heldEffect = "HELD_LEFTOVERS", heldParameter = 0,
|
|
}
|
|
data.items.RED_APRICORN = { id = "RED_APRICORN", index = 91,
|
|
name = "RED APRICORN", price = 0 }
|
|
data.items.LEVEL_BALL = { id = "LEVEL_BALL", index = 92, ball = true,
|
|
name = "LEVEL BALL", price = 0 }
|
|
data.items.ULTRA_BALL = { id = "ULTRA_BALL", index = 93, ball = true,
|
|
name = "ULTRA BALL", price = 0 }
|
|
-- the fixture maps under the key Gold keeps them at, so a contact's `map`
|
|
-- resolves against the same id space src/world/gen2/World.lua walks
|
|
data.gen2Maps = data.maps
|
|
data.gen2Constants = { phoneContactOrder = PHONE_ORDER }
|
|
data.gen2Landmarks = landmarkTable()
|
|
-- src/core/Game2.lua:load builds this before mods:load; the harness
|
|
-- stands in for that boot step
|
|
data.gen2HeldItems = ItemEffects.heldItemsFrom(data.items)
|
|
return data
|
|
end
|
|
|
|
local function memfsFor(body, extra)
|
|
local manifest = [[{
|
|
"id": "fix_gen2_content",
|
|
"name": "Fixture Gen 2 Content",
|
|
"version": "1.0.0",
|
|
"entry": "main.lua",
|
|
"api": 2,
|
|
"gen2compat": true
|
|
}]]
|
|
local files = { ["mods/fix_gen2_content/manifest.json"] = manifest,
|
|
["mods/fix_gen2_content/main.lua"] = body }
|
|
for path, text in pairs(extra or {}) do files[path] = text end
|
|
return T.sdk.memfs(files)
|
|
end
|
|
|
|
-- ------- 2. parity: the seeded record IS the literal
|
|
--
|
|
-- Compared field by field against the module's own table rather than against
|
|
-- a copy of it, so a record that gained or lost a key fails here.
|
|
local function sameRecord(got, want, label)
|
|
if type(got) ~= "table" or type(want) ~= "table" then
|
|
return T.eq(got, want, label)
|
|
end
|
|
local ok = true
|
|
for key, value in pairs(want) do
|
|
if got[key] ~= value then ok = false end
|
|
end
|
|
for key in pairs(got) do
|
|
if want[key] == nil then ok = false end
|
|
end
|
|
return T.check(ok, label)
|
|
end
|
|
|
|
do
|
|
local run = T.sdk.loadNone({ data = goldData(), generation = 2 })
|
|
T.eq(#run.errors, 0, "a zero-mod Gold load reports no errors")
|
|
|
|
local decorations = run.data.gen2Decorations
|
|
T.check(decorations ~= nil, "the decorations merge target appears")
|
|
local rows = 0
|
|
for decoId, attr in pairs(Decorations.ATTRIBUTES) do
|
|
rows = rows + 1
|
|
sameRecord(decorations[Decorations.idFor(decoId)], attr,
|
|
"vanilla decoration is the attribute row: deco:" .. decoId)
|
|
end
|
|
T.eq(rows, 53, "every attribute row is registered")
|
|
|
|
local contacts = run.data.gen2PhoneContacts
|
|
T.check(contacts ~= nil, "the phone_contacts merge target appears")
|
|
sameRecord(contacts.PHONE_YOUNGSTER_JOEY, Phone.CONTACTS[15],
|
|
"vanilla contact is the PhoneContacts row: PHONE_YOUNGSTER_JOEY")
|
|
sameRecord(contacts.PHONE_MOM, Phone.CONTACTS[1],
|
|
"and the non-trainer rows too: PHONE_MOM")
|
|
T.eq(contacts.PHONE_UNUSED, nil,
|
|
"the const_skip holes are not registered under one shared id")
|
|
|
|
local apricorns = run.data.gen2Apricorns
|
|
T.check(apricorns ~= nil, "the apricorns merge target appears")
|
|
sameRecord(apricorns.RED_APRICORN, Apricorns.row("RED_APRICORN"),
|
|
"vanilla apricorn is the ApricornBalls row: RED_APRICORN")
|
|
T.eq(apricorns.RED_APRICORN.ball, "LEVEL_BALL",
|
|
"and it still hands back the LEVEL BALL")
|
|
|
|
local channels = run.data.gen2RadioChannels
|
|
T.check(channels ~= nil, "the radio_channels merge target appears")
|
|
T.eq(channels.OAKS_POKEMON_TALK and channels.OAKS_POKEMON_TALK.channel, 1,
|
|
"OAK's POKEMON TALK keeps its dial position")
|
|
T.eq(channels.ROCKET_RADIO and channels.ROCKET_RADIO.channel, 8,
|
|
"and ROCKET RADIO keeps its own")
|
|
|
|
-- held_items and landmarks merge onto a table that already existed, so the
|
|
-- claim there is that the merge left it exactly as it found it
|
|
T.eq(run.data.gen2HeldItems.FIX_LEFTOVERS.heldEffect, "HELD_LEFTOVERS",
|
|
"the held_items view still holds the item's own effect")
|
|
T.eq(ItemEffects.applyHeldItems(run.data,
|
|
ItemEffects.heldSnapshot(run.data.gen2HeldItems)), 0,
|
|
"and a mod-free merge writes nothing back onto data.items")
|
|
T.eq(run.data.gen2Landmarks.landmarks.LANDMARK_FIX_TOWN.x, 4,
|
|
"the landmark records are the cache's own")
|
|
|
|
run.release()
|
|
end
|
|
|
|
-- ------- 3. the consumers, driven from a mod's registration
|
|
|
|
do
|
|
local data = goldData()
|
|
local run = T.sdk.loadMods({ "mods/fix_gen2_content" }, {
|
|
fs = memfsFor([[
|
|
local mod = ...
|
|
-- an existing row edited, and a new one registered, for each registry
|
|
mod.content.decorations:patch("deco:2", { name = "COZY" })
|
|
mod.content.phone_contacts:patch("PHONE_YOUNGSTER_JOEY",
|
|
{ map = "FIX_ROUTE" })
|
|
mod.content.apricorns:override("RED_APRICORN",
|
|
{ apricorn = "RED_APRICORN", ball = "ULTRA_BALL", event = 600,
|
|
index = 1 })
|
|
mod.content.landmarks:patch("LANDMARK_FIX_TOWN", { x = 9 })
|
|
mod.content.landmarks:register("LANDMARK_MOD_ISLE",
|
|
{ id = "LANDMARK_MOD_ISLE", name = "MOD\nISLE", x = 1, y = 2,
|
|
index = 7 })
|
|
mod.content.radio_channels:register("PIRATE_RADIO",
|
|
{ channel = 9, name = "PIRATE RADIO" })
|
|
mod.content.held_items:patch("FIX_LEFTOVERS", { heldParameter = 7 })
|
|
]]),
|
|
data = data,
|
|
generation = 2,
|
|
})
|
|
T.eq(#run.errors, 0,
|
|
"the mod loads clean (" .. table.concat(run.errors, "; ") .. ")")
|
|
|
|
-- decorations: Decorations.attributes is the one read point every caller
|
|
-- (and src/ui/gen2/DecorationMenu.lua) comes through
|
|
Decorations.useRegistry(run.data)
|
|
T.eq(Decorations.attributes(2).name, "COZY",
|
|
"decorations: the merged row is what attributes() answers")
|
|
T.eq(Decorations.attributes(2).flag, Decorations.ATTRIBUTES[2].flag,
|
|
"and the fields the patch left alone are the cart's own")
|
|
T.eq(Decorations.name(2, nil), "COZY BED",
|
|
"so GetDecoName's port spells the merged row")
|
|
|
|
-- phone: the merged rows are folded onto the contact table every lookup in
|
|
-- src/core/gen2/Phone.lua keys by
|
|
Phone.useRegistry(run.data)
|
|
T.eq(Phone.CONTACTS[15].map, "FIX_ROUTE",
|
|
"phone_contacts: the merged row reaches the contact table")
|
|
T.eq(Phone.CONTACTS[15].class, "YOUNGSTER",
|
|
"and the untouched fields survive the patch")
|
|
-- the cache overlay runs from src/world/gen2/World.lua AFTER this, and must
|
|
-- not undo it
|
|
Phone.useExtracted({ phone = { [15] = { map = "ROUTE_30",
|
|
calleeTime = 7, callerTime = 7,
|
|
callee = "41:0001",
|
|
caller = "41:0002" } } })
|
|
T.eq(Phone.CONTACTS[15].map, "FIX_ROUTE",
|
|
"and the cache overlay does not undo it")
|
|
|
|
-- apricorns: Kurt hands back what the registry says he does
|
|
Apricorns.useRegistry(run.data)
|
|
T.eq(Apricorns.ballFor("RED_APRICORN"), "ULTRA_BALL",
|
|
"apricorns: the merged row is the ball Kurt makes")
|
|
T.eq(Apricorns.apricornFor("ULTRA_BALL"), "RED_APRICORN",
|
|
"and the reverse lookup follows it")
|
|
T.eq(#Apricorns.BALLS, 7, "the table is still the seven rows")
|
|
T.eq(Apricorns.BALLS[1].apricorn, "RED_APRICORN",
|
|
"in the ApricornBalls order the menu walks")
|
|
|
|
-- landmarks: the registry answers the map header's byte, including for an
|
|
-- index the extractor's `order` list has never heard of
|
|
T.eq(Nests.landmarkId(run.data, 1), "LANDMARK_FIX_TOWN",
|
|
"landmarks: a vanilla index still resolves")
|
|
T.eq(Nests.landmark(run.data, 1).x, 9,
|
|
"and the patch reaches the record the town map draws")
|
|
T.eq(Nests.landmarkId(run.data, 7), "LANDMARK_MOD_ISLE",
|
|
"a registered landmark resolves at its own index")
|
|
-- and a registered landmark cannot shadow a vanilla one by claiming its
|
|
-- byte: the cache's own row keeps its slot, so the answer does not depend
|
|
-- on pairs() order. A fresh table, because the index map is memoized per
|
|
-- landmarks table and the run's is already built.
|
|
local shadowed = landmarkTable()
|
|
shadowed.landmarks.LANDMARK_AAA_SHADOW = {
|
|
id = "LANDMARK_AAA_SHADOW", name = "AAA", x = 0, y = 0, index = 1,
|
|
}
|
|
T.eq(Nests.landmarkId({ gen2Landmarks = shadowed }, 1), "LANDMARK_FIX_TOWN",
|
|
"a second record at a taken index does not displace the cache's own")
|
|
local twoNew = landmarkTable()
|
|
twoNew.landmarks.LANDMARK_MOD_B = { id = "LANDMARK_MOD_B", name = "B",
|
|
x = 0, y = 0, index = 7 }
|
|
twoNew.landmarks.LANDMARK_MOD_A = { id = "LANDMARK_MOD_A", name = "A",
|
|
x = 0, y = 0, index = 7 }
|
|
T.eq(Nests.landmarkId({ gen2Landmarks = twoNew }, 7), "LANDMARK_MOD_A",
|
|
"and two registered records at one index resolve the same way every boot")
|
|
|
|
-- radio: a registered station is on the dial
|
|
local record, station = MapRadio.channelRecord(run.data, 9)
|
|
T.eq(station, "PIRATE_RADIO", "radio_channels: the new station is on the dial")
|
|
T.eq(record and record.name, "PIRATE RADIO", "with its own name")
|
|
T.eq(select(2, MapRadio.channelRecord(run.data, 8)), "ROCKET_RADIO",
|
|
"and the vanilla positions are unmoved")
|
|
|
|
-- held items: the merged row is written back onto the item record, which is
|
|
-- what src/battle/gen2/Battle.lua:itemDef reads
|
|
local applied = ItemEffects.applyHeldItems(run.data,
|
|
ItemEffects.heldSnapshot({ FIX_LEFTOVERS = { heldEffect = "HELD_LEFTOVERS",
|
|
heldParameter = 0 } }))
|
|
T.eq(applied, 1, "held_items: one item record changed")
|
|
T.eq(run.data.items.FIX_LEFTOVERS.heldParameter, 7,
|
|
"and the battle's own read sees the merged parameter")
|
|
T.eq(run.data.items.FIX_LEFTOVERS.heldEffect, "HELD_LEFTOVERS",
|
|
"with the effect the patch left alone")
|
|
T.eq(ItemEffects.heldItemFor("FIX_LEFTOVERS", run.data).heldParameter, 7,
|
|
"and heldItemFor answers from the merged table")
|
|
|
|
run.release()
|
|
-- module statics are process-wide; put them back before the next case
|
|
Phone.useRegistry(nil)
|
|
Decorations.useRegistry(nil)
|
|
end
|
|
|
|
-- ------- 4. the mirror case, through a real load
|
|
--
|
|
-- A Red boot takes the write, drops it and says so. Not fatal: a mod that
|
|
-- supports both games registers its Gold content unconditionally and should
|
|
-- still load the half that applies.
|
|
|
|
do
|
|
local run = T.sdk.loadMods({ "mods/fix_gen2_content" }, {
|
|
fs = memfsFor([[
|
|
local mod = ...
|
|
mod.content.pokemon:patch("FIXMON_A", { catchRate = 77 })
|
|
mod.content.decorations:patch("deco:2", { name = "COZY" })
|
|
mod.content.radio_channels:register("PIRATE_RADIO", { channel = 9 })
|
|
]]),
|
|
generation = 1,
|
|
})
|
|
T.eq(run.data.pokemon.FIXMON_A.catchRate, 77,
|
|
"Gen 1: the shared registry still merged")
|
|
T.eq(run.data.gen2Decorations, nil,
|
|
"Gen 1: a Gen 2-only registry merges nothing")
|
|
T.eq(run.data.decorations, nil,
|
|
"Gen 1: and invents no namespace of its own")
|
|
local told = {}
|
|
for _, message in ipairs(run.errors) do
|
|
if message:match("decorations") then told.decorations = true end
|
|
if message:match("radio_channels") then told.radio = true end
|
|
end
|
|
T.check(told.decorations and told.radio,
|
|
"Gen 1: both dropped registrations are reported, not silent")
|
|
run.release()
|
|
end
|
|
|
|
T.finish("gen2_content_registries")
|