big ass modding update

This commit is contained in:
bryanthaboi
2026-07-19 16:18:18 -04:00
parent b5a673b252
commit 47923d95b3
258 changed files with 31048 additions and 2310 deletions
+72
View File
@@ -0,0 +1,72 @@
# Example mod gallery
Seven reference mods, one per modder persona. Each is small enough to read
in one sitting, exercises a different slice of the mod API, and is a real,
runnable, tested mod — not a snippet.
Copy the one closest to what you want to build.
| # | Mod | Persona | Category | What it does |
|---|---|---|---|---|
| 0 | [`../example_mew_starter`](../example_mew_starter) | (legacy) | `GAMEPLAY` | Oak's gift becomes a L20 Mew. The api-1 compatibility proof. |
| 1 | [`example_balance_tweaks`](example_balance_tweaks) | Tweaker | `BALANCE` | Faster starters, half-price TMs, a re-slotted Route 1 |
| 2 | [`example_shiny_palette`](example_shiny_palette) | Artist | `GRAPHICS` | A teal player recolor derived from your own cache |
| 3 | [`example_jukebox`](example_jukebox) | Musician | `AUDIO` | An authored chip song, a new cry, a jukebox screen |
| 4 | [`example_lost_parcel`](example_lost_parcel) | Quest author | `QUEST` | A two-town fetch quest over vanilla NPCs |
| 5 | [`example_weather`](example_weather) | Mechanic designer | `MECHANIC` | Rain that scales WATER and FIRE damage, behind a ruleset |
| 6 | [`example_dexnav`](example_dexnav) | Tool builder | `TOOL` | A START-menu dex overlay with an inter-mod API |
| 7 | [`example_mini_conversion`](example_mini_conversion) | TC team | `TOTAL_CONVERSION` | Sable Cove: one town, three species, one badge |
## None of these load by default
The engine discovers mods one level below `mods/`. The gallery lives one
level deeper, in `mods/examples/`, so a fresh install finds none of them
and the vanilla game is unchanged — the parity invariant holds by
construction.
To run one, copy it up a level:
```sh
cp -r mods/examples/example_balance_tweaks mods/
python3 tools/modkit.py validate mods/example_balance_tweaks --base imported
```
then enable it in `options.lua` (`mods = { example_balance_tweaks = true }`)
or toggle it in the F10 mod manager.
## Coverage
Between them the gallery writes into `pokemon`, `items`, `encounters`,
`maps`, `sprites`, `palettes`, `icons`, `music`, `cries`, `screens`,
`map_scripts`, `commands`, `tokens`, `statuses`, `rulesets`, `constants`
and `field`, and exercises:
- the write verbs `register`, `override` and `patch`, plus `get` and
`each` on the merged view
- both buses — `events:on` / `events:emit` and `hooks:wrap` — across
`music.select`, `battle.damage`, `ui.start_menu.items`, `ui.options.rows`,
`battle.started`, `battle.turn_started`, `battle.ended`, `flag.changed`,
`game.ready` and `assets.transformed`
- `mod.save`, `mod.options`, `mod.exports`, `mod.commands`, `mod.ui`,
`mod:read`, `mod.log` and `mod.path`
- asset transforms, the `trueColor` opt-out, script labels and `choice`,
parallel scripts, `mod:` field routing, replaying an overridden base talk
handler through `MapScripts.baseTalk`, and the no-ROM-content posture
## What every entry has
```
mods/examples/<id>/
manifest.json api = 2, a category from the taxonomy, a semver engine range
main.lua the entry chunk
mod.card sharing metadata: summary, author, tags, differences, credits
README.md what it demonstrates, which persona, the commands to try it
CHANGELOG.md keep-a-changelog; headings match manifest.version
tests/ one runnable suite asserting the mod's stated effect
.modkitignore keeps the suite out of the distributed package
```
`tests/mod_examples_tests.lua` in the engine's own suite loads all seven
together and asserts the above, so the gallery cannot rot.
These example mods arent perfect and are just examples to show you basics of wahts possible, but way more than just this is possible.
@@ -0,0 +1,12 @@
# Changelog
Format: [keep a changelog](https://keepachangelog.com/en/1.1.0/).
Version headings match `manifest.json`'s `version`.
## 1.0.0
### Added
- Base speed patches for VENUSAUR, CHARIZARD and BLASTOISE.
- TM price halving driven off `content.items:each()`.
- Route 1 grass re-slot.
@@ -0,0 +1,59 @@
# Balance Tweaks Example
Raises the final-stage Kanto starters to 100 base speed, halves every TM
price, and re-slots the Route 1 grass table — all without copying a single
record whole.
**Persona: the Tweaker.** A player who wants one number changed and copies
an example to get there. This is the shortest complete mod in the gallery
and the one to start from.
## Try it
```sh
python3 tools/modkit.py validate mods/examples/example_balance_tweaks --base imported
luajit mods/examples/example_balance_tweaks/tests/example_balance_tweaks_test.lua
```
Then enable it: add `example_balance_tweaks = true` under `mods` in your
`options.lua`, or toggle it in the F10 mod manager.
## What it demonstrates
| Seam | Where |
|---|---|
| `content.pokemon:patch` | `main.lua` — deep-merges one leaf, leaves the rest alone |
| `content.items:each` | `main.lua` — walks the merged view to find TMs instead of listing them |
| `content.encounters:patch` | `main.lua` — a list leaf replaces wholesale even inside a patch |
| `content.<r>:get` | `main.lua` — the guard that turns a missing id into a log line, not a crash |
`patch` is the point. The legacy `mods/example_mew_starter` has to copy
every field of Mew to change two sprite paths, because api 1 only had
`override`. With `patch` you name the leaf:
```lua
mod.content.pokemon:patch("VENUSAUR", { baseStats = { speed = 100 } })
```
Everything not named — `learnset`, `types`, `evolutions`, `spriteFront` —
keeps its base value, and a second mod patching `baseStats.attack` on the
same species composes with this one instead of clobbering it.
## Exact changes
- `VENUSAUR` base speed 80 → 100
- `BLASTOISE` base speed 78 → 100
- `CHARIZARD` base speed 100 → 100 (already there; kept for symmetry)
- every item whose `machine.kind == "TM"` has its `price` halved
(`TM_TOXIC` 4000 → 2000, and 49 others; HMs carry `machine.kind == "HM"`
and are left alone)
- `ROUTE_1` grass `rate` 25 → 20, slot table re-weighted to include
`SPEAROW`
Field meanings are in the generated registry reference (`modkit docs`),
section `pokemon`, `items` and `encounters`.
## Credits
Base stat and mart price tables come from the player's own imported ROM;
this mod ships numbers, not data.
@@ -0,0 +1,44 @@
-- Gallery #1 (Tweaker): pure data, no engine seams. Everything here is
-- patch + each, so no record is ever copied whole and another mod editing
-- the same species keeps its own fields.
return function(mod)
-- patch deep-merges only the leaves it names: learnset, sprites, types
-- and evolutions all survive this speed change untouched
for _, id in ipairs({ "VENUSAUR", "CHARIZARD", "BLASTOISE" }) do
if mod.content.pokemon:get(id) then
mod.content.pokemon:patch(id, { baseStats = { speed = 100 } })
else
-- degrade instead of crashing: a species mod loaded ahead of this
-- one may have removed the vanilla starter line
mod.log:warn("%s missing from the merged view; speed patch skipped", id)
end
end
-- each() walks the merged view (engine records plus every mod ahead of
-- this one), so the TM list is discovered rather than hard-coded
local halved = 0
for id, item in mod.content.items:each() do
local machine = item.machine
if machine and machine.kind == "TM" and type(item.price) == "number"
and item.price > 0 then
mod.content.items:patch(id, { price = math.floor(item.price / 2) })
halved = halved + 1
end
end
mod.log:info("halved %d TM prices", halved)
-- lists replace wholesale even inside a patch, so a re-slotted encounter
-- table is written out in full while the rate rides along as a leaf
mod.content.encounters:patch("ROUTE_1", {
grass = {
rate = 20,
slots = {
{ level = 3, species = "PIDGEY" }, { level = 3, species = "RATTATA" },
{ level = 4, species = "SPEAROW" }, { level = 2, species = "RATTATA" },
{ level = 2, species = "PIDGEY" }, { level = 3, species = "SPEAROW" },
{ level = 3, species = "PIDGEY" }, { level = 4, species = "RATTATA" },
{ level = 4, species = "PIDGEY" }, { level = 5, species = "SPEAROW" },
},
},
})
end
@@ -0,0 +1,15 @@
{
"id": "example_balance_tweaks",
"name": "Balance Tweaks Example",
"version": "1.0.0",
"api": 2,
"entry": "main.lua",
"profile": "content",
"category": "BALANCE",
"game_version": ">=1.0.0 <2.0.0",
"priority": 100,
"dependencies": [],
"optional_dependencies": [],
"conflicts": [],
"description": "Tweaker gallery entry: patch and each over the merged view, with no whole-record copies."
}
@@ -0,0 +1,21 @@
-- Sharing metadata (25-community-and-ecosystem.md 3.2). Read by tooling
-- and the manager detail pane; never by the loader's merge.
return {
summary = "Faster final starters, half-price TMs, a re-slotted Route 1.",
author = "Pokemon Gen 1 Recompilation Project",
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
tags = { "balance", "data-only", "beginner" },
differences = {
changed = {
"VENUSAUR and BLASTOISE base speed raised to 100 (CHARIZARD already was)",
"every TM item's price halved",
"Route 1 grass rate 25 -> 20 and SPEAROW added to the slot table",
},
added = {},
known = { "TM prices halve once per load, not once per install" },
},
credits = {
{ who = "pret/pokered", for_ = "the base stat and mart price tables this patches over" },
},
compat = { engine = ">=1.0.0 <2.0.0", modApi = 2 },
}
@@ -0,0 +1,28 @@
-- Standalone: luajit mods/examples/example_balance_tweaks/tests/example_balance_tweaks_test.lua
-- Loads the mod through the real headless loader and asserts its stated
-- effect against the player's imported dataset.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = require("src.core.Data")
Data:load()
local run = T.sdk.loadMod("mods/examples/example_balance_tweaks", { data = Data })
T.eq(#run.errors, 0, "loads clean (" .. tostring(run.errors[1]) .. ")")
T.eq(run.mod and run.mod.state, "loaded", "reached the loaded state")
T.eq(Data.pokemon.VENUSAUR.baseStats.speed, 100, "VENUSAUR speed patched")
T.eq(Data.pokemon.BLASTOISE.baseStats.speed, 100, "BLASTOISE speed patched")
-- the patch named one leaf, so everything else survived
T.check(#Data.pokemon.VENUSAUR.learnset > 0, "VENUSAUR keeps its learnset")
T.eq(Data.pokemon.VENUSAUR.types[1], "GRASS", "VENUSAUR keeps its types")
T.eq(Data.items.TM_TOXIC.price, 2000, "TM price halved")
T.eq(Data.items.POTION.price, 300, "a non-TM item is untouched")
T.eq(Data.encounters.ROUTE_1.grass.rate, 20, "Route 1 grass rate patched")
T.eq(Data.encounters.ROUTE_1.grass.slots[3].species, "SPEAROW",
"Route 1 slot table re-slotted")
run.release()
T.finish("example_balance_tweaks")
+12
View File
@@ -0,0 +1,12 @@
# Changelog
Format: [keep a changelog](https://keepachangelog.com/en/1.1.0/).
Version headings match `manifest.json`'s `version`.
## 1.0.0
### Added
- The `ExampleDexNav` screen and its START-menu row.
- `SORT BY` and `SHOW UNSEEN` mod options.
- `countSeen`, `countOwned` and `species` exports.
+88
View File
@@ -0,0 +1,88 @@
# DexNav Example
A START-menu overlay listing every species in the merged dex with its
seen/owned state, sortable, and publishing a small API other mods can call.
**Persona: the Tool Builder.** Consume the merged view, never `require` a
private module, expose a stable inter-mod surface. This example is the
reference for all three.
## Try it
```sh
python3 tools/modkit.py validate mods/examples/example_dexnav --base imported
luajit mods/examples/example_dexnav/tests/example_dexnav_test.lua
```
Enable it (`example_dexnav = true` under `mods` in `options.lua`, or the
F10 manager), then press START → **DEXNAV**. Its two options live in the
manager's per-mod options pane.
## What it demonstrates
| Seam | Where |
|---|---|
| `content.screens:register` | `main.lua` — a factory the engine instantiates by id |
| `hooks:wrap("ui.start_menu.items")` | `main.lua` — decorate, do not replace |
| `mod.ui.insertBefore` | `main.lua` — anchor on a label, not a row index |
| `mod.options:define` / `:get` | `main.lua` — auto-rendered rows in the manager |
| `mod.exports` | `main.lua` — the inter-mod API |
| `content.pokemon:each` | `main.lua` — the whole world, engine records included |
## Reading, not reaching
Every fact this mod displays comes from two public sources:
- `mod.content.pokemon:each()` — the merged species view. A tool that
hard-codes 151 breaks the moment another mod registers a species; this
one just gets longer.
- `game.save.pokedex` — the seen/owned tables, handed in by the engine.
No `require("src.pokemon.…")`, no permission declared, nothing that a later
refactor of an engine module can break.
## Anchoring a menu row
```lua
mod.hooks:wrap("ui.start_menu.items", function(next, game, items)
local out = next(game, items)
if type(out) ~= "table" then return out end
return mod.ui.insertBefore(out, "SAVE", { label = "DEXNAV", onSelect = ... })
end)
```
Two rules, both load-bearing:
1. **Call `next` first, then decorate what comes back.** Build a fresh list
instead and every other mod's row disappears.
2. **Anchor on a stable label, not an index.** `insertBefore` appends when
the anchor is missing, so the row is always reachable even in a total
conversion that renamed `SAVE`.
## Exporting an API
```lua
mod.exports.countSeen = function(game) ... end
```
Another mod reads it as:
```lua
local nav = mod.find("example_dexnav")
if nav then print(nav.exports.countSeen(game)) end
```
`mod.find` returns nil when the other mod is absent, disabled, failed, or
has not run yet — so a dependent degrades instead of crashing. Declare it
in `optional_dependencies` if you can live without it and `dependencies`
if you cannot.
## Empty state
`SHOW UNSEEN` off on a fresh save means an empty list. `ListMenu` draws
`Nothing here.` and B still exits — never a blank frame with no way out.
The title still reports `DEXNAV 0/0`, so the screen explains itself.
## Credits
- pret/pokered — the START-menu layout the row is anchored into.
+98
View File
@@ -0,0 +1,98 @@
-- Gallery #6 (Tool builder): a read-mostly overlay. Everything it knows
-- comes from the merged view through the public mod API -- no private
-- require, no engine table reached behind the loader's back -- and what it
-- knows is published as a stable export other mods can call.
local SCREEN = "ExampleDexNav"
return function(mod)
mod.options:define({
{ key = "sort", label = "SORT BY", type = "choice", default = "dex",
choices = { { "DEX NO.", "dex" }, { "NAME", "name" } } },
{ key = "unseen", label = "SHOW UNSEEN", type = "toggle", default = true },
})
-- ------- the merged view, read once per open
-- content.pokemon:each() yields the engine's species AND every mod's,
-- which is the whole point: a tool that hard-codes 151 breaks the moment
-- someone adds a species.
local function species()
local rows = {}
for id, mon in mod.content.pokemon:each() do
rows[#rows + 1] = { id = id, name = mon.name or id, dex = mon.dex or 9999 }
end
return rows
end
local function dexOf(game)
return (game and game.save and game.save.pokedex) or { seen = {}, owned = {} }
end
local function counts(game)
local dex = dexOf(game)
local seen, owned = 0, 0
for _, row in ipairs(species()) do
if dex.seen[row.id] then seen = seen + 1 end
if dex.owned[row.id] then owned = owned + 1 end
end
return seen, owned
end
-- ------- the inter-mod API
-- Another mod reads this with mod.find("example_dexnav").exports; it is
-- the supported way to depend on this one, and the reason nothing here
-- reaches into a private module.
mod.exports.countSeen = function(game) return (counts(game)) end
mod.exports.countOwned = function(game) return select(2, counts(game)) end
mod.exports.species = species
-- ------- the screen
mod.content.screens:register(SCREEN, {
new = function(game)
local dex = dexOf(game)
local showUnseen = mod.options:get("unseen")
local rows = species()
if mod.options:get("sort") == "name" then
table.sort(rows, function(a, b) return a.name < b.name end)
else
table.sort(rows, function(a, b)
if a.dex ~= b.dex then return a.dex < b.dex end
return a.id < b.id
end)
end
local items = {}
for _, row in ipairs(rows) do
local state = dex.owned[row.id] and "OWN"
or (dex.seen[row.id] and "SEEN" or "----")
if showUnseen or state ~= "----" then
items[#items + 1] = { label = row.name, right = state, value = row.id }
end
end
local seen, owned = counts(game)
-- ListMenu draws "Nothing here." for an empty set, so a brand new
-- save with SHOW UNSEEN off reads as a sentence, not a blank frame
return mod.ui.ListMenu.new(game,
("DEXNAV %d/%d"):format(owned, seen), items, {
pageJump = true,
onChoose = function(_, menu) menu:close() end,
})
end,
})
-- ------- reaching it
-- Call next() first, then decorate the list it returns: another mod's
-- row survives, and the vanilla rows are never rebuilt by hand.
mod.hooks:wrap("ui.start_menu.items", function(next, game, items)
local out = next(game, items)
if type(out) ~= "table" then return out end
return mod.ui.insertBefore(out, "SAVE", {
label = "DEXNAV",
onSelect = function() mod.ui.push(game, SCREEN) end,
})
end)
end
@@ -0,0 +1,15 @@
{
"id": "example_dexnav",
"name": "DexNav Example",
"version": "1.0.0",
"api": 2,
"entry": "main.lua",
"profile": "content",
"category": "TOOL",
"game_version": ">=1.0.0 <2.0.0",
"priority": 100,
"dependencies": [],
"optional_dependencies": [],
"conflicts": [],
"description": "Tool-builder gallery entry: a start-menu overlay over the merged dex, with mod options and a stable inter-mod export."
}
+21
View File
@@ -0,0 +1,21 @@
-- Sharing metadata (25-community-and-ecosystem.md 3.2). Read by tooling
-- and the manager detail pane; never by the loader's merge.
return {
summary = "A start-menu dex overlay with seen/owned counts and an inter-mod API.",
author = "Pokemon Gen 1 Recompilation Project",
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
tags = { "ui", "tool", "quality-of-life" },
differences = {
changed = { "the START menu gains a DEXNAV row above SAVE" },
added = {
"ExampleDexNav screen",
"mod options: SORT BY and SHOW UNSEEN",
"exports countSeen / countOwned / species for other mods",
},
known = { "the list is built when the screen opens, so catching a mon mid-session needs a reopen" },
},
credits = {
{ who = "pret/pokered", for_ = "the start-menu layout the row is anchored into" },
},
compat = { engine = ">=1.0.0 <2.0.0", modApi = 2 },
}
@@ -0,0 +1,54 @@
-- Standalone: luajit mods/examples/example_dexnav/tests/example_dexnav_test.lua
-- Exercises the export surface, the start-menu wrap and the screen factory.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Runtime = require("src.mods.Runtime")
local Data = require("src.core.Data")
Data:load()
local Font = require("src.render.Font")
Font.load(Data)
local run = T.sdk.loadMod("mods/examples/example_dexnav", { data = Data })
T.eq(#run.errors, 0, "loads clean (" .. tostring(run.errors[1]) .. ")")
local exports = run.loader.exports.example_dexnav
T.check(type(exports.countSeen) == "function", "countSeen is exported")
T.check(type(exports.species) == "function", "species is exported")
T.eq(#exports.species(), 151, "the export reads the whole merged dex")
local game = {
data = Data,
save = { pokedex = { seen = { PIKACHU = true, MEW = true },
owned = { PIKACHU = true } } },
}
T.eq(exports.countSeen(game), 2, "countSeen counts the seen set")
T.eq(exports.countOwned(game), 1, "countOwned counts the owned set")
-- an empty dex is a legal state, not a crash
T.eq(exports.countSeen({ data = Data, save = {} }), 0, "a fresh save counts zero")
-- ------- the start-menu wrap decorates rather than replaces
local vanilla = { { label = "POKéDEX" }, { label = "SAVE" }, { label = "QUIT" } }
local hooked = Runtime.call("ui.start_menu.items",
function(_, items) return items end, game, vanilla)
T.eq(#hooked, 4, "the wrap added exactly one row")
T.eq(hooked[2].label, "DEXNAV", "the row is anchored before SAVE")
T.eq(hooked[3].label, "SAVE", "the vanilla rows are still in order")
-- ------- the screen factory builds a real state
local Screens = require("src.ui.Screens")
Screens.invalidate()
local factory = Screens.get(game, "ExampleDexNav")
T.check(factory and factory.new, "the screen resolves through the registry")
local screen = factory.new(game)
T.check(screen.items ~= nil and #screen.items == 151,
"the list shows every species with SHOW UNSEEN on")
T.eq(screen.title, "DEXNAV 1/2", "the title carries the owned/seen counts")
run.release()
Screens.invalidate()
T.finish("example_dexnav")
@@ -0,0 +1,13 @@
# Changelog
Format: [keep a changelog](https://keepachangelog.com/en/1.1.0/).
Version headings match `manifest.json`'s `version`.
## 1.0.0
### Added
- `Music_ExamplePalletRain`, a two-pulse loop authored in `song.lua`.
- A `music.select` wrap that swaps the Pallet Town map theme.
- An authored chip cry for MEW.
- The `ExampleJukebox` screen and its OPTIONS row.
+99
View File
@@ -0,0 +1,99 @@
# Jukebox Example
Adds one song authored note-by-note in Lua, swaps it in as the Pallet Town
theme, replaces Mew's cry, and ships a jukebox screen that lists every song
in the merged registry.
**Persona: the Musician.** Nothing here is a `.ogg`. The song is a
Game Boy channel program assembled at load time by `ChipAsm`.
## Try it
```sh
python3 tools/modkit.py validate mods/examples/example_jukebox --base imported
luajit mods/examples/example_jukebox/tests/example_jukebox_test.lua
# render the song to a wav to hear it without launching the game
python3 tools/modkit.py bounce Music_ExamplePalletRain --seconds 20 --out bounce
```
Enable it (`example_jukebox = true` under `mods` in `options.lua`, or the
F10 manager), then open **OPTIONS → JUKEBOX**.
## What it demonstrates
| Seam | Where |
|---|---|
| `content.music:register` (ChipAsm DSL) | `song.lua` + `main.lua` |
| `content.cries:override` (chip program) | `main.lua` |
| `hooks:wrap("music.select")` | `main.lua` — one choke point covers map, battle and jingle music |
| `content.screens:register` | `main.lua` — a screen factory the engine instantiates by id |
| `hooks:wrap("ui.options.rows")` | `main.lua` — how the player reaches the screen |
| `content.music:each` | `main.lua` — the jukebox list is the merged view, not a hard-coded array |
| `mod:read` | `main.lua` — loading a sibling file through the loader's filesystem |
## Authoring a song
`song.lua` returns what `ChipAsm.song{...}` builds: a self-contained
program blob plus its channel layout. Events are Lua tables, one per
command:
```lua
{ duty = 2 },
{ notetype = { speed = 12, volume = 11, fade = 2 } },
{ octave = 4 },
{ label = "lead" },
{ note = "E", len = 6 }, { note = "D", len = 2 },
{ loop = { count = 0, to = "lead" } },
```
The assembler is the validator. An unknown note name or an out-of-range
length raises *there*, naming the channel and event index, and `main.lua`
turns that into one mod-attributed load error. The music system never
latches on a bad program, because a bad program never reaches it.
Two shapes share the `music` registry and are dispatched per definition,
not by a global flag: `{ chip = ... }` for an authored program and
`{ file = "..." }` for an audio file. This example uses the first; a file
track is one line:
```lua
mod.content.music:register("Music_MyTheme", { file = mod.path .. "/theme.ogg" })
```
Note the cry uses `ChipAsm.sfx{...}.chip` — the assembler returns
`{ chip = program }`, and a cry record wants the program under its own
`chip` key.
## Deferring is the parity guarantee
```lua
mod.hooks:wrap("music.select", function(next, chosen, ctx)
if ctx and ctx.reason == "map" and ctx.mapId == "PALLET_TOWN" then
return next(SONG_ID, ctx)
end
return next(chosen, ctx)
end)
```
Every path that is not Pallet Town calls `next(chosen, ctx)` with the
argument it was given, so playback everywhere else is exactly what it was
before the mod loaded.
## Empty state
The jukebox is a `ListMenu`, which draws `Nothing here.` when its item list
is empty rather than an empty frame. That cannot happen with the engine's
45 songs present, but it is the behavior a mod screen owes the player.
## Permissions
This mod declares `engine_internals`, because playing a song from a screen
currently needs `require("src.core.Music")` — the mod surface has no audio
playback facade yet. Declaring it is the honest path: `modkit validate`
accepts a declared require and flags an undeclared one.
## Credits
- Pallet Rain arrangement: this project.
- pret/pokered: the channel command set `ChipAsm` assembles to.
+96
View File
@@ -0,0 +1,96 @@
-- Gallery #3 (Musician): one authored chip song, one hook that swaps the
-- Pallet Town theme, one derived cry, and a jukebox screen that lists the
-- merged music registry.
local SONG_ID = "Music_ExamplePalletRain"
return function(mod)
-- the song lives in its own file; mod:read + load keeps it addressable
-- through the loader's filesystem instead of the host package.path, so
-- the mod works the same installed as it does in the repo
local source = mod:read("song.lua")
if not source then
mod.log:error("song.lua missing from %s -- reinstall the mod", mod.path)
return
end
local chunk, compileErr = load(source, "@" .. mod.path .. "/song.lua")
if not chunk then
mod.log:error("song.lua did not compile: %s", tostring(compileErr))
return
end
-- a malformed note table raises inside ChipAsm; catching it here turns
-- the whole mod into a mod-attributed load error instead of latching the
-- music system at playback time
local ok, song = pcall(chunk)
if not ok then
mod.log:error("song.lua failed to assemble: %s", tostring(song))
return
end
mod.content.music:register(SONG_ID, song)
-- a derived cry: the ChipAsm effect command set (channels 5-8), keyed by
-- species exactly like the vanilla cry table
mod.content.cries:override("MEW", {
-- .chip, not the whole return: ChipAsm hands back { chip = program }
-- and a cry record carries the program under its own chip key
chip = require("src.audio.ChipAsm").sfx{
channels = {
{ hw = 1, program = {
{ pitchSweep = { pace = 3, subtract = false, shift = 2 } },
{ squareNote = { len = 6, volume = 14, fade = 2, frequency = 0x5C0 } },
{ squareNote = { len = 8, volume = 12, fade = 3, frequency = 0x680 } },
} },
},
}.chip,
pitch = 128, length = 128,
})
-- music.select is the single choke point every song choice passes
-- through. Defer to next() for everything that is not the case this mod
-- cares about: with the mod installed but off the map, playback is
-- byte-for-byte what it was.
mod.hooks:wrap("music.select", function(next, chosen, ctx)
if ctx and ctx.reason == "map" and ctx.mapId == "PALLET_TOWN" then
return next(SONG_ID, ctx)
end
return next(chosen, ctx)
end)
-- the jukebox itself: a screen factory in the screens registry
mod.content.screens:register("ExampleJukebox", {
new = function(game)
local ids = {}
for id in mod.content.music:each() do ids[#ids + 1] = id end
table.sort(ids)
local items = {}
for _, id in ipairs(ids) do
items[#items + 1] = { label = id:gsub("^Music_", ""), value = id }
end
-- ListMenu draws "Nothing here." on an empty set, so the empty state
-- is a sentence rather than a blank box
return mod.ui.ListMenu.new(game, "JUKEBOX", items, {
onChoose = function(item)
require("src.core.Music").play(game.data, item.value, true,
{ reason = "direct" })
end,
onCancel = function()
require("src.core.Music").stop()
end,
})
end,
})
-- reachable from OPTIONS; call next() first and decorate what comes back,
-- so every other mod's rows survive this one
mod.hooks:wrap("ui.options.rows", function(next, game, rows)
local out = next(game, rows)
if type(out) ~= "table" then return out end
out[#out + 1] = {
id = "example_jukebox",
label = "JUKEBOX",
value = function() return "OPEN" end,
activate = function(g) mod.ui.push(g, "ExampleJukebox") end,
}
return out
end)
end
@@ -0,0 +1,16 @@
{
"id": "example_jukebox",
"name": "Jukebox Example",
"version": "1.0.0",
"api": 2,
"entry": "main.lua",
"profile": "content",
"category": "AUDIO",
"game_version": ">=1.0.0 <2.0.0",
"priority": 100,
"permissions": ["engine_internals"],
"dependencies": [],
"optional_dependencies": [],
"conflicts": [],
"description": "Musician gallery entry: an authored ChipAsm song, a music.select hook, a new cry and a jukebox screen."
}
+24
View File
@@ -0,0 +1,24 @@
-- Sharing metadata (25-community-and-ecosystem.md 3.2). Read by tooling
-- and the manager detail pane; never by the loader's merge.
return {
summary = "An authored chip song for Pallet Town, a new Mew cry, and a jukebox screen.",
author = "Pokemon Gen 1 Recompilation Project",
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
tags = { "audio", "chiptune", "ui" },
differences = {
changed = {
"Pallet Town's map theme becomes Music_ExamplePalletRain",
"MEW's cry is replaced with an authored chip effect",
},
added = {
"Music_ExamplePalletRain song record",
"ExampleJukebox screen, reachable from the OPTIONS menu",
},
known = { "the jukebox plays looping songs only; jingles stop on their own" },
},
credits = {
{ who = "Pokemon Gen 1 Recompilation Project", for_ = "the Pallet Rain arrangement" },
{ who = "pret/pokered", for_ = "the channel command set ChipAsm assembles to" },
},
compat = { engine = ">=1.0.0 <2.0.0", modApi = 2 },
}
+40
View File
@@ -0,0 +1,40 @@
-- "Pallet Rain": a two-pulse loop authored in the ChipAsm note-event DSL
-- (13-audio-modding.md). ChipAsm is on the loader's supported-require
-- list, so authoring a song needs no permissions.
--
-- The assembler is the validator: an out-of-range length or an unknown
-- note name raises here, at load, naming the channel and event index --
-- not silently at playback.
local ChipAsm = require("src.audio.ChipAsm")
return ChipAsm.song{
tempo = 0x120,
channels = {
-- lead: a four-bar descending figure that loops forever
{ hw = 1, program = {
{ duty = 2 },
{ notetype = { speed = 12, volume = 11, fade = 2 } },
{ octave = 4 },
{ label = "lead" },
{ note = "E", len = 6 }, { note = "D", len = 2 },
{ note = "C", len = 6 }, { rest = 2 },
{ note = "A", len = 4 }, { note = "G", len = 4 },
{ note = "C", len = 8 },
{ note = "E", len = 6 }, { note = "G", len = 2 },
{ note = "A", len = 8 },
{ rest = 8 },
{ loop = { count = 0, to = "lead" } },
} },
-- counter-line: same length, one octave down, softer
{ hw = 2, program = {
{ duty = 1 },
{ notetype = { speed = 12, volume = 7, fade = 1 } },
{ octave = 3 },
{ label = "bass" },
{ note = "C", len = 8 }, { note = "G", len = 8 },
{ note = "A", len = 8 }, { note = "F", len = 8 },
{ note = "C", len = 8 }, { note = "G", len = 8 },
{ loop = { count = 0, to = "bass" } },
} },
},
}
@@ -0,0 +1,70 @@
-- Standalone: luajit mods/examples/example_jukebox/tests/example_jukebox_test.lua
-- Asserts the song assembles, the cry merges, and music.select swaps only
-- the map this mod claims.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Runtime = require("src.mods.Runtime")
local Data = require("src.core.Data")
Data:load()
local run = T.sdk.loadMod("mods/examples/example_jukebox", { data = Data })
T.eq(#run.errors, 0, "loads clean (" .. tostring(run.errors[1]) .. ")")
-- ------- the authored song
local song = Data.audio.songs.Music_ExamplePalletRain
T.check(type(song) == "table", "the song registered")
T.check(type(song.chip) == "table" and #song.chip.blob > 0,
"it assembled to a non-empty program blob")
T.eq(#song.chip.channels, 2, "both channels are laid out")
T.eq(song.chip.channels[1].address, 0x4000,
"the first channel is based at the 0x4000 window")
-- ------- the cry
local cry = Data.audio.cries.MEW
T.check(type(cry) == "table" and type(cry.chip) == "table",
"the MEW cry is an authored chip program")
T.check(#cry.chip.blob > 0, "the cry program is non-empty")
T.eq(cry.chip.channels[1].number, 5,
"an sfx program lives on the effect channels (5-8)")
-- ------- the hook swaps exactly one map
local function select(song_, ctx)
return Runtime.call("music.select", function(chosen) return chosen end, song_, ctx)
end
T.eq(select("Music_PalletTown", { reason = "map", mapId = "PALLET_TOWN" }),
"Music_ExamplePalletRain", "Pallet Town gets the new theme")
T.eq(select("Music_Routes1", { reason = "map", mapId = "ROUTE_1" }),
"Music_Routes1", "every other map defers to the vanilla choice")
T.eq(select("Music_Battle", { reason = "battle", kind = "wild" }),
"Music_Battle", "battle music defers too")
T.eq(select("Music_PalletTown", nil), "Music_PalletTown",
"a direct play with no context defers")
-- ------- the screen and its options row
local Font = require("src.render.Font")
Font.load(Data)
local Screens = require("src.ui.Screens")
Screens.invalidate()
local factory = Screens.get({ data = Data }, "ExampleJukebox")
T.check(factory and factory.new, "the jukebox resolves through the screens registry")
local screen = factory.new({ data = Data })
T.check(#screen.items > 0, "the jukebox lists the merged music registry")
local listed = false
for _, item in ipairs(screen.items) do
if item.value == "Music_ExamplePalletRain" then listed = true end
end
T.check(listed, "the mod's own song is in the list")
local rows = Runtime.call("ui.options.rows", function(_, r) return r end,
{ data = Data }, { { id = "text_speed" } })
T.eq(#rows, 2, "the options hook added exactly one row")
T.eq(rows[2].id, "example_jukebox", "the row is the jukebox entry")
run.release()
Screens.invalidate()
T.finish("example_jukebox")
@@ -0,0 +1,15 @@
# Changelog
Format: [keep a changelog](https://keepachangelog.com/en/1.1.0/).
Version headings match `manifest.json`'s `version`.
## 1.0.0
### Added
- The two-town fetch quest over `VIRIDIAN_CITY` and `PEWTER_CITY`.
- `EXAMPLE_LOST_PARCEL_PARCEL` key item and the `{EXAMPLE_PARCEL_REWARD}` token.
- The `example_lost_parcel:count_ask` verb and the ambient parallel script.
- `example_lost_parcel:base_nerd_chat`, which replays the Pewter super
nerd's base handler through `MapScripts.baseTalk` so the branches the
quest does not own keep the vanilla YES/NO conversation intact.
+131
View File
@@ -0,0 +1,131 @@
# The Lost Parcel
A courier in Viridian City dropped a parcel somewhere in Pewter City.
Fetch it and he pays you a NUGGET.
**Persona: the Quest Author.** Two towns, two vanilla NPCs, a branching
conversation, a key item, a reward and some ambience — and not one line of
map data or engine source changed.
## Try it
```sh
python3 tools/modkit.py validate mods/examples/example_lost_parcel --base imported
luajit mods/examples/example_lost_parcel/tests/example_lost_parcel_test.lua
```
Enable it (`example_lost_parcel = true` under `mods` in `options.lua`, or
the F10 manager), then:
```
VIRIDIAN CITY PEWTER CITY
┌───────────────────┐ ┌───────────────────┐
│ GAMBLER ◀──────┼── accept ───┼──▶ SUPER NERD │
│ (quest giver) │ │ (has the │
│ ▲ │ │ parcel) │
└───────┼───────────┘ └─────────┬─────────┘
└──────────── return ───────────────┘
```
Talk to the gambler in Viridian (the one south of the Poké Mart), say
`SURE`, walk to Pewter, talk to the super nerd by the museum, walk back.
## What it demonstrates
| Seam | Where |
|---|---|
| `content.map_scripts:register` (compose) | `main.lua` — two maps, no map edits |
| talk override on a real `TEXT_` constant | `main.lua` — `TEXT_VIRIDIANCITY_GAMBLER1`, `TEXT_PEWTERCITY_SUPER_NERD1` |
| `choice` + `label` + `jump_if_true/false` | `main.lua` — a five-branch conversation |
| `MOD_` flag convention | `main.lua` — `MOD_EXAMPLE_LOST_PARCEL_*` |
| `set_field "mod:key"` | `main.lua` — quest scratch state in `save.modData[mod.id]` |
| `mod.save:get/set` | `main.lua` — the same value through the loader-side namespace |
| `content.commands:register` (table form) | `main.lua` — a `foreground` verb of the mod's own |
| `MapScripts.baseTalk` (replay the overridden handler) | `main.lua` — `example_lost_parcel:base_nerd_chat` |
| `content.tokens:register` | `main.lua` — `{EXAMPLE_PARCEL_REWARD}` |
| `content.items:register` | `main.lua` — the parcel key item |
| a parallel ambient script | `main.lua` — `scripts.example_nerd_pace` + `onEnter` |
| `events:on` / `events:emit` | `main.lua` — announcing completion under `mod.<id>.*` |
## How the compose merge works
`map_scripts` is a **compose** registry, not a record registry. Registering
does not replace the engine's contribution for a map; it prepends to an
ordered chain, and each key composes by its own rule
(`09-scripting-and-quests.md` §4.4):
| key | rule |
|---|---|
| `talk`, `scripts` | single winner per name; `false` suppresses and falls through |
| `onEnter`, `onVictory`, `onBoulderMoved` | all contributions run, each `pcall`-guarded |
| `onStep`, `onInteract` | first truthy return consumes the step |
So this mod's `onEnter` for Pewter City runs *alongside* the engine's, not
instead of it. Its `talk` entry for `TEXT_PEWTERCITY_SUPER_NERD1` does win
outright — the engine's handler for that constant stops being dispatched
the moment this mod loads. Giving it back is the last branch's whole job:
```lua
{ "label", "vanilla" },
{ "example_lost_parcel:base_nerd_chat" },
```
```lua
local base = MapScripts.baseTalk("PEWTER_CITY", "TEXT_PEWTERCITY_SUPER_NERD1")
base(ctx.game, ctx.overworld, ctx.npc, function() ctx.runner:resume() end)
ctx.runner:yield()
```
`MapScripts.baseTalk` reaches the engine handler still sitting behind the
override (`09-scripting-and-quests.md` §6) — the supported replacement for
re-wrapping it. A `{ "show_text", "TEXT_PEWTERCITY_SUPER_NERD1" }` row
*looks* like it does the same thing and does not: this NPC's base handler
is a Lua function that asks YES/NO and answers with one of two follow-ups,
while `show_text` resolves the constant to its opening line and stops. So
before the quest starts and after the parcel is taken, the player gets the
whole conversation they always got, choice included. The test drives both
answers, in both states, and asserts every line.
This is also why the manifest declares `engine_internals`: replaying a base
handler means requiring `src.script.MapScripts`.
## Flags, fields and mod state
Three storage routes, three jobs:
- **`MOD_`-prefixed flags** — the quest's public state machine. In the
normal flag namespace so `check_flag` works, prefixed so it can never
collide with a pokered event constant.
- **`set_field "mod:asked_count"`** — script-visible scratch state, routed
into `save.modData[mod.id]` by the owning contribution's attribution.
Two copies of a quest cannot collide on one key.
- **`mod.save:get/set`** — the same namespace from Lua, for code that is
not a script row.
## Verb metadata
The custom verb is registered in the table form:
```lua
mod.content.commands:register("example_lost_parcel:count_ask", {
foreground = true,
fn = function(ctx) ... end,
})
```
`foreground = true` marks it illegal inside a parallel script, so the
ambient runner can never touch quest state. Namespacing the verb with the
mod id keeps it from colliding with another mod's — `register` on a name
the engine already owns is an error, and replacing one requires `override`.
## Validation
Every row is checked against the merged command set once all entry chunks
have run. Typo a verb or jump to a label that does not exist and this mod
fails at *load* with the row number, is rolled back whole, and says so in
the manager — it never half-loads into a broken conversation.
## Credits
- pret/pokered — the `TEXT_` constants and base conversations this
composes with.
+172
View File
@@ -0,0 +1,172 @@
-- Gallery #4 (Quest author): the worked multi-map fetch quest from
-- 09-scripting-and-quests.md 6. A courier in Viridian City lost a parcel
-- in Pewter City; the player retrieves it for a NUGGET.
--
-- No map is edited and no engine file is touched. Both NPCs are vanilla
-- objects addressed by their real TEXT_ constants, and the base
-- conversation is still reachable on every branch the quest does not own.
local MapScripts = require("src.script.MapScripts")
local PARCEL = "EXAMPLE_LOST_PARCEL_PARCEL"
local REWARD = "NUGGET"
-- flags a mod writes are MOD_-prefixed by convention, so a save never
-- confuses them with the pokered event namespace
local STARTED = "MOD_EXAMPLE_LOST_PARCEL_STARTED"
local TAKEN = "MOD_EXAMPLE_LOST_PARCEL_TAKEN"
local DONE = "MOD_EXAMPLE_LOST_PARCEL_DONE"
-- the Pewter super nerd's object index on PEWTER_CITY; the ambient script
-- makes this one fidget while the parcel is still lying around
local NERD_INDEX = 3
return function(mod)
-- ------- the reward item
mod.content.items:register(PARCEL, {
id = PARCEL,
name = "PARCEL?",
price = 0,
keyItem = true,
tossable = false,
})
-- ------- a text token, so the reward name is written once
mod.content.tokens:register("EXAMPLE_PARCEL_REWARD", function(game)
local item = game and game.data and game.data.items[REWARD]
return item and item.name or REWARD
end)
-- ------- a script verb of this mod's own
-- The table form carries dispatch metadata: foreground marks it illegal
-- inside a parallel script, which is what keeps the ambient runner from
-- ever touching quest state.
mod.content.commands:register("example_lost_parcel:count_ask", {
foreground = true,
fn = function(ctx)
-- mod: fields route into save.modData[owner], so quest scratch state
-- is attributable and two quests never collide on one key
local base = ctx.save.modData and ctx.save.modData[mod.id]
local asked = (base and base.asked_count or 0) + 1
ctx.save.modData = ctx.save.modData or {}
ctx.save.modData[mod.id] = ctx.save.modData[mod.id] or {}
ctx.save.modData[mod.id].asked_count = asked
-- the same number through the loader-side namespace, which is what a
-- screen or another mod would read
mod.save:set("asked_count", asked)
end,
})
-- ------- handing a branch back to the base conversation
-- talk dispatch is single-winner, so the Pewter rows below replace the
-- engine's handler outright. Re-running the TEXT_ constant with
-- show_text would only replay its opening line: the base handler is a
-- Lua function that asks YES/NO and answers with one of two follow-ups,
-- and none of that survives a text lookup. baseTalk reaches the handler
-- still sitting behind the override (09 6), so the branches the quest
-- does not own play the whole vanilla conversation.
mod.content.commands:register("example_lost_parcel:base_nerd_chat", {
foreground = true,
fn = function(ctx)
local base = MapScripts.baseTalk("PEWTER_CITY", "TEXT_PEWTERCITY_SUPER_NERD1")
if not base then return end
local runner = ctx.runner
base(ctx.game, ctx.overworld, ctx.npc, function() runner:resume() end)
runner:yield()
end,
})
-- ------- Viridian City: the quest giver
mod.content.map_scripts:register("VIRIDIAN_CITY", {
talk = {
TEXT_VIRIDIANCITY_GAMBLER1 = {
{ "check_flag", DONE },
{ "jump_if_true", "after" },
{ "check_flag", STARTED },
{ "jump_if_true", "pending" },
{ "show_text", "I dropped a parcel\nsomewhere in\nPEWTER CITY..." },
{ "choice", { "SURE", "NO WAY" } },
{ "jump_if_false", "refused" },
{ "set_flag", STARTED },
{ "set_field", "mod:asked_count", 0 },
{ "show_text", "Thanks! A {EXAMPLE_PARCEL_REWARD}\nawaits you!" },
{ "jump", "end" },
{ "label", "pending" },
{ "example_lost_parcel:count_ask" },
{ "check_item", PARCEL },
{ "jump_if_false", "remind" },
{ "take_item", PARCEL },
{ "give_item", REWARD },
{ "set_flag", DONE },
{ "emote", "player", "happy", 45 },
{ "show_text", "You found it!\nHere, as promised!" },
{ "jump", "end" },
{ "label", "remind" },
{ "show_text", "It's a small brown\nparcel. PEWTER CITY!" },
{ "jump", "end" },
{ "label", "refused" },
{ "show_text", "Aww. GYMs are\nclosed anyway..." },
{ "jump", "end" },
{ "label", "after" },
{ "show_text", "Thanks again,\n{PLAYER}!" },
},
},
})
-- ------- Pewter City: the parcel, and some ambience while it is lost
mod.content.map_scripts:register("PEWTER_CITY", {
talk = {
TEXT_PEWTERCITY_SUPER_NERD1 = {
{ "check_flag", STARTED },
{ "jump_if_false", "vanilla" },
{ "check_flag", TAKEN },
{ "jump_if_true", "vanilla" },
{ "show_text", "Someone dropped\nthis parcel by the\nMUSEUM." },
{ "give_item", PARCEL, 1, false },
{ "set_flag", TAKEN },
{ "show_text", "{PLAYER} got the\nparcel back!" },
{ "jump", "end" },
-- every branch the quest does not own replays the base handler, so
-- the vanilla conversation is never lost to the override
{ "label", "vanilla" },
{ "example_lost_parcel:base_nerd_chat" },
},
},
-- all-run: this composes with the engine's own onEnter for the map
-- instead of replacing it
onEnter = function(game, ow)
local flags = game.save and game.save.flags or {}
if flags[STARTED] and not flags[TAKEN] then
ow:queueScript({ { "run_parallel", "PEWTER_CITY/example_nerd_pace" } })
end
end,
scripts = {
-- background-legal verbs only; the runner rejects foreground rows in
-- a parallel slot, and the script dies on map exit
example_nerd_pace = {
{ "label", "top" },
{ "march_in_place", NERD_INDEX, true }, { "wait", 90 },
{ "march_in_place", NERD_INDEX, false }, { "wait", 150 },
{ "jump", "top" },
},
},
})
-- quest completion is worth announcing to other mods; a mod may only
-- broadcast under its own prefix
mod.events:on("flag.changed", function(ev)
if ev.name == DONE and ev.value then
mod.events:emit("mod.example_lost_parcel.completed", { reward = REWARD })
end
end)
end
@@ -0,0 +1,16 @@
{
"id": "example_lost_parcel",
"name": "The Lost Parcel",
"version": "1.0.0",
"api": 2,
"entry": "main.lua",
"profile": "content",
"category": "QUEST",
"game_version": ">=1.0.0 <2.0.0",
"priority": 100,
"permissions": ["engine_internals"],
"dependencies": [],
"optional_dependencies": [],
"conflicts": [],
"description": "Quest-author gallery entry: a two-town fetch quest over real TEXT constants, with choices, labels, MOD_ flags and a parallel ambient script."
}
@@ -0,0 +1,27 @@
-- Sharing metadata (25-community-and-ecosystem.md 3.2). Read by tooling
-- and the manager detail pane; never by the loader's merge.
return {
summary = "A courier in Viridian lost a parcel in Pewter. Fetch it for a NUGGET.",
author = "Pokemon Gen 1 Recompilation Project",
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
tags = { "quest", "story", "scripting" },
differences = {
changed = {
"the Viridian gambler and the Pewter super nerd gain quest branches; "
.. "their vanilla lines still play on every other branch",
},
added = {
"EXAMPLE_LOST_PARCEL_PARCEL key item",
"{EXAMPLE_PARCEL_REWARD} text token",
"example_lost_parcel:count_ask script verb",
"example_lost_parcel:base_nerd_chat script verb, which replays the "
.. "super nerd's base conversation the quest branches around",
"an ambient parallel script on PEWTER_CITY while the parcel is lost",
},
known = { "the parcel can be tossed from the bag; the quest then stalls at the reminder line" },
},
credits = {
{ who = "pret/pokered", for_ = "the TEXT_ constants and base conversations this composes with" },
},
compat = { engine = ">=1.0.0 <2.0.0", modApi = 2 },
}
@@ -0,0 +1,163 @@
-- Standalone: luajit mods/examples/example_lost_parcel/tests/example_lost_parcel_test.lua
-- Plays the quest end to end headlessly: accept, fetch, hand over.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = require("src.core.Data")
Data:load()
local Font = require("src.render.Font")
local ScriptRunner = require("src.script.ScriptRunner")
local MapScripts = require("src.script.MapScripts")
-- the engine attaches its hand-ported scripts as the base contribution at
-- boot; the quest composes on top of them, so the harness needs them too
require("data.scripts.init")
Font.load(Data)
local run = T.sdk.loadMod("mods/examples/example_lost_parcel", { data = Data })
T.eq(#run.errors, 0, "loads clean (" .. tostring(run.errors[1]) .. ")")
T.check(Data.items.EXAMPLE_LOST_PARCEL_PARCEL ~= nil, "the parcel item merged")
T.check(Data.tokens.EXAMPLE_PARCEL_REWARD ~= nil, "the reward token merged")
T.check(Data.commands["example_lost_parcel:count_ask"] ~= nil,
"the mod's script verb merged")
-- a stack whose boxes are answered from OUTSIDE the coroutine: a text box
-- pushed by show_text resolves on the next drive tick, never re-entrantly
local choice = 1
local function newGame()
local game = { data = Data, save = {
flags = {}, inventory = {}, modData = {},
player = { name = "RED", rival = "BLUE" },
} }
local stack = { states = {} }
function stack:push(state) self.states[#self.states + 1] = state end
function stack:pop() return table.remove(self.states) end
function stack:top() return self.states[#self.states] end
game.stack = stack
game.shown = {} -- first line of every text box, in order
game.asked = 0 -- YES/NO boxes the conversation put up
return game
end
-- advance one pending box or emote hold; choice menus pick `choice`
local function settle(game, ow)
if ow.emote then
local held = ow.emote
ow.emote = nil
held.onDone()
return true
end
local top = table.remove(game.stack.states)
if not top then return false end
if top.pages then
game.lastText = top.pages[1] and top.pages[1][1]
game.shown[#game.shown + 1] = game.lastText
end
if top.items then
local item = top.items[choice]
if item and item.onSelect then item.onSelect() end
elseif top.onChoose then
-- a bare ChoiceBox: what the engine's own Lua talk handlers ask with
game.asked = game.asked + 1
top.onChoose(choice == 1)
elseif top.onDone then
top.onDone()
end
return true
end
-- the first line of a generated text constant, which is what a TextBox
-- paginates onto its first row
local function firstLine(s) return (tostring(s):match("^[^\n\f\v]*")) end
-- the overworld the talk dispatch would supply; its map label is what
-- show_text resolves a bare TEXT_ constant through
local function overworldFor(mapId)
return { map = { id = mapId, def = { label = Data.maps[mapId].label } } }
end
local function talk(game, mapId, textConst)
local rows = MapScripts.talkScript(mapId, textConst)
T.check(rows ~= nil, mapId .. "." .. textConst .. " has a talk script")
local ow = overworldFor(mapId)
local runner = ScriptRunner.new(game, ow)
runner:run(rows, { source = MapScripts.talkSource(mapId, textConst) })
for _ = 1, 400 do
if not runner:isRunning() then break end
if not settle(game, ow) then runner:update() end
end
T.check(not runner:isRunning(), "the " .. textConst .. " script completed")
end
-- ------- branch 1: refuse the quest
do
choice = 2
local game = newGame()
talk(game, "VIRIDIAN_CITY", "TEXT_VIRIDIANCITY_GAMBLER1")
T.check(not game.save.flags.MOD_EXAMPLE_LOST_PARCEL_STARTED,
"refusing the choice leaves the quest unstarted")
end
-- ------- branch 2: accept, fetch, deliver
local game = newGame()
choice = 1
talk(game, "VIRIDIAN_CITY", "TEXT_VIRIDIANCITY_GAMBLER1")
T.check(game.save.flags.MOD_EXAMPLE_LOST_PARCEL_STARTED, "accepting sets the started flag")
T.eq(game.save.modData.example_lost_parcel.asked_count, 0,
"set_field mod: wrote into the mod's own save namespace")
-- The override wins talk dispatch outright, so the branches the quest does
-- not own owe the player the whole base conversation -- which for this NPC
-- is an opening line, a YES/NO prompt and one of two follow-ups, not a
-- single line. Both answers are driven, before the quest starts and again
-- once the parcel is gone.
local NERD_INTRO = Data.text._PewterCitySuperNerd1DidYouCheckOutMuseumText
local NERD_YES = Data.text._PewterCitySuperNerd1WerentThoseFossilsAmazingText
local NERD_NO = Data.text._PewterCitySuperNerd1YouHaveToGoText
T.check(NERD_INTRO and NERD_YES and NERD_NO,
"the base super nerd conversation is in the generated text")
local function readsVanillaNerd(when, flags)
for _, answer in ipairs({ 1, 2 }) do
local plain = newGame()
for name, value in pairs(flags or {}) do plain.save.flags[name] = value end
choice = answer
talk(plain, "PEWTER_CITY", "TEXT_PEWTERCITY_SUPER_NERD1")
T.check((plain.save.inventory.EXAMPLE_LOST_PARCEL_PARCEL or 0) == 0,
when .. ": the base branch never hands out the parcel")
T.eq(plain.asked, 1, when .. ": the vanilla YES/NO prompt still comes up")
T.eq(#plain.shown, 2, when .. ": the opening line and a follow-up both play")
T.eq(plain.shown[1], firstLine(NERD_INTRO), when .. ": the vanilla opening line")
T.eq(plain.shown[2], firstLine(answer == 1 and NERD_YES or NERD_NO),
when .. ": the follow-up answers the choice the player made")
end
end
readsVanillaNerd("before the quest")
readsVanillaNerd("after the parcel is taken", {
MOD_EXAMPLE_LOST_PARCEL_STARTED = true,
MOD_EXAMPLE_LOST_PARCEL_TAKEN = true,
})
choice = 1
talk(game, "PEWTER_CITY", "TEXT_PEWTERCITY_SUPER_NERD1")
T.check(game.save.flags.MOD_EXAMPLE_LOST_PARCEL_TAKEN, "the parcel is taken")
T.check((game.save.inventory.EXAMPLE_LOST_PARCEL_PARCEL or 0) > 0,
"the parcel is in the bag")
talk(game, "VIRIDIAN_CITY", "TEXT_VIRIDIANCITY_GAMBLER1")
T.check(game.save.flags.MOD_EXAMPLE_LOST_PARCEL_DONE, "the quest completes")
T.check((game.save.inventory.EXAMPLE_LOST_PARCEL_PARCEL or 0) == 0,
"the parcel is consumed")
T.check((game.save.inventory.NUGGET or 0) > 0, "the NUGGET reward is paid")
T.eq(game.save.modData.example_lost_parcel.asked_count, 1,
"the mod's own verb counted the one pending visit")
-- the ambient script is background-legal: no foreground verb in it
local rows = MapScripts.namedScript("PEWTER_CITY", "example_nerd_pace")
T.check(rows ~= nil, "the parallel ambient script is registered")
run.release()
T.finish("example_lost_parcel")
@@ -0,0 +1,13 @@
# Changelog
Format: [keep a changelog](https://keepachangelog.com/en/1.1.0/).
Version headings match `manifest.json`'s `version`.
## 1.0.0
### Added
- Three original species with cries and icons, and their art generator.
- The `SABLE_COVE` map and encounter table.
- `SABLE_TIDE_BADGE` and the one-badge constants override.
- The `SableTitle` screen and the `field.boot` override that reaches it.
@@ -0,0 +1,103 @@
# Sable Cove (Mini Conversion)
The smallest thing that is recognizably a *different game*: its own title
screen, its own starting town, its own three-species dex, its own single
badge — running on the same engine, with the same import.
**Persona: the Total-Conversion Team.** This is the capstone skeleton. It
is deliberately incomplete as a game and deliberately complete as a
demonstration of which seams a conversion owns.
## Legal callout (read this first)
A total conversion on this engine is a **recipe, not a redistribution**.
- The Red import still runs. It supplies the fallback infrastructure this
conversion sits on: the `OVERWORLD` tileset, the font, the move table,
the type chart. That data lives on the player's machine, decoded from
the player's own ROM.
- The conversion overrides on top. Everything under `assets/` here is
original work, plotted pixel by pixel by
`tools/make_assets.py` — run it yourself and diff the output.
- It never ships extracted content, and it never launders extracted
content into "new" species by transforming Red sprites. If your
conversion wants to *derive* art from the player's cache, that is what
`assets_transforms` is for — see
`mods/examples/example_shiny_palette/` for the worked pattern.
## Try it
```sh
python3 tools/modkit.py validate mods/examples/example_mini_conversion --base imported
python3 tools/modkit.py lint mods/examples/example_mini_conversion
luajit mods/examples/example_mini_conversion/tests/example_mini_conversion_test.lua
# regenerate the original art from its shape tables
python3 mods/examples/example_mini_conversion/tools/make_assets.py
```
Enable it (`example_mini_conversion = true` under `mods` in `options.lua`,
or the F10 manager) and start the game. You land on the SABLE COVE title
screen; NEW GAME spawns you in Sable Cove with 1500 money as SABLE.
## What it demonstrates
| Seam | Where |
|---|---|
| `profile = "total_conversion"` | `manifest.json` — implies `affects_link` |
| `content.field:patch("boot", …)` | `main.lua` — spawn, names, money, boot screens |
| `content.constants:patch` / `:override` | `main.lua` — dex size, level cap, badge list |
| `content.pokemon:register` | `main.lua` — three species with full records |
| `content.cries:register` (ChipAsm) | `main.lua` — one authored effect per species |
| `content.icons:register` | `main.lua` — party icons keyed by species id |
| `content.maps:register` | `main.lua` — one map on the imported tileset |
| `content.encounters:register` | `main.lua` — its wild table |
| `content.items:register` | `main.lua` — the badge, which is an item |
| `content.screens:register` | `main.lua` — the title screen the boot config names |
| `events:on("game.ready")` | `main.lua` — checking the boot merge actually took |
## patch vs override on a deep registry
`constants` and `field` are **deep** registries. Two rules differ from the
record registries, and both bite a conversion:
1. `register` and `patch` are the same verb. A partial payload is the
normal case; only the keys you name move.
2. **Lists append.** That is deliberate — two mods each adding a badge both
land. But a conversion wants to *replace* the badge list, and appending
would leave Kanto's eight in front of its one:
```lua
mod.content.constants:patch("badges", { … }) -- 8 + 1 = 9 badges
mod.content.constants:override("badges", { … }) -- 1 badge
```
`override` is the verb that drops a list. The test asserts both behaviors.
`field.boot` is the opposite case: `patch` is right there, because the keys
this conversion does not name (`startFacing`, the `splash` and `newGame`
screen ids) should keep the engine's values.
## Priority
`"priority": 900`. A conversion wants to merge *after* content mods so its
`field.boot` and `constants` win. If another mod still beats it, the
`game.ready` listener says so by name instead of leaving the player on a
map they did not expect.
## What a real conversion adds next
This skeleton stops at the boundary of the mechanism demonstration. A
shipping conversion continues with:
- `map_scripts` for the story (see `mods/examples/example_lost_parcel/`)
- `maps:remove` / `pokemon:remove` tombstones to hide Kanto content
- `content.text` and `text_pointers` for its own dialogue
- `content.music` for its soundtrack (see `mods/examples/example_jukebox/`)
- `link_fields` and an honest `affects_link` so its players do not corrupt
each other's saves in a trade
## Credits
- All original sprite art: this project (`tools/make_assets.py`).
- pret/pokered: the `OVERWORLD` tileset, font and move table this builds on.
Binary file not shown.

After

Width:  |  Height:  |  Size: 191 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 B

@@ -0,0 +1,180 @@
-- Gallery #7 (Total-conversion team): the smallest thing that is
-- recognizably a different game. Its own boot config, its own title
-- screen, its own three-species dex, its own single badge, and one map.
--
-- LEGAL POSTURE (constraint 1): the Red import still runs and supplies the
-- fallback infrastructure this conversion sits on -- the OVERWORLD tileset,
-- the font, the move table. The conversion overrides on top. Every pixel
-- under assets/ is original work generated by tools/make_assets.py; nothing
-- here transforms extracted Red art into "new" content.
local MAP = "SABLE_COVE"
local BADGE = "SABLE_TIDE_BADGE"
-- three original species; ids are namespaced so a mixed load cannot
-- collide with Red's
local DEX = {
{ id = "SABLE_EMBERKIT", name = "EMBERKIT", dex = 1, art = "emberkit",
types = { "FIRE" }, stats = { hp = 45, attack = 60, defense = 40,
speed = 65, special = 50 } },
{ id = "SABLE_TIDEPUP", name = "TIDEPUP", dex = 2, art = "tidepup",
types = { "WATER" }, stats = { hp = 50, attack = 48, defense = 60,
speed = 45, special = 55 } },
{ id = "SABLE_MOSSLING", name = "MOSSLING", dex = 3, art = "mossling",
types = { "GRASS", "POISON" }, stats = { hp = 55, attack = 50,
defense = 55, speed = 40,
special = 60 } },
}
return function(mod)
local ChipAsm = require("src.audio.ChipAsm")
-- ------- species, cries and icons
for _, entry in ipairs(DEX) do
mod.content.pokemon:register(entry.id, {
id = entry.id,
name = entry.name,
dex = entry.dex,
types = entry.types,
baseStats = entry.stats,
catchRate = 190,
baseExp = 64,
growthRate = "MEDIUM_FAST",
level1Moves = { "TACKLE" },
learnset = {
{ level = 7, move = "GROWL" },
{ level = 13, move = "QUICK_ATTACK" },
},
evolutions = {},
spriteFront = mod.path .. "/assets/" .. entry.art .. "_front.png",
spriteBack = mod.path .. "/assets/" .. entry.art .. "_back.png",
frontSize = 5,
cry = entry.id,
icon = { image = mod.path .. "/assets/" .. entry.art .. "_icon.png",
frames = 2 },
})
-- one authored chip effect per species, keyed by species id exactly
-- like the vanilla cry table
mod.content.cries:register(entry.id, {
chip = ChipAsm.sfx{
channels = { { hw = 1, program = {
{ pitchSweep = { pace = 2 + entry.dex, subtract = entry.dex == 2,
shift = 3 } },
{ squareNote = { len = 5, volume = 13, fade = 2,
frequency = 0x480 + entry.dex * 0x60 } },
} } },
}.chip,
pitch = 128, length = 128,
})
mod.content.icons:register(entry.id, {
image = mod.path .. "/assets/" .. entry.art .. "_icon.png",
frames = 2,
})
end
-- ------- the badge, which is an item like every vanilla badge
mod.content.items:register(BADGE, {
id = BADGE, name = "TIDEBADGE", price = 0, keyItem = true, tossable = false,
})
-- ------- the rules the engine used to hard-code
-- deep registry: register and patch are the same verb, and only the keys
-- named here move. Everything else keeps its imported value.
mod.content.constants:patch("dexSize", #DEX)
mod.content.constants:patch("dexDigits", 1)
mod.content.constants:patch("levelCap", 50)
-- override, not patch: under deep semantics a list APPENDS, so patching
-- here would leave Kanto's eight badges in front of this one. override
-- is the verb that drops a list, which is exactly what a conversion wants
mod.content.constants:override("badges", { { id = BADGE, name = "TIDE" } })
mod.content.constants:override("hmMoves", { "CUT", "SURF" })
-- ------- the one map
local blocks = {}
for i = 1, 10 * 9 do blocks[i] = 1 end
mod.content.maps:register(MAP, {
id = MAP,
label = "SableCove",
index = 1000,
tileset = "OVERWORLD",
width = 10, height = 9,
blocks = blocks,
borderBlock = 11,
warps = {}, objects = {}, signs = {},
})
mod.content.encounters:register(MAP, {
grass = { rate = 25, slots = {
{ level = 3, species = "SABLE_EMBERKIT" },
{ level = 3, species = "SABLE_TIDEPUP" },
{ level = 3, species = "SABLE_MOSSLING" },
{ level = 4, species = "SABLE_TIDEPUP" },
{ level = 4, species = "SABLE_MOSSLING" },
{ level = 5, species = "SABLE_EMBERKIT" },
{ level = 5, species = "SABLE_TIDEPUP" },
{ level = 5, species = "SABLE_MOSSLING" },
{ level = 6, species = "SABLE_EMBERKIT" },
{ level = 6, species = "SABLE_MOSSLING" },
} },
})
-- ------- the new game itself
mod.content.field:patch("boot", {
startMap = MAP, startX = 5, startY = 4, startFacing = "down",
playerName = "SABLE", rivalName = "CORAL",
startMoney = 1500,
lastHeal = { map = MAP, x = 5, y = 4 },
namePresets = { player = { "SABLE", "WREN", "PIKE" },
rival = { "CORAL", "REEF", "SHOAL" } },
-- the conversion owns the boot flow; splash and newGame keep the
-- engine screens, which is the point of naming them individually
screens = { title = "SableTitle" },
})
-- ------- the title screen
mod.content.screens:register("SableTitle", {
new = function(game, opts)
opts = opts or {}
local Font = mod.ui.Font
local state = { game = game, isOpaque = true, blink = 0 }
function state:update()
self.blink = (self.blink + 1) % 60
local input = game.input
if not input then return end
if input:wasPressed("a") or input:wasPressed("start") then
if opts.onNewGame then opts.onNewGame() end
end
end
function state:draw()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
love.graphics.setColor(0, 0, 0, 1)
Font.draw("SABLE COVE", 40, 40)
Font.draw("A MINI CONVERSION", 12, 56)
if self.blink < 40 then Font.draw("PRESS A", 52, 104) end
end
return state
end,
})
mod.events:on("game.ready", function(ev)
-- the payload carries the live Game; a conversion uses it to check
-- that its own boot config actually took
local boot = ev.game and ev.game.data and ev.game.data.field
and ev.game.data.field.boot
if not (boot and boot.startMap == MAP) then
mod.log:warn("another mod owns field.boot; raise this mod's priority "
.. "above %s to win the merge", tostring(boot and boot.startMap))
end
end)
end
@@ -0,0 +1,15 @@
{
"id": "example_mini_conversion",
"name": "Sable Cove (Mini Conversion)",
"version": "1.0.0",
"api": 2,
"entry": "main.lua",
"profile": "total_conversion",
"category": "TOTAL_CONVERSION",
"game_version": ">=1.0.0 <2.0.0",
"priority": 900,
"dependencies": [],
"optional_dependencies": [],
"conflicts": [],
"description": "Total-conversion gallery entry: a one-town game with its own boot, title, three-species dex and one badge."
}
@@ -0,0 +1,31 @@
-- Sharing metadata (25-community-and-ecosystem.md 3.2). Read by tooling
-- and the manager detail pane; never by the loader's merge.
return {
summary = "Sable Cove: one town, three species, one badge. The smallest whole conversion.",
author = "Pokemon Gen 1 Recompilation Project",
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
tags = { "total-conversion", "capstone" },
differences = {
changed = {
"field.boot spawns on SABLE_COVE as SABLE, rival CORAL, 1500 money",
"constants.dexSize 151 -> 3, dexDigits 3 -> 1, levelCap 100 -> 50",
"constants.badges replaced with one TIDEBADGE",
"constants.hmMoves replaced with CUT and SURF",
"the title screen is SableTitle",
},
added = {
"SABLE_EMBERKIT, SABLE_TIDEPUP and SABLE_MOSSLING with cries and icons",
"SABLE_TIDE_BADGE key item",
"the SABLE_COVE map and its encounter table",
},
known = {
"no gym, no story and no warps yet: this is the skeleton the TC guide elaborates",
"Red's maps and species stay merged and reachable; a real conversion removes them",
},
},
credits = {
{ who = "Pokemon Gen 1 Recompilation Project", for_ = "all original sprite art under assets/" },
{ who = "pret/pokered", for_ = "the OVERWORLD tileset, font and move table this builds on" },
},
compat = { engine = ">=1.0.0 <2.0.0", modApi = 2 },
}
@@ -0,0 +1,70 @@
-- Standalone: luajit mods/examples/example_mini_conversion/tests/example_mini_conversion_test.lua
-- Asserts the conversion owns the boot flow, the dex and the badge list.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = require("src.core.Data")
Data:load()
local run = T.sdk.loadMod("mods/examples/example_mini_conversion", { data = Data })
T.eq(#run.errors, 0, "loads clean (" .. tostring(run.errors[1]) .. ")")
T.eq(run.mod and run.mod.manifest.profile, "total_conversion",
"the manifest declares the total_conversion profile")
-- ------- boot: the new game the conversion starts
local boot = Data.field.boot
T.eq(boot.startMap, "SABLE_COVE", "boot spawns on the conversion's own map")
T.eq(boot.playerName, "SABLE", "boot renames the player")
T.eq(boot.screens.title, "SableTitle", "the conversion owns the title screen")
-- patch is a merge: the keys the conversion did not name keep their values
T.eq(boot.startFacing, "down", "an unnamed boot key survives the patch")
-- ------- the dex
T.eq(Data.constants.dexSize, 3, "the dex shrinks to three species")
T.eq(#Data.constants.badges, 1, "one badge replaces the eight")
T.eq(Data.constants.badges[1].id, "SABLE_TIDE_BADGE", "the badge is the mod's item")
T.check(Data.items.SABLE_TIDE_BADGE ~= nil, "the badge item merged")
-- constants is a deep registry, so untouched keys keep their imported value
T.eq(Data.constants.partyMax, 6, "an unpatched constant is unchanged")
-- and a deep list REPLACES only because the mod said override
T.eq(#Data.constants.hmMoves, 2, "override drops a deep list instead of appending")
for _, id in ipairs({ "SABLE_EMBERKIT", "SABLE_TIDEPUP", "SABLE_MOSSLING" }) do
local mon = Data.pokemon[id]
T.check(mon ~= nil, id .. " merged into the species table")
T.check(Data.audio.cries[id] ~= nil, id .. " has a cry")
T.check(Data.icons.bySpecies[id] ~= nil, id .. " has an icon")
-- the art it points at really exists, not a path into the void. Read it
-- back through the loader's own filesystem: mod.path is whatever the
-- loader mounted the mod at, which is not the repo-relative directory
for _, path in ipairs({ mon.spriteFront, mon.spriteBack }) do
T.check(run.loader.fs.getInfo(path) ~= nil,
"sprite exists: " .. tostring(path))
end
end
-- ------- the map and its encounters
local map = Data.maps.SABLE_COVE
T.check(map ~= nil, "the conversion's map merged")
T.eq(#map.blocks, map.width * map.height, "the block array matches the map size")
T.eq(#Data.encounters.SABLE_COVE.grass.slots, 10, "the map has a full slot table")
-- ------- the title screen resolves through the registry
local Screens = require("src.ui.Screens")
Screens.invalidate()
local factory = Screens.get({ data = Data }, "SableTitle")
T.check(factory and factory.new, "SableTitle resolves through the screens registry")
local reached = false
local state = factory.new({ data = Data, input = {
wasPressed = function(_, key) return key == "a" end } },
{ onNewGame = function() reached = true end })
state:update()
T.check(reached, "pressing A on the title starts a new game")
run.release()
Screens.invalidate()
T.finish("example_mini_conversion")
@@ -0,0 +1,121 @@
#!/usr/bin/env python3
"""Regenerate this mod's original sprite art.
python3 mods/examples/example_mini_conversion/tools/make_assets.py
Every pixel below is plotted from the shape tables in this file, so the
output is original work and nothing is read from the player's imported
cache. The four colors are the Game Boy shade ramp; the renderer
re-shades them into the active palette, so no trueColor opt-out is needed.
Front sheets are 40x40 (frontSize 5), backs 32x32 and icons 16x32
(two 16x16 frames), matching what the battle and party screens expect.
"""
import os
from PIL import Image
ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "assets")
# lightest first; index 0 is the transparent-white background
SHADES = [(248, 248, 248), (168, 168, 168), (88, 88, 88), (8, 8, 8)]
# Each species is a coarse silhouette painted from primitives: the point is
# that these are geometric marks, not creature art traced from anything.
SPECIES = {
"emberkit": {"body": "triangle", "accent": 1},
"tidepup": {"body": "diamond", "accent": 1},
"mossling": {"body": "hex", "accent": 2},
}
def blank(w, h):
return [[0] * w for _ in range(h)]
def stroke(grid, x, y, shade):
if 0 <= y < len(grid) and 0 <= x < len(grid[0]):
grid[y][x] = shade
def triangle(grid, cx, cy, r, shade):
for row in range(r * 2):
half = row // 2
for x in range(cx - half, cx + half + 1):
stroke(grid, x, cy - r + row, shade)
def diamond(grid, cx, cy, r, shade):
for dy in range(-r, r + 1):
span = r - abs(dy)
for dx in range(-span, span + 1):
stroke(grid, cx + dx, cy + dy, shade)
def hexagon(grid, cx, cy, r, shade):
for dy in range(-r, r + 1):
span = r if abs(dy) <= r // 2 else r - (abs(dy) - r // 2)
for dx in range(-span, span + 1):
stroke(grid, cx + dx, cy + dy, shade)
SHAPES = {"triangle": triangle, "diamond": diamond, "hex": hexagon}
def outline(grid, shade):
"""Darken every lit pixel that touches an unlit one."""
h, w = len(grid), len(grid[0])
edges = []
for y in range(h):
for x in range(w):
if not grid[y][x]:
continue
for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1)):
nx, ny = x + dx, y + dy
if not (0 <= nx < w and 0 <= ny < h) or not grid[ny][nx]:
edges.append((x, y))
break
for x, y in edges:
grid[y][x] = shade
def save(grid, path):
h, w = len(grid), len(grid[0])
img = Image.new("RGBA", (w, h))
img.putdata([SHADES[grid[y][x]] + (255,)
for y in range(h) for x in range(w)])
os.makedirs(os.path.dirname(path), exist_ok=True)
img.save(path)
print("wrote", os.path.relpath(path))
def build(name, spec):
shape = SHAPES[spec["body"]]
accent = spec["accent"]
front = blank(40, 40)
shape(front, 20, 22, 13, accent)
shape(front, 20, 12, 5, 2)
for x in (16, 24):
stroke(front, x, 11, 3)
stroke(front, x, 12, 3)
outline(front, 3)
save(front, os.path.join(ROOT, name + "_front.png"))
back = blank(32, 32)
shape(back, 16, 20, 11, accent)
outline(back, 3)
save(back, os.path.join(ROOT, name + "_back.png"))
# two 16x16 frames stacked: the party-menu bob
icon = blank(16, 32)
for frame, lift in enumerate((0, 1)):
shape(icon, 8, 9 + frame * 16 - lift, 5, accent)
outline(icon, 3)
save(icon, os.path.join(ROOT, name + "_icon.png"))
if __name__ == "__main__":
for name, spec in sorted(SPECIES.items()):
build(name, spec)
@@ -0,0 +1,12 @@
# Changelog
Format: [keep a changelog](https://keepachangelog.com/en/1.1.0/).
Version headings match `manifest.json`'s `version`.
## 1.0.0
### Added
- `transforms.lua` recolor of the player overworld sheets.
- `EXAMPLE_SHINY` palette record and a `PALLET` town palette override.
- `trueColor` opt-out patches for `SPRITE_RED` and `SPRITE_RED_BIKE`.
@@ -0,0 +1,78 @@
# Shiny Palette Example
Recolors the player's overworld sheets to teal and repaints Pallet Town —
and ships no ROM-derived pixels to do it.
**Persona: the Artist.** This is the canonical answer to "how do I ship a
recolor legally": you ship the *transform*, not the image.
## Try it
```sh
python3 tools/modkit.py validate mods/examples/example_shiny_palette --base imported
python3 tools/modkit.py lint mods/examples/example_shiny_palette
luajit mods/examples/example_shiny_palette/tests/example_shiny_palette_test.lua
```
Enable it (`example_shiny_palette = true` under `mods` in `options.lua`, or
the F10 manager) and start the game. On first load the transform runs once
and writes `save/mod-derived/example_shiny_palette/sprites/*.png`. Delete
that directory to force a re-run.
## What it demonstrates
| Seam | Where |
|---|---|
| `assets_transforms` | `manifest.json` + `transforms.lua` — the recipe that derives art |
| `content.palettes:register` | `main.lua` — the v2 named-record palette shape |
| `content.palettes:override` | `main.lua` — the vanilla four-triple shape |
| `content.sprites:patch` | `main.lua` — `trueColor` opt-out, nothing else touched |
| `events:on("assets.transformed")` | `main.lua` — the empty-state warning |
## The legal pattern
`transforms.lua` runs inside a restricted context with exactly two
filesystem roots: read `assets/generated/**` (the player's own imported
cache) and write `save/mod-derived/example_shiny_palette/**`. There is no
`require`, no `love`, no `io`, no `os`. The only way data leaves the
sandbox is the `ctx` table.
Because the derived file keeps the *same relative name* as the cache file
it came from, the asset resolver finds it automatically:
```
assets/generated/sprites/red.png <- the player's import
save/mod-derived/example_shiny_palette/sprites/red.png <- this mod's recolor
```
Every consumer of the first path transparently gets the second. No
`sprites:override`, no path string in `main.lua` — and `modkit lint` can
prove the repo carries no cache-derived bytes, because it carries no bytes
at all.
The one thing that *does* need a registry entry is the 4-shade contract.
The renderer normally re-shades an overworld sheet into the current
palette's four grays, which would throw the teal away. `trueColor = true`
opts out:
```lua
mod.content.sprites:patch("SPRITE_RED", { trueColor = true })
```
`patch`, not `override`: `image`, `frames` and `walker` stay whatever the
merged view already holds, so the derived sheet keeps supplying the pixels.
## Empty state
No ROM imported yet? `ctx.exists(rel)` is false, the transform writes
nothing, the mod still loads, and `main.lua` logs a remediation line naming
the directory to delete once you have imported. It never errors.
## Original assets
`assets/accent_sparkle.png` is a 16x16 four-shade sparkle drawn for this
example. It is the only image in the directory and it is original work.
## Credits
- pret/pokered — the overworld sheet layout the transform recolors.
Binary file not shown.

After

Width:  |  Height:  |  Size: 224 B

@@ -0,0 +1,37 @@
-- Gallery #2 (Artist): a recolor that ships no pixels. transforms.lua
-- derives the sheets from the player's own cache; this file only declares
-- the palette records and the one flag the recolor needs.
return function(mod)
-- v2 record shape: a named table of four colors, lightest first
mod.content.palettes:register("EXAMPLE_SHINY", {
colors = {
{ r = 248, g = 248, b = 248 },
{ r = 120, g = 224, b = 216 },
{ r = 32, g = 128, b = 152 },
{ r = 8, g = 32, b = 64 },
},
})
-- vanilla raw shape: four {r,g,b} triples. Overriding a town palette is
-- the smallest visible artist change there is -- no assets involved.
mod.content.palettes:override("PALLET", {
{ 248, 248, 248 }, { 152, 232, 224 }, { 64, 152, 168 }, { 8, 32, 64 },
})
-- trueColor opts SPRITE_RED out of the 4-shade re-shade so the teal the
-- transform baked in survives to the screen. patch, not override: the
-- image path and frame count stay whatever the merged view already has,
-- which is how the derived sheet keeps supplying the pixels.
mod.content.sprites:patch("SPRITE_RED", { trueColor = true })
mod.content.sprites:patch("SPRITE_RED_BIKE", { trueColor = true })
mod.events:on("assets.transformed", function(ev)
if ev.modId ~= mod.id then return end
if ev.count == 0 then
mod.log:warn("no sheets derived -- import your ROM first, then "
.. "delete save/mod-derived/%s to re-run the transform", mod.id)
else
mod.log:info("derived %d recolored sheets", ev.count)
end
end)
end
@@ -0,0 +1,16 @@
{
"id": "example_shiny_palette",
"name": "Shiny Palette Example",
"version": "1.0.0",
"api": 2,
"entry": "main.lua",
"profile": "content",
"category": "GRAPHICS",
"game_version": ">=1.0.0 <2.0.0",
"priority": 100,
"assets_transforms": "transforms.lua",
"dependencies": [],
"optional_dependencies": [],
"conflicts": [],
"description": "Artist gallery entry: a recolored player sheet derived from the player's own cache, plus two palette records."
}
@@ -0,0 +1,26 @@
-- Sharing metadata (25-community-and-ecosystem.md 3.2). Read by tooling
-- and the manager detail pane; never by the loader's merge.
return {
summary = "A teal player recolor derived from your own cache, plus two palette records.",
author = "Pokemon Gen 1 Recompilation Project",
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
tags = { "cosmetic", "graphics", "beginner" },
screenshots = {
{ transform = "shots/pallet_town.lua", caption = "Pallet Town under the recolored palette" },
},
differences = {
changed = {
"SPRITE_RED and SPRITE_RED_BIKE opt into trueColor",
"the PALLET town palette is recolored",
},
added = { "EXAMPLE_SHINY palette record" },
known = {
"the derived sheets only appear after a ROM import; without a cache "
.. "the transform writes nothing and the vanilla sheets keep rendering",
},
},
credits = {
{ who = "pret/pokered", for_ = "the overworld sheet layout the transform recolors" },
},
compat = { engine = ">=1.0.0 <2.0.0", modApi = 2 },
}
@@ -0,0 +1,108 @@
-- Standalone: luajit mods/examples/example_shiny_palette/tests/example_shiny_palette_test.lua
-- Asserts the palette records merge and the transform degrades cleanly
-- when there is no imported cache to read.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = require("src.core.Data")
Data:load()
-- The transform reads assets/generated/** and writes save/mod-derived/**.
-- Hiding the cache is what puts this run on the no-cache path, which is
-- the branch a mod owes the player: write nothing, load anyway. The pixel
-- path needs a real LOVE run (love.image is not in the headless stub).
local MOD = "mods/examples/example_shiny_palette"
local function noCacheFs()
local inner = T.fs.new(".")
local overlay = {}
local hidden = "assets/generated/"
local mount = "mods/example_shiny_palette"
local function map(path)
if path == mount then return MOD end
if path and path:sub(1, #mount + 1) == mount .. "/" then
return MOD .. path:sub(#mount + 1)
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 return { "example_shiny_palette" } end
return inner.getDirectoryItems(map(path))
end
return fs
end
local run = T.sdk.loadMod(MOD, { data = Data, fs = noCacheFs() })
T.eq(#run.errors, 0,
"loads clean with no cache to transform (" .. tostring(run.errors[1]) .. ")")
T.eq(run.mod and run.mod.manifest.assets_transforms, "transforms.lua",
"the manifest declares its transform")
-- ------- palettes
local shiny = Data.palettes.palettes.EXAMPLE_SHINY
T.check(shiny ~= nil, "the v2 named palette record merged")
T.eq(#shiny.colors, 4, "it carries exactly four colors")
T.eq(shiny.colors[1].r, 248, "the lightest shade is first")
local pallet = Data.palettes.palettes.PALLET
T.eq(#pallet, 4, "the town palette override kept the raw four-triple shape")
T.eq(pallet[2][2], 232, "the override took")
-- ------- the trueColor opt-out, applied by patch
for _, id in ipairs({ "SPRITE_RED", "SPRITE_RED_BIKE" }) do
local sprite = Data.sprites[id]
T.eq(sprite.trueColor, true, id .. " opted into trueColor")
T.check(sprite.image ~= nil and sprite.image ~= "",
id .. " kept its sheet path (patch named only the flag)")
T.check(sprite.frames ~= nil, id .. " kept its frame count")
end
-- ------- the recipe itself compiles and is a function(ctx)
local chunk = assert(loadfile(MOD .. "/transforms.lua"))
local transform = chunk()
T.check(type(transform) == "function", "transforms.lua returns a function(ctx)")
-- driven with an empty cache it must write nothing and not raise
local wrote = 0
local ok, err = pcall(transform, {
exists = function() return false end,
readImage = function() error("must not read without exists()", 0) end,
writeImage = function() wrote = wrote + 1 end,
recolor = function(img) return img end,
})
T.check(ok, "the recipe survives an empty cache (" .. tostring(err) .. ")")
T.eq(wrote, 0, "and writes nothing rather than failing the mod")
-- with a cache present it derives one file per declared sheet
wrote = 0
local read = {}
T.check(pcall(transform, {
exists = function() return true end,
readImage = function(rel) read[#read + 1] = rel return { rel } end,
writeImage = function() wrote = wrote + 1 end,
recolor = function(img) return img end,
}), "the recipe runs over a populated cache")
T.eq(wrote, #read, "every sheet it read, it wrote back")
T.check(wrote >= 1, "at least one sheet is derived")
run.release()
T.finish("example_shiny_palette")
@@ -0,0 +1,32 @@
-- Asset transform: the whole point of this example. It runs once at
-- install inside the restricted context -- read the player's own imported
-- cache, write under save/mod-derived/<id>/ -- so the repo ships the
-- recipe and never a ROM-derived pixel.
--
-- The derived path mirrors the cache path, so Assets.resolve picks it up
-- for every consumer of assets/generated/sprites/red.png with no registry
-- entry at all. A recolor is exactly this: read, recolor, write back
-- under the same relative name.
local SHEETS = {
"sprites/red.png",
"sprites/red_bike.png",
}
-- lightest shade first; the recolor buckets every ink pixel into one of
-- these four by luminance, matching the importer's own 4-gray split
local TEAL = {
{ 248, 248, 248 },
{ 120, 224, 216 },
{ 32, 128, 152 },
{ 8, 32, 64 },
}
return function(ctx)
for _, rel in ipairs(SHEETS) do
-- a player who has not imported yet simply gets no derived art; the
-- vanilla sheet keeps rendering and the mod stays loaded
if ctx.exists(rel) then
ctx.writeImage(ctx.recolor(ctx.readImage(rel), TEAL), rel)
end
end
end
@@ -0,0 +1,12 @@
# Changelog
Format: [keep a changelog](https://keepachangelog.com/en/1.1.0/).
Version headings match `manifest.json`'s `version`.
## 1.0.0
### Added
- `EXAMPLE_RAIN` status record and the `example_weather_battles` ruleset.
- A `battle.damage` wrap that scales WATER and FIRE while it rains.
- Rain lifecycle driven by `battle.started` / `battle.turn_started` / `battle.ended`.
+96
View File
@@ -0,0 +1,96 @@
# Weather Battles Example
Adds rain: for the first five turns of every battle, WATER moves deal 1.5x
damage and FIRE moves deal 0.5x. Only under the `WEATHER` ruleset — pick
`gen1_faithful` and the mod is installed and inert.
**Persona: the Mechanic Designer.** A new battle mechanic, no engine fork.
The status and ruleset registries plus one hook carry the whole thing.
## Try it
```sh
python3 tools/modkit.py validate mods/examples/example_weather --base imported
luajit mods/examples/example_weather/tests/example_weather_test.lua
```
Enable it (`example_weather = true` under `mods` in `options.lua`, or the
F10 manager), then **OPTIONS → RULESET → WEATHER**.
## What it demonstrates
| Seam | Where |
|---|---|
| `content.statuses:register` | `main.lua` — declaring a field effect |
| `content.rulesets:register` | `main.lua` — a ruleset the OPTIONS menu lists automatically |
| `content.rulesets:get` | `main.lua` — deriving from vanilla without requiring a private module |
| `hooks:wrap("battle.damage")` | `main.lua` — the one behavior change |
| `events:on("battle.started" / "battle.turn_started" / "battle.ended")` | `main.lua` — the rain counter |
| `mod.save:get/set` | `main.lua` — per-mod state, not a global |
## Parity, at the mod level
The engine's promise is that a mod-free game is unchanged. This example
makes the same promise one level up: a *player* who installs it but does
not select the ruleset gets vanilla battles.
```lua
if not (ctx.ruleset and ctx.ruleset.exampleWeather and raining()) then
return next(ctx)
end
```
`next(ctx)` with the arguments it was handed *is* the vanilla call. No
allocation, no rounding, no reordering — the same number the engine would
have produced. Everything above that line is a gate, and every gate that
fails defers.
## Preserving multiple returns
`Damage.compute` returns two values: the damage number and an info table
carrying the crit flag and the type multiplier. A wrapper that returns only
the first silently throws the second away, and the battle log stops saying
"A critical hit!".
```lua
local damage, info = next(ctx)
if type(damage) ~= "number" then return damage, info end
return math.max(1, math.floor(damage * scale)), info
```
Hook chains preserve every return value, so passing `info` back through is
all it takes.
## Deriving a ruleset from vanilla
A ruleset record is the *whole* rule table — `oneIn256Miss`,
`critUsesBaseSpeed`, `randMin`, `randMax` and the rest. Registering one
that only sets `name` would silently drop every Gen 1 quirk. So this mod
reads the vanilla record out of the merged registry and copies it:
```lua
local base = mod.content.rulesets:get("gen1_faithful")
local weather = {}
for key, value in pairs(base) do weather[key] = value end
weather.name = "WEATHER"
weather.exampleWeather = true
```
`:get` on a registry is the public path to engine content. It needs no
permission, and it composes: if another mod patched `gen1_faithful` first,
this ruleset inherits that patch too.
`exampleWeather` is not in the ruleset schema. Unknown fields on a record
registry are preserved rather than rejected — that is what makes rulesets
extensible, and it is how the damage hook recognizes its own ruleset
without a second lookup.
## Missing dependency, handled
If another mod removed `gen1_faithful`, `:get` returns nil. This example
logs a remediation line and returns — the rest of the game keeps working
and the manager shows one attributed message. No `assert`, no crash.
## Credits
- pret/pokered — the damage formula the hook scales.
+86
View File
@@ -0,0 +1,86 @@
-- Gallery #5 (Mechanic designer): a new battle mechanic with no engine
-- fork. A rain field effect scales WATER and FIRE damage, lives behind an
-- opt-in ruleset, and keeps its own counter in mod.save.
--
-- The parity lesson is the ruleset gate: install this mod, leave the
-- ruleset on gen1_faithful, and every battle is byte-for-byte vanilla
-- because the hook returns next(...) untouched.
local RULESET = "example_weather_battles"
local RAIN_TURNS = 5
local BOOST = { WATER = 1.5 }
local DAMPEN = { FIRE = 0.5 }
return function(mod)
-- ------- the field effect, declared as a status record
mod.content.statuses:register("EXAMPLE_RAIN", {
id = "EXAMPLE_RAIN",
label = "RAIN",
hudLabel = "RAIN",
-- a field effect is never inflicted on a battler; the record is the
-- declaration the HUD and other mods read, the hook is the behavior
canInflict = function() return false end,
})
-- ------- the ruleset that turns it on
-- Read the vanilla record out of the merged registry rather than
-- requiring the module: same table, no engine_internals permission.
local base = mod.content.rulesets:get("gen1_faithful")
if not base then
mod.log:error("gen1_faithful missing from the rulesets registry; "
.. "another mod removed it, so %s cannot be derived", RULESET)
return
end
local weather = {}
for key, value in pairs(base) do weather[key] = value end
weather.name = "WEATHER"
-- the marker the damage hook gates on; unknown fields ride through the
-- schema untouched, which is what makes rulesets extensible
weather.exampleWeather = true
mod.content.rulesets:register(RULESET, weather)
-- ------- rain lifecycle, in this mod's own save namespace
local function raining()
return (mod.save:get("turnsLeft", 0)) > 0
end
mod.events:on("battle.started", function(ev)
local ruleset = ev.battle and ev.battle.ruleset
if ruleset and ruleset.exampleWeather then
mod.save:set("turnsLeft", RAIN_TURNS)
else
mod.save:set("turnsLeft", 0)
end
end)
mod.events:on("battle.turn_started", function()
local left = mod.save:get("turnsLeft", 0)
if left > 0 then mod.save:set("turnsLeft", left - 1) end
end)
mod.events:on("battle.ended", function()
mod.save:set("turnsLeft", 0)
end)
-- ------- the one behavior change
mod.hooks:wrap("battle.damage", function(next, ctx)
-- the two gates, cheapest first: the player has to have picked the
-- ruleset, and it has to still be raining
if not (ctx.ruleset and ctx.ruleset.exampleWeather and raining()) then
return next(ctx)
end
local moveType = ctx.move and ctx.move.type
local scale = BOOST[moveType] or DAMPEN[moveType]
if not scale then return next(ctx) end
-- Damage.compute returns (damage, info); pass the second value through
-- untouched or the crit and type-effectiveness flags vanish
local damage, info = next(ctx)
if type(damage) ~= "number" then return damage, info end
return math.max(1, math.floor(damage * scale)), info
end)
end
@@ -0,0 +1,15 @@
{
"id": "example_weather",
"name": "Weather Battles Example",
"version": "1.0.0",
"api": 2,
"entry": "main.lua",
"profile": "overhaul",
"category": "MECHANIC",
"game_version": ">=1.0.0 <2.0.0",
"priority": 100,
"dependencies": [],
"optional_dependencies": [],
"conflicts": [],
"description": "Mechanic-designer gallery entry: a rain field effect behind an opt-in ruleset, driven by the battle.damage hook."
}
+24
View File
@@ -0,0 +1,24 @@
-- Sharing metadata (25-community-and-ecosystem.md 3.2). Read by tooling
-- and the manager detail pane; never by the loader's merge.
return {
summary = "Opt-in rain: WATER hits harder, FIRE hits softer, for five turns a battle.",
author = "Pokemon Gen 1 Recompilation Project",
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
tags = { "battle", "mechanic", "ruleset", "hardcore" },
differences = {
changed = {
"under the WEATHER ruleset only: WATER move damage x1.5 and FIRE x0.5 while it rains",
},
added = {
"example_weather_battles ruleset, selectable in OPTIONS",
"EXAMPLE_RAIN field-effect status record",
},
known = {
"rain is unconditional at battle start and has no on-screen indicator yet",
},
},
credits = {
{ who = "pret/pokered", for_ = "the damage formula the hook scales" },
},
compat = { engine = ">=1.0.0 <2.0.0", modApi = 2 },
}
@@ -0,0 +1,60 @@
-- Standalone: luajit mods/examples/example_weather/tests/example_weather_test.lua
-- Drives the battle.damage hook through the runtime bus, both with the
-- ruleset on and with it off.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Runtime = require("src.mods.Runtime")
local Data = require("src.core.Data")
Data:load()
local run = T.sdk.loadMod("mods/examples/example_weather", { data = Data })
T.eq(#run.errors, 0, "loads clean (" .. tostring(run.errors[1]) .. ")")
local weather = Data.rulesets.example_weather_battles
T.check(weather ~= nil, "the ruleset merged and is selectable")
T.eq(weather.name, "WEATHER", "the ruleset carries a display name")
T.eq(weather.oneIn256Miss, Data.rulesets.gen1_faithful.oneIn256Miss,
"the derived ruleset keeps every gen1_faithful rule")
T.check(Data.statuses.EXAMPLE_RAIN ~= nil, "the rain status record merged")
-- the vanilla stand-in the hook wraps; 100 damage and an info table
local function vanilla() return 100, { crit = false, typeMult = 10 } end
local function hit(ruleset, moveType)
return Runtime.call("battle.damage", vanilla,
{ ruleset = ruleset, move = { type = moveType } })
end
-- ------- ruleset off: the mod is installed and changes nothing
Runtime.emit("battle.started", { battle = { ruleset = Data.rulesets.gen1_faithful } })
T.eq(hit(Data.rulesets.gen1_faithful, "WATER"), 100,
"gen1_faithful is untouched with the mod installed")
T.eq(hit(Data.rulesets.gen1_faithful, "FIRE"), 100,
"FIRE is untouched under gen1_faithful too")
-- ------- ruleset on: rain scales WATER up and FIRE down
Runtime.emit("battle.started", { battle = { ruleset = weather } })
T.eq(hit(weather, "WATER"), 150, "rain boosts WATER damage")
T.eq(hit(weather, "FIRE"), 50, "rain dampens FIRE damage")
T.eq(hit(weather, "NORMAL"), 100, "every other type is untouched")
local damage, info = hit(weather, "WATER")
T.eq(damage, 150, "the scaled damage is the first return")
T.check(info ~= nil and info.typeMult == 10,
"the info table survives the wrap")
-- ------- the counter runs out
for _ = 1, 5 do Runtime.emit("battle.turn_started", {}) end
T.eq(hit(weather, "WATER"), 100, "rain stops after its turn count")
Runtime.emit("battle.started", { battle = { ruleset = weather } })
T.eq(hit(weather, "WATER"), 150, "a new battle starts the rain again")
Runtime.emit("battle.ended", {})
T.eq(hit(weather, "WATER"), 100, "battle.ended clears the rain")
run.release()
T.finish("example_weather")