mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-13 00:40:59 +02:00
167 lines
6.5 KiB
Lua
167 lines
6.5 KiB
Lua
-- T2 engine-invariant tier (21-testing-and-ci "test taxonomy"): the
|
|
-- formulas and machinery, parameterized by whatever dataset is loaded.
|
|
--
|
|
-- Nothing here names a Red value. Every assertion is a property that has
|
|
-- to hold for any dataset the engine can boot -- which is what lets it run
|
|
-- in CI against tests/fixture_data with no ROM, and lets a total
|
|
-- conversion keep the whole tier green. The pinned Red numbers live in
|
|
-- tests/content_red/ instead.
|
|
|
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
|
|
|
local T = require("tests.modkit")
|
|
|
|
local data = T.fixtures.fresh()
|
|
local run = T.sdk.loadNone({ data = data })
|
|
T.eq(#run.errors, 0, "the dataset under test loads with no mods and no errors")
|
|
|
|
local Pokemon = require("src.pokemon.Pokemon")
|
|
local Growth = require("src.pokemon.Growth")
|
|
local TypeChart = require("src.battle.TypeChart")
|
|
local Damage = require("src.battle.Damage")
|
|
|
|
TypeChart.load(data)
|
|
|
|
local speciesIds = {}
|
|
for id in pairs(data.pokemon) do speciesIds[#speciesIds + 1] = id end
|
|
table.sort(speciesIds)
|
|
T.check(#speciesIds > 0, "the dataset has at least one species")
|
|
|
|
-- ------- growth curves
|
|
|
|
-- the curve is an inverse pair; a dataset that ships a curve the level
|
|
-- lookup cannot invert breaks every exp gain in the game
|
|
for _, id in ipairs(speciesIds) do
|
|
local def = data.pokemon[id]
|
|
local rate = def.growthRate
|
|
T.eq(Growth.expForLevel(rate, 1), 0, "level 1 costs no exp: " .. tostring(rate))
|
|
|
|
local previous = -1
|
|
local monotonic = true
|
|
for level = 1, data.constants.levelCap do
|
|
local need = Growth.expForLevel(rate, level)
|
|
if need < previous then monotonic = false end
|
|
previous = need
|
|
end
|
|
T.check(monotonic, "exp requirement never decreases with level: " .. tostring(rate))
|
|
|
|
-- levelForExp is the inverse: standing exactly on a threshold reports
|
|
-- that level, one point short reports the one below
|
|
local mid = math.max(2, math.floor(data.constants.levelCap / 2))
|
|
local atMid = Growth.expForLevel(rate, mid)
|
|
T.eq(Growth.levelForExp(rate, atMid), mid,
|
|
"levelForExp inverts expForLevel at a threshold: " .. tostring(rate))
|
|
if atMid > 0 then
|
|
T.check(Growth.levelForExp(rate, atMid - 1) < mid,
|
|
"one exp short of a threshold is the level below: " .. tostring(rate))
|
|
end
|
|
end
|
|
|
|
-- ------- stats
|
|
|
|
for _, id in ipairs(speciesIds) do
|
|
local low = Pokemon.new(data, id, 5)
|
|
local high = Pokemon.new(data, id, math.min(50, data.constants.levelCap))
|
|
|
|
T.check(low.stats.hp > 0, "a fresh mon has positive max HP: " .. id)
|
|
T.eq(low.hp, low.stats.hp, "a fresh mon starts at full HP: " .. id)
|
|
T.check(#low.moves > 0, "a fresh mon knows at least one move: " .. id)
|
|
T.check(#low.moves <= data.constants.moveMax,
|
|
"a fresh mon never exceeds the move cap: " .. id)
|
|
|
|
for _, stat in ipairs({ "hp", "attack", "defense", "speed", "special" }) do
|
|
T.check(high.stats[stat] >= low.stats[stat],
|
|
("%s never decreases with level: %s"):format(stat, id))
|
|
end
|
|
|
|
-- level is clamped to the dataset's cap, not to a literal 100
|
|
local capped = Pokemon.new(data, id, data.constants.levelCap)
|
|
T.eq(capped.level, data.constants.levelCap, "a mon can reach the dataset's level cap: " .. id)
|
|
end
|
|
|
|
-- ------- type chart
|
|
|
|
-- categories come out of the loaded type records, not a hard-coded
|
|
-- physical/special split; this is the de-hard-coded seam from 07
|
|
local typeIds = {}
|
|
for id in pairs(data.type_chart.types or {}) do typeIds[#typeIds + 1] = id end
|
|
table.sort(typeIds)
|
|
T.check(#typeIds > 0, "the dataset supplies type category records")
|
|
|
|
for _, id in ipairs(typeIds) do
|
|
local category = TypeChart.category(id)
|
|
T.check(category == "physical" or category == "special",
|
|
("every type declares a damage category: %s (%s)"):format(id, tostring(category)))
|
|
T.eq(Damage.isSpecial(id), category == "special",
|
|
"Damage.isSpecial agrees with the loaded type record: " .. id)
|
|
end
|
|
|
|
-- every matchup the dataset declares is reachable through effectiveness,
|
|
-- and neutral is the default for an undeclared pair
|
|
for _, row in ipairs(data.type_chart.matchups or {}) do
|
|
local mult = TypeChart.effectiveness(row.attacker, { row.defender })
|
|
T.eq(mult, row.multiplier,
|
|
("declared matchup applies: %s vs %s"):format(row.attacker, row.defender))
|
|
end
|
|
|
|
do
|
|
local declared = {}
|
|
for _, row in ipairs(data.type_chart.matchups or {}) do
|
|
declared[row.attacker .. ">" .. row.defender] = true
|
|
end
|
|
local checkedNeutral = false
|
|
for _, attacker in ipairs(typeIds) do
|
|
for _, defender in ipairs(typeIds) do
|
|
if not declared[attacker .. ">" .. defender] and not checkedNeutral then
|
|
T.eq(TypeChart.effectiveness(attacker, { defender }), 10,
|
|
("an undeclared matchup is neutral: %s vs %s"):format(attacker, defender))
|
|
checkedNeutral = true
|
|
end
|
|
end
|
|
end
|
|
T.check(checkedNeutral, "the dataset has at least one undeclared (neutral) matchup")
|
|
end
|
|
|
|
-- ------- damage
|
|
|
|
do
|
|
local ruleset = { critIgnoresStages = true }
|
|
local attacker = Pokemon.new(data, speciesIds[1], 20)
|
|
local defender = Pokemon.new(data, speciesIds[#speciesIds], 20)
|
|
|
|
local function battler(mon)
|
|
return { mon = mon, curStats = mon.stats, stages = {}, level = mon.level,
|
|
curTypes = data.pokemon[mon.species].types }
|
|
end
|
|
|
|
local moveId = next(data.moves)
|
|
local move = data.moves[moveId]
|
|
|
|
-- max roll is deterministic under a fixed rng, and damage is never zero
|
|
-- for a damaging move nor negative for any input
|
|
local dealt = Damage.compute(ruleset, battler(attacker), battler(defender), move,
|
|
{ rng = T.rng.fixed(255), forceCrit = false })
|
|
T.check(dealt >= 1, "a damaging move always deals at least 1: " .. moveId)
|
|
|
|
local minRoll = Damage.compute(ruleset, battler(attacker), battler(defender), move,
|
|
{ rng = T.rng.fixed(0), forceCrit = false })
|
|
T.check(minRoll <= dealt, "the low damage roll never exceeds the high roll")
|
|
T.check(minRoll >= 1, "even the low roll deals at least 1")
|
|
|
|
-- a crit is never weaker than the same non-crit roll
|
|
local crit = Damage.compute(ruleset, battler(attacker), battler(defender), move,
|
|
{ rng = T.rng.fixed(255), forceCrit = true })
|
|
T.check(crit >= dealt, "a critical hit never deals less than a normal hit")
|
|
|
|
-- a zero-power move deals nothing regardless of the roll
|
|
local status = { id = "T_STATUS", power = 0, type = move.type, category = "status" }
|
|
local none, info = Damage.compute(ruleset, battler(attacker), battler(defender), status,
|
|
{ rng = T.rng.fixed(255) })
|
|
T.eq(none, 0, "a zero-power move deals no damage")
|
|
T.eq(info.typeMult, 10, "a zero-power move reports neutral effectiveness")
|
|
end
|
|
|
|
run.release()
|
|
|
|
T.finish("engine_formulas")
|