Files
gen1recomp/src/core/gen2/BugContest.lua
T
bryanthaboi 1905261c5b Spider Car Unleashed
CLOSES #1483, CLOSES #1610, CLOSES #1615, CLOSES #1646, CLOSES #1649, CLOSES #1651, CLOSES #1653, CLOSES #1656, CLOSES #1683, CLOSES #1685, CLOSES #1686, CLOSES #1687, CLOSES #1688, CLOSES #1689, CLOSES #1690, CLOSES #1693, CLOSES #1694, CLOSES #1695, CLOSES #1696, CLOSES #1702, CLOSES #1704, CLOSES #1705, CLOSES #1706, CLOSES #1707, CLOSES #1708, CLOSES #1710, CLOSES #1711, CLOSES #1712, CLOSES #1713, CLOSES #1716, CLOSES #1717, CLOSES #1718, CLOSES #1719, CLOSES #1720, CLOSES #1721, CLOSES #1725, CLOSES #1732, CLOSES #1745, CLOSES #1748, CLOSES #1749, CLOSES #1751, CLOSES #1754
2026-08-24 07:52:05 -04:00

837 lines
36 KiB
Lua
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
-- The Bug Catching Contest (engine/events/bug_contest/).
--
-- A MODE, not a menu. For its duration the party is masked down to the lead
-- mon, the pack is replaced by twenty PARK BALLs, a twenty minute clock runs
-- off the RTC, wild encounters come from the contest's OWN table rather than
-- from National Park's grass, and only ONE caught mon is kept at a time. When
-- it is over the judge scores that mon against five rolled contestants, and
-- the placing decides whether the player walks out with a SUN STONE.
--
-- The pieces, and the file each is transcribed from:
--
-- ContestScore bug_contest/judging.asm the player's score
-- ComputeAIContestantScores bug_contest/judging.asm the five AI rolls
-- DetermineContestWinners bug_contest/judging.asm the podium
-- BugContest_GetPlayersResult bug_contest/judging.asm the placing, 0..3
-- BugContestantPointers data/events/bug_contest_winners.asm
-- BugCatchingContestantEventFlagTable data/events/bug_contest_flags.asm
-- ContestMons data/wild/bug_contest_mons.asm
-- ChooseWildEncounter_BugContest engine/overworld/events.asm
-- TryWildEncounter_BugContest engine/overworld/events.asm
-- ContestDropOffMons / ContestReturnMons bug_contest/contest_2.asm
-- BugContest_SetCaughtContestMon bug_contest/caught_mon.asm
-- GiveParkBalls bug_contest/contest.asm
-- StartBugContestTimer / CheckBugContestTimer engine/overworld/time.asm
--
-- NOTHING here draws. src/ui/gen2/ContestMenu.lua is the STOCK-versus-THIS
-- comparison screen, and it asks this module every question it needs answered,
-- so the rules can be tested with no love at all.
--
-- The DRIVER is the extracted script bytecode: Route35NationalParkGate's
-- officer calls ContestDropOffMons, GiveParkBalls and
-- SelectRandomBugContestContestants, and BugContestResultsScript calls
-- BugContestJudging, ContestReturnMons and CheckPartyFullAfterContest. Each of
-- those specials is one call into this module -- see the module map at the
-- bottom of the file.
--
-- This file also carries the RTC delta helpers from engine/overworld/time.asm.
-- They live here because the contest timer is the port's only SECOND
-- resolution consumer of them; src/core/gen2/Apricorns.lua reuses the same two
-- functions at day resolution for Kurt's wait. Both belong in a
-- src/core/gen2/Time.lua the day one exists.
local Runtime = require("src.mods.Runtime")
local BugContest = {}
-- ---------------------------------------------------------------- constants
--
-- constants/script_constants.asm.
BugContest.BALLS = 20 -- BUG_CONTEST_BALLS
BugContest.MINUTES = 20 -- BUG_CONTEST_MINUTES
BugContest.SECONDS = 0 -- BUG_CONTEST_SECONDS
BugContest.PLAYER = 1 -- BUG_CONTEST_PLAYER
BugContest.NUM_CONTESTANTS = 10 -- NUM_BUG_CONTESTANTS, not counting the player
BugContest.CONTESTANT_SIZE = 4 -- BUG_CONTESTANT_SIZE: id, mon, score hi, lo
-- SelectRandomBugContestContestants sets five of the ten flags.
BugContest.CONTESTANTS_PICKED = 5
-- The three-way answer CheckPartyFullAfterContest leaves in wScriptVar, which
-- BugContestResults_DidNotLeaveMons branches on.
BugContest.CAUGHT_MON = 0
BugContest.BOXED_MON = 1
BugContest.NO_CATCH = 2
-- constants/engine_flags.asm, by index: the two ENGINE_* ids the gate scripts
-- set and clear around a contest. ENGINE_BUG_CONTEST_TIMER is what makes
-- CheckTimeEvents poll the clock instead of the daily reset;
-- ENGINE_DAILY_BUG_CONTEST is a wDailyFlags1 bit, so it clears itself overnight
-- and that is what makes the contest a once-a-day thing.
BugContest.ENGINE_BUG_CONTEST_TIMER = 16
BugContest.ENGINE_DAILY_BUG_CONTEST = 80
-- Route35OfficerScriptContest turns you away on SUNDAY, MONDAY, WEDNESDAY and
-- FRIDAY, so the contest runs Tuesday, Thursday and Saturday. Weekday numbers
-- are GetWeekday's, which is wCurDay mod 7 with SUNDAY == 0
-- (constants/ram_constants.asm).
BugContest.SUNDAY, BugContest.MONDAY, BugContest.TUESDAY = 0, 1, 2
BugContest.WEDNESDAY, BugContest.THURSDAY = 3, 4
BugContest.FRIDAY, BugContest.SATURDAY = 5, 6
BugContest.CONTEST_DAYS = { [2] = true, [4] = true, [6] = true }
-- BugContestResults_FirstPlace / _SecondPlace / _ThirdPlace, and the
-- consolation BERRY every entrant who placed nowhere gets.
BugContest.PRIZES = { "SUN_STONE", "EVERSTONE", "GOLD_BERRY" }
BugContest.CONSOLATION_PRIZE = "BERRY"
-- The one ball that works inside the park. BattleMenu_Pack's `.contest`
-- branch does not open the pack at all: it loads PARK_BALL into wCurItem and
-- runs the item effect, so there is no way to throw anything else.
BugContest.BALL = "PARK_BALL"
-- ------------------------------------------------------------- the RTC
--
-- wCurDay counts 0..139 (_CalcDaysSince wraps by adding 20 * 7), hours by
-- MAX_HOUR, minutes and seconds by 60.
BugContest.DAY_WRAP = 20 * 7
BugContest.HOUR_WRAP = 24 -- MAX_HOUR, constants/misc_constants.asm
-- The host clock in the cart's shape. `stamp` is optional and is an os.time()
-- value, so a test can pin the clock without touching the real one.
function BugContest.now(stamp)
local t = os.date("*t", stamp)
-- Days since the epoch in LOCAL time, folded into wCurDay's range. The
-- absolute value is meaningless (the cart's own wCurDay is only ever read
-- through a difference or a mod 7), so any monotone daily counter serves,
-- and folding it here is what makes a save survive a year of real time.
local midday = os.time({ year = t.year, month = t.month, day = t.day,
hour = 12, min = 0, sec = 0 })
local day = math.floor(midday / 86400) % BugContest.DAY_WRAP
return { day = day, hour = t.hour, minute = t.min, second = t.sec }
end
-- GetWeekday: wCurDay mod 7, SUNDAY == 0.
function BugContest.weekday(now)
return ((now or BugContest.now()).day or 0) % 7
end
function BugContest.isContestDay(now)
return BugContest.CONTEST_DAYS[BugContest.weekday(now)] == true
end
local function borrowed(value, wrap)
-- One `sub`/`sbc` step: the wrapped difference, plus the borrow that has to
-- carry into the next unit up.
if value < 0 then return value + wrap, 1 end
return value, 0
end
-- CalcSecsMinsHoursDaysSince. Two things about this routine matter and both
-- are easy to lose in a port:
--
-- * it ADVANCES the stored stamp to `now` in place (`ld [hl], c ; current
-- seconds`, and again for minutes, hours and days). So the deltas are
-- "since the LAST poll", not "since the timer started", and two polls a
-- minute apart subtract one minute each rather than one and then two.
-- * every unit wraps rather than going negative. A clock moved BACKWARDS
-- therefore reads as a very large jump forward, which is exactly what
-- makes a rewound clock end the contest and roll the daily flags.
--
-- `depth` picks how far down the struct the cart went: CalcDaysSince stops at
-- days, CalcMinsHoursDaysSince at minutes, CalcSecsMinsHoursDaysSince runs the
-- lot. Unread units are left alone in the stamp, the same way the shorter
-- entry points never touch them.
function BugContest.elapsedSince(stamp, now, depth)
now = now or BugContest.now()
depth = depth or "second"
local out = { days = 0, hours = 0, minutes = 0, seconds = 0 }
local carry = 0
if depth == "second" then
local value
value, carry = borrowed((now.second or 0) - (stamp.second or 0), 60)
stamp.second = now.second or 0
out.seconds = value
end
if depth == "second" or depth == "minute" then
local value
value, carry = borrowed((now.minute or 0) - (stamp.minute or 0) - carry, 60)
stamp.minute = now.minute or 0
out.minutes = value
end
if depth ~= "day" then
local value
value, carry = borrowed((now.hour or 0) - (stamp.hour or 0) - carry,
BugContest.HOUR_WRAP)
stamp.hour = now.hour or 0
out.hours = value
end
local days
days, carry = borrowed((now.day or 0) - (stamp.day or 0) - carry,
BugContest.DAY_WRAP)
stamp.day = now.day or 0
out.days = days
return out
end
-- ------------------------------------------------------------- the state
--
-- Everything the contest owns lives under save.bugContest, spelled after the
-- WRAM it stands in for:
--
-- active ENGINE_BUG_CONTEST_TIMER (wStatusFlags2 bit 0)
-- balls wParkBallsRemaining
-- minutes wBugContestMinsRemaining
-- seconds wBugContestSecsRemaining
-- startTime wBugContestStartTime, { day, hour, minute, second }
-- caught wContestMon, one party-shaped mon or nil
-- stash the party tail ContestDropOffMons masks off
-- contestants { [1..10] = true } for a SET flag, i.e. NOT in this contest
-- results wBugContestResults, { first, second, third }
-- place what BugContestJudging left in wScriptVar, 0..3
function BugContest.state(save)
if type(save) ~= "table" then return nil end
save.bugContest = save.bugContest or {}
return save.bugContest
end
function BugContest.isActive(save)
local state = BugContest.state(save)
return (state and state.active) == true
end
-- ------------------------------------------------------- the encounter table
--
-- data/wild/bug_contest_mons.asm, transcribed. `chance` is the row's slice of
-- 100, NOT a cumulative total: ChooseWildEncounter_BugContest subtracts each
-- row from the roll until it borrows. The ten real rows already add to 100, so
-- the trailing VENOMOTH row -- whose chance byte is -1, i.e. "always" -- is
-- unreachable. It is kept because dropping it would be editing the cart's
-- table, and because it is the row a modified chance list would fall through
-- to.
BugContest.MONS = {
{ chance = 20, species = "CATERPIE", min = 7, max = 18 },
{ chance = 20, species = "WEEDLE", min = 7, max = 18 },
{ chance = 10, species = "METAPOD", min = 9, max = 18 },
{ chance = 10, species = "KAKUNA", min = 9, max = 18 },
{ chance = 5, species = "BUTTERFREE", min = 12, max = 15 },
{ chance = 5, species = "BEEDRILL", min = 12, max = 15 },
{ chance = 10, species = "VENONAT", min = 10, max = 16 },
{ chance = 10, species = "PARAS", min = 10, max = 17 },
{ chance = 5, species = "SCYTHER", min = 13, max = 14 },
{ chance = 5, species = "PINSIR", min = 13, max = 14 },
{ chance = 255, species = "VENOMOTH", min = 30, max = 40 },
}
-- The extractor writes the same eleven rows as encounters.bugContest, which is
-- its own table in the cache and not a grass row: the park's grass entry is
-- what the map rolls off OUTSIDE the twenty minutes. This reader prefers the
-- cache, so the transcription above is the pinned fallback rather than a
-- second source of truth that can drift.
function BugContest.contestMons(data)
local extracted = data and data.encounters and data.encounters.bugContest
if type(extracted) == "table" and #extracted > 0 then return extracted end
return BugContest.MONS
end
-- `call Random` gives one byte. Kept on the module, not taken from the VM, so
-- every roll below can be pinned by a test; the convention is the ASM's, a
-- function of no arguments returning 0..255.
function BugContest.random()
if love and love.math and love.math.random then
return love.math.random(0, 255)
end
return math.random(0, 255)
end
local function byte(random)
return (random or BugContest.random)()
end
-- TryWildEncounter_BugContest: 40 percent in super-tall grass, 20 percent in
-- ordinary grass, and `percent` is `* $ff / 100` so those are 102 and 51 out of
-- 256, not 40 and 20 out of 100.
BugContest.ENCOUNTER_RATE_SUPER_TALL = math.floor(40 * 0xff / 100)
BugContest.ENCOUNTER_RATE_GRASS = math.floor(20 * 0xff / 100)
function BugContest.encounterRate(superTallGrass)
if superTallGrass then return BugContest.ENCOUNTER_RATE_SUPER_TALL end
return BugContest.ENCOUNTER_RATE_GRASS
end
function BugContest.triggers(superTallGrass, random)
return byte(random) < BugContest.encounterRate(superTallGrass)
end
-- ChooseWildEncounter_BugContest. The roll is rejected until it is under
-- 200 and then halved, which is a uniform 0..99 with no modulo bias, and the
-- level is `min + Random % (max - min + 1)` unless min and max are equal.
function BugContest.chooseWild(data, random)
local rows = BugContest.contestMons(data)
local roll
repeat
roll = byte(random)
until roll < 200
roll = math.floor(roll / 2)
local row
for index = 1, #rows do
row = rows[index]
local chance = row.chance or 0
if roll < chance then break end
roll = roll - chance
end
if not row then return nil end
local level = row.min or 1
local span = (row.max or level) - level
if span ~= 0 then
-- SimpleDivide's remainder over (max - min + 1), added to min.
level = level + (byte(random) % (span + 1))
end
return { species = row.species, level = level }
end
-- ------------------------------------------------------------ the contestants
--
-- data/events/bug_contest_winners.asm. Each row is `db class, id` followed by
-- three `dbw mon, score` rows, best first.
--
-- INDEXING, which is where this table bites. BugContestantPointers has
-- NUM_BUG_CONTESTANTS + 1 entries: slot 0 is a duplicate of Bug Catcher Don
-- that the comment marks "this reverts back to the player" and nothing ever
-- reads. ComputeAIContestantScores walks e = 0..9 and looks up slot e + 1,
-- and LoadContestantName takes a winner ID and looks up slot ID - 1. So the
-- ten real contestants are slots 1..10 and their winner IDs are 2..11, with
-- ID 1 reserved for the player (BUG_CONTEST_PLAYER). A 1-based Lua list lines
-- up with the slot numbers exactly, which is why this one is NOT zero based.
BugContest.CONTESTANTS = {
{ class = "BUG_CATCHER", trainer = 1, name = "DON",
mons = { { species = "KAKUNA", score = 300 },
{ species = "METAPOD", score = 285 },
{ species = "CATERPIE", score = 226 } } },
{ class = "BUG_CATCHER", trainer = 3, name = "ED",
mons = { { species = "BUTTERFREE", score = 286 },
{ species = "BUTTERFREE", score = 251 },
{ species = "CATERPIE", score = 237 } } },
{ class = "COOLTRAINERM", trainer = 1, name = "NICK",
mons = { { species = "SCYTHER", score = 357 },
{ species = "BUTTERFREE", score = 349 },
{ species = "PINSIR", score = 368 } } },
{ class = "POKEFANM", trainer = 1, name = "WILLIAM",
mons = { { species = "PINSIR", score = 332 },
{ species = "BUTTERFREE", score = 324 },
{ species = "VENONAT", score = 321 } } },
{ class = "BUG_CATCHER", trainer = 5, name = "BENNY",
mons = { { species = "BUTTERFREE", score = 318 },
{ species = "WEEDLE", score = 295 },
{ species = "CATERPIE", score = 285 } } },
{ class = "CAMPER", trainer = 5, name = "BARRY",
mons = { { species = "PINSIR", score = 366 },
{ species = "VENONAT", score = 329 },
{ species = "KAKUNA", score = 314 } } },
{ class = "PICNICKER", trainer = 5, name = "CINDY",
mons = { { species = "BUTTERFREE", score = 341 },
{ species = "METAPOD", score = 301 },
{ species = "CATERPIE", score = 264 } } },
{ class = "BUG_CATCHER", trainer = 7, name = "JOSH",
mons = { { species = "SCYTHER", score = 326 },
{ species = "BUTTERFREE", score = 292 },
{ species = "METAPOD", score = 282 } } },
{ class = "YOUNGSTER", trainer = 5, name = "SAMUEL",
mons = { { species = "WEEDLE", score = 270 },
{ species = "PINSIR", score = 282 },
{ species = "CATERPIE", score = 251 } } },
{ class = "SCHOOLBOY", trainer = 2, name = "KIPP",
mons = { { species = "VENONAT", score = 267 },
{ species = "PARAS", score = 254 },
{ species = "KAKUNA", score = 259 } } },
}
-- The winner ID a slot answers to, and back. BUG_CONTEST_PLAYER is 1.
function BugContest.contestantId(slot) return slot + 1 end
function BugContest.contestantSlot(id) return id - 1 end
-- LoadContestantName: the trainer CLASS name, its trailing terminator replaced
-- by a space, then the trainer's own name appended -- "BUG CATCHER DON". ID 1
-- is the player, whose name is copied straight out of wPlayerName.
--
-- The class and trainer names come from the cache (trainers.lua keys classes
-- by their constant and lists each class's members in order), and fall back to
-- the transcribed row when a class is missing, so a headless test needs no
-- cache at all.
function BugContest.contestantName(data, id, playerName)
if id == BugContest.PLAYER then return playerName or "<PLAYER>" end
local row = BugContest.CONTESTANTS[BugContest.contestantSlot(id)]
if not row then return "" end
local classes = data and data.trainers and data.trainers.classes
local class = classes and classes[row.class]
local className = (class and class.name) or row.class
local member = class and class.trainers and class.trainers[row.trainer]
local trainerName = (member and member.name) or row.name
return className .. " " .. trainerName
end
-- ---------------------------------------------------------------- the score
--
-- ContestScore. Everything it tallies is an EIGHT BIT read out of the party
-- struct, and the struct is big-endian, so `[wContestMonMaxHP + 1]` is the LOW
-- byte of max HP and not the high one. The accumulator itself is 16 bit:
-- .AddContestStat adds into hMultiplicand and carries into the byte below it,
-- which the union in ram/hram.asm makes hProduct, and BugContest_JudgeContestants
-- reads that pair back as the score.
--
-- max HP low byte, four times
-- Attack, Defense, Speed, Special Attack, Special Defense, low bytes
-- a DV term (below)
-- current HP low byte, shifted right three times
-- 1 if the mon is holding an item
--
-- The DV term is the odd one. It reads BIT 1 of four of the DVs -- never bit
-- 0, never the whole nibble -- and weights them 16 for Defense, 8 for Attack,
-- 4 for Special and 1 for Speed:
--
-- ld a, [wContestMonDVs + 0] / and %0010 / add a / add a ; c = 8 * def_bit1
-- swap b / and %0010 / add a / add c ; d = 4 * atk_bit1 + c
-- ld a, [wContestMonDVs + 1] / and %0010 ; c = 2 * spc_bit1
-- swap b / and %0010 / srl a / add c / add c / add d / add d
--
-- which lands on spd_bit1 + 4 * spc_bit1 + 8 * atk_bit1 + 16 * def_bit1.
local function low(value) return math.floor(value or 0) % 256 end
local function bit1(value) return math.floor((value or 0) / 2) % 2 end
function BugContest.score(mon)
-- `ld a, [wContestMonSpecies] / and a / jr z, .done`: an empty slot scores 0.
if not (mon and mon.species) then return 0 end
local stats = mon.stats or {}
local maxHp = mon.maxHp or stats.hp
local total = low(maxHp) * 4
total = total + low(stats.attack) + low(stats.defense) + low(stats.speed)
+ low(stats.specialAttack) + low(stats.specialDefense)
local dvs = mon.dvs or {}
total = total + 16 * bit1(dvs.defense) + 8 * bit1(dvs.attack)
+ 4 * bit1(dvs.special) + bit1(dvs.speed)
total = total + math.floor(low(mon.hp) / 8)
-- `ld a, [wContestMonItem] / and a / jr z, .done`, the last term either way.
if mon.item then total = total + 1 end
return total % 65536
end
-- ---------------------------------------------------------------- the podium
--
-- DetermineContestWinners compares the temp entry against first, then second,
-- then third, with CompareBytes over the two score bytes. CompareBytes only
-- sets carry when the temp score is STRICTLY LESS, so an equal score DISPLACES
-- the sitting entry and pushes it down a place. That is not a rounding detail:
-- it is why the player, who is scored last, wins a tie.
local function beats(entry, incumbent)
return (entry.score or 0) >= ((incumbent and incumbent.score) or 0)
end
local function copyEntry(entry)
if not entry then return nil end
return { id = entry.id, species = entry.species, score = entry.score }
end
function BugContest.placeEntry(results, entry)
if beats(entry, results.first) then
results.third = copyEntry(results.second)
results.second = copyEntry(results.first)
results.first = copyEntry(entry)
elseif beats(entry, results.second) then
results.third = copyEntry(results.second)
results.second = copyEntry(entry)
elseif beats(entry, results.third) then
results.third = copyEntry(entry)
end
return results
end
-- ComputeAIContestantScores' inner roll, for ONE contestant. Two `call Random`
-- bytes: the first picks which of the three listed mons this contestant turned
-- up with (masked to 2 bits and REROLLED on 3, so 0, 1 and 2 are equally
-- likely), the second is a 0..7 bump added to that mon's listed score.
function BugContest.rollContestant(slot, random)
local row = BugContest.CONTESTANTS[slot]
if not row then return nil end
local pick
repeat
pick = byte(random) % 4
until pick ~= 3
local mon = row.mons[pick + 1]
return {
id = BugContest.contestantId(slot),
species = mon.species,
score = mon.score + (byte(random) % 8),
}
end
-- BugContest_JudgeContestants. ClearContestResults wipes the podium,
-- ComputeAIContestantScores walks the ten contestants and skips any whose flag
-- is SET (a set flag is what kept that trainer OFF the contest map, so the five
-- SelectRandomBugContestContestants picked are the five who do NOT score), and
-- only THEN is the player's own entry placed.
--
-- `state.contestants` is that flag table: state.contestants[slot] == true means
-- the flag is set, i.e. absent. A state with no table at all scores all ten,
-- which is what a save from before the picking ran looks like.
function BugContest.judge(state, playerMon, playerScore, random)
local absent = (state and state.contestants) or {}
local results = { first = nil, second = nil, third = nil }
for slot = 1, BugContest.NUM_CONTESTANTS do
if not absent[slot] then
local entry = BugContest.rollContestant(slot, random)
if entry then BugContest.placeEntry(results, entry) end
end
end
BugContest.placeEntry(results, {
id = BugContest.PLAYER,
species = playerMon and playerMon.species or nil,
score = playerScore or BugContest.score(playerMon),
})
return results
end
-- BugContest_GetPlayersResult: walk the podium from THIRD upwards with b
-- counting 3, 2, 1 and stop at the player. Falling off the end leaves b at 0,
-- and that 0 is what sends the script down the consolation-BERRY branch.
function BugContest.playerPlace(results)
local order = { results.third, results.second, results.first }
for index = 1, 3 do
local entry = order[index]
if entry and entry.id == BugContest.PLAYER then return 4 - index end
end
return 0
end
-- _BugContestJudging end to end: score, judge, and leave the placing where
-- BugContestJudging's `ld a, b / ld [wScriptVar], a` leaves it. The results
-- are kept on the state so the gate's three text pages can name each winner.
function BugContest.runJudging(save, random)
local state = BugContest.state(save)
if not state then return 0 end
local mon = state.caught
local score = BugContest.score(mon)
local results = BugContest.judge(state, mon, score, random)
state.results = results
state.playerScore = score
state.place = BugContest.playerPlace(results)
-- bug_contest.scored, a Gen 2 invention: Gen 1 has no contest, so there is
-- no name to share. Raised once per contest, after the podium is settled
-- and before the gate prints it, which is the only moment both the player's
-- number and the three winners exist together.
--
-- mon the mon the player brought out of the park, or nil for none
-- score BugContest.score of it, the number DetermineContestWinners used
-- place 1, 2, 3, or 0 for the consolation BERRY branch
-- results the podium, { first, second, third }, each
-- { id, species, score } with id BugContest.PLAYER for the player
if Runtime.wants("bug_contest.scored") then
Runtime.emit("bug_contest.scored", {
mon = mon, score = score, place = state.place, results = results,
})
end
return state.place
end
-- The prize for a placing, or the consolation BERRY for 0.
function BugContest.prizeFor(place)
return BugContest.PRIZES[place] or BugContest.CONSOLATION_PRIZE
end
-- ---------------------------------------------------- SelectRandomBugContestContestants
--
-- data/events/bug_contest_flags.asm, transcribed: EVENT_BUG_CATCHING_CONTESTANT_1A
-- through _10A, by NUMBER, because wEventFlags is keyed by number. These are
-- the ten NationalParkBugContest object_event flags, in slot order, and they
-- are the *A set -- the *B set is the same ten people standing in
-- Route36NationalParkGate before the contest starts and nothing here touches
-- it.
--
-- The extractor now writes the same ten as events.bugContestFlags, so this is
-- the pinned fallback for a cache that predates it rather than a second source
-- of truth; tests/gen2_contest_test.lua compares the two row by row.
BugContest.FLAGS = {
1814, 1815, 1816, 1817, 1818, 1819, 1820, 1821, 1822, 1823,
}
-- data/generated/events.lua's copy when it is there, the transcription above
-- when it is not. `tables` is the eventTables the VM carries, not the whole
-- cache: the flag table is a side table a command NAMES, the same as the
-- trades and the floor labels.
function BugContest.contestantFlags(tables)
local extracted = tables and tables.bugContestFlags
if type(extracted) == "table" and #extracted == BugContest.NUM_CONTESTANTS then
return extracted
end
return BugContest.FLAGS
end
-- Five flags chosen at uniform random out of ten, rejecting a duplicate rather
-- than reshuffling: `call Random / cp $ff / 10 * 10 / jr nc` throws away any
-- byte 250 or over, divides by 25 to land on 0..9, and rerolls a slot whose
-- flag is already set. A SET flag hides that contestant's sprite, so these
-- five are the ones NOT in the park -- and, downstream, the five
-- ComputeAIContestantScores skips.
function BugContest.pickContestants(save, random)
local state = BugContest.state(save)
if not state then return nil end
local n = BugContest.NUM_CONTESTANTS
local limit = math.floor(0xff / n) * n
local step = math.floor(0xff / n)
local chosen = {}
local picked = 0
while picked < BugContest.CONTESTANTS_PICKED do
local roll
repeat
roll = byte(random)
until roll < limit
-- SimpleDivide's quotient, i.e. 0..9, then 1-based for the Lua table.
local slot = math.floor(roll / step) + 1
if not chosen[slot] then
chosen[slot] = true
picked = picked + 1
end
end
state.contestants = chosen
return chosen
end
-- The half of SelectRandomBugContestContestants that touches wEventFlags, and
-- the reason it is a separate call: `.loop1` runs EventFlagAction RESET_FLAG
-- over ALL TEN before a single pick is made. That reset is not tidiness --
-- without it last contest's five are still hidden, and the two contests
-- together would empty the park. So every slot is written here, true for a
-- picked one and false for the rest, rather than only the five being set.
--
-- `events` is src/world/gen2/Events.lua (wEventFlags); a nil one is a headless
-- caller with no flag store, which changes nothing else about the pick.
function BugContest.applyContestantFlags(events, chosen, tables)
if not events then return nil end
local flags = BugContest.contestantFlags(tables)
chosen = chosen or {}
for slot = 1, BugContest.NUM_CONTESTANTS do
local flag = flags[slot]
if flag then events:set(flag, chosen[slot] == true) end
end
return flags
end
-- ------------------------------------------------------- entering and leaving
--
-- ContestDropOffMons. The party is not stored anywhere on the cart, it is
-- MASKED: wPartyCount is written down to 1 and the second species byte is
-- replaced with the -1 terminator, so for the duration only the lead mon
-- exists. A Lua list cannot be truncated in place and restored, so the tail
-- moves to state.stash -- which MUST live on the save, because the cart's
-- masked-off party is still sitting in SRAM and survives a save and reload
-- mid-contest.
--
-- Returns FALSE (0) on success and TRUE (1) when the lead mon has fainted,
-- which is the wScriptVar the officer's `iftrue` branches on.
function BugContest.dropOffMons(save)
local state = BugContest.state(save)
local party = save and save.party
if not (state and party) then return 1 end
local lead = party[1]
if not lead or (lead.hp or 0) <= 0 then return 1 end
local stash = {}
for index = 2, #party do stash[#stash + 1] = party[index] end
state.stash = stash
for index = #party, 2, -1 do party[index] = nil end
return 0
end
-- ContestReturnMons. The species of the second mon goes back and the party
-- count is RECOMPUTED by walking to the terminator, which is why a mon caught
-- during the contest -- already sitting in slot 2 by the time this runs -- is
-- kept and the tail lands BEHIND it rather than over it.
function BugContest.returnMons(save)
local state = BugContest.state(save)
local party = save and save.party
if not (state and party) then return end
for _, mon in ipairs(state.stash or {}) do
party[#party + 1] = mon
end
state.stash = nil
end
-- GiveParkBalls, plus the StartBugContestTimer it farcalls. wContestMon is
-- cleared first, so entering a second contest cannot inherit the last one's
-- catch.
function BugContest.start(save, now)
local state = BugContest.state(save)
if not state then return nil end
state.active = true
state.caught = nil
state.balls = BugContest.BALLS
state.minutes = BugContest.MINUTES
state.seconds = BugContest.SECONDS
state.results = nil
state.place = nil
state.playerScore = nil
local stamp = now or BugContest.now()
state.startTime = { day = stamp.day, hour = stamp.hour,
minute = stamp.minute, second = stamp.second }
return state
end
-- BugContestResultsScript's `clearflag ENGINE_BUG_CONTEST_TIMER`, and the
-- clean-up around it. The caught mon is deliberately LEFT on the state:
-- CheckPartyFullAfterContest is what consumes it, and it runs after this.
function BugContest.stop(save)
local state = BugContest.state(save)
if not state then return end
state.active = false
state.minutes = 0
state.seconds = 0
state.startTime = nil
end
-- CheckBugContestTimer. Called from CheckTimeEvents while
-- ENGINE_BUG_CONTEST_TIMER is set, and returning true is what makes the
-- overworld call BugCatchingContestOverScript.
--
-- Any whole day or hour of elapsed time ends it outright. Otherwise the
-- seconds are subtracted with a wrap, and the BORROW that wrap produces is
-- carried into the minutes by the `sbc` -- the `add 60` that rewraps the
-- seconds always overflows a byte (the largest possible shortfall is 59), so
-- the carry it leaves behind is the borrow, not an accident. A minute count
-- that goes negative is the timeout.
function BugContest.tickTimer(save, now)
local state = BugContest.state(save)
if not (state and state.active and state.startTime) then return false end
local since = BugContest.elapsedSince(state.startTime, now, "second")
if since.days ~= 0 or since.hours ~= 0 then
state.minutes, state.seconds = 0, 0
return true
end
local seconds, borrow = borrowed((state.seconds or 0) - since.seconds, 60)
state.seconds = seconds
local minutes = (state.minutes or 0) - since.minutes - borrow
if minutes < 0 then
state.minutes, state.seconds = 0, 0
return true
end
state.minutes = minutes
return false
end
function BugContest.timeLeft(save)
local state = BugContest.state(save)
if not state then return 0, 0 end
return state.minutes or 0, state.seconds or 0
end
-- ------------------------------------------------------------- the park balls
--
-- ContestBattleMenu's third row is "PARKBALL×" followed by
-- wParkBallsRemaining, and PokeBallEffect's `.used_park_ball` does `dec [hl]`
-- instead of tossing an item out of the pack -- so a Park Ball is never in the
-- bag, never taken from it, and never restored.
function BugContest.ballsLeft(save)
local state = BugContest.state(save)
return (state and state.balls) or 0
end
function BugContest.useBall(save)
local state = BugContest.state(save)
if not state then return 0 end
state.balls = math.max(0, (state.balls or 0) - 1)
return state.balls
end
-- CheckContestBattleOver: no balls left is a DRAW and ends the battle, and
-- BugCatchingContestBattleScript's `readmem wParkBallsRemaining / iffalse`
-- then sends the player back to the gate.
function BugContest.isOver(save)
return BugContest.ballsLeft(save) <= 0
end
-- ----------------------------------------------------------- the caught mon
--
-- BugContest_SetCaughtContestMon. With no stock mon the catch is kept
-- outright; with one, the player is shown the STOCK versus THIS comparison and
-- asked, and a YES swaps. PlaceYesNoBox's `ret c` is the NO, so the DEFAULT --
-- cancelling out of the box with B -- keeps the mon already in stock.
BugContest.KEEP_FIRST = "first" -- .firstcatch, no question asked
BugContest.ASK_SWITCH = "switch" -- DisplayCaughtContestMonStats, then yes/no
function BugContest.catch(save, mon)
local state = BugContest.state(save)
if not state then return nil end
BugContest.useBall(save)
if not state.caught then
state.caught = mon
return BugContest.KEEP_FIRST
end
return BugContest.ASK_SWITCH, state.caught, mon
end
-- The YES arm of that question.
function BugContest.switchCaught(save, mon)
local state = BugContest.state(save)
if not state then return nil end
state.caught = mon
return mon
end
function BugContest.caughtMon(save)
local state = BugContest.state(save)
return state and state.caught or nil
end
-- ------------------------------------------------- CheckPartyFullAfterContest
--
-- The catch joins the party if there is room and goes to the current box if
-- there is not, and the answer is the three-way wScriptVar the gate branches
-- on. Boxing needs src/core/gen2/Boxes.lua, which is required lazily so this
-- module stays loadable on its own.
function BugContest.collectCaughtMon(save, partySize, boxes)
local state = BugContest.state(save)
local mon = state and state.caught
if not mon then return BugContest.NO_CATCH end
state.caught = nil
-- caught_nickname.asm:34-39 copies wPlayerName when the contest mon joins.
require("src.battle.gen2.Mon").stampOT(save, mon)
local party = save.party or {}
save.party = party
partySize = partySize or 6
if #party < partySize then
party[#party + 1] = mon
return BugContest.CAUGHT_MON, mon
end
boxes = boxes or require("src.core.gen2.Boxes")
local box = boxes.box(save, save.currentBox or 1)
-- .TryAddToBox copies BOXMON_STRUCT_LENGTH and tails into
-- RestorePPOfDepositedPokemon (engine/pokemon/caught_nickname.asm:72-90).
if box then box[#box + 1] = boxes.enterBox(mon) end
return BugContest.BOXED_MON, mon
end
-- ------------------------------------------------------------- the module map
--
-- What each special in data/events/special_pointers.asm should call, so the
-- half of this system that lives in src/script/gen2/Specials.lua meets this
-- half exactly once:
--
-- ContestDropOffMons BugContest.dropOffMons(save) -> scriptVar
-- ContestReturnMons BugContest.returnMons(save)
-- GiveParkBalls BugContest.start(save)
-- BugContestJudging BugContest.runJudging(save) -> scriptVar
-- CheckPartyFullAfterContest BugContest.collectCaughtMon(save)
-- -> scriptVar
-- SelectRandomBugContestContestants BugContest.pickContestants(save) and
-- BugContest.applyContestantFlags(events,
-- chosen, eventTables)
--
-- and outside the specials table:
--
-- CheckTimeEvents BugContest.tickTimer(save) -> ended?
-- ChooseWildEncounter_BugContest BugContest.chooseWild(data)
-- TryWildEncounter_BugContest BugContest.triggers(superTall)
-- PokeBallEffect .used_park_ball BugContest.catch(save, mon)
BugContest.SCREEN_ID = "Gen2ContestMenu"
return BugContest