mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-22 13:36:14 +02:00
5ec9ce5f88
battle.exp_award hands a mod ctx.applyShare(mon, split, announce) on both
generations, and on Gen 1 the third argument decides whether the mon's
GainedText box is printed -- which is how a mod paying the whole party
prints ONE summary line instead of a box per recipient. Gold accepted
the argument and ignored it, so the same mod source printed one line on
Red and one per party member on Gold.
The Exp Share mod is the live case: it declares games gen1+gen2 and its
description promises "a single shared-exp line instead of one message per
Pokemon", passing true for the fighters and nil for the bench exactly as
the Gen 1 seam asks. On Gold every nil call announced anyway, so a
five-mon party turned every KO into six boxes. There was no mod-side
fix: the emit sits behind no hook, and the argument meaning "quietly" was
discarded.
Gold now reads it, and ONLY when it is actually passed -- by argument
count, not by value. select("#", ...) counts an explicit nil, so
applyShare(mon, split) is distinguishable from applyShare(mon, split,
nil); the first is a Gen 2-era call written against a seam that always
announced and keeps announcing, the second is a deliberate "pay this one
quietly" and is now silent on both games. No mod that exists today
changes behaviour, and a mod that passes the argument gets parity.
Only the { kind = "experience" } event is affected. A silent award is
still a whole award: exp, stat exp, battle.exp_gained, "grew to level",
learned moves and the forget prompt are untouched, in the same order.
giveExperiencePass takes a sixth `silent` parameter that defaults to
announcing, so both of the cart's own passes are unchanged.
RFC: docs/rfcs/0012-gen2-exp-award-announce.md
Docs: docs/mod-api-gen2-compat.md gains the reading and the residual
omitted-argument difference beside the existing payload note.
Tests: tests/gen2_exp_share_test.lua grows the two Route B tests -- the
no-mod parity case and the seam driven through hooks:wrap -- and
its 23 existing checks are unchanged.
328 lines
12 KiB
Lua
328 lines
12 KiB
Lua
-- GiveExperiencePoints' three boosts and the EXP.SHARE double pass
|
|
-- (engine/battle/core.asm:6747, dispatch at :2099-2130).
|
|
--
|
|
-- luajit tests/gen2_exp_share_test.lua
|
|
--
|
|
-- ROM-free. Traded mons (OT id differs from the player's) earn x1.5 with
|
|
-- BoostedExpPointsText; a held LUCKY_EGG is another x1.5, checked by item
|
|
-- id; any live EXP.SHARE holder halves the pool up front, participants
|
|
-- split the first pass and every holder is paid a second pass -- stat exp
|
|
-- included, from the same halved base stats.
|
|
|
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
|
|
|
local S = require("tests.harness").suite("gen2 exp share")
|
|
local check, eq = S.check, S.eq
|
|
|
|
local Battle = require("src.battle.gen2.Battle")
|
|
local Mon = require("src.battle.gen2.Mon")
|
|
|
|
-- ---------------------------------------------------------------- fixtures
|
|
|
|
local TYPES = {
|
|
NORMAL = { id = "NORMAL", index = 0, category = "physical" },
|
|
}
|
|
|
|
local MOVES = {
|
|
TACKLE = { id = "TACKLE", name = "TACKLE", power = 35, type = "NORMAL",
|
|
accuracy = 95, pp = 35, effect = "EFFECT_NORMAL_HIT" },
|
|
}
|
|
|
|
local GROWTH = {
|
|
GROWTH_SLOW = { numerator = 5, denominator = 4, squared = 0, linear = 0,
|
|
constant = 0 },
|
|
}
|
|
|
|
local POKEMON = {
|
|
growthRates = GROWTH,
|
|
MACHOP = {
|
|
id = "MACHOP", index = 66, name = "MACHOP",
|
|
baseStats = { hp = 70, attack = 80, defense = 50, speed = 35,
|
|
specialAttack = 35, specialDefense = 35 },
|
|
types = { "NORMAL", "NORMAL" }, catchRate = 180, baseExp = 75,
|
|
growthRate = "GROWTH_SLOW", genderRatio = 63,
|
|
levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {},
|
|
},
|
|
PIDGEY = {
|
|
id = "PIDGEY", index = 16, name = "PIDGEY",
|
|
baseStats = { hp = 40, attack = 45, defense = 40, speed = 56,
|
|
specialAttack = 35, specialDefense = 35 },
|
|
types = { "NORMAL", "NORMAL" }, catchRate = 255, baseExp = 55,
|
|
growthRate = "GROWTH_SLOW", genderRatio = 127,
|
|
levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {},
|
|
},
|
|
}
|
|
|
|
local ITEMS = {
|
|
LUCKY_EGG = { id = "LUCKY_EGG", name = "LUCKY EGG",
|
|
heldEffect = "HELD_NONE", heldParameter = 0 },
|
|
EXP_SHARE = { id = "EXP_SHARE", name = "EXP.SHARE",
|
|
heldEffect = "HELD_NONE", heldParameter = 0 },
|
|
}
|
|
|
|
local DATA = {
|
|
pokemon = POKEMON,
|
|
moves = MOVES,
|
|
type_chart = { types = TYPES, matchups = {} },
|
|
items = ITEMS,
|
|
}
|
|
|
|
local perfect = { attack = 15, defense = 15, speed = 15, special = 15 }
|
|
perfect.hp = Mon.hpDV(perfect)
|
|
|
|
local PLAYER_ID = 31337
|
|
|
|
-- One KO's worth of exp, replayed against a fresh battle each time.
|
|
-- `partySpec` rows: { otId =, item =, participant =, hp = }.
|
|
local function award(partySpec)
|
|
local party, participants = {}, {}
|
|
for index, spec in ipairs(partySpec) do
|
|
local mon = Mon.new(DATA, "MACHOP", 20, { dvs = perfect })
|
|
mon.otId = spec.otId
|
|
mon.item = spec.item
|
|
if spec.hp then mon.hp = spec.hp end
|
|
party[index] = mon
|
|
if spec.participant then participants[index] = true end
|
|
end
|
|
local wild = Mon.new(DATA, "PIDGEY", 14, { dvs = perfect })
|
|
local battle = Battle.new({ data = DATA, party = party, wild = wild,
|
|
save = { player = { id = PLAYER_ID, badges = {} } },
|
|
random = function(n) return (n or 1) > 1 and 1 or 0 end })
|
|
battle.participants = participants
|
|
local before = {}
|
|
for index, mon in ipairs(party) do before[index] = mon.experience end
|
|
battle:awardExperience(wild)
|
|
local gained = {}
|
|
for index, mon in ipairs(party) do
|
|
gained[index] = mon.experience - before[index]
|
|
end
|
|
return gained, battle:takeEvents(), party
|
|
end
|
|
|
|
-- The wild PIDGEY: baseExp 55, level 14 -> floor(55 * 14 / 7) = 110.
|
|
local BASE = 110
|
|
|
|
-- ---- the native baseline --------------------------------------------------
|
|
do
|
|
local gained, events = award({ { otId = PLAYER_ID, participant = true } })
|
|
eq(gained[1], BASE, "a native solo participant earns baseExp * level / 7")
|
|
local boosted = false
|
|
for _, event in ipairs(events) do
|
|
if event.kind == "experience"
|
|
and event.text:find("boosted", 1, true) then
|
|
boosted = true
|
|
end
|
|
end
|
|
eq(boosted, false, "and its line is the plain ExpPointsText")
|
|
end
|
|
|
|
-- ---- the traded 1.5x ------------------------------------------------------
|
|
do
|
|
local gained, events = award({ { otId = 48926, participant = true } })
|
|
eq(gained[1], math.floor(BASE * 3 / 2),
|
|
"an outsider mon (ROCKY the Onix's OT, say) earns x1.5")
|
|
local line
|
|
for _, event in ipairs(events) do
|
|
if event.kind == "experience" then line = event.text end
|
|
end
|
|
eq(line, "MACHOP gained a boosted 165 EXP. Points!",
|
|
"with BoostedExpPointsText's own wording")
|
|
end
|
|
|
|
-- ---- the Lucky Egg 1.5x and the stack -------------------------------------
|
|
do
|
|
local gained = award({
|
|
{ otId = PLAYER_ID, item = "LUCKY_EGG", participant = true } })
|
|
eq(gained[1], math.floor(BASE * 3 / 2), "a held LUCKY EGG is x1.5")
|
|
|
|
gained = award({ { otId = 48926, item = "LUCKY_EGG", participant = true } })
|
|
eq(gained[1], math.floor(math.floor(BASE * 3 / 2) * 3 / 2),
|
|
"traded and egg stack to x2.25, floored in the cart's order")
|
|
end
|
|
|
|
-- ---- EXP.SHARE ------------------------------------------------------------
|
|
do
|
|
-- Holder on the bench: the pool halves (baseExp 55 -> 27), the fighter
|
|
-- takes the first pass, the holder the second.
|
|
local halved = math.floor(math.floor(55 / 2) * 14 / 7) -- 54
|
|
local gained = award({
|
|
{ otId = PLAYER_ID, participant = true },
|
|
{ otId = PLAYER_ID, item = "EXP_SHARE" },
|
|
})
|
|
eq(gained[1], halved, "the participant's share is halved")
|
|
eq(gained[2], halved, "the benched holder is paid the second pass")
|
|
|
|
-- A holder that fought collects BOTH passes.
|
|
gained = award({
|
|
{ otId = PLAYER_ID, item = "EXP_SHARE", participant = true } })
|
|
eq(gained[1], halved * 2, "a fighting holder is paid twice")
|
|
|
|
-- Two participants split pass one; the one holder still takes a full
|
|
-- second-pass share.
|
|
gained = award({
|
|
{ otId = PLAYER_ID, participant = true },
|
|
{ otId = PLAYER_ID, participant = true },
|
|
{ otId = PLAYER_ID, item = "EXP_SHARE" },
|
|
})
|
|
local split = math.floor(halved / 2)
|
|
eq(gained[1], split, "two participants split the halved pool")
|
|
eq(gained[2], split, "both of them")
|
|
eq(gained[3], halved, "the lone holder's pass divides by one")
|
|
|
|
-- A fainted holder is skipped by the pass loop, and with no live holder
|
|
-- the pool is never halved.
|
|
gained = award({
|
|
{ otId = PLAYER_ID, participant = true },
|
|
{ otId = PLAYER_ID, item = "EXP_SHARE", hp = 0 },
|
|
})
|
|
eq(gained[1], BASE, "a fainted holder does not tax the pool")
|
|
eq(gained[2], 0, "and earns nothing")
|
|
end
|
|
|
|
-- ---- stat exp rides the same halving --------------------------------------
|
|
do
|
|
local _, _, party = award({
|
|
{ otId = PLAYER_ID, participant = true },
|
|
{ otId = PLAYER_ID, item = "EXP_SHARE" },
|
|
})
|
|
-- PIDGEY base attack 45: halved to 22 for each pass.
|
|
eq(party[1].statExp.attack, 22, "the participant's stat exp is halved")
|
|
eq(party[2].statExp.attack, 22, "the holder earns stat exp too")
|
|
-- And the special word takes the loser's Special ATTACK, halved.
|
|
eq(party[2].statExp.special, math.floor(35 / 2),
|
|
"the special stat exp word follows")
|
|
|
|
_, _, party = award({ { otId = PLAYER_ID, participant = true } })
|
|
eq(party[1].statExp.attack, 45, "no holder, no halving")
|
|
end
|
|
|
|
-- ---- Mon.experienceGain's arms stay honest --------------------------------
|
|
do
|
|
local def = POKEMON.PIDGEY
|
|
eq(Mon.experienceGain(def, 14, 1, false), BASE, "bare")
|
|
eq(Mon.experienceGain(def, 14, 1, true), math.floor(BASE * 3 / 2),
|
|
"trainer battles boost x1.5, unchanged")
|
|
eq(Mon.experienceGain(def, 14, 1, false, { traded = true, luckyEgg = true }),
|
|
math.floor(math.floor(BASE * 3 / 2) * 3 / 2), "traded + egg")
|
|
eq(Mon.experienceGain(def, 14, 1, false, { halved = true }),
|
|
math.floor(math.floor(55 / 2) * 14 / 7), "the share tax halves baseExp")
|
|
eq(Mon.experienceGain(def, 14, 1, true,
|
|
{ halved = true, traded = true, luckyEgg = true }),
|
|
math.floor(math.floor(math.floor(
|
|
math.floor(math.floor(55 / 2) * 14 / 7) * 3 / 2) * 3 / 2) * 3 / 2),
|
|
"all four arms compose in the cart's order")
|
|
end
|
|
|
|
-- ---- battle.exp_award: applyShare's third argument ------------------------
|
|
--
|
|
-- Gen 1's applyShare(mon, split, announce) pays the mon and prints its
|
|
-- GainedText only when `announce` is truthy (src/battle/BattleState.lua).
|
|
-- Gold accepted the argument and ignored it, so a mod that pays the whole
|
|
-- party -- the Exp Share mod's own documented behaviour -- could not print one
|
|
-- summary line here; it got a box per recipient.
|
|
--
|
|
-- Two tests, per Route B in CONTRIBUTING-mods.md: vanilla is unchanged with no
|
|
-- subscriber, and the seam is driven through the public hook API.
|
|
|
|
local Events = require("src.mods.Events")
|
|
local Hooks = require("src.mods.Hooks")
|
|
local Runtime = require("src.mods.Runtime")
|
|
|
|
-- Counts the GainedText boxes an award produced.
|
|
local function expLines(events)
|
|
local out = {}
|
|
for _, event in ipairs(events) do
|
|
if event.kind == "experience" then out[#out + 1] = event.text end
|
|
end
|
|
return out
|
|
end
|
|
|
|
local function kindsOf(events)
|
|
local out = {}
|
|
for _, event in ipairs(events) do out[#out + 1] = event.kind end
|
|
return table.concat(out, ",")
|
|
end
|
|
|
|
-- ---- the no-mod test: nothing subscribed, nothing changes -----------------
|
|
do
|
|
local _, events = award({ { otId = PLAYER_ID, participant = true } })
|
|
eq(#expLines(events), 1, "no subscriber: a solo participant still prints one line")
|
|
|
|
local _, twoPass = award({
|
|
{ otId = PLAYER_ID, participant = true },
|
|
{ otId = PLAYER_ID, item = "EXP_SHARE" },
|
|
})
|
|
eq(#expLines(twoPass), 2,
|
|
"no subscriber: the EXP.SHARE double pass still prints both lines")
|
|
end
|
|
|
|
-- ---- the mod-API test: driven through hooks:wrap, not internals -----------
|
|
do
|
|
local savedEvents, savedHooks = Runtime.events, Runtime.hooks
|
|
local hooks = Hooks.new()
|
|
Runtime.install(Events.new(), hooks)
|
|
|
|
-- Each case pays a two-mon party through ctx.applyShare and reports the
|
|
-- GainedText boxes that survived. The party mon in slot 2 never fights, so
|
|
-- it stands in for the bench a party-wide mod pays.
|
|
local function awardVia(payer)
|
|
local unsub = hooks:wrap("battle.exp_award", function(_nextFn, ctx)
|
|
payer(ctx)
|
|
end)
|
|
local gained, events, party = award({
|
|
{ otId = PLAYER_ID, participant = true },
|
|
{ otId = PLAYER_ID },
|
|
})
|
|
unsub()
|
|
return gained, events, party
|
|
end
|
|
|
|
-- omitted: the Gen 2-era call, which has always announced
|
|
local gained, events = awardVia(function(ctx)
|
|
ctx.applyShare(ctx.battle.party[1], 1)
|
|
ctx.applyShare(ctx.battle.party[2], 1)
|
|
end)
|
|
eq(#expLines(events), 2,
|
|
"applyShare(mon, split) still announces -- no existing mod changes")
|
|
check(gained[1] > 0 and gained[2] > 0, "and both mons were paid")
|
|
|
|
-- passed nil: "pay this one quietly", Gen 1's reading
|
|
local quietGained, quietEvents = awardVia(function(ctx)
|
|
ctx.applyShare(ctx.battle.party[1], 1, true)
|
|
ctx.applyShare(ctx.battle.party[2], 1, nil)
|
|
end)
|
|
eq(#expLines(quietEvents), 1,
|
|
"an explicit nil third argument pays the mon silently")
|
|
eq(expLines(quietEvents)[1], "MACHOP gained 110 EXP. Points!",
|
|
"and the announced participant keeps its own line")
|
|
check(quietGained[2] > 0, "the silent mon is still paid the same exp")
|
|
eq(quietGained[1], gained[1], "and the announced mon's exp is untouched")
|
|
eq(quietGained[2], gained[2], "as is the silent one's")
|
|
|
|
-- false reads the same as nil; a truthy string is Gen 1's EXP.ALL variant
|
|
local _, falseEvents = awardVia(function(ctx)
|
|
ctx.applyShare(ctx.battle.party[1], 1, false)
|
|
end)
|
|
eq(#expLines(falseEvents), 0, "false is silent too")
|
|
|
|
local _, expAllEvents = awardVia(function(ctx)
|
|
ctx.applyShare(ctx.battle.party[1], 1, "expAll")
|
|
end)
|
|
eq(#expLines(expAllEvents), 1,
|
|
"any truthy value announces, so Gen 1's \"expAll\" carries over")
|
|
|
|
-- a silent award is still a whole award: only the line goes
|
|
local _, levelEvents, levelParty = awardVia(function(ctx)
|
|
ctx.applyShare(ctx.battle.party[2], 1, nil)
|
|
end)
|
|
eq(#expLines(levelEvents), 0, "the silent pass prints no GainedText")
|
|
check(levelParty[2].statExp.attack > 0,
|
|
"but stat exp is still awarded on a silent pass")
|
|
check(not kindsOf(levelEvents):find("experience", 1, true),
|
|
"and no experience event leaks into the queue")
|
|
|
|
Runtime.install(savedEvents, savedHooks)
|
|
end
|
|
|
|
S.finish()
|