diff --git a/docs/native-battler-construction.md b/docs/native-battler-construction.md new file mode 100644 index 000000000..cedfb5684 --- /dev/null +++ b/docs/native-battler-construction.md @@ -0,0 +1,34 @@ +# Native battler construction without presentation resources + +The shared native `BattleState.makeBattler` previously built combat data, +selected pictures and created animation resources in one function. Extract +its data construction into `src/battle/Battler.lua`, and have the existing +screen constructor hydrate that same table with its original art factories. +There is no second turn simulator, replacement Pokemon or replacement art. + +This offered host retains its existing Gen2 calculation binding and poison +initialization. It does not import the fork-only `HeldItemRead` dependency +or silently introduce the fork's later toxic-counter behavior. Party/move/ +stat/registry identities, badge defaults, outside-battle sleep and form +personal records retain the existing native construction semantics. + +On implementation/test commit `281e5185`, fresh plain-Lua construction passes +577 checks without a LÖVE stub, with presentation-module loads rejected. +Actual wild construction and switches pass 810 checks across this host's +15 advertised editions using the real sprite and animation factories. +These are selected ROM-free checks, not complete current-host, GPU or +release qualification. The previous round-clock offer has separate scoped +RNG/link/model evidence; it does not qualify this new combined source. + +This is a prerequisite for sharing native battlers with standalone model +construction. It does not implement the complete source `Battle.new`, pure +turn/event executor, arbitrary writable model fields, full unchanged-mod +visual parity or final release acceptance. + +The data code is extracted from the existing engine; original authors' +rights and applicable licences remain effective. Extraction/integration +and tests by Roxas2712 are offered under +, +including its specific permission for Cedric / UNDERdecoded with credit. +That contribution notice does not replace earlier permissions or assign +ownership of the underlying engine. diff --git a/docs/native-gen2-model-boundary.md b/docs/native-gen2-model-boundary.md new file mode 100644 index 000000000..4abcd7bd4 --- /dev/null +++ b/docs/native-gen2-model-boundary.md @@ -0,0 +1,38 @@ +# Native Gen2 model boundary + +`src.battle.gen2.Battle` is a distinct model class attached to the shared +native battle controller by `Gen2BattleView`. It is not a name alias for +`BattleState`, a second controller, or a detached copy of the saved party. +Methods resolve through the class so an SDK-owned decoration also reaches +already-created models and is restored by loader retirement. + +The boundary supplies live `moveDef`, `speciesDef`, `sideOf`, raw `battleStat` +and `endBattle` operations. Damage, speed, status immunity, weather and shared +move effects consult the native bound reads. Stages, badge/status/item +modifiers, RNG and PP stay with their existing native owners. With no +decoration the read returns the original native table, without a copy. +Temporary stat/type answers do not replace a species registry row, party +statistics, wrapper identity or checkpoint data. Explicit native +Conversion/Transform types keep their priority over a species decoration. + +The native finish path invokes the model's end operation, including the +skipped battle path. It raises one finish notification and permits transient +cleanup before subscribers observe it. `completeBattle` runs the existing +screen finish/return path, with repeated completion prevented from popping +another screen. Existing native consumers retain the screen in the event's +`battle`; the distinct model is available in `model`. + +This is an implemented boundary, not complete source-model parity. The split +Gen2 model contract was inspected at Gen1Recomp `535a5ec0` and its current +`e46d1eb2` counterpart. That model additionally has constructors and a pure +turn/event API, which are not guessed from screen methods here. Its inspected +class has no `battleStat` method; the raw-stat operation above is an explicit +native extension, not a claim that all source versions expose it. Source +event payload normalization and the remaining model operations still require +separate implementation and acceptance. Existing project and third-party +credits remain applicable. + +ROM-free coverage uses an SDK-loaded generic decorator and actual native +damage, speed, status, switch, Transform and completion calls. It also checks +restoration and direct table identity in other generations. It does not prove +an unchanged client, GPU presentation, a full story run or a release package. diff --git a/docs/native-gen2-model-rng.md b/docs/native-gen2-model-rng.md new file mode 100644 index 000000000..1de093552 --- /dev/null +++ b/docs/native-gen2-model-rng.md @@ -0,0 +1,31 @@ +# Native Gen2 model RNG + +Expose `rng`, `random` and `roller()` on the live source model, backed by the +existing native controller's current function. LOVE-style ranges normalize +missing/small bounds and reversed ranges; the source random/roller domain is +zero-based for positive integer bounds. Queries consume no randomness and +retained adapters follow native function replacement, including link setup. + +The source random field and decorated roller method stay distinct. The native +read-only reader policy remains in effect. Model-owned function caches do not +enter party mons or serialized battlers, and no second generator is created. +Standalone constructor/RNG-injection and complete source-model parity remain +separate work. + +Reference: read-only Gen1Recomp Gen2 revision +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`, `Battle.new`, its LOVE-style RNG +view and `Battle:roller`. This older host's native model, volatile-state and +protection prerequisites are offered separately. The fork-only semantic +checkpoint backend is absent here, so its checkpoint result is not inherited. + +Native integration/tests by Roxas2712, offered to Cedric / UNDERdecoded under +the specific PR contribution permission, with existing rights intact. +Independent validation on this offered host passes 7951 native RNG checks, +174 actual host/guest/spectator source-callback checks, model (262), volatile +(423) and protection-link (66) regressions. A separately executed frozen Gen1 +comparison passes 9471 comparisons / 7020 draw cases over actual seeded native +LinkBattle backends; reference hashes remain unchanged. The exact official +whole-host command passes 71/82 engine suites and SDK3/3, with the same eleven +pre-existing failures as its protection base (69/80). It adds two passing suites +without adding a whole-host failure; this older prerequisite stack is not +entirely green. Full model/mod/GPU/release acceptance remains separate. diff --git a/docs/native-gen2-move-priority.md b/docs/native-gen2-move-priority.md new file mode 100644 index 000000000..235fd9692 --- /dev/null +++ b/docs/native-gen2-move-priority.md @@ -0,0 +1,32 @@ +# Native GSC move priority + +Older native GSC caches mark priority-hit moves explicitly, but leave Protect, +Endure, Mirror Coat and other priority classes without a `priority` field. +The shared comparator then treated them as ordinary moves. A slow Pokemon +could take damage before its Protect action; the live Gen2 model also reported +neutral priority for these moves. + +`Gen2MovePriority` supplies the cartridge's missing priority classes at the +actual native comparator and live-model query. Protect/Endure use +3; +Counter/Mirror Coat and Vital Throw use -1. Roar/Whirlwind use -1 by their +native IDs because the existing cache combines their effect with Teleport; +Teleport remains neutral. Explicit native cartridge/mod priority values, +including zero, take precedence. Other generations retain their own rules. +This does not require a re-import, change a move record or registry fingerprint, +or replace the native battle engine. It adds no RNG draw. + +The reference is the GSC `GetMovePriority`/effect-priority table also represented +by the read-only Gen1Recomp Gen2 model at +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`. This is an implementation in the +native shared controller, not an alias to a second combat simulator. + +The exact old-source regression reproduces 57 failed assertions, including +actual HP loss before slow Protect. The corrected 83-check contract exercises +Gold/Silver/Crystal, renamed effect-equivalent moves, explicit overrides, +unknown moves and the public model. It executes the real queued native actions +and verifies Protect/Endure state and PP; the enemy's real attack is blocked +after Protect. Cross-generation comparator checks and existing battle/model, +queue and held-item regressions remain separate checks. These ROM-free fixtures +are not a claim of complete move parity, unchanged-mod story/window behavior, +or release acceptance. Full model-constructor and other Gen1 behavior gaps +remain open. diff --git a/docs/native-gen2-protection.md b/docs/native-gen2-protection.md new file mode 100644 index 000000000..e0348b7f6 --- /dev/null +++ b/docs/native-gen2-protection.md @@ -0,0 +1,38 @@ +# Native GSC Protect and Endure + +Run GSC protection on the real shared-controller battler, with the source +model's nonzero-byte odds (`255 >> prior successes`), first-side and Substitute +restrictions. Actual action entry records the first side for that round, +including switches and failed/recharge actions; called effects retain it. +The next round replaces it. GSC self-protection bypasses the generic incoming +status gate so the real handler can clear its failed-use counter. + +Changed command/RNG semantics advertise `gsc-1` in link hello and a separate +GSC fingerprint domain. Older rules cannot enter the changed lockstep battle; +the compatibility screen names the native rules difference. Other generations +retain their existing handlers and fingerprints. This older host does not +carry the fork's semantic-checkpoint backend, so no checkpoint result is +claimed here. + +References: read-only Gen1Recomp Gen2 revision +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`, its protection handlers and +`Effects.protectSucceeds`; `pret/pokecrystal` +`5c877ef55197039c9676e8e3eec6d254b1c40fe8`, +`engine/battle/move_effects/protect.asm`. The nonzero-domain native RNG call +matches the source model convention; this does not emulate a hardware PRNG or +claim raw zero-rejection trace parity. + +The native model/volatile/priority prerequisites remain separate offered PRs. +Independent results on this offered host: 3796 native effect/queued-order/PP/ +switch checks, 66 host/guest/spectator and admission checks, 37 actual protocol/ +cache/digest checks, 423 volatile checks and 83 priority checks pass. The frozen +Gen1 comparison independently passes 30,624 actual effect cases / 165,336 +comparisons. A separate Python integer-FNV audit confirms this host's legacy +fixture digest e33d251961f2a41d and GSC digest 40df18dc503213c8. Tests drive this +older host's actual queue-row closures; they do not assume the newer fork's +runQueueAction or beginTurn helpers exist. Fork-only checkpoint and complete +release acceptance are not inherited. GPU, complete called-move/turn mechanics, +unchanged-mod gameplay and full Gen1 feature parity remain separate work. + +Native integration/tests by Roxas2712, offered to Cedric / UNDERdecoded under +the fork's specific PR contribution permission, with existing rights intact. diff --git a/docs/native-gen2-round-clock.md b/docs/native-gen2-round-clock.md new file mode 100644 index 000000000..faeda7aae --- /dev/null +++ b/docs/native-gen2-round-clock.md @@ -0,0 +1,32 @@ +# Native Gen2 model round clock + +An API2 reader of `screen.battle.turn` previously received `nil` even while +the native controller emitted numbered turn events. The frozen Gen1 Gen2 +model starts its `turn` field at zero and increments at round admission +(`src/battle/gen2/Battle.lua`, reference revision +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`). + +The source model now reads the existing native `turnCount`, or zero before +its first round. There is no second counter, timer, event or random stream. +The native controller still owns round admission. Repeated reads, individual +moves, residuals and switches do not advance or reset that clock. Link hosts, +guests and spectators read their actual lockstep controller. Semantic +checkpoint reconstruction reads its restored native count. + +Validation uses actual native constructors, queued actions, API2 event +callbacks, lockstep participants/spectator and semantic checkpoint restore. +The corrected queue probe on the preceding engine reports 295 failures in +400 checks: the public clock is absent at initialization and actual execution +edges. With the engine reader, all 400 pass across Gold, Silver, Crystal, +Prism and Polished Crystal. The link probe passes 210 checks, checkpoint +probe 57, unchanged RNG probe 7951, calculation/completion probe 252 and +friendship checkpoint probe 30 on the fork. The earlier queue-probe drafts +had test-harness mistakes; only its corrected negative run supports the +295-failure comparison. + +This closes the missing live clock observation. It does not implement the +source standalone constructor/turn executor, arbitrary writes to source +model fields, every battle event, full mod visual parity or release-package +acceptance. Those remain separate parity requirements. No mod or original +Gen1/player installation is changed. Implementation and tests are original +contributions by Roxas2712 under `LICENSE-ROXAS2712.md`. diff --git a/docs/native-gen2-trainer-friendship.md b/docs/native-gen2-trainer-friendship.md new file mode 100644 index 000000000..b21b759eb --- /dev/null +++ b/docs/native-gen2-trainer-friendship.md @@ -0,0 +1,66 @@ +# Native GSC trainer friendship and source class predicates + +The shared native trainer constructor now applies the original Gold/Silver/ +Crystal `GYMBATTLE` friendship event before the first turn. Previously the +award was coupled to `BattleState:enter()` and the narrower badge-leader +marker: the Elite Four, Champion and Red received no award, and construction +alone did not expose the updated values to source consumers. + +`src.battle.gen2.Battle` exposes the source `GYM_LEADER_CLASSES`, +`KANTO_GYM_LEADER_CLASSES`, `isGymLeader(class)` and +`isKantoGymLeader(class)` contracts. The first predicate covers all 22 classes +in the cartridge's combined leader list; the second covers its eight Kanto +classes. The actual native constructor calls the public predicate, so an +API2 class decoration affects the real battle rather than a second model. + +Imported native records retain their numeric group index. The engine maps +that identity to the canonical class identifier rather than comparing the +localized display name, which is often just `LEADER`. It does not mutate the +imported trainer record or claim that every source trainer-view field is +implemented. + +The award uses the existing native friendship owner and the battle's ordered +party view. Living, non-egg members receive +3 below 100, +2 below 200, or +1 +otherwise, clamped to 255. Excluded party members are untouched. API2 +`happiness.changed` callbacks carry the original native mon and the source +symbolic event (`GYMBATTLE`); the numeric cartridge index remains available +as `eventId` (4). Applied events at saturation (and other zero-delta happiness +events) remain observable, as on the reference engine; unchanged walking +steps remain silent. + +The award is not replayed by screen entry. Native badge/music and Yellow companion markers keep +their separate meaning. Other editions and Gen2 hacks keep their existing +friendship rules. No mod, Gen1 installation, player profile, save or ROM is +modified by this change. + +## Older-host party prerequisite + +This offered host did not have the fork's battle-local party selection or +`playerPartyView()`. Supply the original native selection integration here: +validate and retain ordered original mon references in the trainer constructor, +then use the same roster for the lead, doubles, native party menus, benches, +party-wide effects, EXP/EXP.ALL and defeat decisions. Never replace or reorder +`save.party`. Invalid selections fall back to the complete original party. + +The standalone `native_trainer_party_scope` regression drives actual older-host +constructors, generation-appropriate menus, AI, Heal Bell/Beat Up, EXP and defeat +across its supported editions. Qualification requires execution on this offered +host; the corresponding fork result is not inherited. Execution status is +recorded in the draft PR. + +## Validation + +The committed ROM-free regressions exercise real `BattleState.newTrainer`, +the production API2 loader and event bus, all vanilla classes and seven +friendship boundaries, native egg representations, fainted/selected party +members, decorator reachability and other-edition guards. This older offered +host lacks the fork's semantic checkpoint backend, so the fork-only checkpoint +test/result is not inherited. Execution results and independent frozen-reference +comparison are recorded in the PR after running those checks. + +This closes the class-predicate/constructor-award gap. It does not establish +complete source-model constructors, turn/event parity, graphical parity, +unchanged VASC acceptance or release readiness. + +Native integration/tests by Roxas2712, offered to Cedric / UNDERdecoded under +the specific PR permission in LICENSE-ROXAS2712.md; existing rights remain intact. diff --git a/docs/native-gen2-volatile-state.md b/docs/native-gen2-volatile-state.md new file mode 100644 index 000000000..32fdaf375 --- /dev/null +++ b/docs/native-gen2-volatile-state.md @@ -0,0 +1,35 @@ +# Native GSC volatile state + +The live Gen2 battle model exposes `volatile(mon)` as a view of its existing +native battler, rather than a disconnected metadata table. Canonical source +names `confuseCount`, `flinched`, `endure`, `recharge`, `protectCount`, +`focusEnergy` and `xAccuracy` address the native confusion counter, admission, +damage, action-lock, protection, critical and hit-check owners. Substitute, +protect and mist aliases are also supported. Unknown keys remain transient +consumer metadata, not newly implemented mechanics. + +Views are cached on the transient model only. Module-global weak-key caches can +retain retired battlers on LuaJIT when value closures reference the key. The +same active native owner has a stable view; withdrawn entries retire on query +and incoming wrappers get new views. No view or metadata is stored in a party +mon or serialized battler. + +GSC consecutive-protection retention follows the actual PROTECT/ENDURE effect, +including renamed moves; an old PROTECT ID with another effect resets it. +Protection RNG, opponent-first and Substitute rules are addressed by the +separate [native protection work](native-gen2-protection.md). +No second battle simulator is introduced. + +On this older host, 423 real native state/mechanics/ownership/collection checks, +262 model checks and 83 move-priority checks pass independently. The fixture +loads an actual API2 source consumer and covers Gold/Silver/Crystal, Prism and +Polished Crystal. The fork's additional semantic-checkpoint backend is absent +here, so its checkpoint result is not claimed for this offered branch. +These ROM-free CPU checks do not establish complete source-model, GPU, +unchanged-mod or release acceptance. Depends on the native model boundary and +move-priority contributions offered separately. + +Source-field names were inspected against Gen1Recomp Gen2 revision +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`. Native integration and tests are +contributions by Roxas2712, offered to Cedric / UNDERdecoded under the fork's +specific PR contribution permission, with existing third-party rights intact. diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 8f8e1409c..2a15aeb0c 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -38,6 +38,9 @@ local WideBattle = require("src.battle.WideBattle") local Gen3Battle = require("src.battle.Gen3Battle") local Gen4Battle = require("src.battle.Gen4Battle") local GameVersion = require("src.core.GameVersion") +local Gen2BattleView = require('src.battle.Gen2BattleView') +local Gen2Read = require('src.battle.Gen2BattleRead') +local Battler = require('src.battle.Battler') -- FORWARD-DECLARED, because they are defined next to the other doubles -- helpers two thousand lines down but read from `stepHPDrain` up here. A @@ -981,75 +984,30 @@ end -- construction -- --------------------------------------------------------------------- -local function makeBattler(data, mon, isPlayer, save) - local def = require('src.pokemon.Gen4Forms').definition(data,mon) - local badgeBoosts = data.constants and data.constants.badgeBoosts - local badges = nil - if isPlayer and save then - -- Gen 1 badge stat boosts (x9/8); the badge set follows the merged - -- badgeBoosts rows so a retuned list changes what gets baked in - badges = {} - for _, row in ipairs(badgeBoosts or Damage.BADGE_BOOSTS) do - if save.inventory[row.badge] then badges[row.badge] = true end - end - end - return { - mon = mon, - def = def, - name = mon.nickname or def.name, - -- The species this battler is SHOWING, which is not always `mon.species`: - -- a Transformed mon wears the copied species' pic and a Tower ghost wears - -- none at all. Both of those move this field where they move `sprite`, - -- so anything drawing the battler -- the flat pic here, or a renderer - -- standing a model in its place -- reads one answer and agrees with the - -- other. `mon.species` stays the truth about the Pokemon itself; this is - -- the truth about the picture. - species = mon.species, - isPlayer = isPlayer, - badges = badges, - -- merged registry views consumed by the pure battle modules - badgeBoosts = badgeBoosts, - statuses = data.statuses, - -- ...and the two HoldItems needs: the item table to look `mon.item` up - -- in, and the nature table, because whether a FIGY BERRY confuses the - -- Pokemon that ate it depends on which stat its nature lowers. Views, - -- not copies, so a berry eaten mid-battle is seen the next time it is - -- asked for. - items = data.items, - natures = data.constants and data.constants.natures, - shownHP = mon.hp, -- the HP the bar displays (UpdateHPBar drain) - -- HUD status label (DrawHUDsAndHPBars); mon.status can land mid-move - -- while the tilemap still shows the prior condition until the next - -- post-action HUD refresh (core.asm after Execute*Move) - shownStatus = mon.status, - -- a sleep put on outside battle with its own count (the Battle Pike's - -- Kirlia: STATUS1_SLEEP, all three bits); spent, it is gone - sleepTurns = (mon.status == "SLP" and mon.sleepTurns) or nil, - stages = {}, - -- volatile state; Transform/Conversion/Mimic override the cur* fields - curStats = mon.stats, - curTypes = def.types, - curMoves = mon.moves, - sprite = (function() - local Sprites = require("src.pokemon.Sprites") - local path, tc = Sprites.path(data, mon.species, - isPlayer and "back" or "front", - { mon = mon, kind = "battle" }) - return getImage(path, monPalette(data, mon.species, - require("src.pokemon.Pokemon").isShiny(mon)), tc) - end)(), - -- Crystal's front-pic frame animation. Only the FRONT pic carries - -- animation tiles, so the player's back pic has none -- and Gold and - -- Silver have none at all, where this stays nil and the pic holds still. - -- forMon rather than new: a shiny Gen 3 Pokemon animates from its own - -- strip, and only the Pokemon can say whether it is one - picAnim = (not isPlayer or (data.pokemon[mon.species] or {}).gen5Animation) - and require("src.pokemon.PicAnim").forMon(data, mon,isPlayer and "back" or "front") or nil, - -- Emerald's procedural front-pic animation -- the squash/hop/glow the - -- sprite itself does when it appears. Front pic only, same as above. - monAnim = (not isPlayer) - and require("src.pokemon.MonAnim").new(data, mon.species) or nil, - } +local function makeBattler(data, mon, isPlayer, save, battle) + local battler = Battler.new(data, mon, isPlayer, save, battle) + -- The regular native screen hydrates the same art as before. Pure model + -- construction can use the data factory without allocating GPU resources. + battler.sprite = (function() + local Sprites = require("src.pokemon.Sprites") + local path, tc = Sprites.path(data, mon.species, + isPlayer and "back" or "front", + { mon = mon, kind = "battle" }) + return getImage(path, monPalette(data, mon.species, + require("src.pokemon.Pokemon").isShiny(mon)), tc) + end)() + -- Crystal's front-pic frame animation. Only the FRONT pic carries + -- animation tiles, so the player's back pic has none -- and Gold and + -- Silver have none at all, where this stays nil and the pic holds still. + -- forMon rather than new: a shiny Gen 3 Pokemon animates from its own + -- strip, and only the Pokemon can say whether it is one. + battler.picAnim = (not isPlayer or (data.pokemon[mon.species] or {}).gen5Animation) + and require("src.pokemon.PicAnim").forMon(data, mon, isPlayer and "back" or "front") or nil + -- Emerald's procedural front-pic animation -- the squash/hop/glow the + -- sprite itself does when it appears. Front pic only, same as above. + battler.monAnim = (not isPlayer) + and require("src.pokemon.MonAnim").new(data, mon.species) or nil + return battler end -- LinkBattle builds clamped copies with save=nil (no badge boosts); wild @@ -1164,12 +1122,9 @@ local function newBattle(game) local self = setmetatable({}, BattleState) self.game = game self.data = game.data - -- On this engine the battle screen IS the battle logic object: there is no - -- separate model that a screen points at. Publishing the self-reference makes - -- that explicit so anything holding "a thing with a .battle" (the party menu - -- during a switch, mods walking the state stack) can treat the battle screen - -- and the party menu uniformly instead of special-casing this one screen. - self.battle = self + -- Gen2 exposes its actual party through a distinct live model; native + -- decisions, wrappers and presentation remain on this controller. + if GameVersion.isGen2() then Gen2BattleView.bind(self) else self.battle = self end -- BLACK AND WHITE: their move effects named for this engine, their species' -- abilities and their ruleset, before the ruleset below is chosen -- (src/battle/Gen5MoveEffects.lua; sets self.bw on a BW cache only) @@ -1371,7 +1326,7 @@ function BattleState.newWild(game, species, level, opts) Logger.warn("wild battle with no healthy party; skipping") self.dead = true else - self.player = makeBattler(game.data, playerMon, true, game.save) + self.player = makeBattler(game.data, playerMon, true, game.save, self) end local form=opts and opts.form if form==nil and (game.data.constants or {}).gen==4 then @@ -1428,7 +1383,7 @@ function BattleState.newWild(game, species, level, opts) if opts and opts.roamerSeed then Pokemon.applySeed(game.data, wild, opts.roamerSeed) end - self.enemy = makeBattler(game.data, wild, false) + self.enemy = makeBattler(game.data, wild, false, nil, self) require("src.battle.Gen2Wild").initialize(self, opts) if self.roamer then local kept = math.floor(tonumber(opts.roamerHP) or 0) @@ -1552,9 +1507,9 @@ function BattleState.newPokedudeDemo(game, lesson) local self = newBattle(game) self.kind = "wild" self.noRun = true - self.player = makeBattler(game.data, party[1], true, game.save) + self.player = makeBattler(game.data, party[1], true, game.save, self) self.enemyParty = { pokedudeMon(game, def.enemy) } - self.enemy = makeBattler(game.data, self.enemyParty[1], false) + self.enemy = makeBattler(game.data, self.enemyParty[1], false, nil, self) self.introText = self:romText("_WildMonAppearedText", "Wild %s\nappeared!", self.enemy.name) self.demoName = "POKé DUDE" @@ -1702,14 +1657,20 @@ end BattleState.buildTrainerParty = buildTrainerParty +function BattleState:playerPartyView() + return self.playerParty or self.game.save.party or {} +end + function BattleState:trainerLabel_() return self.trainerLabel or (self.trainer and self.trainer.name) or "?" end -function BattleState.newTrainer(game, oppClass, partyIndex) +function BattleState.newTrainer(game, oppClass, partyIndex, opts) local self = newBattle(game) self.kind = "trainer" self.oppClass = oppClass + self.playerParty, self.playerPartyIndices = require("src.battle.PlayerPartyScope").select(game, + type(opts) == "table" and opts.playerPartyIndices or nil) -- wOtherTrainerID: which trainer inside the class. PlayBattleMusic reads -- it to tell the two RIVAL2 fights apart, so keep it on the battle. self.partyIndex = tonumber(partyIndex) or 1 @@ -1737,14 +1698,14 @@ function BattleState.newTrainer(game, oppClass, partyIndex) end self.enemyParty = buildTrainerParty(game, oppClass, partyIndex or 1, partyDef) self.enemyIndex = 1 - local playerMon = Party.firstHealthy(game.save.party) + local playerMon = Party.firstHealthy(self:playerPartyView()) if not playerMon then Logger.warn("trainer battle with no healthy party; skipping") self.dead = true else - self.player = makeBattler(game.data, playerMon, true, game.save) + self.player = makeBattler(game.data, playerMon, true, game.save, self) end - self.enemy = makeBattler(game.data, self.enemyParty[1], false) + self.enemy = makeBattler(game.data, self.enemyParty[1], false, nil, self) self.aiUses = self:aiUsesFor() -- wAICount, reset per enemy mon markSeen(game, self.enemyParty[1].species, self.enemyParty[1]) @@ -1788,6 +1749,15 @@ function BattleState.newTrainer(game, oppClass, partyIndex) self.trainerLabel = shownName end self.introText = Strings("%s wants\nto fight!", shownName) + -- InitEnemyTrainer awards GSC friendship before the first turn, including + -- Elite Four/Champion/Red. Resolve the public source predicate so API2 + -- decorators affect this actual native owner. The party view contains the + -- original selected mons; excluded save-party members receive no award. + if require("src.pokemon.Gen2Friendship").isVanilla() + and require("src.battle.gen2.Battle").isGymLeader( + require("src.battle.Gen2TrainerClass").fromIndex(self.trainer.index)) then + require("src.pokemon.Gen2Friendship").gymBattle({party=self:playerPartyView()}) + end return self end @@ -1836,7 +1806,7 @@ function BattleState:sendOutSecondPair() if self:battlerAt(BattleState.POS.PLAYER_RIGHT) == nil and self.ally then local first = Party.firstHealthy(self.ally.party or {}) if first then - local b = makeBattler(game.data, first, true, nil) + local b = makeBattler(game.data, first, true, nil, self) b.ally = true self:placeBattler(BattleState.POS.PLAYER_RIGHT, b) end @@ -1844,10 +1814,10 @@ function BattleState:sendOutSecondPair() if self:battlerAt(BattleState.POS.PLAYER_RIGHT) == nil and not self.ally then -- the next healthy one that is not already standing in the left slot local lead = self.player and self.player.mon - local second = Party.firstHealthy(game.save.party, lead) + local second = Party.firstHealthy(self:playerPartyView(), lead) if second then self:placeBattler(BattleState.POS.PLAYER_RIGHT, - makeBattler(game.data, second, true, game.save)) + makeBattler(game.data, second, true, game.save, self)) elseif GameVersion.isGen4() then -- ...EXCEPT ON PLATINUM, where an escort makes it reachable: the -- partner flag forces hasTwoAliveMons, so a same-id double spots a @@ -1874,7 +1844,7 @@ function BattleState:sendOutSecondPair() if party and party[2] then self.enemyIndexRight = 2 self:placeBattler(BattleState.POS.OPPONENT_RIGHT, - makeBattler(game.data, party[2], false)) + makeBattler(game.data, party[2], false, nil, self)) markSeen(game, party[2].species, party[2]) else -- a "double" trainer with one Pokemon fights alone on their side, @@ -1932,7 +1902,7 @@ function BattleState:addOpponentTrainer(oppClass) -- the right-hand opponent is the SECOND trainer's lead, replacing -- whatever the first trainer's second Pokemon had been put there self:placeBattler(BattleState.POS.OPPONENT_RIGHT, - makeBattler(self.game.data, self.enemyPartyB[1], false)) + makeBattler(self.game.data, self.enemyPartyB[1], false, nil, self)) markSeen(self.game, self.enemyPartyB[1].species, self.enemyPartyB[1]) end return true @@ -1991,7 +1961,7 @@ BattleState.EDITOR_TRAINER_CLASS = "OPP_EDITOR_TRAINER" -- `party` is an array of slots in newTrainer's own shape; `look` is the class -- to borrow a pic, palette and AI from (the NPC's own trainerClass, when it -- named one). -function BattleState.newEditorTrainer(game, party, look, name) +function BattleState.newEditorTrainer(game, party, look, name, opts) local trainers = game.data.trainers if type(party) ~= "table" or #party == 0 then return nil, "that team is empty" end local base = look and trainers[look] or nil @@ -2009,7 +1979,7 @@ function BattleState.newEditorTrainer(game, party, look, name) def.paletteSource, def.aiMods = base.paletteSource, base.aiMods end trainers[BattleState.EDITOR_TRAINER_CLASS] = def - return BattleState.newTrainer(game, BattleState.EDITOR_TRAINER_CLASS, 1) + return BattleState.newTrainer(game, BattleState.EDITOR_TRAINER_CLASS, 1, opts) end -- PLATINUM'S ESCORT WILD BATTLE (BATTLE_TYPE_AI_PARTNER, wild_encounters.c): @@ -2167,7 +2137,7 @@ function BattleState:makeOldManDemo(name) if not self.player then self.dead = false self.player = makeBattler(self.game.data, - Pokemon.new(self.game.data, self.enemy.mon.species, 5), true) + Pokemon.new(self.game.data, self.enemy.mon.species, 5), true, nil, self) end end @@ -3432,7 +3402,10 @@ end function BattleState:placeBattler(pos, battler) local side, flank = slotOf(pos) self.sides[side].battlers[flank] = battler - if battler then battler.position = pos end + if battler then + battler.position = pos + Gen2Read.bind(battler, self) + end if flank == 1 then if side == 1 then self.player = battler else self.enemy = battler end end @@ -3552,6 +3525,9 @@ function BattleState:syncSides() if pr then pr.position = BattleState.POS.PLAYER_RIGHT end if orr then orr.position = BattleState.POS.OPPONENT_RIGHT end end + for _, battler in activeBattlers(self) do + Gen2Read.bind(battler, self) + end end -- IS THIS SIDE STILL ARRIVING? @@ -3616,7 +3592,8 @@ function BattleState:enter() self.result = "lose" self.game.stack:pop() require("src.core.Music").restoreMap(self.data) - Runtime.emit("battle.ended", { battle = self, result = "lose", skipped = true }) + if GameVersion.isGen2() then self.battle:endBattle('lose', {skipped=true}) + else Runtime.emit("battle.ended", { battle = self, result = "lose", skipped = true }) end local onFinish = self.onFinish local function blackedOut() if onFinish then onFinish("lose") end @@ -3637,7 +3614,6 @@ function BattleState:enter() if self.isGymLeader then require("src.world.PikachuFollower") .modifyHappiness(self.game.save, "GYMLEADER") - require("src.pokemon.Gen2Friendship").gymBattle(self.game.save) end -- normally already playing: the transition wipe starts the theme -- (audio/play_battle_music.asm runs before the transition, and @@ -4494,7 +4470,7 @@ function BattleState:update(dt) -- loops the party menu until a healthy mon is picked, so B and -- fainted picks land back here and reopen it if acting.mon.hp <= 0 then - if Party.firstHealthy(self.game.save.party) then + if Party.firstHealthy(self:playerPartyView()) then self:openReplacementMenu() end return @@ -5104,7 +5080,7 @@ function BattleState:openPokedudeTarget(step, switchMode) if switchMode and p.scriptTick == 48 then p.index = step.switchTo or 2 end if p.scriptTick > 96 and not p._pokedudePicked then p._pokedudePicked = true - local mon = self.game.save.party[p.index] + local mon = self:playerPartyView()[p.index] p:close() if switchMode then self:resolveSwitch(mon) @@ -5904,7 +5880,7 @@ function BattleState:switchPlayerInto(pos, newMon) end local previous = self:battlerAt(pos) self:restoreMimicked(previous) -- the battle copy leaves with it - local incoming = makeBattler(self.data, newMon, true, self.game.save) + local incoming = makeBattler(self.data, newMon, true, self.game.save, self) self:placeBattler(pos, incoming) -- BATON PASS: the stat stages and the volatiles go with the switch, which -- is the whole move. Nothing else survives a switch, so a pass that was @@ -6019,7 +5995,7 @@ function BattleState:abilitySwitchIn(battler) local side = self.sideOf and self:sideOf(battler) if side and side.spikes then local grounded = Abilities.of(battler) ~= "LEVITATE" - for _, t in ipairs(battler.curTypes or {}) do + for _, t in ipairs(Gen2Read.types(battler) or {}) do if t == "FLYING" then grounded = false end end -- PLATINUM / HGSS: subscript_hazards_check decides -- Magic Guard spares @@ -6232,7 +6208,7 @@ function BattleState:abilityForecast(battler) if not (battler and battler.mon and battler.mon.hp > 0) then return false end local want = Abilities.forecastType(battler, Weather.current(self)) if not want then return false end - local types = battler.curTypes + local types = Gen2Read.types(battler) if type(types) == "table" and #types == 1 and types[1] == want then return false end @@ -7716,6 +7692,7 @@ function BattleState:executeAction(user, target, action) if user.g4Gone then return end -- this battler's action is under way (Payback, Sucker Punch, Me First) user.actedTurn = self.turnCount + if GameVersion.isGen2() then require("src.battle.Gen2Protection").actionStarted(self, user) end -- A SWITCH NEEDS NO TARGET. Everything else does, and with four on the -- field the one it was aimed at can already be off it -- the entry's -- target is resolved as the entry runs, and there may be nobody left to @@ -7785,7 +7762,7 @@ function BattleState:executeAction(user, target, action) local previous = self.enemy local oldName = self.enemy.name self.enemyIndex = action.index - self.enemy = makeBattler(self.data, self.enemyParty[action.index], false) + self.enemy = makeBattler(self.data, self.enemyParty[action.index], false, nil, self) -- EnemySendOutFirstMon (core.asm:1314-1315): clears player's trap clearTrapping(self.player) self:syncSides() @@ -8120,7 +8097,7 @@ function BattleState:applyGen3TransformVisual(user, target, whiteCoeff) end local function battlerHasType(battler, wanted) - for _, t in ipairs(battler and battler.curTypes or {}) do + for _, t in ipairs(Gen2Read.types(battler) or {}) do if t == wanted then return true end end return false @@ -8297,9 +8274,12 @@ function BattleState:performMove(user, target, moveInst, isCalled, suppressAnima -- FURY CUTTER and ROLLOUT double for each consecutive LANDING and reset -- the same way. Both counters live on the user and are cleared here, -- which is the one place every move passes through. - if move.id ~= "PROTECT" and move.id ~= "DETECT" and move.id ~= "ENDURE" then - user.protectRun = nil + local keepsProtection = move.id == "PROTECT" or move.id == "DETECT" or move.id == "ENDURE" + if GameVersion.isGen2() then + -- The GSC counter follows the effect, including renamed source moves. + keepsProtection = move.effect == "PROTECT_EFFECT" or move.effect == "ENDURE_EFFECT" end + if not keepsProtection then user.protectRun = nil end if user.rampMove and user.rampMove ~= move.id then user.rampRun, user.rampMove = nil, nil end @@ -8701,7 +8681,7 @@ function BattleState:benchFor(isPlayer, position) if isPlayer and self.ally and position == BattleState.POS.PLAYER_RIGHT then return self.ally.party or {} end - if isPlayer then return self.game.save.party or {} end + if isPlayer then return self:playerPartyView() end if position == BattleState.POS.OPPONENT_RIGHT and self.enemyPartyB then return self.enemyPartyB end @@ -8786,7 +8766,7 @@ function BattleState:fillEmptySlots() if pick then local allySlot = isPlayer and self.ally and pos == BattleState.POS.PLAYER_RIGHT self:placeBattler(pos, makeBattler(self.data, pick, isPlayer, - (isPlayer and not allySlot) and self.game.save or nil)) + (isPlayer and not allySlot) and self.game.save or nil, self)) self:syncSides() if not isPlayer then markSeen(self.game, pick.species, pick) end local who = self:battlerAt(pos) @@ -8828,7 +8808,7 @@ function BattleState:promptDoubleReplacement(pos, rest) end end done = true - self:placeBattler(pos, makeBattler(self.data, mon, true, game.save)) + self:placeBattler(pos, makeBattler(self.data, mon, true, game.save, self)) self:syncSides() local who = self:battlerAt(pos) self.nextInsert = 0 @@ -8889,7 +8869,7 @@ function BattleState:awardExp(fallen) -- (RemoveFaintedPlayerMon), so it drops out of the divisor and only -- the surviving participants are counted and paid local participants, alive = 0, {} - for _, mon in ipairs(self.game.save.party) do + for _, mon in ipairs(self:playerPartyView()) do if self.participants and self.participants[mon] then participants = participants + 1 if mon.hp > 0 then table.insert(alive, mon) end @@ -9010,7 +8990,7 @@ function BattleState:awardExp(fallen) local expAll = (self.game.save.inventory.EXP_ALL or 0) > 0 if not expAll then local holders = {} - for _, mon in ipairs(self.game.save.party) do + for _, mon in ipairs(self:playerPartyView()) do if (mon.hp or 0) > 0 and holdsExpShare(mon) then holders[#holders + 1] = mon end @@ -9040,9 +9020,9 @@ function BattleState:awardExp(fallen) -- experience.asm:9-13); each mon gets its own GainedText with the -- "with EXP.ALL," tail (wBoostExpByExpAll) -- pokered prints no -- summary line - for _, mon in ipairs(self.game.save.party) do + for _, mon in ipairs(self:playerPartyView()) do if mon.hp > 0 then - ctx.applyShare(mon, math.max(1, ctx.participants) * #self.game.save.party * 2, "expAll") + ctx.applyShare(mon, math.max(1, ctx.participants) * #self:playerPartyView() * 2, "expAll") end end end @@ -9087,7 +9067,7 @@ function BattleState:enemyMonFainted() local nextName = nextMon.nickname or self.data.pokemon[nextMon.species].name local style = tostring((self.game.save.options or {}).battleStyle or "shift") :lower() - local partyCount = #self.game.save.party + local partyCount = #self:playerPartyView() -- ReplaceFaintedEnemyMon (core.asm:892-896): DrawEnemyPokeballs puts the -- foe's party ball row -- and the HUD chrome PlaceEnemyHUDTiles lays -- down under it (draw_hud_pokeball_gfx.asm:9-11, 33-45, 134-141) -- into @@ -9125,7 +9105,7 @@ function BattleState:enemyMonFainted() end self:act(function() local previous = self.enemy - self.enemy = makeBattler(self.data, self.enemyParty[self.enemyIndex], false) + self.enemy = makeBattler(self.data, self.enemyParty[self.enemyIndex], false, nil, self) -- EnemySendOutFirstMon (core.asm:1314-1315): clears player's trap clearTrapping(self.player) self:syncSides() @@ -9163,7 +9143,7 @@ function BattleState:enemyMonFainted() local mon = shiftSwitchMon if not mon then return end local previous = self.player - self.player = makeBattler(self.data, mon, true, self.game.save) + self.player = makeBattler(self.data, mon, true, self.game.save, self) clearTrapping(self.enemy) self:syncSides() Runtime.emit("battle.battler_switched", { @@ -9417,7 +9397,7 @@ function BattleState.isOaksLabStarterRival(self) end function BattleState:playerMonFainted() - local nextMon = Party.firstHealthy(self.game.save.party) + local nextMon = Party.firstHealthy(self:playerPartyView()) -- Being out of useable POKéMON blacks you out even when the battle was -- already decided in our favour. A double faint -- our last mon dying -- to residual damage on the turn it lands the KO -- used to hit the @@ -9505,7 +9485,7 @@ function BattleState:playerMonFainted() local ChoiceBox = require("src.ui.ChoiceBox") return ChoiceBox.new(game, function(yes) if yes then return end -- the menu-phase guard opens the party menu - local pSpd = (game.save.party[1].stats or { speed = 0 }).speed or 0 + local pSpd = (self:playerPartyView()[1].stats or { speed = 0 }).speed or 0 if self:runRoll(pSpd, TurnOrder.effectiveSpeed(self.enemy)) then require("src.core.Sound").play(self.data, "Run") self:say(self:romText("_GotAwayText", "Got away safely!")) @@ -9536,7 +9516,7 @@ function BattleState:openReplacementMenu() end self:restoreMimicked(self.player) local previous = self.player - self.player = makeBattler(self.data, mon, true, game.save) + self.player = makeBattler(self.data, mon, true, game.save, self) clearTrapping(self.enemy) -- SendOutMon clears foe trap self:syncSides() Runtime.emit("battle.battler_switched", { @@ -9563,7 +9543,7 @@ function BattleState:openReplacementMenu() -- unasked (PlayerHandleChoosePokemon's arena arm returns the next slot) if self.palace or self.arena then self:act(function() - for _, mon in ipairs(game.save.party or {}) do + for _, mon in ipairs(self:playerPartyView()) do if mon.hp > 0 and not Party.isEgg(mon) and mon ~= (self.player and self.player.mon) then onSwitch(mon) return @@ -9764,7 +9744,7 @@ function BattleState:safariEnemyTurn() self:sayNext(self:romText("_SafariZoneAngryText", "Wild %s\nis angry!", self.enemy.name)) end self:act(function() - local speed = self.enemy.curStats.speed % 256 + local speed = Gen2Read.stats(self.enemy).speed % 256 local fled = speed > 127 local b = (speed * 2) % 256 if not fled then @@ -10800,6 +10780,7 @@ function BattleState:playVictoryMusic() end function BattleState:finish() + if self._gen2Complete then return end local pd = self.pokedudeDemo if pd and not pd.finalVoiceQueued then pd.finalVoiceQueued = true @@ -10842,7 +10823,7 @@ function BattleState:finish() -- go on, the partner heals in the field), so it stands. if self.result ~= "lose" and not self.demo and not self.pokedudeDemo and not (self.ally and self.result == "win") - and not Party.firstHealthy(self.game.save.party) then + and not Party.firstHealthy(self:playerPartyView()) then Logger.warn("battle finished %s with no healthy party; forcing blackout", tostring(self.result)) self.result = "lose" @@ -10860,8 +10841,10 @@ function BattleState:finish() -- like the overworld reload's PlayDefaultMusicFadeOutCurrent -- (home/overworld.asm:2343-2348) require("src.core.Music").restoreMap(self.data) + if GameVersion.isGen2() then self._gen2Complete = true end self.game.stack:pop() - Runtime.emit("battle.ended", { battle = self, result = self.result or "run" }) + if GameVersion.isGen2() then self.battle:endBattle(self.result or 'run', {nativeNotification=true}) + else Runtime.emit("battle.ended", { battle = self, result = self.result or "run" }) end -- Coming back from the battle screen is a fade, not a cut: EnterMap sees -- BIT_BATTLE_OVER_OR_BLACKOUT set and runs MapEntryAfterBattle -- (home/overworld.asm:22, :749-753) = GBFadeInFromWhite. This is the one @@ -12589,7 +12572,7 @@ function BattleState:drawHUDs(slide) for i = 10, 17 do hudTile(0x76, i * 8, 88) end hudTile(0x6F, 72, 88) love.graphics.setColor(1, 1, 1, 1) - self:drawBallRow(self.playerParty or self.game.save.party, 88, 80, 8) + self:drawBallRow(self:playerPartyView(), 88, 80, 8) end local hidePlayer = self.safari or self:demoHidesPlayer() if self.player and not hidePlayer and not self.showPlayerBack @@ -12963,4 +12946,5 @@ function BattleState:drawClassic() end end +Gen2BattleView.install(BattleState) return BattleState diff --git a/src/battle/Battler.lua b/src/battle/Battler.lua new file mode 100644 index 000000000..4fc8e58cd --- /dev/null +++ b/src/battle/Battler.lua @@ -0,0 +1,51 @@ +-- Native battler data, extracted from BattleState.makeBattler. +-- Existing engine authors' rights and licences remain in effect (see the upstream licences). +-- Data/presentation boundary contributions by Roxas2712; see +-- https://github.com/Roxas2712/Gen2Recomped/blob/main/LICENSE-ROXAS2712.md. +-- This module allocates no picture or animation resource. +local Forms = require('src.pokemon.Gen4Forms') +local Gen2 = require('src.battle.Gen2BattleRead') +local Battler = {} + +function Battler.new(data, mon, isPlayer, save, battle) + local def = Forms.definition(data, mon) + local badgeBoosts = data.constants and data.constants.badgeBoosts + local badges + if isPlayer and save then + -- Gen 1 badge stat boosts (x9/8); the badge set follows the merged + -- badgeBoosts rows so a retuned list changes what gets baked in. + badges = {} + for _, row in ipairs(badgeBoosts or require('src.battle.Damage').BADGE_BOOSTS) do + if save.inventory[row.badge] then badges[row.badge] = true end + end + end + local battler = { + mon = mon, + def = def, + name = mon.nickname or def.name, + -- Presentation species may later change for Transform or a Tower ghost; + -- mon.species remains the Pokemon's identity in the save. + species = mon.species, + isPlayer = isPlayer, + badges = badges, + badgeBoosts = badgeBoosts, + statuses = data.statuses, + -- Registry views, not copies: a consumed berry and its nature lookup + -- must see the live merged records throughout the battle. + items = data.items, + natures = data.constants and data.constants.natures, + shownHP = mon.hp, + -- HUDs catch up at native refresh edges, not during a move's mutation. + shownStatus = mon.status, + -- A sleep applied outside battle keeps its own remaining count. + sleepTurns = (mon.status == 'SLP' and mon.sleepTurns) or nil, + stages = {}, + -- Transform/Conversion/Mimic replace these transient views. + curStats = mon.stats, + curTypes = def.types, + curMoves = mon.moves, + } + return Gen2.bind(battler, battle) +end + +return Battler diff --git a/src/battle/Damage.lua b/src/battle/Damage.lua index c287f54a9..60cd86214 100644 --- a/src/battle/Damage.lua +++ b/src/battle/Damage.lua @@ -6,6 +6,7 @@ -- Battlers built by makeBattler also carry the merged badgeBoosts rows and -- statuses records; hand-built battlers fall back to the vanilla tables. +local Gen2Read = require('src.battle.Gen2BattleRead') local Logger = require("src.core.Logger") local Runtime = require("src.mods.Runtime") local Stats = require("src.pokemon.Stats") @@ -115,7 +116,7 @@ function Damage.critRoll(ruleset, attacker, moveId, rng, highCrit, data) local function shl(x) return math.min(255, x * 2) end local speed if ruleset.critUsesBaseSpeed == false then - speed = Stats.applyStage(attacker.curStats.speed, + speed = Stats.applyStage(Gen2Read.stats(attacker).speed, attacker.stages and attacker.stages.speed or 0) else speed = attacker.def.baseStats.speed @@ -231,6 +232,8 @@ end -- self-hit reads the opponent's screens). -- Returns damage, {crit=bool, typeMult=x10}. function Damage.compute(ruleset, attacker, defender, move, opts) + local attackStats, defendStats = Gen2Read.stats(attacker), Gen2Read.stats(defender) + local attackTypes, defendTypes = Gen2Read.types(attacker), Gen2Read.types(defender) opts = opts or {} local rng = opts.rng or love.math.random -- ...and the same field again, because a STATUS move must not roll damage. @@ -297,18 +300,18 @@ function Damage.compute(ruleset, attacker, defender, move, opts) -- Gen 2 kept the same type-based physical/special split but gave the -- attacker Sp.Atk and the defender Sp.Def. A Gen 1 stat block has -- neither key, so it stays on the single `special`. - local split = special and attacker.curStats.spatk and defender.curStats.spdef + local split = special and attackStats.spatk and defendStats.spdef local atkStat = special and (split and "spatk" or "special") or "attack" local defStat = special and (split and "spdef" or "special") or "defense" local atk, dfn if crit and ruleset.critIgnoresStages then - atk = attacker.curStats[atkStat] - dfn = defender.curStats[defStat] + atk = attackStats[atkStat] + dfn = defendStats[defStat] else - atk = Stats.applyStage(attacker.curStats[atkStat], + atk = Stats.applyStage(attackStats[atkStat], stageOf(attacker, atkStat)) - dfn = Stats.applyStage(defender.curStats[defStat], + dfn = Stats.applyStage(defendStats[defStat], stageOf(defender, defStat)) -- badge boosts (x9/8), engine/battle/core.asm ApplyBadgeStatBoosts: -- Boulder -> attack, Thunder -> defense, Soul -> speed (TurnOrder), @@ -464,7 +467,7 @@ function Damage.compute(ruleset, attacker, defender, move, opts) if not opts.typeless then -- STAB local stab = false - for _, t in ipairs(attacker.curTypes) do + for _, t in ipairs(attackTypes) do if t == move.type then stab = true break end end if stab then @@ -474,7 +477,7 @@ function Damage.compute(ruleset, attacker, defender, move, opts) -- type effectiveness: each TypeEffects row is applied to the -- running damage separately with its own floor (0.5*0.5 lands on -- floor(floor(d/2)/2), not d*0.25) - mult = TypeChart.effectiveness(move.type, defender.curTypes) + mult = TypeChart.effectiveness(move.type, defendTypes) -- FORESIGHT / ODOR SLEUTH: a GHOST that has been identified stops being -- immune to NORMAL and FIGHTING. It does not become weak to them -- -- the immunity simply becomes neutral -- so this reads the chart's @@ -489,7 +492,7 @@ function Damage.compute(ruleset, attacker, defender, move, opts) if mult == 0 and bwLift then foresight = true mult = 10 - for _, m in ipairs(TypeChart.rows(move.type, defender.curTypes)) do + for _, m in ipairs(TypeChart.rows(move.type, defendTypes)) do if m ~= 0 then mult = math.floor(mult * m / 10) end end end @@ -504,7 +507,7 @@ function Damage.compute(ruleset, attacker, defender, move, opts) return 0, { crit = false, typeMult = 0, ability = Abilities.of(defender) } end - for _, m in ipairs(TypeChart.rows(move.type, defender.curTypes)) do + for _, m in ipairs(TypeChart.rows(move.type, defendTypes)) do -- the identified GHOST's zero row is the one skipped; every other -- row a dual-typed Pokemon carries still applies if not (foresight and m == 0) then d = math.floor(d * m / 10) end diff --git a/src/battle/EffectRegistry.lua b/src/battle/EffectRegistry.lua index 87a4bd83a..25c1f8600 100644 --- a/src/battle/EffectRegistry.lua +++ b/src/battle/EffectRegistry.lua @@ -44,6 +44,9 @@ EffectRegistry.displayName = displayName -- flags recorded (every Gen 1 and Gen 2 one) is affected, that generation's -- rule. function EffectRegistry.protectAffected(move) + -- GSC self-protection must reach its own restriction even if the foe protects. + if require('src.core.GameVersion').isGen2() + and (move.effect == 'PROTECT_EFFECT' or move.effect == 'ENDURE_EFFECT') then return false end if move.protectAffected ~= nil then return move.protectAffected ~= false end if move.gen4Effect ~= nil and tonumber(move.flags) then return math.floor(tonumber(move.flags) / 2) % 2 == 1 diff --git a/src/battle/Gen2BattleRead.lua b/src/battle/Gen2BattleRead.lua new file mode 100644 index 000000000..f4e044834 --- /dev/null +++ b/src/battle/Gen2BattleRead.lua @@ -0,0 +1,47 @@ +-- Copyright (c) 2026 Roxas2712. See https://github.com/Roxas2712/Gen2Recomped/blob/main/LICENSE-ROXAS2712.md. +-- Pure reads at native calculation sites. Wrappers, PP, volatile state and +-- checkpoint tables retain their identity and original serializable fields. +local Model = require('src.battle.gen2.Battle') +local Read = {} +local owners = setmetatable({}, { __mode = 'kv' }) +local nativeStat, nativeDef = Model.battleStat, Model.speciesDef + +function Read.bind(battler, screen) + if battler and screen and screen.battle and screen.battle ~= screen then + owners[battler] = screen + end + return battler +end + +local function modelOf(battler) + local screen = owners[battler] + local model = screen and screen.battle + -- Non-Gen2 screens use themselves as their model and keep direct reads. + return model ~= screen and model or nil +end + +function Read.stats(battler) + local stats = battler and battler.curStats + if Model.battleStat == nativeStat then return stats end + local model = battler and modelOf(battler) + if not model then return stats end + local values = {} + for key in pairs(stats or {}) do + values[key] = model:battleStat(battler.mon, key) + end + return values +end + +function Read.types(battler) + local types = battler and battler.curTypes + if Model.speciesDef == nativeDef then return types end + local model = battler and modelOf(battler) + if not model then return types end + -- Conversion/Transform own an explicit native type list. A species reader + -- must not erase a move's live volatile override, or mutate its source row. + if not battler.def or types ~= battler.def.types then return types end + local def = model:speciesDef(battler.mon) + return def and def.types or types +end + +return Read diff --git a/src/battle/Gen2BattleView.lua b/src/battle/Gen2BattleView.lua new file mode 100644 index 000000000..787584ccb --- /dev/null +++ b/src/battle/Gen2BattleView.lua @@ -0,0 +1,67 @@ +-- Copyright (c) 2026 Roxas2712. See the offered contribution licence. +-- Separate live Gen2 model over the existing shared native controller. +local View = {} +local Model = require('src.battle.gen2.Battle') +local function partyOf(screen) + return screen.playerParty or screen.game.save.party or {} +end + +local function indexOf(party, mon) + for index, candidate in ipairs(party or {}) do + if candidate == mon then return index end + end +end + +function View.bind(screen) + -- Bind once; callers read the current native catalog without copying a + -- move, caching a definition, or switching the controller's moveset. + local readers = { + player = function() return screen.player and screen.player.mon end, + enemy = function() return screen.enemy and screen.enemy.mon end, + party = function() return partyOf(screen) end, + enemyParty = function() return screen.enemyParty end, + save = function() return screen.game.save end, + data = function() return screen.data end, + rng = function() return rawget(screen.battle, '_nativeLoveRng') end, + random = function() return rawget(screen.battle, '_nativeRandom') end, + wild = function() return screen.kind == "wild" end, + playerIndex = function() return indexOf(partyOf(screen), screen.player and screen.player.mon) end, + enemyIndex = function() return indexOf(screen.enemyParty, screen.enemy and screen.enemy.mon) end, + over = function() return screen.result ~= nil or screen.dead == true or screen._gen2Complete == true end, + outcome = function() return screen.result end, + -- The controller increments once per actual round, before turn-start + -- observers, including link participants and spectators. A source query + -- must not start a round or initialize checkpoint/persistent state. + turn = function() return screen.turnCount or 0 end, + battleType = function() return screen.battleType end, + trainer = function() return screen.trainer end, + tutorial = function() return screen.demo or screen.dudeDemo or screen.pokedudeDemo end, + contest = function() return screen.contest end, + safari = function() return screen.safari and true or false end, + link = function() return screen.kind == "link" end, + kind = function() return screen.kind end, + inBattleTowerBattle = function() return screen.battleTower == true end, + } + screen.battle = Model.bind(setmetatable({}, { + __index = function(_, key) + local read = readers[key] + if read then return read() end + return Model[key] + end, + __newindex = function(model, key, value) + if readers[key] or Model[key] ~= nil then error("native Gen2 battle view is read-only", 2) end + -- Native combat fields stay owned by the engine. Presentation consumers + -- may attach and release their own transient owners, as on the native + -- Gen2 model; these extensions never write to a battler or save. + rawset(model, key, value) + end, + }), screen) +end + +function View.install(Screen) + function Screen:completeBattle() + assert(require('src.core.GameVersion').isGen2(), 'Gen2 completion requires a Gen2 battle') + return self:finish() + end +end +return View diff --git a/src/battle/Gen2MovePriority.lua b/src/battle/Gen2MovePriority.lua new file mode 100644 index 000000000..9f9ac43c5 --- /dev/null +++ b/src/battle/Gen2MovePriority.lua @@ -0,0 +1,23 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- pokecrystal GetMovePriority/data/moves/effects_priorities.asm. Older native +-- caches only stamp priority-hit moves; derive the other GSC classes from +-- their native effect rather than treating Protect/Endure as ordinary moves. +local Priority = {} +local effects = { + PROTECT_EFFECT = 3, ENDURE_EFFECT = 3, + COUNTER_EFFECT = -1, MIRROR_COAT_EFFECT = -1, + FORCE_SWITCH_EFFECT = -1, +} +local ids = { + QUICK_ATTACK = 1, COUNTER = -1, VITAL_THROW = -1, + -- The shared native cache merges Roar/Whirlwind and Teleport into + -- SWITCH_AND_TELEPORT_EFFECT. Teleport is not a negative-priority move. + ROAR = -1, WHIRLWIND = -1, +} +function Priority.get(move) + if not move then return 0 end + -- Preserve explicit cartridge/mod data, including an explicit zero. + if move.priority then return move.priority end + return effects[move.effect] or ids[move.id] or 0 +end +return Priority diff --git a/src/battle/Gen2Protection.lua b/src/battle/Gen2Protection.lua new file mode 100644 index 000000000..299260dc6 --- /dev/null +++ b/src/battle/Gen2Protection.lua @@ -0,0 +1,30 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- GSC ProtectChance: a nonzero byte minus one against FF >> prior uses. +-- Keep the existing native action, battler and RNG owners. +local Protection={} +local function side(user)return user.isPlayer and 'player' or 'enemy'end + +function Protection.actionStarted(battle,user) + local turn=battle.turnCount or 0 + if battle._gen2ProtectionTurn~=turn then + battle._gen2ProtectionTurn=turn;battle._gen2ProtectionFirst=nil + end + if not battle._gen2ProtectionFirst then battle._gen2ProtectionFirst=side(user)end +end + +function Protection.apply(battle,user,field) + local turn=battle.turnCount or 0 + local first=battle._gen2ProtectionTurn==turn and battle._gen2ProtectionFirst + local count=math.max(0,math.floor(tonumber(user.protectRun)or 0)) + local threshold=count<8 and math.floor(255/2^count)or 0 + -- These branches precede the draw. A called protection effect retains the + -- original round's first side, including after a native switch replaces it. + if (first and first~=side(user))or(user.substituteHP or 0)>0 + or threshold==0 or battle.rng(1,255)-1>=threshold then + user.protectRun=0 + return false + end + user.protectRun=count+1;user[field]=true + return true +end +return Protection diff --git a/src/battle/Gen2TrainerClass.lua b/src/battle/Gen2TrainerClass.lua new file mode 100644 index 000000000..3a4044175 --- /dev/null +++ b/src/battle/Gen2TrainerClass.lua @@ -0,0 +1,20 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Canonical vanilla GSC class identities in TrainerGroups order. Imported +-- records carry this numeric index; their translated/display name is not an +-- identity (all badge leaders can be named LEADER). +local Class = {} +local names = { + 'FALKNER','WHITNEY','BUGSY','MORTY','PRYCE','JASMINE','CHUCK','CLAIR', + 'RIVAL1','POKEMON_PROF','WILL','CAL','BRUNO','KAREN','KOGA','CHAMPION', + 'BROCK','MISTY','LT_SURGE','SCIENTIST','ERIKA','YOUNGSTER','SCHOOLBOY', + 'BIRD_KEEPER','LASS','JANINE','COOLTRAINERM','COOLTRAINERF','BEAUTY','POKEMANIAC', + 'GRUNTM','GENTLEMAN','SKIER','TEACHER','SABRINA','BUG_CATCHER','FISHER','SWIMMERM', + 'SWIMMERF','SAILOR','SUPER_NERD','RIVAL2','GUITARIST','HIKER','BIKER','BLAINE', + 'BURGLAR','FIREBREATHER','JUGGLER','BLACKBELT_T','EXECUTIVEM','PSYCHIC_T', + 'PICNICKER','CAMPER','EXECUTIVEF','SAGE','MEDIUM','BOARDER','POKEFANM', + 'KIMONO_GIRL','TWINS','POKEFANF','RED','BLUE','OFFICER','GRUNTF','MYSTICALMAN', +} +function Class.fromIndex(index) + return names[tonumber(index)] +end +return Class diff --git a/src/battle/Gen3MoveEffects.lua b/src/battle/Gen3MoveEffects.lua index 1704c46d1..384f1af58 100644 --- a/src/battle/Gen3MoveEffects.lua +++ b/src/battle/Gen3MoveEffects.lua @@ -256,7 +256,7 @@ P.HEAL_BELL_EFFECT = function(battle, user) user.mon.status = nil user.toxicCounter = nil local party = user.isPlayer and battle.game and battle.game.save - and battle.game.save.party + and (battle.playerParty or battle.game.save.party) for _, mon in ipairs(party or {}) do mon.status = nil end return { Strings("A bell chimed!") } end @@ -284,6 +284,10 @@ end -- else, so a spammed PROTECT fails the way the cartridge makes it fail. local function protection(field, text) return function(battle, user) + if require("src.core.GameVersion").isGen2() then + if not require("src.battle.Gen2Protection").apply(battle, user, field) then return { Strings("But, it failed!") } end + return { Strings(text, displayName(user)) } + end local run = user.protectRun or 0 -- 1/1, then 1/2, 1/4, 1/8... A run of four is already a coin flip -- three times over, which is what stops it being a stall lock. @@ -1024,7 +1028,7 @@ F.FUTURE_SIGHT_EFFECT = { F.BEAT_UP_EFFECT = { hitCount = function(ctx) local party = ctx.user.isPlayer and ctx.battle.game - and ctx.battle.game.save and ctx.battle.game.save.party + and ctx.battle.game.save and (ctx.battle.playerParty or ctx.battle.game.save.party) if not party then return 1 end local n = 0 for _, mon in ipairs(party) do diff --git a/src/battle/Gen5MoveEffects.lua b/src/battle/Gen5MoveEffects.lua index 64682dade..46abc5276 100644 --- a/src/battle/Gen5MoveEffects.lua +++ b/src/battle/Gen5MoveEffects.lua @@ -1545,7 +1545,7 @@ end }) DEF.BW_BEAT_UP = F({ hitCount = function(ctx) local party = ctx.user.isPlayer and ctx.battle.game and ctx.battle.game.save - and ctx.battle.game.save.party or ctx.battle.enemyParty + and (ctx.battle.playerParty or ctx.battle.game.save.party) or ctx.battle.enemyParty local n = 0 for _, mon in ipairs(party or {}) do if (mon.hp or 0) > 0 and not mon.status then n = n + 1 end @@ -1708,7 +1708,7 @@ end }) local function benchOf(battle, b) local out = {} if b.isPlayer then - local party = battle.game and battle.game.save and battle.game.save.party or {} + local party = battle.game and battle.game.save and (battle.playerParty or battle.game.save.party) or {} for _, mon in ipairs(party) do if mon ~= b.mon and (mon.hp or 0) > 0 and not mon.isEgg and not mon.egg then local onField = false @@ -2105,7 +2105,7 @@ DEF.BW_REFRESH = P({ run = enginePrimary("REFRESH_EFFECT") }) DEF.BW_HEAL_BELL = P({ run = function(ctx) local battle, user = ctx.battle, ctx.user user.mon.status, user.toxicCounter = nil, nil - local party = user.isPlayer and battle.game and battle.game.save and battle.game.save.party + local party = user.isPlayer and battle.game and battle.game.save and (battle.playerParty or battle.game.save.party) or battle.enemyParty for _, mon in ipairs(party or {}) do mon.status = nil end return { T.std(battle, ctx.move.id == 312 and "aroma" or "bell") } @@ -2490,7 +2490,7 @@ DEF.BW_METRONOME = F({ callsMove = function(ctx) end }) DEF.BW_ASSIST = F({ callsMove = function(ctx) local battle, u = ctx.battle, ctx.user - local party = u.isPlayer and battle.game and battle.game.save and battle.game.save.party or battle.enemyParty + local party = u.isPlayer and battle.game and battle.game.save and (battle.playerParty or battle.game.save.party) or battle.enemyParty local pool = {} for _, mon in ipairs(party or {}) do if mon ~= u.mon then diff --git a/src/battle/Gen5MoveRules.lua b/src/battle/Gen5MoveRules.lua index b6faa003d..c8afd08cf 100644 --- a/src/battle/Gen5MoveRules.lua +++ b/src/battle/Gen5MoveRules.lua @@ -609,7 +609,7 @@ end local function beatUp(G5) return F({ perform = function(ctx, rec) local battle, user, target, move = ctx.battle, ctx.user, ctx.target, ctx.move - local party = user.isPlayer and battle.game and battle.game.save and battle.game.save.party + local party = user.isPlayer and battle.game and battle.game.save and (battle.playerParty or battle.game.save.party) or battle.enemyParty or {} local members = {} for _, mon in ipairs(party) do diff --git a/src/battle/MoveEffects.lua b/src/battle/MoveEffects.lua index 5772141ae..d5e4059ae 100644 --- a/src/battle/MoveEffects.lua +++ b/src/battle/MoveEffects.lua @@ -15,6 +15,7 @@ local Logger = require("src.core.Logger") local StatusRegistry = require("src.battle.StatusRegistry") local TurnOrder = require("src.battle.TurnOrder") local TypeChart = require("src.battle.TypeChart") +local Gen2Read = require('src.battle.Gen2BattleRead') local Strings = require("src.core.Strings") local HeldItems = require("src.battle.HeldItems") local Weather = require("src.battle.Weather") @@ -274,7 +275,7 @@ MoveEffects.primary = { if target.leechSeeded then return { Strings("But, it failed!") } end - for _, t in ipairs(target.curTypes) do + for _, t in ipairs(Gen2Read.types(target)) do if t == "GRASS" then return { Strings("But, it failed!") } end end target.leechSeeded = true @@ -370,7 +371,8 @@ MoveEffects.primary = { if target.invulnerable then return { Strings("But, it failed!") } end - user.curTypes = { target.curTypes[1], target.curTypes[2] } + local types = Gen2Read.types(target) + user.curTypes = { types[1], types[2] } -- _ConvertedTypeText return { Strings("Converted type to\n%s's!", displayName(target)) } end, @@ -394,13 +396,15 @@ MoveEffects.primary = { local okT, T = pcall(require, "src.battle.Gen5Transform") if okT then T.apply(battle, user, target) end end + local stats = Gen2Read.stats(target) user.curStats = { hp = user.mon.stats.hp, -- HP is kept - attack = target.curStats.attack, defense = target.curStats.defense, - speed = target.curStats.speed, special = target.curStats.special, - spatk = target.curStats.spatk, spdef = target.curStats.spdef, + attack = stats.attack, defense = stats.defense, + speed = stats.speed, special = stats.special, + spatk = stats.spatk, spdef = stats.spdef, } - user.curTypes = { target.curTypes[1], target.curTypes[2] } + local types = Gen2Read.types(target) + user.curTypes = { types[1], types[2] } -- transform.asm:130-132 copies the target's stat MODS into the user -- (wEnemyMonStatMods -> wPlayerMonStatMods), it does NOT clear them; -- deep copy so later stage changes on either mon stay independent @@ -566,7 +570,7 @@ end -- effectiveness step. So in Gen 1 NIGHT_SHADE hits Normal-types and -- SUPER_FANG hits Ghosts, and only OHKO_EFFECT calls this (#616). local function immuneMsg(ctx) - if TypeChart.effectiveness(ctx.move.type, ctx.target.curTypes) == 0 then + if TypeChart.effectiveness(ctx.move.type, Gen2Read.types(ctx.target)) == 0 then return Strings("It doesn't affect\n%s!", displayName(ctx.target)) end return nil diff --git a/src/battle/PlayerPartyScope.lua b/src/battle/PlayerPartyScope.lua new file mode 100644 index 000000000..6706a2b4b --- /dev/null +++ b/src/battle/PlayerPartyScope.lua @@ -0,0 +1,36 @@ +-- Battle-local ordered references; never reorder or replace save.party. +local Logger = require("src.core.Logger") +local M = {} +function M.select(game, indices) + if indices == nil then + return nil, nil + end + local function fallback() + Logger.warn("trainer battle party scope is invalid; using full party") + return nil, nil + end + if type(indices) ~= "table" or #indices == 0 then + return fallback() + end + local count = #indices + local keys = 0 + for key in pairs(indices) do + keys = keys + 1 + if type(key) ~= "number" or key % 1 ~= 0 or key < 1 or key > count then + return fallback() + end + end + if keys ~= count then + return fallback() + end + local party, normalized, seen = {}, {}, {} + for i = 1, count do + local index = indices[i] + if type(index) ~= "number" or index % 1 ~= 0 or not game.save.party[index] or seen[index] then + return fallback() + end + seen[index], normalized[i], party[i] = true, index, game.save.party[index] + end + return party, normalized +end +return M diff --git a/src/battle/Status.lua b/src/battle/Status.lua index 955361db0..d81ec5c1b 100644 --- a/src/battle/Status.lua +++ b/src/battle/Status.lua @@ -5,6 +5,7 @@ -- and residual sweep. Callers without a battle (pure-module tests) fall -- back to the vanilla records, which is bit-identical behavior. +local Gen2Read = require('src.battle.Gen2BattleRead') local Strings = require("src.core.Strings") local HeldItems = require("src.battle.HeldItems") @@ -24,7 +25,7 @@ end local VOLATILE_PRIORITY = 20 local function hasType(battler, wanted) - for _, t in ipairs(battler.curTypes or {}) do + for _, t in ipairs(Gen2Read.types(battler) or {}) do if t == wanted then return true end end return false diff --git a/src/battle/StatusRegistry.lua b/src/battle/StatusRegistry.lua index d65321c78..b0b44f619 100644 --- a/src/battle/StatusRegistry.lua +++ b/src/battle/StatusRegistry.lua @@ -3,6 +3,7 @@ -- (canInflict) and so does the landing text (onInflict), so a mod status -- inflicts through the same path as the vanilla five. +local Gen2Read = require('src.battle.Gen2BattleRead') local Runtime = require("src.mods.Runtime") local Status = require("src.battle.Status") local Strings = require("src.core.Strings") @@ -32,7 +33,7 @@ function StatusRegistry.inflict(battle, target, status, opts) -- move's type matches either of the target's types (Body Slam can't -- paralyze Normals, Fire can't burn Fire, Ice can't freeze Ice) if opts.secondary and status ~= "PSN" then - for _, t in ipairs(target.curTypes or {}) do + for _, t in ipairs(Gen2Read.types(target) or {}) do if opts.moveType == t then return {} end end end diff --git a/src/battle/TurnOrder.lua b/src/battle/TurnOrder.lua index bb481a7ad..1cd1a6cf2 100644 --- a/src/battle/TurnOrder.lua +++ b/src/battle/TurnOrder.lua @@ -3,6 +3,7 @@ -- record's priority field; the id table below covers pre-existing -- imported caches (Gen 1 has only QUICK_ATTACK first and COUNTER last). +local Gen2Read = require('src.battle.Gen2BattleRead') local Damage = require("src.battle.Damage") local Stats = require("src.pokemon.Stats") local Status = require("src.battle.Status") @@ -17,7 +18,7 @@ local Abilities = require("src.battle.Abilities") -- nowhere else the weather is visible from here. Every existing caller -- passes nothing and gets exactly the old answer. local function effectiveSpeed(battler, battle) - local spd = Stats.applyStage(battler.curStats.speed, + local spd = Stats.applyStage(Gen2Read.stats(battler).speed, battler.stages and battler.stages.speed or 0) -- ApplyBadgeStatBoosts: the SOULBADGE boosts speed; the rows come from -- the battler's merged badgeBoosts with the vanilla list as fallback @@ -65,6 +66,9 @@ end local PRIORITY = { QUICK_ATTACK = 1, COUNTER = -1 } local function priority(move) + if require('src.core.GameVersion').isGen2() then + return require('src.battle.Gen2MovePriority').get(move) + end if not move then return 0 end if move.priority then return move.priority end return PRIORITY[move.id] or 0 diff --git a/src/battle/Weather.lua b/src/battle/Weather.lua index e3a3fc95e..fc9a18bef 100644 --- a/src/battle/Weather.lua +++ b/src/battle/Weather.lua @@ -14,6 +14,7 @@ -- Weather.start and every other entry point is a nil check on -- battle.field.weather. +local Gen2Read = require('src.battle.Gen2BattleRead') local Strings = require("src.core.Strings") local Weather = {} @@ -296,7 +297,7 @@ local function weatherDamage(battle, battler, weather) return end local immune = WEATHER_IMMUNE[weather] or {} - for _, t in ipairs(battler.curTypes or {}) do + for _, t in ipairs(Gen2Read.types(battler) or {}) do if immune[t] then return end end battle:animNext(weather == Weather.HAIL and "inHail" or "inSandstorm", diff --git a/src/battle/gen2/Battle.lua b/src/battle/gen2/Battle.lua new file mode 100644 index 000000000..a4a0252d7 --- /dev/null +++ b/src/battle/gen2/Battle.lua @@ -0,0 +1,146 @@ +-- Copyright (c) 2026 Roxas2712. See https://github.com/Roxas2712/Gen2Recomped/blob/main/LICENSE-ROXAS2712.md. +-- The live Gen2 model boundary of the shared native controller. This is not +-- an alias to the screen and does not construct or advance a second battle. +local Runtime = require('src.mods.Runtime') +local Model = {} +-- IsGymLeader's cartridge list includes the Elite Four, Champion and Red, +-- then falls through into the eight Kanto leaders. Keep this public source +-- predicate distinct from the native badge/Pikachu companion marker. +Model.KANTO_GYM_LEADER_CLASSES = { + BROCK=true, MISTY=true, LT_SURGE=true, ERIKA=true, + JANINE=true, SABRINA=true, BLAINE=true, BLUE=true, +} +Model.GYM_LEADER_CLASSES = { + FALKNER=true, WHITNEY=true, BUGSY=true, MORTY=true, + PRYCE=true, JASMINE=true, CHUCK=true, CLAIR=true, + WILL=true, BRUNO=true, KAREN=true, KOGA=true, CHAMPION=true, RED=true, +} +for class in pairs(Model.KANTO_GYM_LEADER_CLASSES) do + Model.GYM_LEADER_CLASSES[class] = true +end +function Model.isGymLeader(class) + return class ~= nil and Model.GYM_LEADER_CLASSES[class] == true +end +function Model.isKantoGymLeader(class) + return class ~= nil and Model.KANTO_GYM_LEADER_CLASSES[class] == true +end +local screens = setmetatable({}, { __mode = 'kv' }) +local ended = setmetatable({}, { __mode = 'k' }) + +local function owner(model) + return assert(screens[model], 'native Gen2 model is not attached to a battle') +end + +function Model.bind(model, screen) + screens[model] = screen + rawset(model, '_nativeRandom', function(n) return owner(model).rng(n) - 1 end) + -- Source GSC's LOVE-style view uses the current native function even if + -- link setup or a scoped native owner replaces it after this bind. + rawset(model, '_nativeLoveRng', function(lo, hi) + local rng = owner(model).rng + if hi == nil then return rng(math.max(1, lo or 1)) end + if hi < lo then lo, hi = hi, lo end + return rng(lo, hi) + end) + return model +end + +function Model:roller() + owner(self) + local roller = rawget(self, '_nativeRoller') + if not roller then + -- Zero-based BattleRandom over the same existing stream. Keep the cache + -- on the transient model; a global value closure could root a weak key. + local random = rawget(self, '_nativeRandom') + roller = function(n) return random(n) end + rawset(self, '_nativeRoller', roller) + end + return roller +end + +local function active(screen, mon) + if screen.player == mon or screen.enemy == mon then return mon end + if screen.player and screen.player.mon == mon then return screen.player end + if screen.enemy and screen.enemy.mon == mon then return screen.enemy end +end + +local volatileNames={substitute='substituteHP',protect='protecting',mist='mist', + confusion='confused',confusionTurns='confusionTurns',flinch='flinched', + -- Canonical source GSC fields must drive the actual shared controller. + confuseCount='confusedTurns',flinched='flinched',endure='enduring', + recharge='mustRecharge',protectCount='protectRun', + focusEnergy='focusEnergy',xAccuracy='xAccuracy'} +function Model:volatile(mon) + local screen=owner(self) + local b=assert(active(screen,mon),'volatile state requires an active battler') + -- A global weak-key cache still roots its battlers on LuaJIT when the + -- value's closures retain their key. Keep views on the transient model, + -- never on a serialized battler or party mon, and retire withdrawn owners. + local views=rawget(self,'_nativeVolatileViews') + if not views then views={};rawset(self,'_nativeVolatileViews',views)end + for previous in pairs(views)do + if previous~=screen.player and previous~=screen.enemy then views[previous]=nil end + end + if not views[b] then + local extensions={} + views[b]=setmetatable({}, { + __index=function(_,key)local field=volatileNames[key];if field then return b[field] end;return extensions[key]end, + __newindex=function(_,key,value)local field=volatileNames[key];if field then b[field]=value else extensions[key]=value end end, + }) + end + return views[b] +end + + +function Model:moveDef(move) + -- Older native screens take a move instance; scalar catalog queries belong + -- to this model and must not pass an id through that older signature. + if type(move) ~= 'table' then return self.data.moves[move] end + return owner(self):moveDef(move) +end + +function Model:movePriority(id) + return require('src.battle.Gen2MovePriority').get(self:moveDef(id)) +end + +function Model:speciesDef(mon) + return mon and self.data.pokemon and self.data.pokemon[mon.species] or nil +end + +-- Raw native battle statistics, before stages, badges, status or held items. +-- Those modifiers remain in the actual damage/order owners and run once. +-- A caller's temporary shadow mon uses its own stats without changing a party +-- member. Active Transform statistics remain on the existing native wrapper. +function Model:battleStat(mon, key) + local screen = screens[self] + local battler = screen and active(screen, mon) + local stats = battler and battler.curStats or mon and mon.stats + return stats and stats[key] +end + +function Model:sideOf(mon) + local screen = owner(self) + local battler = active(screen, mon) + if battler then return battler.isPlayer and 'player' or 'enemy' end +end + +-- Finish notification is owned by the native result path. Presentation stays +-- on the native queue; completeBattle/finish perform its actual screen return. +-- Existing native event consumers retain the screen in `battle`; the distinct +-- live model is also provided explicitly. No second save/party snapshot exists. +function Model:endBattle(outcome, details) + local screen = owner(self) + -- Preserve link/spectator and extension-owned native string outcomes. + assert(type(outcome) == 'string' and outcome ~= '', 'invalid native Gen2 battle outcome') + if ended[self] then return false end + if not (details and details.nativeNotification) then screen.result = outcome end + screen:restoreMimicked(screen.player) + screen:restoreMimicked(screen.enemy) + ended[self] = true + local event = { battle=screen, model=self, result=outcome } + if details and details.skipped then event.skipped = true end + Runtime.emit('battle.ended', event) + return true +end + +return Model diff --git a/src/core/ModCompat.lua b/src/core/ModCompat.lua index ee5e198fd..69eece921 100644 --- a/src/core/ModCompat.lua +++ b/src/core/ModCompat.lua @@ -37,17 +37,10 @@ -- src.ui.gen2.BattleAnimView Gen2AnimPlayer is a player, not a view -- src.battle.UIVisibility no equivalent -- src.ui.PartyPanel no equivalent --- src.battle.gen2.Battle AMBIGUOUS, which is the same as absent here. --- src.ui.gen2.BattleState is already mapped to --- src.battle.BattleState below; a port that --- spells BOTH names is not spelling one thing --- twice, it is naming the battle CONTROLLER and --- the battle STATE. This engine has one object --- for both, so whichever of the two a mod meant --- it would get -- and the half of the contract --- it did not mean would be missing, somewhere --- far away from the require. It goes in when a --- mod that wants it says what it calls on it. +-- +-- src.battle.gen2.Battle is a real live model module, not an alias. Its native +-- reads/completion are attached by Gen2BattleView and Gen2BattleRead. Unsupported +-- constructors and turn APIs are not guessed from similarly named screens. -- -- Each of those turns one optional feature off in the mod and says so in the -- log, which is the outcome we want. diff --git a/src/link/Fingerprint.lua b/src/link/Fingerprint.lua index 699eb68ef..fd8654fb8 100644 --- a/src/link/Fingerprint.lua +++ b/src/link/Fingerprint.lua @@ -17,6 +17,10 @@ local Runtime = require("src.mods.Runtime") local Fingerprint = {} +-- Command/RNG compatibility is independent of byte-identical ROM data. +function Fingerprint.nativeBattleRevision() + return require('src.core.GameVersion').isGen2() and 'gsc-1' or nil +end -- ------- FNV-1a, two lanes @@ -238,6 +242,8 @@ local function surface(data, mods) writeSection(out, data, "move_effects") writeConstants(out, data) writeLinkFields(out, data) + local revision=Fingerprint.nativeBattleRevision() + if revision then out[#out+1]='[native_gsc_commands]'..revision end out[#out + 1] = "[mods]" .. modKey(mods) return table.concat(out) end @@ -247,7 +253,7 @@ Fingerprint.surface = surface -- mods: { { id, version, affectsLink } } -- the hello's mod array function Fingerprint.compute(data, mods) if not data then return digest("") end - local key = modKey(mods) + local key = modKey(mods).."|native_commands:"..tostring(Fingerprint.nativeBattleRevision()) local hit = cache[data] if hit and hit.key == key then return hit.value end local value = Runtime.call("link.fingerprint", function(d, m) diff --git a/src/link/Handshake.lua b/src/link/Handshake.lua index aec8437e7..cec478157 100644 --- a/src/link/Handshake.lua +++ b/src/link/Handshake.lua @@ -10,6 +10,7 @@ local Fingerprint = require("src.link.Fingerprint") local Schemas = require("src.mods.Schemas") local Version = require("src.core.Version") +local Strings = require("src.core.Strings") local Handshake = {} @@ -158,6 +159,7 @@ function Handshake.hello(game, mode) engineVersion = Version.engine, apiVersion = Version.modApi, fingerprint = Fingerprint.compute(game and game.data, mods), + nativeBattleRevision = Fingerprint.nativeBattleRevision(), linkModified = Handshake.linkModified(game), mods = mods, } @@ -174,6 +176,7 @@ end function Handshake.checkCompat(localHello, remoteHello) localHello = localHello or {} if not remoteHello or not remoteHello.protocol then + if localHello.nativeBattleRevision then return "refused", "native_rules_required" end if localHello.linkModified then return "refused", "peer_v1_modified" end @@ -182,6 +185,7 @@ function Handshake.checkCompat(localHello, remoteHello) if major(remoteHello.engineVersion) ~= major(localHello.engineVersion) then return "refused", "engine_mismatch" end + if localHello.nativeBattleRevision ~= remoteHello.nativeBattleRevision then return "subset", "native_rules_mismatch" end if remoteHello.fingerprint == localHello.fingerprint then return "full", nil end @@ -264,6 +268,11 @@ end function Handshake.describe(localHello, remoteHello, verdict, mode) local lines = {} local peer = (remoteHello and remoteHello.name) or "THEY" + if (localHello and localHello.nativeBattleRevision) ~= (remoteHello and remoteHello.nativeBattleRevision) then + wrap(lines, Strings("The native battle rules differ.")) + wrap(lines, Strings("Use matching engine builds for link battles.")) + return lines + end if verdict == "refused" then if not (remoteHello and remoteHello.protocol) then wrap(lines, "The other game is") diff --git a/src/pokemon/Gen2Friendship.lua b/src/pokemon/Gen2Friendship.lua index 7ef888c15..6d58ab815 100644 --- a/src/pokemon/Gen2Friendship.lua +++ b/src/pokemon/Gen2Friendship.lua @@ -11,6 +11,21 @@ local changes = { LEVELUPATHOME = {10,6,4}, } +local events={LEVELUP=1,VITAMIN=2,XITEM=3,GYMBATTLE=4,LEARNMOVE=5, + FAINT=6,POISONFAINT=7,STRONGFOE=8,BITTERPOWDER=15,ENERGYROOT=16,REVIVALHERB=17,LEVELUPATHOME=19} +local sourceEvents={LEVELUP='GAINLEVEL',VITAMIN='USEDITEM',XITEM='USEDXITEM', + FAINT='FAINTED',STRONGFOE='BEATENBYSTRONGFOE',LEVELUPATHOME='GAINLEVELATHOME'} +local function changed(mon,from,event,reason) + local Runtime=require('src.mods.Runtime') + -- Applied source events remain observable even at byte saturation; + -- unchanged walking steps do not report a change. + if (event~=nil or from~=mon.happiness) and Runtime.wants("happiness.changed")then + Runtime.emit("happiness.changed",{mon=mon,event=event and (sourceEvents[event]or event), + eventId=events[event],reason=reason, + from=from,to=mon.happiness,delta=mon.happiness-from}) + end +end + function Friendship.isVanilla() return require("src.pokemon.Gen2Breeding").isVanilla() end @@ -23,6 +38,7 @@ function Friendship.change(mon, reason) local h = mon.happiness local band = h < 100 and 1 or h < 200 and 2 or 3 mon.happiness = math.max(0, math.min(255, h + row[band])) + changed(mon,h,reason,"event") return true end @@ -34,7 +50,9 @@ function Friendship.stepCycle(save) if save.g2HappinessStepCount ~= 0 then return end for _, mon in ipairs(save.party or {}) do if not require("src.pokemon.Party").isEgg(mon) and mon.happiness ~= nil then + local from=mon.happiness mon.happiness = math.min(255, mon.happiness + 1) + changed(mon,from,nil,"step") end end end diff --git a/src/ui/Gen3PartyMenu.lua b/src/ui/Gen3PartyMenu.lua index 4f3f78b39..59029684f 100644 --- a/src/ui/Gen3PartyMenu.lua +++ b/src/ui/Gen3PartyMenu.lua @@ -168,7 +168,9 @@ function Gen3PartyMenu.new(game, opts) return self end -function Gen3PartyMenu:party() return self.game.save.party or {} end +function Gen3PartyMenu:party() + return (self.battle and self.battle.playerParty) or self.game.save.party or {} +end -- slots 1..6, then the CANCEL button as slot #party+1 function Gen3PartyMenu:slots() return #self:party() + 1 end diff --git a/src/ui/Gen4PartyMenu.lua b/src/ui/Gen4PartyMenu.lua index 670908c8c..bbf5f6d0c 100644 --- a/src/ui/Gen4PartyMenu.lua +++ b/src/ui/Gen4PartyMenu.lua @@ -261,7 +261,9 @@ function Gen4PartyMenu:drawAt(key, x, y) return true end -function Gen4PartyMenu:party() return self.game.save.party or {} end +function Gen4PartyMenu:party() + return (self.battle and self.battle.playerParty) or self.game.save.party or {} +end -- Slots 1..#party, then CANCEL. function Gen4PartyMenu:count() return #self:party() + (self.select and 2 or 1) end diff --git a/src/ui/PartyMenu.lua b/src/ui/PartyMenu.lua index e59d7173b..8a32551c2 100644 --- a/src/ui/PartyMenu.lua +++ b/src/ui/PartyMenu.lua @@ -438,7 +438,7 @@ function PartyMenu.new(game, opts) self.tmhm = opts.tmhm self.forceSwitch = opts.forceSwitch self.battle = opts.battle - self.party = opts.party -- link battles pass their clamped copies + self.party = opts.party or (opts.battle and opts.battle.playerParty) -- link copies or trainer selection self.swapFrom = nil self.submenu = nil self.subIndex = 1 diff --git a/tests/engine/battler_data.lua b/tests/engine/battler_data.lua new file mode 100644 index 000000000..03b7ef5c9 --- /dev/null +++ b/tests/engine/battler_data.lua @@ -0,0 +1,92 @@ +-- Copyright (c) 2026 Roxas2712. See https://github.com/Roxas2712/Gen2Recomped/blob/main/LICENSE-ROXAS2712.md. +-- This process deliberately has no LÖVE stub. Model construction must not +-- silently allocate pictures, animations or a battle screen. +package.path = './?.lua;./?/init.lua;' .. package.path +local T = require('tests.harness') +T.eq(_G.love, nil, 'fresh plain-Lua process has no rendering runtime') +for _, name in ipairs({ + 'src.battle.BattleState', 'src.pokemon.Sprites', 'src.pokemon.PicAnim', + 'src.pokemon.MonAnim', 'src.render.Assets', +}) do + package.preload[name] = function() error('data constructor loaded presentation: ' .. name) end +end +local Battler = require('src.battle.Battler') +local Version = require('src.core.GameVersion') +local initial = Version.get() +local function copy(t) + if type(t) ~= 'table' then return t end + local r = {}; for k, v in pairs(t) do r[k] = copy(v) end; return r +end + +for _, edition in ipairs(Version.ORDER) do + Version.set(edition) + local label = edition .. ': ' + local types, moves = {'NORMAL'}, {{id='QA_MOVE', pp=8}} + local stats = {hp=40, attack=20, defense=21, speed=22, special=23, spatk=24, spdef=25} + local def = {name='SPECIES', types=types} + local data = {pokemon={QA_MON=def}, statuses={PSN={}}, items={QA_BERRY={}}, + constants={gen=Version.generation(), natures={}, badgeBoosts={ + {badge='QA_BADGE', stat='speed', num=2, den=1}, + {badge='QA_MISSING', stat='attack', num=2, den=1}, + }}} + local mon = {species='QA_MON', nickname='NICK', hp=17, status='PSN', toxic=true, + stats=stats, moves=moves, item='QA_BERRY'} + local save = {inventory={QA_BADGE=1, BOULDERBADGE=1}, party={mon}} + local beforeMon, beforeSave, beforeData = copy(mon), copy(save), copy(data) + local player = Battler.new(data, mon, true, save) + local opponent = Battler.new(data, mon, false, save) + local link = Battler.new(data, mon, true, nil) + T.eq(player.mon, mon, label .. 'original party member is retained') + T.eq(player.def, def, label .. 'species registry row is retained') + T.eq(player.name, 'NICK', label .. 'nickname is displayed') + T.eq(player.curStats, stats, label .. 'stats are a live native view') + T.eq(player.curTypes, types, label .. 'types are a live registry view') + T.eq(player.curMoves, moves, label .. 'PP-bearing move slots retain identity') + T.eq(player.items, data.items, label .. 'item registry retains identity') + T.eq(player.statuses, data.statuses, label .. 'status registry retains identity') + T.eq(player.natures, data.constants.natures, label .. 'nature registry retains identity') + T.eq(player.badgeBoosts, data.constants.badgeBoosts, label .. 'merged badge rows retain identity') + T.same(player.badges, {QA_BADGE=true}, label .. 'only merged possessed badges are captured') + T.eq(opponent.badges, nil, label .. 'opponent does not inherit player badges') + T.eq(link.badges, nil, label .. 'link construction without save has no badge boosts') + T.neq(player.stages, opponent.stages, label .. 'each battler owns transient stages') + T.eq(player.shownHP, 17, label .. 'HUD starts at actual HP') + T.eq(player.shownStatus, 'PSN', label .. 'HUD starts at actual condition') + T.eq(player.toxicCounter, nil, label .. 'older host retains native poison defaults') + for _, battler in ipairs({player, opponent, link}) do + for _, key in ipairs({'sprite', 'picAnim', 'monAnim'}) do + T.eq(battler[key], nil, label .. 'data construction has no ' .. key) + end + end + T.same(mon, beforeMon, label .. 'construction cannot change a Pokemon') + T.same(save, beforeSave, label .. 'construction cannot change a save') + T.same(data, beforeData, label .. 'construction cannot change registries') + mon.hp, mon.status = 3, 'SLP'; mon.sleepTurns = 4; mon.nickname = nil + T.eq(player.shownHP, 17, label .. 'native HUD snapshot waits for refresh') + T.eq(player.shownStatus, 'PSN', label .. 'native condition snapshot waits for refresh') + moves[1].pp = 7; T.eq(player.curMoves[1].pp, 7, label .. 'real PP writes remain visible') + local asleep = Battler.new(data, mon, true, save) + T.eq(asleep.name, def.name, label .. 'unnamed Pokemon uses species name') + T.eq(asleep.sleepTurns, 4, label .. 'outside-battle sleep count survives') + T.eq(asleep.toxicCounter, nil, label .. 'sleep does not acquire a toxic counter') + T.neq(asleep.badges, player.badges, label .. 'badge snapshots are per battler') + data.constants.badgeBoosts = nil + local fallback = Battler.new(data, mon, true, save) + T.same(fallback.badges, {BOULDERBADGE=true}, label .. 'unmerged badges use actual native Damage defaults') + T.eq(_G.love, nil, label .. 'construction never installs a fake rendering runtime') +end + +Version.set('platinum') +local base = {name='DEOXYS', types={'PSYCHIC'}, baseStats={attack=1}} +local attack = {types={'FIGHTING'}, baseStats={attack=180}} +local data = {constants={gen=4}, pokemon={[386]=base, [496]=attack}} +local mon = {species=386, form=1, hp=1, stats={}, moves={}} +local form = Battler.new(data, mon, false) +T.eq(form.species, 386, 'form construction keeps actual species identity') +T.eq(form.name, 'DEOXYS', 'form construction keeps base display name') +T.eq(form.curTypes, attack.types, 'form construction uses native personal-record types') +T.eq(form.def.baseStats, attack.baseStats, 'form construction uses native personal-record stats') +T.eq(data.pokemon[386], base, 'form view does not replace registry row') +T.same(base.types, {'PSYCHIC'}, 'alternate form does not mutate base types') +Version.set(initial) +T.finish('native battler data without LÖVE or presentation resources') diff --git a/tests/engine/battler_native_construction.lua b/tests/engine/battler_native_construction.lua new file mode 100644 index 000000000..cdc21a7b0 --- /dev/null +++ b/tests/engine/battler_native_construction.lua @@ -0,0 +1,82 @@ +-- Copyright (c) 2026 Roxas2712. See https://github.com/Roxas2712/Gen2Recomped/blob/main/LICENSE-ROXAS2712.md. +-- Exercise the actual native constructor and switches, including real sprite +-- selection and animation factories. No replacement art or cloned battle loop. +package.path = './?.lua;./?/init.lua;' .. package.path +local T = require('tests.modkit') +local Version = require('src.core.GameVersion') +local Battler = require('src.battle.Battler') +local Screen = require('src.battle.BattleState') +local Pokemon = require('src.pokemon.Pokemon') +local Save = require('src.core.SaveData') +local Sprites = require('src.pokemon.Sprites') +local PicAnim = require('src.pokemon.PicAnim') +local MonAnim = require('src.pokemon.MonAnim') +local initial = Version.get() +local make, path, pic, anim = Battler.new, Sprites.path, PicAnim.forMon, MonAnim.new +local builds, paths, pics, anims +Battler.new = function(...) + local b = make(...) + T.eq(b.sprite, nil, 'shared native factory does not hydrate a sprite') + T.eq(b.picAnim, nil, 'shared native factory does not hydrate a pic animation') + T.eq(b.monAnim, nil, 'shared native factory does not hydrate a procedural animation') + local fields = {}; for key, value in pairs(b) do fields[key] = value end + builds[#builds+1] = {battler=b, fields=fields} + return b +end +Sprites.path = function(data, species, side, opts) + paths[#paths+1] = {species=species, side=side, mon=opts.mon, kind=opts.kind} + return path(data, species, side, opts) +end +PicAnim.forMon = function(data, mon, side) + local value = pic(data, mon, side) + pics[#pics+1] = {mon=mon, side=side, value=value} + return value +end +MonAnim.new = function(data, species) + local value = anim(data, species) + anims[#anims+1] = {species=species, value=value} + return value +end + +for _, edition in ipairs(Version.ORDER) do + Version.set(edition) + builds, paths, pics, anims = {}, {}, {}, {} + local data = T.fixtures.fresh(); data.constants.gen = Version.generation() + require('src.render.Font').load(data) + local save = Save.newGame() + save.party = {Pokemon.new(data, 'FIXMON_A', 25), Pokemon.new(data, 'FIXMON_B', 20)} + local game = {data=data, save=save, stack={top=function() end}} + local battle = Screen.newWild(game, 'FIXMON_C', 15) + T.eq(#builds, 2, edition .. ' real wild constructor uses shared data factory for both sides') + T.eq(builds[1].battler, battle.player, 'player uses the actual constructed table') + T.eq(builds[2].battler, battle.enemy, 'enemy uses the actual constructed table') + T.eq(battle.player.mon, save.party[1], 'native player retains save identity') + T.eq(#paths, 2, 'one actual battle picture selection per side') + T.eq(paths[1].side, 'back', 'player keeps native back picture') + T.eq(paths[2].side, 'front', 'enemy keeps native front picture') + T.eq(paths[1].mon, battle.player.mon, 'player picture sees actual form/shiny-bearing Pokemon') + T.eq(paths[2].mon, battle.enemy.mon, 'enemy picture sees actual form/shiny-bearing Pokemon') + T.eq(paths[1].kind, 'battle', 'actual sprite selector retains battle context') + T.eq(#pics, 1, 'ordinary player back picture does not acquire a front animation') + T.eq(pics[1].mon, battle.enemy.mon, 'native picture animation owns actual opponent') + T.eq(pics[1].value, battle.enemy.picAnim, 'native picture animation result is retained') + T.eq(#anims, 1, 'only the opponent gets native procedural animation construction') + T.eq(anims[1].value, battle.enemy.monAnim, 'native procedural animation result is retained') + for _, row in ipairs(builds) do + for key, value in pairs(row.fields) do + T.eq(row.battler[key], value, edition .. ' hydration preserves native field ' .. key) + end + end + local incoming = save.party[2] + battle:switchPlayerInto(Screen.POS.PLAYER_LEFT, incoming) + T.eq(#builds, 3, 'actual switch constructs a fresh transient battler') + T.eq(battle.player, builds[3].battler, 'actual switch uses shared factory result') + T.eq(battle.player.mon, incoming, 'actual switch retains live bench Pokemon') + T.eq(paths[3].mon, incoming, 'switched sprite selector receives live bench Pokemon') + T.eq(paths[3].side, 'back', 'switch retains native player picture direction') + T.eq(#pics, 1, 'ordinary back-picture switch does not allocate front animation') + T.eq(#anims, 1, 'player switch does not allocate opponent procedural animation') +end +Battler.new, Sprites.path, PicAnim.forMon, MonAnim.new = make, path, pic, anim +Version.set(initial) +T.finish('shared native battler construction and actual art hydration across editions') diff --git a/tests/engine/gen2_battle_model_contract.lua b/tests/engine/gen2_battle_model_contract.lua new file mode 100644 index 000000000..b16f8d48c --- /dev/null +++ b/tests/engine/gen2_battle_model_contract.lua @@ -0,0 +1,193 @@ +-- Native calculation and completion paths, decorated through the real SDK. +package.path = './?.lua;./?/init.lua;' .. package.path +local T = require('tests.modkit') +local Version = require('src.core.GameVersion') +local Screen = require('src.battle.BattleState') +local Model = require('src.battle.gen2.Battle') +local Read = require('src.battle.Gen2BattleRead') +local Pokemon = require('src.pokemon.Pokemon') +local Save = require('src.core.SaveData') +local Order = require('src.battle.TurnOrder') +local Status = require('src.battle.StatusRegistry') +local Effects = require('src.battle.MoveEffects') +local savedVersion = Version.get() +local nativeStat, nativeDef, nativeEnd = Model.battleStat, Model.speciesDef, Model.endBattle + +local function fixture(version) + Version.set(version) + local data = T.fixtures.fresh() + data.constants.gen, data.constants.generation = Version.generation(version), Version.generation(version) + data.type_chart.matchups[#data.type_chart.matchups+1] = { + attacker='NORMAL', defender='GHOST', multiplier=0, + } + local run = T.sdk.loadMods({'mods/model_probe'}, { + data=data, fs=T.sdk.memfs({ + ['mods/model_probe/manifest.json']=[[{ + "id":"model_probe","name":"Native model probe","version":"1.0.0", + "api":2,"entry":"main.lua","affects_link":true + }]], + ['mods/model_probe/main.lua']=[[ + local mod=... + local Model=require('src.battle.gen2.Battle') + local stat,def,finish=Model.battleStat,Model.speciesDef,Model.endBattle + mod.exports.scale=1 + Model.battleStat=function(model,mon,key) + local value=stat(model,mon,key) + if mon==model.player and (key=='attack' or key=='speed') then + return value*mod.exports.scale + end + return value + end + Model.speciesDef=function(model,mon) + local value=def(model,mon) + if not (value and mon==model.enemy and mod.exports.types) then return value end + local result={};for key,item in pairs(value)do result[key]=item end + result.types=mod.exports.types + return result + end + Model.endBattle=function(model,...) + if model.player then model.player._probeTransient=nil end + mod.exports.endCalls=(mod.exports.endCalls or 0)+1 + return finish(model,...) + end + mod.events:on('battle.ended',function(event) + mod.exports.ended=(mod.exports.ended or 0)+1 + mod.exports.event=event + mod.exports.cleanAtEvent=not(event.model and event.model.player + and event.model.player._probeTransient) + end) + ]], + }), + }) + T.eq(#run.errors,0,version..' real SDK model decorator loads') + require('src.render.Font').load(data) + local save=Save.newGame() + save.party={Pokemon.new(data,'FIXMON_A',30),Pokemon.new(data,'FIXMON_B',18)} + local game={data=data,save=save} + local stack={states={},pops=0} + function stack:top()return self.states[#self.states]end + function stack:pop()self.pops=self.pops+1;return table.remove(self.states)end + function stack:push(state)self.states[#self.states+1]=state end + game.stack=stack + local battle=Screen.newWild(game,'FIXMON_C',23) + stack.states={{},battle} + return battle,game,run,assert(run.loader.exports.model_probe) +end + +for _,version in ipairs({'gold','silver','crystal','prism','polishedcrystal'})do + local b,game,run,probe=fixture(version) + local model=b.battle + local supported=type(model.battleStat)=='function' and type(model.speciesDef)=='function' + T.check(supported,version..' actual model resolves the genuine model class') + T.check(model~=b,version..' screen and model stay distinct') + T.eq(model.player,game.save.party[1],'model retains live party identity') + T.eq(model.moveDef,Model.moveDef,'method lookup retains the class decoration seam') + if supported then + T.eq(model:speciesDef(model.player),game.data.pokemon[model.player.species],'actual species catalog row') + T.eq(model:speciesDef(nil),nil,'no invented missing species') + T.eq(model:battleStat(model.player,'attack'),b.player.curStats.attack,'native raw stat authority') + T.eq(model:battleStat({},'attack'),nil,'unknown stat stays absent') + T.eq(model:sideOf(model.player),'player','native player side') + T.eq(model:sideOf(model.enemy),'enemy','native enemy side') + end + T.eq(Read.stats(b.player).attack,b.player.curStats.attack,'default decorated read keeps native values') + local playerStats,enemyStats=b.player.curStats,b.enemy.curStats + local partyStats,types=b.player.mon.stats,b.enemy.def.types + local pp=b.player.curMoves[1].pp + local move=b:moveDef(b.player.curMoves[1]) + local function damage() + return b:computeDamage(b.player,b.enemy,move,{forceCrit=false,rng=function(lo,hi)return hi or lo end}) + end + local normal=damage() + local speed=Order.effectiveSpeed(b.player,b) + probe.scale=4 + local boosted=damage() + T.check(boosted>normal,version..' actual native damage consumes decorated model stat') + T.check(Order.effectiveSpeed(b.player,b)>speed,'actual native speed consumes same model stat') + T.eq(b.player.curStats,playerStats,'native stat table identity is retained') + T.eq(b.enemy.curStats,enemyStats,'native opposing stat table is retained') + T.eq(b.player.mon.stats,partyStats,'save stats are not replaced') + T.eq(b.player.curMoves[1].pp,pp,'queries cannot spend PP') + T.eq(Read.stats(b.player).attack,partyStats.attack*4,'live value has no frame cache') + probe.scale=1 + T.eq(damage(),normal,'removing presentation-owned stat override restores native damage') + probe.types={'GHOST'} + T.eq(damage(),0,'actual damage honors model species type immunity') + T.eq(b.enemy.def.types,types,'species registry type list is not replaced') + T.eq(b.enemy.curTypes,types,'native volatile type list is not replaced') + probe.types={'FIRE'} + b.enemy.mon.status=nil + Status.inflict(b,b.enemy,'BRN',{}) + T.eq(b.enemy.mon.status,nil,'actual native status immunity honors model type') + probe.types={'NORMAL'} + Status.inflict(b,b.enemy,'BRN',{}) + T.eq(b.enemy.mon.status,'BRN','removing immunity restores native status path') + b.enemy.mon.status=nil + b.enemy.curTypes={'WATER'} + probe.types={'GHOST'} + T.eq(Read.types(b.enemy)[1],'WATER','native Conversion list wins over a species decoration') + T.check(damage()>0,'volatile type override reaches actual damage') + b.enemy.curTypes=types + probe.types=nil + local old=b.player + b:switchPlayerInto(Screen.POS.PLAYER_LEFT,game.save.party[2]) + T.eq(model.player,game.save.party[2],'model follows actual native switch') + probe.scale=2 + T.eq(Read.stats(b.player).attack,b.player.curStats.attack*2,'new native battler is bound') + T.neq(b.player,old,'native wrapper really changed') + probe.scale=1 + -- Native Transform keeps temporary stats on its wrapper, never the save. + -- Link/scaffold owners use the public factory without a screen argument, + -- then place/synchronize their actual wrappers on the native controller. + local placed=Screen.makeBattler(game.data,game.save.party[1],true,nil) + b:placeBattler(Screen.POS.PLAYER_LEFT,placed) + probe.scale=3 + T.eq(Read.stats(placed).attack,placed.curStats.attack*3,'native placement binds a public-factory wrapper') + local rebound=Screen.makeBattler(game.data,game.save.party[2],true,nil) + b.player=rebound + b:syncSides() + T.eq(Read.stats(rebound).attack,rebound.curStats.attack*3,'native side synchronization binds external wrappers') + T.eq(model.player,game.save.party[2],'external native replacement retains live model identity') + probe.scale=1 + local source=b.enemy.curStats.attack + Effects.primary.TRANSFORM_EFFECT(b,b.player,b.enemy) + if supported then T.eq(model:battleStat(model.player,'attack'),source,'Transform uses actual native volatile stat')end + T.eq(model.player.stats,game.save.party[2].stats,'Transform does not replace saved stats') + model.player._probeTransient=true + b.result=({gold='ended',silver='hostWin',crystal='guestWin',prism='draw'})[version] + local outcome=b.result + local complete=type(b.completeBattle)=='function' + T.check(complete,'native Gen2 completion seam exists') + if complete then + b:completeBattle() + T.eq(probe.ended,1,'actual native finish emits one ended event') + T.eq(probe.event.result,outcome or 'run','notification retains the native event fallback') + T.eq(b.result,outcome,'notification does not persist an event-only fallback') + T.eq(probe.event.battle,b,'existing native consumers retain their screen owner') + T.eq(probe.event.model,model,'notification also exposes actual live model') + T.check(probe.cleanAtEvent,'model end decoration cleans before native end notification') + T.eq(game.stack.pops,1,'completion uses native stack return once') + b:completeBattle() + T.eq(game.stack.pops,1,'repeated completion cannot pop another screen') + if type(model.endBattle)=='function'then model:endBattle('run')end + T.eq(probe.ended,1,'repeated model completion cannot re-emit ended event') + end + require('src.mods.Loader').teardown() + run.release() + T.eq(Model.battleStat,nativeStat,'loader retirement restores model stat method') + T.eq(Model.speciesDef,nativeDef,'loader retirement restores model species method') + T.eq(Model.endBattle,nativeEnd,'loader retirement restores model end method') + T.eq(Read.stats(b.player),b.player.curStats,'undecorated read returns original stat table') + T.eq(Read.types(b.enemy),b.enemy.curTypes,'undecorated read returns original type list') +end +for _,version in ipairs({'red','firered','emerald'})do + local b,game,run,probe=fixture(version) + T.eq(b.battle,b,version..' keeps its original shared native identity') + probe.scale=9;probe.types={'GHOST'} + T.eq(Read.stats(b.player),b.player.curStats,'other generation retains exact raw stat table') + T.eq(Read.types(b.enemy),b.enemy.curTypes,'other generation retains exact raw type list') + require('src.mods.Loader').teardown() + run.release() +end +Version.set(savedVersion) +T.finish('native Gen2 model calculation and completion boundary') diff --git a/tests/engine/gen2_model_rng.lua b/tests/engine/gen2_model_rng.lua new file mode 100644 index 000000000..be92999ce --- /dev/null +++ b/tests/engine/gen2_model_rng.lua @@ -0,0 +1,133 @@ +-- Copyright (c) 2026 Roxas2712. See https://github.com/Roxas2712/Gen2Recomped/blob/main/LICENSE-ROXAS2712.md. +-- Actual source consumers must share the native stream, including after its +-- function is replaced. No shadow generator, draws on lookup or saved closures. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion') +local Screen=require('src.battle.BattleState') +local Model=require('src.battle.gen2.Battle') +local Pokemon=require('src.pokemon.Pokemon') +local Save=require('src.core.SaveData') +local before=V.get() +for _,edition in ipairs{'gold','silver','crystal','prism','polishedcrystal'}do + V.set(edition) + local data=T.fixtures.fresh();data.constants.gen=2 + local run=T.sdk.loadMods({'mods/rng_probe'},{data=data,fs=T.sdk.memfs({ + ['mods/rng_probe/manifest.json']='{"id":"rng_probe","name":"Native RNG probe","version":"1.0.0","api":2,"entry":"main.lua"}', + ['mods/rng_probe/main.lua']=[[ + local mod=... + local Battle=require('src.battle.gen2.Battle') + mod.exports.love=function(model,lo,hi)return model.rng(lo,hi)end + mod.exports.cart=function(model,n)return model.random(n)end + mod.exports.method=function(model,n)return Battle.roller(model)(n)end + mod.exports.decorate=function() + local native=Battle.roller + Battle.roller=function(model) + mod.exports.lookups=(mod.exports.lookups or 0)+1 + return native(model) + end + end + ]], + })}) + T.eq(#run.errors,0,'actual API2 source RNG consumer loads') + require('src.render.Font').load(data) + local save=Save.newGame();save.party={Pokemon.new(data,'FIXMON_A',30),Pokemon.new(data,'FIXMON_B',25)} + local b=Screen.newWild({data=data,save=save,stack={top=function()end}},'FIXMON_C',30) + local probe=run.loader.exports.rng_probe + local trace={} + local function stream(tag) + return function(lo,hi) + local first,last=lo,hi + if hi==nil then lo,hi=1,lo end + trace[#trace+1]={tag=tag,lo=first,hi=last} + return lo+(#trace*37)%(hi-lo+1) + end + end + local native=stream('initial');b.rng=native + local model=b.battle + T.eq(type(model.rng),'function',edition..' source LOVE RNG exists') + T.eq(type(model.random),'function','source zero-based random exists') + T.eq(type(model.roller),'function','source roller method exists') + if type(model.rng)=='function'and type(model.random)=='function'and type(model.roller)=='function'then + local loveRng,random,roller=model.rng,model.random,model:roller() + T.eq(model.rng,loveRng,'LOVE adapter identity is stable') + T.eq(model.random,random,'zero-based adapter identity is stable') + T.eq(model:roller(),roller,'roller identity is stable') + T.check(random~=roller,'source random field remains separate from the roller method wrapper') + T.eq(#trace,0,'lookup and cache creation consume no native draws') + for n=1,256 do + local index=#trace+1 + T.eq(probe.love(model,n),1+(index*37)%n,'source LOVE result uses the next actual native draw') + T.eq(#trace,index,'one actual draw for LOVE call') + index=#trace+1 + T.eq(probe.cart(model,n),(index*37)%n,'source random uses zero-based native result') + T.eq(#trace,index,'one actual draw for source random') + index=#trace+1 + T.eq(probe.method(model,n),(index*37)%n,'public class roller uses the next same native draw') + T.eq(#trace,index,'one actual draw for source roller') + end + for _,bounds in ipairs{{5,9},{9,5},{-3,2},{4,4}}do + local lo,hi=math.min(bounds[1],bounds[2]),math.max(bounds[1],bounds[2]) + local index=#trace+1 + T.eq(probe.love(model,bounds[1],bounds[2]),lo+(index*37)%(hi-lo+1),'source range normalization uses native draw') + T.eq(trace[index].lo,lo,'normalized native lower bound') + T.eq(trace[index].hi,hi,'normalized native upper bound') + end + for _,bound in ipairs{0,-7}do + local index=#trace+1 + T.eq(probe.love(model,bound),1,'source small bound normalizes to one') + T.eq(#trace,index,'normalized one-point range still consumes one actual draw') + end + local index=#trace+1 + T.eq(probe.love(model),1,'source missing bound normalizes to one') + T.eq(#trace,index,'missing bound uses one actual native draw') + local replacement=stream('replacement');b.rng=replacement + index=#trace+1 + T.eq(loveRng(2,8),2+(index*37)%7,'retained LOVE view follows replacement owner') + T.eq(trace[index].tag,'replacement','retained LOVE view cannot draw from old function') + index=#trace+1 + T.eq(random(256),(index*37)%256,'retained random follows replacement owner') + T.eq(trace[index].tag,'replacement','retained zero-based view cannot draw from old function') + index=#trace+1 + T.eq(roller(255),(index*37)%255,'retained roller follows replacement owner') + T.eq(trace[index].tag,'replacement','retained roller cannot draw from old function') + T.eq(b.rng,replacement,'adapters never replace the actual native function') + b:switchPlayerInto(Screen.POS.PLAYER_LEFT,save.party[2]) + T.eq(b.battle,model,'real native switch retains its model') + T.eq(model.rng,loveRng,'real native switch retains LOVE adapter') + T.eq(model:roller(),roller,'real native switch retains source roller') + index=#trace+1 + T.eq(probe.method(model,256),(index*37)%256,'incoming actor shares the same real stream') + T.eq(#trace,index,'incoming actor does not create another generator') + probe.decorate() + local count=#trace + T.eq(model.random,random,'decorating roller does not replace the source random field') + T.eq(model.rng,loveRng,'decorating roller does not replace source LOVE field') + T.eq(probe.lookups,nil,'source field lookup does not invoke a decorated roller method') + index=#trace+1 + T.eq(probe.method(model,255),(index*37)%255,'actual API2 roller decoration keeps native draw ownership') + T.eq(probe.lookups,1,'actual API2 consumer reaches the live class decoration') + T.eq(#trace,count+1,'decoration lookup itself adds no draw') + T.raises(function()model.rng=function()end end,'read-only','source view cannot replace native RNG authority') + T.raises(function()model.random=function()end end,'read-only','source view cannot replace cart RNG authority') + T.raises(function()model.roller=function()end end,'read-only','source metadata cannot replace the public method') + end + for _,mon in ipairs(save.party)do + T.eq(mon.rng,nil,'no RNG function enters party mon') + T.eq(mon.random,nil,'no random function enters party mon') + T.eq(mon._nativeRoller,nil,'no adapter cache enters party mon') + end + run.release() +end +-- Internal adapter cycles must be collectible when their native owner and +-- caller references end. A global value closure retaining a weak key is not. +local weak=setmetatable({},{__mode='k'}) +local function retired() + local screen={rng=function(n)return n end};local model=Model.bind({},screen) + Model.roller(model);weak[model]=true;weak[screen]=true +end +for _=1,12 do retired()end +collectgarbage('collect');collectgarbage('collect') +T.eq(next(weak),nil,'retired model/RNG adapters do not leak through module state') +V.set(before) +T.finish('native source model RNG fields, domains, replacement, switch and lifetime') diff --git a/tests/engine/gen2_model_rng_link.lua b/tests/engine/gen2_model_rng_link.lua new file mode 100644 index 000000000..5b0194b4c --- /dev/null +++ b/tests/engine/gen2_model_rng_link.lua @@ -0,0 +1,78 @@ +-- Copyright (c) 2026 Roxas2712. See https://github.com/Roxas2712/Gen2Recomped/blob/main/LICENSE-ROXAS2712.md. +-- Source callbacks draw from the actual seeded link stream in every role. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion') +local Copy=require('src.mods.Merge').deepCopy +local P,N,L=require('src.link.Protocol'),require('src.link.Net'),require('src.link.LinkBattle') +local old=V.get() +for _,edition in ipairs{'gold','silver','crystal'}do + V.set(edition) + local data=T.fixtures.fresh();data.constants.gen=2 + data.moves.QA_ENDURE={id='QA_ENDURE',name='QA ENDURE',power=0,type='NORMAL',accuracy=100,pp=20,effect='ENDURE_EFFECT'} + local run=T.sdk.loadMods({'mods/source_rng'},{data=data,fs=T.sdk.memfs({ + ['mods/source_rng/manifest.json']='{"id":"source_rng","name":"Source RNG","version":"1.0.0","api":2,"entry":"main.lua","affects_link":true}', + ['mods/source_rng/main.lua']=[[ + local mod=... + local Model=require('src.battle.gen2.Battle') + mod.exports.rows={} + mod.events:on('battle.move_used',function(c) + local model=c.battle.battle + local rows=mod.exports.rows[c.battle] + if not rows then rows={};mod.exports.rows[c.battle]=rows end + rows[#rows+1]={name=c.user.mon.nickname,turn=model.turn,love=model.rng(16), + cart=model.random(256),roller=Model.roller(model)(255)} + end) + ]], + })}) + T.eq(#run.errors,0,'actual source callback consumer loads');T.link.prepare(data) + local games={} + for i=1,3 do + local game=T.link.fakeGame(data,'FIXMON_A',{name='ROLE'..i,level=30}) + game.save.version=edition;game.save.party[1].nickname=i==1 and'HOST'or'GUEST' + game.save.party[1].moves={{id='QA_ENDURE',pp=20}};games[i]=game + end + local na,nb=N.loopbackPair();local inbox={} + local sn={update=function()end,poll=function()local packets=inbox;inbox={};return packets end} + for i,net in ipairs{na,nb}do + local send,side=net.send,i==1 and'host'or'guest' + net.send=function(self,packet)inbox[#inbox+1]={type='spectate',side=side,msg=Copy(packet)};return send(self,packet)end + end + local pa,pb=P.packParty(games[1].save.party),P.packParty(games[2].save.party) + local a=assert(L.newHost(games[1],na,{myParty=pa,theirParty=pb,theirName='GUEST',seed=327,verdict='full',strict=true})) + local b=assert(L.newGuest(games[2],nb,{myParty=pb,theirParty=pa,theirName='HOST',seed=327,verdict='full',strict=true})) + local s=assert(L.newSpectator(games[3],sn,{hostParty=pa,guestParty=pb,hostName='HOST',guestName='GUEST',seed=327,verdict='full',strict=true})) + local roles={a,b,s} + for i,battle in ipairs(roles)do games[i].stack:push(battle)end + for _=1,30000 do + for i,game in ipairs(games)do + require('src.core.Input').pressed=(i==3 or not roles[i].localHashes[2])and{a=true}or{} + game.stack:update(1/60) + end + if a.localHashes[2]and b.localHashes[2]and s.turnCount==2 and s.phase=='waitBoth'then break end + end + for turn=1,2 do + T.check(a.localHashes[turn]~=nil and b.localHashes[turn]~=nil,'actual source-consumer link turn completes') + T.eq(a.localHashes[turn],b.localHashes[turn],'source RNG calls preserve native lockstep') + T.same(a.localParts[turn],b.localParts[turn],'every completed native state component agrees') + end + local rows=run.loader.exports.source_rng.rows + T.same(rows[a],rows[b],'both participants observe the same source RNG results') + T.same(rows[a],rows[s],'spectator observes the same source RNG results') + for _,battle in ipairs(roles)do + T.eq(#(rows[battle]or{}),4,'every actual move callback reads all three source RNG domains') + for index,row in ipairs(rows[battle]or{})do + T.eq(row.turn,math.floor((index-1)/2)+1,'actual participant/spectator moves observe the same native round') + T.check(row.love>=1 and row.love<=16,'source LOVE call retains its positive range') + T.check(row.cart>=0 and row.cart<256,'source random retains its byte range') + T.check(row.roller>=0 and row.roller<255,'source roller retains its nonzero-byte-index range') + end + T.eq(battle.player.mon.moves[1].pp,18,'own native moves spend PP once despite source random calls') + T.eq(battle.enemy.mon.moves[1].pp,18,'other native moves spend PP once despite source random calls') + T.eq(battle.result,nil,'no hidden link simulation error') + end + T.eq(#run.errors,0,'no source callback failure is swallowed') + run.release() +end +V.set(old) +T.finish('actual source RNG callbacks in native GSC link participant and spectator roles') diff --git a/tests/engine/gen2_model_round_clock.lua b/tests/engine/gen2_model_round_clock.lua new file mode 100644 index 000000000..fb1b7055c --- /dev/null +++ b/tests/engine/gen2_model_round_clock.lua @@ -0,0 +1,85 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- An actual API2 consumer observes the native round clock at execution edges. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion') +local Screen=require('src.battle.BattleState') +local Pokemon=require('src.pokemon.Pokemon') +local Save=require('src.core.SaveData') +local old=V.get() +for _,edition in ipairs{'gold','silver','crystal','prism','polishedcrystal'}do + V.set(edition) + local data=T.fixtures.fresh();data.constants.gen=2 + data.moves.QA_CLOCK={id='QA_CLOCK',name='CLOCK',power=0,type='NORMAL',accuracy=100,pp=20,effect='ENDURE_EFFECT'} + local run=T.sdk.loadMods({'mods/round_clock'},{data=data,fs=T.sdk.memfs({ + ['mods/round_clock/manifest.json']='{"id":"round_clock","name":"Round clock","version":"1.0.0","api":2,"entry":"main.lua"}', + ['mods/round_clock/main.lua']=[[ + local mod=... + mod.exports.rows={} + for _,name in ipairs{'battle.turn_started','battle.move_used','battle.turn_ended'}do + local eventName=name + mod.events:on(name,function(event) + local b=event.battle + mod.exports.rows[#mod.exports.rows+1]={name=eventName,turn=b.battle.turn,native=b.turnCount,eventTurn=event.turn} + end) + end + mod.exports.read=function(model)return model.turn end + ]], + })}) + T.eq(#run.errors,0,'real API2 round observer loads') + require('src.render.Font').load(data) + local save=Save.newGame();save.party={Pokemon.new(data,'FIXMON_A',30),Pokemon.new(data,'FIXMON_B',30)} + save.party[1].moves={{id='QA_CLOCK',pp=20}} + local b=Screen.newWild({data=data,save=save,stack={top=function()end}},'FIXMON_C',30) + b.enemy.curMoves={{id='QA_CLOCK',pp=20}} + b.enemyAction=function()return b.enemy.curMoves[1]end + local probe=run.loader.exports.round_clock + local model=b.battle + T.eq(probe.read(model),0,edition..' starts at source round zero') + T.eq(b.turnCount,nil,'reading initial round does not initialize native state') + for round=1,3 do + local pp=b.player.mon.moves[1].pp + b.queue={};b:resolveTurn(b.player.curMoves[1]) + T.eq(probe.read(model),round,'actual queued native round reaches public model') + local queued=#b.queue + for _=1,10 do T.eq(probe.read(model),round,'repeated observations never advance a round')end + T.eq(#b.queue,queued,'clock observation cannot drain native actions') + T.eq(b.player.mon.moves[1].pp,pp,'clock observation cannot spend PP') + -- Run the actual two chosen moves; their callback reads must see the same + -- round as the earlier turn-start callback, not a per-move/frame counter. + local first,second=b.queue[1],b.queue[2] + T.check(first and second and first.fn and second.fn,'native planner queues both actual actions') + -- This older host executes action rows inline in updateQueue; the newer + -- runQueueAction helper belongs to a separately offered prerequisite. + -- Execute the actual planner-owned closures with that host's insertion + -- context, rather than adding a fake helper or a second move executor. + for _,item in ipairs{first,second}do + b.nextInsert=0;item.fn();b.nextInsert=nil;b.current=nil + end + T.eq(b.player.mon.moves[1].pp,pp-1,'actual native move spends PP once') + T.eq(probe.read(model),round,'both actual moves share one round') + b:endOfTurn() + T.eq(probe.read(model),round,'residual processing does not create a new round') + end + local starts,moves,ends=0,0,0 + for _,row in ipairs(probe.rows)do + T.eq(row.turn,row.native,'API2 execution event sees actual native round') + if row.eventTurn then T.eq(row.turn,row.eventTurn,'public model agrees with native event round')end + if row.name=='battle.turn_started'then starts=starts+1 + elseif row.name=='battle.move_used'then moves=moves+1 + elseif row.name=='battle.turn_ended'then ends=ends+1 end + end + T.eq(starts,3,'one real start callback per chosen round') + T.eq(moves,6,'both sides execute in every real round') + T.eq(ends,3,'one real residual completion callback per round') + T.eq(save.party[1].turn,nil,'round state never enters a party Pokemon') + T.eq(save.turn,nil,'round state never enters persistent save data') + local incoming=save.party[2] + b:switchPlayerInto(Screen.POS.PLAYER_LEFT,incoming) + T.eq(model.player,incoming,'native switch really replaces the player') + T.eq(probe.read(model),3,'native switch does not reset battle-wide clock') + T.eq(#run.errors,0,'no swallowed source callback error') + run.release() +end +V.set(old) +T.finish('native GSC source model round clock at actual queue and API2 event edges') diff --git a/tests/engine/gen2_move_priority.lua b/tests/engine/gen2_move_priority.lua new file mode 100644 index 000000000..3538a9bcc --- /dev/null +++ b/tests/engine/gen2_move_priority.lua @@ -0,0 +1,90 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- GSC's GetMovePriority precedes Speed. Exercise the shared comparator, the +-- actual native queued turn, and the public live-model query together. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Version=require('src.core.GameVersion') +local Screen=require('src.battle.BattleState') +local Pokemon=require('src.pokemon.Pokemon') +local Order=require('src.battle.TurnOrder') +local Save=require('src.core.SaveData') +local saved=Version.get() +local cases={ + {id='QA_PROTECT',effect='PROTECT_EFFECT',priority=3}, + {id='QA_ENDURE',effect='ENDURE_EFFECT',priority=3}, + {id='QA_COUNTER',effect='COUNTER_EFFECT',priority=-1}, + {id='QA_MIRROR',effect='MIRROR_COAT_EFFECT',priority=-1}, + {id='VITAL_THROW',effect='ALWAYS_HIT_EFFECT',priority=-1}, + {id='ROAR',effect='SWITCH_AND_TELEPORT_EFFECT',priority=-1}, + {id='WHIRLWIND',effect='SWITCH_AND_TELEPORT_EFFECT',priority=-1}, + {id='TELEPORT',effect='SWITCH_AND_TELEPORT_EFFECT',priority=0}, +} +local function fixture(version,chosen) + Version.set(version) + local data=T.fixtures.fresh();data.constants.gen=2 + for _,row in ipairs(cases)do + data.moves[row.id]={id=row.id,name=row.id,effect=row.effect,power=0,type='NORMAL',accuracy=100,pp=10} + end + local run=T.sdk.loadNone({data=data}) + require('src.render.Font').load(data) + local save=Save.newGame();save.party={Pokemon.new(data,'FIXMON_A',25)} + save.party[1].moves={{id=chosen or 'QA_PROTECT',pp=10}} + local game={data=data,save=save,stack={top=function()end}} + local b=Screen.newWild(game,'FIXMON_B',25) + b.rng=function(lo)return lo end + return b,run +end +for _,version in ipairs{'gold','silver','crystal'}do + local b,run=fixture(version) + local a,z=b.player,b.enemy + local plain=b.data.moves.FIX_TACKLE + for _,row in ipairs(cases)do + local move=b.data.moves[row.id] + a.curStats.speed=row.priority>0 and 1 or 999 + z.curStats.speed=row.priority>0 and 999 or 1 + local expect=row.priority>=0 + T.eq(Order.firstMover(a,move,z,plain,b.rng,false,b,b.data),expect, + version..' native effect priority for '..row.id) + T.eq(b.battle:movePriority(row.id),row.priority,'public live-model priority agrees: '..row.id) + end + local explicit={id='QA_EXPLICIT',effect='PROTECT_EFFECT',priority=-7} + T.eq(Order.firstMover(a,explicit,z,plain,b.rng,false,b,b.data),false,'explicit native mod priority still wins') + b.data.moves[explicit.id]=explicit + T.eq(b.battle:movePriority(explicit.id),-7,'model uses the same explicit override') + T.eq(b.battle:movePriority('UNKNOWN_NATIVE_MOVE'),0,'missing priority stays neutral') + run.release() + for _,id in ipairs{'QA_PROTECT','QA_ENDURE'}do + local battle,owner=fixture(version,id) + battle.player.curStats.speed=1;battle.enemy.curStats.speed=999 + local selected=battle.player.curMoves[1] + local enemyMove=battle.enemy.curMoves[1] + -- Fix only the legal AI choice; the turn planner, queue closures, move + -- execution, PP and protection handlers below are the genuine owners. + battle.enemyAction=function()return enemyMove end + battle.queue={} + local hp=battle.player.mon.hp + battle:resolveTurn(selected) + local first,second=battle.queue[1],battle.queue[2] + T.check(first and second and first.fn and second.fn,'native turn queued both actual actions') + battle.nextInsert=0;first.fn();battle.nextInsert=nil + local field=id=='QA_PROTECT'and'protecting'or'enduring' + T.eq(battle.player[field],true,'slow '..id..' really executes before the faster enemy') + T.eq(selected.pp,9,'actual first move spends one PP') + if id=='QA_PROTECT'then + battle.nextInsert=0;second.fn();battle.nextInsert=nil + T.eq(battle.player.mon.hp,hp,'faster enemy attack is blocked by the actual protection state') + end + owner.release() + end +end +-- The added effect fallback belongs only to the GSC controller. Preserve +-- other generations' explicit imported priorities and legacy ID behavior. +for _,version in ipairs{'red','firered','emerald','heartgold','black'}do + Version.set(version) + local a={curStats={speed=1},stages={},mon={hp=100,stats={speed=1}}} + local z={curStats={speed=999},stages={},mon={hp=100,stats={speed=999}}} + T.eq(Order.firstMover(a,{id='QA_OTHER',effect='PROTECT_EFFECT'},z,{id='PLAIN'},function(lo)return lo end),false, + version..' retains its own priority contract') +end +Version.set(saved) +T.finish('native GSC priority, live model queries and actual protection turns') diff --git a/tests/engine/gen2_protection.lua b/tests/engine/gen2_protection.lua new file mode 100644 index 000000000..af5440e26 --- /dev/null +++ b/tests/engine/gen2_protection.lua @@ -0,0 +1,99 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Native registered protection effects, RNG boundaries and real queued order. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion') +local Screen=require('src.battle.BattleState') +local Pokemon=require('src.pokemon.Pokemon') +local Save=require('src.core.SaveData') +local P=require('src.battle.Gen3MoveEffects').primary +local before=V.get() +local function fixture(edition) + V.set(edition) + local data=T.fixtures.fresh();data.constants.gen=V.generation() + data.moves.QA_PROTECT={id='QA_PROTECT',name='QA PROTECT',power=0,type='NORMAL',accuracy=100,pp=20,effect='PROTECT_EFFECT'} + data.moves.QA_ENDURE={id='QA_ENDURE',name='QA ENDURE',power=0,type='NORMAL',accuracy=100,pp=20,effect='ENDURE_EFFECT'} + local run=T.sdk.loadNone({data=data});require('src.render.Font').load(data) + local save=Save.newGame();save.party={Pokemon.new(data,'FIXMON_A',30),Pokemon.new(data,'FIXMON_B',30)} + save.party[1].moves={{id='QA_PROTECT',pp=20}} + local b=Screen.newWild({data=data,save=save,stack={top=function()end}},'FIXMON_C',30) + return b,run +end +for _,edition in ipairs{'gold','silver','crystal','prism','polishedcrystal'}do + local b,run=fixture(edition) + for _,effect in ipairs{{'PROTECT_EFFECT','protecting'},{'ENDURE_EFFECT','enduring'}}do + for count=0,9 do + local threshold=math.floor(255/2^count) + for _,byte in ipairs{0,1,62,63,126,127,253,254}do + local draws,loSeen,hiSeen=0 + b.rng=function(lo,hi)draws=draws+1;loSeen,hiSeen=lo,hi;return lo+byte%(hi-lo+1)end + b.player.protectRun=count;b.player[effect[2]]=nil + P[effect[1]](b,b.player) + local succeeds=threshold>0 and byte0 and 1 or 0,'one nonzero-byte draw until threshold reaches zero') + if threshold>0 then + T.eq(loSeen,1,'native draw starts at one') + T.eq(hiSeen,255,'native draw spans all nonzero bytes') + end + end + end + for _,reason in ipairs{'substitute','opponent-first'}do + b.player.protectRun=3;b.player[effect[2]]=nil + b.player.substituteHP=reason=='substitute'and 10 or nil + b.turnCount=10;b._gen2ProtectionTurn=10 + b._gen2ProtectionFirst=reason=='opponent-first'and'enemy'or'player' + local draws=0;b.rng=function(lo)draws=draws+1;return lo end + P[effect[1]](b,b.player) + T.eq(b.player[effect[2]],nil,reason..' prevents '..effect[1]) + T.eq(b.player.protectRun,0,reason..' resets actual counter') + T.eq(draws,0,reason..' fails before the random draw') + end + b.player.substituteHP=nil;b._gen2ProtectionTurn=nil;b._gen2ProtectionFirst=nil + end + -- Both sides choose the same priority. The slower user's actual queued + -- action must fail after the faster opponent has acted, without a draw. + b.player.curStats.speed=1;b.enemy.curStats.speed=999 + local selected=b.player.curMoves[1] + b.enemy.curMoves[1]={id='QA_ENDURE',pp=20} + b.enemyAction=function()return b.enemy.curMoves[1]end + b.queue={};local draws=0;b.rng=function(lo)draws=draws+1;return lo end + b:resolveTurn(selected) + local first,second=b.queue[1],b.queue[2] + T.check(first and second and first.fn and second.fn,'real native actions are queued') + b.nextInsert=0;first.fn() + T.eq(b.enemy.enduring,true,'the faster opponent executes actual Endure first') + local afterFirst=draws + b.nextInsert=0;second.fn() + T.eq(b.player.protecting,nil,'second actor cannot Protect after opponent') + T.eq(b.player.protectRun,0,'second actor resets source-readable counter') + T.eq(draws,afterFirst,'opponent-first failure consumes no protection RNG') + T.eq(selected.pp,19,'failed protection still spends the actual move PP') + -- A new round retires the previous first actor. Called effects remain in + -- the same round and cannot change its first side. + b.queue={};b:resolveTurn(b.player.curMoves[1]);b:executeAction(b.player,b.enemy,{special='recharge'}) + T.eq(b._gen2ProtectionFirst,'player','next round records its actual first side') + -- First-side identity must survive replacement of that side's wrapper. + b.queue={};b:resolveTurn(b.player.curMoves[1]) + local withdrawn=b.player + b:executeAction(withdrawn,b.enemy,{special='playerSwitch',mon=b.game.save.party[2]}) + T.check(b.player~=withdrawn,'real native switch replaces the first wrapper') + b.enemy.protectRun=2 + b:executeAction(b.enemy,b.player,{id='QA_ENDURE',pp=20}) + T.eq(b.enemy.enduring,nil,'second actor cannot Endure after opponent switches') + T.eq(b.enemy.protectRun,0,'switch-first failure resets actual counter') + run.release() +end +-- Existing Gen1/GBA behavior is deliberately untouched by the GSC owner. +for _,edition in ipairs{'red','firered','emerald'}do + local b,run=fixture(edition);local draws=0 + b.rng=function(lo)draws=draws+1;return lo end + b.player.substituteHP=10;b._gen2ProtectionTurn=b.turnCount;b._gen2ProtectionFirst='enemy' + P.PROTECT_EFFECT(b,b.player) + T.eq(b.player.protecting,true,edition..' retains its previous protection behavior') + T.eq(draws,0,edition..' first use retains its old random protocol') + run.release() +end +V.set(before) +T.finish('native GSC protection thresholds, restrictions, order, PP and RNG') diff --git a/tests/engine/gen2_protection_link.lua b/tests/engine/gen2_protection_link.lua new file mode 100644 index 000000000..f39e734c5 --- /dev/null +++ b/tests/engine/gen2_protection_link.lua @@ -0,0 +1,64 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Actual participant/spectator queues must share GSC order and RNG ownership. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion') +local Copy=require('src.mods.Merge').deepCopy +local H=require('src.link.Handshake') +local P,N,L=require('src.link.Protocol'),require('src.link.Net'),require('src.link.LinkBattle') +local old=V.get() +for _,edition in ipairs{'gold','silver','crystal'}do + V.set(edition) + local data=T.fixtures.fresh();data.constants.gen=2 + data.moves.QA_PROTECT={id='QA_PROTECT',name='QA PROTECT',power=0,type='NORMAL',accuracy=100,pp=20,effect='PROTECT_EFFECT'} + local run=T.sdk.loadNone({data=data});T.link.prepare(data) + local games={} + for i=1,3 do + local game=T.link.fakeGame(data,'FIXMON_A',{name='ROLE'..i,level=30}) + game.save.version=edition;game.save.party[1].moves={{id='QA_PROTECT',pp=20}} + games[i]=game + end + local hello=H.hello(games[1],'battle');local legacy=Copy(hello);legacy.nativeBattleRevision=nil + T.eq(hello.nativeBattleRevision,'gsc-1','GSC advertises changed command/RNG semantics') + T.eq(H.checkCompat(hello,legacy),'subset','legacy GSC cannot enter changed lockstep') + T.eq(H.checkCompat(legacy,hello),'subset','GSC command mismatch is symmetric') + for _,host in ipairs{false,true}do + local state=setmetatable({game=games[1],myHello=hello,peerHello=legacy},{__index=require('src.link.LinkState')}) + state:decideCompat('battle',host) + T.eq(state.stage,'notice','actual admission refuses old commands before exchange') + end + local na,nb=N.loopbackPair();local inbox={} + local sn={update=function()end,poll=function()local packets=inbox;inbox={};return packets end} + for i,net in ipairs{na,nb}do + local send,side=net.send,i==1 and'host'or'guest' + net.send=function(self,packet)inbox[#inbox+1]={type='spectate',side=side,msg=Copy(packet)};return send(self,packet)end + end + local pa,pb=P.packParty(games[1].save.party),P.packParty(games[2].save.party) + local a=assert(L.newHost(games[1],na,{myParty=pa,theirParty=pb,theirName='GUEST',seed=327,verdict='full',strict=true})) + local b=assert(L.newGuest(games[2],nb,{myParty=pb,theirParty=pa,theirName='HOST',seed=327,verdict='full',strict=true})) + local s=assert(L.newSpectator(games[3],sn,{hostParty=pa,guestParty=pb,hostName='HOST',guestName='GUEST',seed=327,verdict='full',strict=true})) + local roles={a,b,s} + for i,battle in ipairs(roles)do games[i].stack:push(battle)end + for _=1,20000 do + for i,game in ipairs(games)do + require('src.core.Input').pressed=(i==3 or not roles[i].localHashes[1])and{a=true}or{} + game.stack:update(1/60) + end + if a.localHashes[1]and b.localHashes[1]and s.turnCount==1 and s.phase=='waitBoth'then break end + end + T.check(a.localHashes[1]~=nil and b.localHashes[1]~=nil,'both actual participant queues complete') + T.eq(a.localHashes[1],b.localHashes[1],'GSC protection RNG and state hashes agree') + T.same(a.localParts[1],b.localParts[1],'all completed native state components agree') + for _,battle in ipairs(roles)do + local x,y=battle.player.protectRun,battle.enemy.protectRun + T.check(x==1 and y==0 or x==0 and y==1,'only actual first actor succeeds; second clears its counter') + T.eq(battle.player.mon.moves[1].pp,19,'own first-round move spends PP exactly once') + T.eq(battle.enemy.mon.moves[1].pp,19,'other first-round move spends PP exactly once') + T.eq(battle.result,nil,'no hidden participant/spectator error') + end + T.eq(s.player.protectRun,a.player.protectRun,'spectator preserves host protection owner') + T.eq(s.enemy.protectRun,a.enemy.protectRun,'spectator preserves guest protection owner') + run.release() +end +V.set(old) +T.finish('native GSC protection participant, spectator and protocol admission') diff --git a/tests/engine/gen2_protection_protocol.lua b/tests/engine/gen2_protection_protocol.lua new file mode 100644 index 000000000..fa78cb78d --- /dev/null +++ b/tests/engine/gen2_protection_protocol.lua @@ -0,0 +1,27 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V,F,H=require('src.core.GameVersion'),require('src.link.Fingerprint'),require('src.link.Handshake') +local Copy=require('src.mods.Merge').deepCopy +local before=V.get();local data=T.fixtures.fresh() +-- Independently audited Python integer FNV: the old raw fixture surface, +-- with only [native_gsc_commands]gsc-1 inserted before its mods domain. +for _,edition in ipairs{'red','gold','firered','crystal','emerald','silver','red'}do + V.set(edition);local gsc=V.isGen2() + T.eq(F.compute(data,{}),gsc and '40df18dc503213c8' or 'e33d251961f2a41d','only GSC changes the older host fixture surface') + T.eq(F.nativeBattleRevision(),gsc and 'gsc-1'or nil,'only GSC advertises new native rules') + local hello=H.hello({data=data,save={player={name='RULES'}}},'battle') + T.eq(H.checkCompat(hello,Copy(hello)),'full','matching actual hello admits lockstep') + if gsc then + local old=Copy(hello);old.nativeBattleRevision=nil + T.eq(H.checkCompat(hello,old),'subset','equal-digest old rule field cannot enter lockstep') + T.eq(H.checkCompat(old,hello),'subset','rule mismatch remains symmetric') + T.eq(H.checkCompat(hello,{}),'refused','unversioned peer cannot assume changed GSC rules') + local lines=H.describe(hello,old,'subset','battle') + T.check(table.concat(lines,' '):find('native battle rules',1,true)~=nil,'visible mismatch describes native rules') + else + T.eq(H.checkCompat(hello,{}),'vanilla_peer','other old-host editions retain old wire behavior') + end +end +V.set(before) +T.finish('older-host native GSC fingerprint cache and actual protocol negotiation') diff --git a/tests/engine/gen2_trainer_friendship.lua b/tests/engine/gen2_trainer_friendship.lua new file mode 100644 index 000000000..fd688edbf --- /dev/null +++ b/tests/engine/gen2_trainer_friendship.lua @@ -0,0 +1,159 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Real native constructors and an API2 consumer, without ROMs or mod edits. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion') +local Screen=require('src.battle.BattleState') +local Model=require('src.battle.gen2.Battle') +local Pokemon=require('src.pokemon.Pokemon') +local Save=require('src.core.SaveData') +local old=V.get() +local originalGym,originalKanto=Model.isGymLeader,Model.isKantoGymLeader +-- Independent cartridge class order; display names deliberately all agree. +local classes={'FALKNER','WHITNEY','BUGSY','MORTY','PRYCE','JASMINE','CHUCK','CLAIR', + 'RIVAL1','POKEMON_PROF','WILL','CAL','BRUNO','KAREN','KOGA','CHAMPION', + 'BROCK','MISTY','LT_SURGE','SCIENTIST','ERIKA','YOUNGSTER','SCHOOLBOY', + 'BIRD_KEEPER','LASS','JANINE','COOLTRAINERM','COOLTRAINERF','BEAUTY','POKEMANIAC', + 'GRUNTM','GENTLEMAN','SKIER','TEACHER','SABRINA','BUG_CATCHER','FISHER','SWIMMERM', + 'SWIMMERF','SAILOR','SUPER_NERD','RIVAL2','GUITARIST','HIKER','BIKER','BLAINE', + 'BURGLAR','FIREBREATHER','JUGGLER','BLACKBELT_T','EXECUTIVEM','PSYCHIC_T', + 'PICNICKER','CAMPER','EXECUTIVEF','SAGE','MEDIUM','BOARDER','POKEFANM', + 'KIMONO_GIRL','TWINS','POKEFANF','RED','BLUE','OFFICER','GRUNTF','MYSTICALMAN'} +local leaders={FALKNER=true,WHITNEY=true,BUGSY=true,MORTY=true,PRYCE=true, + JASMINE=true,CHUCK=true,CLAIR=true,WILL=true,BRUNO=true,KAREN=true,KOGA=true, + CHAMPION=true,RED=true,BROCK=true,MISTY=true,LT_SURGE=true,ERIKA=true,JANINE=true, + SABRINA=true,BLAINE=true,BLUE=true} +local kanto={BROCK=true,MISTY=true,LT_SURGE=true,ERIKA=true,JANINE=true, + SABRINA=true,BLAINE=true,BLUE=true} +local function fixture(edition) + V.set(edition) + local data=T.fixtures.fresh();data.constants.gen=V.generation(edition) + local run=T.sdk.loadMods({'mods/friendship_probe'},{data=data,fs=T.sdk.memfs({ + ['mods/friendship_probe/manifest.json']='{"id":"friendship_probe","name":"Native friendship probe","version":"1.0.0","api":2,"entry":"main.lua"}', + ['mods/friendship_probe/main.lua']=[[ + local mod=... + local Battle=require('src.battle.gen2.Battle') + mod.exports.events={} + mod.events:on('happiness.changed',function(ev) + mod.exports.events[#mod.exports.events+1]=ev + end) + mod.exports.query=function(class) + if type(Battle.isGymLeader)~='function' or type(Battle.isKantoGymLeader)~='function' then return end + return Battle.isGymLeader(class),Battle.isKantoGymLeader(class) + end + mod.exports.decorate=function() + local native=Battle.isGymLeader + if type(native)~='function' then return end + Battle.isGymLeader=function(class) + mod.exports.lastClass=class + if mod.exports.override~=nil then return mod.exports.override end + return native(class) + end + end + ]], + })}) + T.eq(#run.errors,0,'real API2 friendship consumer loads: '..edition) + require('src.render.Font').load(data) + local save=Save.newGame();save.party={} + for i=1,5 do save.party[i]=Pokemon.new(data,'FIXMON_A',15)end + local game={data=data,save=save,stack={top=function()end}} + data.trainers.OWN_FRIENDSHIP={id='OWN_FRIENDSHIP',index=1,name='LEADER', + baseMoney=10,parties={{{level=5,species='FIXMON_C'}}}} + return game,run,run.loader.exports.friendship_probe +end +local function prepare(game,h) + for _,mon in ipairs(game.save.party)do + mon.hp,mon.happiness,mon.isEgg,mon.eggSteps=mon.stats.hp,h,nil,nil + end + game.save.party[3].hp=0 + game.save.party[4].isEgg=true + game.save.party[5].eggSteps=256 +end +for _,edition in ipairs{'gold','silver','crystal'}do + local game,run,probe=fixture(edition) + T.eq(type(Model.isGymLeader),'function','public source class predicate exists') + T.eq(type(Model.isKantoGymLeader),'function','public Kanto predicate exists') + for i,class in ipairs(classes)do + local gym,kgym=probe.query(class) + T.eq(gym,leaders[class]==true,'API2 source predicate '..class) + T.eq(kgym,kanto[class]==true,'API2 Kanto predicate '..class) + for _,h in ipairs{0,99,100,199,200,254,255}do + prepare(game,h);probe.events={} + game.data.trainers.OWN_FRIENDSHIP.index=i + local party=game.save.party + local b=Screen.newTrainer(game,'OWN_FRIENDSHIP',1) + local delta=h<100 and 3 or h<200 and 2 or 1 + local expected=leaders[class] and math.min(255,h+delta)or h + for n=1,5 do + T.eq(party[n].happiness,n<=2 and expected or h, + edition..' constructor award '..class..' '..h..' slot '..n) + T.eq(party[n].hp,n==3 and 0 or party[n].stats.hp,'bonus does not heal') + end + T.eq(b:playerPartyView(),party,'constructor keeps actual native party') + T.eq(#probe.events,leaders[class]and 2 or 0,'one event per standing non-egg, including byte saturation') + for n,ev in ipairs(probe.events)do + T.eq(ev.mon,party[n],'API2 event retains actual native mon') + T.eq(ev.event,'GYMBATTLE','source constructor event name') + T.eq(ev.eventId,4,'native cartridge event index remains available') + T.eq(ev.reason,'event','source reason') + T.eq(ev.from,h,'event before value') + T.eq(ev.to,expected,'event after value') + T.eq(ev.delta,expected-h,'event clamped delta') + end + if h==100 then + local events=#probe.events + b:enter() + T.eq(party[1].happiness,expected,'real enter does not award a second bonus') + T.eq(#probe.events,events,'real enter does not publish duplicate friendship events') + end + end + end + for _,class in ipairs{'UNKNOWN','falkner','LEADER','',false,1}do + local gym,kgym=probe.query(class) + T.eq(gym,false,'unknown source class does not become a leader') + T.eq(kgym,false,'unknown source class does not become a Kanto leader') + end + local gym,kgym=probe.query(nil) + T.eq(gym,false,'nil source class is not a leader');T.eq(kgym,false,'nil Kanto class is not a leader') + -- The native constructor must call the public source seam with canonical + -- identity, rather than a frozen private boolean or localized display name. + probe.decorate();probe.override=false;prepare(game,100);probe.events={} + game.data.trainers.OWN_FRIENDSHIP.index=63 + Screen.newTrainer(game,'OWN_FRIENDSHIP',1) + T.eq(probe.lastClass,'RED','API2 decorator observes actual native Red class') + T.eq(game.save.party[1].happiness,100,'decorated rejection reaches native constructor') + probe.override=true;prepare(game,100);probe.events={} + game.data.trainers.OWN_FRIENDSHIP.index='22' + local b=Screen.newTrainer(game,'OWN_FRIENDSHIP',1,{playerPartyIndices={2,3,4,5}}) + T.eq(probe.lastClass,'YOUNGSTER','ordinary imported numeric-string class resolves canonically') + T.eq(game.save.party[1].happiness,100,'out-of-scope save party member earns no bonus') + T.eq(game.save.party[2].happiness,102,'selected native standing member earns bonus') + T.eq(#probe.events,1,'selected fainted and both native egg representations stay excluded') + T.eq(probe.events[1]and probe.events[1].mon,game.save.party[2],'scoped event uses original selected mon') + T.eq(b:playerPartyView()[1],game.save.party[2],'ordered scope remains live') + -- Source ChangeHappiness reports applied zero-delta events, while its step + -- loop suppresses unchanged saturated values. Keep the two sites distinct. + prepare(game,255);probe.events={} + local friendship=require('src.pokemon.Gen2Friendship') + friendship.change(game.save.party[1],'XITEM') + T.eq(#probe.events,1,'zero-delta event remains observable by the actual API2 consumer') + T.eq(probe.events[1]and probe.events[1].delta,0,'zero-delta event is explicit') + probe.events={};game.save.g2HappinessStepCount=1 + friendship.stepCycle(game.save) + T.eq(#probe.events,0,'unchanged saturated step does not publish a change') + Model.isGymLeader,Model.isKantoGymLeader=originalGym,originalKanto + run.release() +end +for _,edition in ipairs{'red','blue','yellow','ruby','sapphire','emerald','firered','leafgreen','prism','polishedcrystal'}do + local game,run,probe=fixture(edition) + prepare(game,100) + for _,index in ipairs{1,11,16,63,64}do + game.data.trainers.OWN_FRIENDSHIP.index=index + Screen.newTrainer(game,'OWN_FRIENDSHIP',1) + end + T.eq(game.save.party[1].happiness,100,edition..' keeps its own constructor friendship rules') + T.eq(#probe.events,0,edition..' does not receive vanilla GSC award events') + run.release() +end +V.set(old) +T.finish('native GSC trainer class predicates, constructor friendship, live events and scoped party') diff --git a/tests/engine/gen2_volatile_state.lua b/tests/engine/gen2_volatile_state.lua new file mode 100644 index 000000000..a498fb18b --- /dev/null +++ b/tests/engine/gen2_volatile_state.lua @@ -0,0 +1,133 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Source GSC volatile names must reach the actual native status/damage owner, +-- not a disconnected metadata table. All monsters below are ROM-free fixtures. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion') +local Screen=require('src.battle.BattleState') +local Model=require('src.battle.gen2.Battle') +local Pokemon=require('src.pokemon.Pokemon') +local Save=require('src.core.SaveData') +local Damage=require('src.battle.Damage') +local oldVersion=V.get() +local function fixture(version,chosen,effect) + V.set(version) + local data=T.fixtures.fresh();data.constants.gen=2;data.constants.generation=2 + for _,id in ipairs{'PROTECT','QA_GUARD'}do + data.moves[id]={id=id,name=id,effect=effect or'PROTECT_EFFECT',power=0,type='NORMAL',accuracy=100,pp=10} + end + local run=T.sdk.loadMods({'mods/volatile_probe'},{data=data,fs=T.sdk.memfs({ + ['mods/volatile_probe/manifest.json']='{"id":"volatile_probe","name":"Owned state probe","version":"1.0.0","api":2,"entry":"main.lua"}', + ['mods/volatile_probe/main.lua']=[[ + local mod=... + local Model=require('src.battle.gen2.Battle') + mod.exports.write=function(model,mon,key,value)Model.volatile(model,mon)[key]=value end + mod.exports.read=function(model,mon,key)return Model.volatile(model,mon)[key]end + ]], + })}) + T.eq(#run.errors,0,'actual API2 source consumer loads') + require('src.render.Font').load(data) + local save=Save.newGame();save.party={Pokemon.new(data,'FIXMON_A',30),Pokemon.new(data,'FIXMON_B',30)} + if chosen then save.party[1].moves={{id=chosen,pp=10}}end + local game={data=data,save=save,stack={top=function()end}} + local b=Screen.newWild(game,'FIXMON_C',30) + local rolls=0;b.rng=function(lo,hi)rolls=rolls+1;return hi or lo end + return b,run,run.loader.exports.volatile_probe,function()return rolls end +end +local fields={ + {'flinched','flinched',true}, {'confuseCount','confusedTurns',3}, + {'endure','enduring',true}, {'recharge','mustRecharge',true}, + {'protectCount','protectRun',2}, {'focusEnergy','focusEnergy',true}, + {'xAccuracy','xAccuracy',true}, {'substitute','substituteHP',17}, + {'protect','protecting',true}, {'mist','mist',true}, +} +for _,version in ipairs{'gold','silver','crystal','prism','polishedcrystal'}do + local b,run,probe,rolls=fixture(version) + local model,mon=b.battle,b.player.mon + local view=model:volatile(mon) + T.eq(view,model:volatile(mon),'one stable source view for the same active battler') + T.eq(view,model:volatile(b.player),'party and native-wrapper queries share the owner') + for _,row in ipairs(fields)do + probe.write(model,mon,row[1],row[3]) + T.eq(b.player[row[2]],row[3],version..' source write reaches native '..row[2]) + b.player[row[2]]=nil + T.eq(probe.read(model,mon,row[1]),nil,'source read sees native clear: '..row[1]) + b.player[row[2]]=row[3] + T.eq(probe.read(model,mon,row[1]),row[3],'native state reaches source read: '..row[1]) + probe.write(model,mon,row[1],nil) + T.eq(b.player[row[2]],nil,'source clear retires native state: '..row[1]) + T.eq(mon[row[1]],nil,'transient source field is not written into party/save') + end + probe.write(model,mon,'flinched',true) + local pp=mon.moves[1].pp + T.eq(b:statusInterrupt(b.player,b.enemy,{id='FIX_TACKLE'}),true,'source flinch actually blocks native admission') + T.eq(not not probe.read(model,mon,'flinched'),false,'native admission clears source flinch') + T.eq(rolls(),0,'flinch does not consume battle RNG') + T.eq(mon.moves[1].pp,pp,'blocked admission cannot spend PP') + probe.write(model,mon,'confuseCount',2) + T.eq(b:statusInterrupt(b.player,b.enemy,{id='FIX_TACKLE'}),false,'high native roll allows a confused attack') + T.eq(b.player.confusedTurns,1,'actual native confusion decrements source counter') + T.eq(probe.read(model,mon,'confuseCount'),1,'source sees the actual remaining counter') + T.eq(rolls(),1,'confusion consumes exactly one actual byte roll') + probe.write(model,mon,'confuseCount',nil) + probe.write(model,mon,'recharge',true) + local locked=b:lockedAction(b.player) + T.eq(locked and locked.special,'recharge','source recharge reaches the real move lock') + probe.write(model,mon,'recharge',nil) + local hp=mon.hp + probe.write(model,mon,'endure',true) + b:applyDamage(b.player,100000,true) + T.eq(mon.hp,1,'source Endure caps actual incoming damage at one HP') + mon.hp=hp + b:applyDamage(b.player,100000,false) + T.eq(mon.hp,0,'Endure does not absorb native residual damage') + probe.write(model,mon,'endure',nil);mon.hp=hp + local low={id='QA_LOW_ACCURACY',accuracy=1,type='NORMAL'} + local function accuracy()return Damage.accuracyRoll({oneIn256Miss=true},low,b.player,b.enemy,function()return 255 end,nil,b.data)end + T.eq(accuracy(),false,'low accuracy misses before source override') + probe.write(model,mon,'xAccuracy',true);T.eq(accuracy(),true,'source X Accuracy bypasses the real hit check') + probe.write(model,mon,'xAccuracy',nil) + local function critical()return Damage.critRoll({},b.player,'FIX_TACKLE',function()return 20 end,false,b.data,b)end + T.eq(critical(),false,'fixed byte is outside the native base critical stage') + probe.write(model,mon,'focusEnergy',true);T.eq(critical(),true,'source Focus Energy reaches native GSC critical stage') + probe.write(model,mon,'focusEnergy',nil) + local marker={owned=true};view._qaPresentation=marker + collectgarbage('collect');T.eq(model:volatile(mon)._qaPresentation,marker,'consumer metadata persists while active') + local old=b.player + b:switchPlayerInto(Screen.POS.PLAYER_LEFT,b.game.save.party[2]) + local nextView=model:volatile(model.player) + T.check(nextView~=view,'new native wrapper has a new volatile owner') + T.eq(nextView._qaPresentation,nil,'source metadata does not leak to incoming actor') + probe.write(model,model.player,'confuseCount',4) + T.eq(b.player.confusedTurns,4,'incoming native battler owns the new source state') + T.eq(old.confusedTurns,nil,'new state cannot change the withdrawn native wrapper') + T.eq(mon.volatile,nil,'source view cannot create persistent party volatile data') + run.release() + for _,chosen in ipairs{'PROTECT','QA_GUARD'}do + local battle,owner,source=fixture(version,chosen) + source.write(battle.battle,battle.player.mon,'protectCount',2) + battle:performMove(battle.player,battle.enemy,battle.player.curMoves[1],false,true) + T.eq(battle.player.protecting,nil,'native repeated '..chosen..' fails its real odds roll') + T.eq(source.read(battle.battle,battle.player.mon,'protectCount'),0,'native failed protection clears source counter') + owner.release() + end + local battle,owner,source=fixture(version,'PROTECT','NO_ADDITIONAL_EFFECT') + source.write(battle.battle,battle.player.mon,'protectCount',2) + battle:performMove(battle.player,battle.enemy,battle.player.curMoves[1],false,true) + T.eq(source.read(battle.battle,battle.player.mon,'protectCount'),nil,'non-protection effect resets run even with old PROTECT ID') + owner.release() +end +-- A module-global weak-key map whose value closure retains its key is not +-- collectible on LuaJIT's Lua5.1 collector. No caller-held view remains here. +local weak=setmetatable({},{__mode='k'}) +local function retired() + local mon={};local b={mon=mon};local screen={player=b};local model={} + Model.bind(model,screen);Model.volatile(model,mon)._owned=true + T.eq(screen.player,b,'owner stays live during its actual view creation') + weak[b]=true +end +for _=1,12 do retired()end +collectgarbage('collect');collectgarbage('collect') +T.eq(next(weak),nil,'module cache cannot retain retired battlers through view closures') +V.set(oldVersion) +T.finish('native source volatile state, real mechanics, switch and cache lifetime') diff --git a/tests/engine/native_trainer_party_scope.lua b/tests/engine/native_trainer_party_scope.lua new file mode 100644 index 000000000..ee05106f1 --- /dev/null +++ b/tests/engine/native_trainer_party_scope.lua @@ -0,0 +1,117 @@ +-- Copyright (c) 2026 Roxas2712. See the offered contribution licence. +-- Actual older-host constructors, menus, EXP/faint owners and party effects. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion') +local B=require('src.battle.BattleState') +local Pokemon=require('src.pokemon.Pokemon') +local Save=require('src.core.SaveData') +local old=V.get() +for _,edition in ipairs(V.ORDER)do + V.set(edition) + local data=T.fixtures.fresh();data.constants.gen=V.generation(edition) + if data.constants.gen>=3 then + -- These editions use the structural six-stat/IV schema. The base fixture + -- is Gen1-shaped, so give the owned species their proper native layout. + for _,def in pairs(data.pokemon)do + if type(def)=='table'and def.baseStats then + def.baseStats.spatk=def.baseStats.spatk or def.baseStats.special + def.baseStats.spdef=def.baseStats.spdef or def.baseStats.special + def.evYield={hp=0,attack=0,defense=0,speed=0,spatk=0,spdef=0} + end + end + end + local run=T.sdk.loadMods({'mods/scope_probe'},{data=data,fs=T.sdk.memfs({ + ['mods/scope_probe/manifest.json']='{"id":"scope_probe","name":"Native scope probe","version":"1.0.0","api":2,"entry":"main.lua"}', + ['mods/scope_probe/main.lua']=[[ + local mod=... + mod.hooks:wrap('battle.exp_award',function(next,ctx) + mod.exports.award=ctx + return next(ctx) + end) + ]], + })}) + T.eq(#run.errors,0,'real native party observer loads: '..edition) + require('src.render.Font').load(data) + local save=Save.newGame();save.party={} + for i=1,3 do save.party[i]=Pokemon.new(data,'FIXMON_A',20+i)end + local game={data=data,save=save,stack={top=function()end}} + data.trainers.OWN_SCOPE={id='OWN_SCOPE',index=22,name='SCOPE',baseMoney=10, + parties={{{level=5,species='FIXMON_C'}}}} + local original,first,second,third=save.party,save.party[1],save.party[2],save.party[3] + local selected={3,2} + local b=B.newTrainer(game,'OWN_SCOPE',1,{playerPartyIndices=selected}) + T.eq(b.player.mon,third,'native lead is the first selected original mon') + T.eq(b:playerPartyView()[1],third,'ordered scope retains actual first reference') + T.eq(b:playerPartyView()[2],second,'ordered scope retains actual second reference') + T.neq(b.playerPartyIndices,selected,'caller cannot mutate retained selection indices') + T.eq(b:benchFor(true),b.playerParty,'native bench uses the actual selected roster') + T.eq(#b:benchFor(true),2,'excluded healthy mon is not a replacement') + -- AI runs after real screen entry initializes native on-field sides; a + -- constructor's not-yet-entered empty side table is not an AI phase. + b:enter() + T.eq(b.sides[1].battlers[1],b.player,'actual enter initializes the selected on-field owner') + local menu=b:buildScreen(require('src.battle.Gen4Battle').partyScreenId(b)or 'PartyMenu', + {battle=b,onSwitch=function()end}) + local shown=type(menu.party)=='function'and menu:party()or menu.party + T.eq(shown,b.playerParty,'actual generation-appropriate party menu uses scope') + T.eq(#shown,2,'actual party menu cannot offer an excluded mon') + -- The existing AI already understands playerParty. Ensure construction + -- and replacement draw from that same original roster, including doubles. + local vm={battle=b,user=b.player,target=b.enemy} + require('src.battle.Gen3AI').OPS.count_usable_party_mons(vm,{1}) + T.eq(vm.result,1,'real AI counts only one selected off-field replacement') + b.double=true;b:sendOutSecondPair() + T.eq(b:battlerAt(B.POS.PLAYER_RIGHT).mon,second,'second flank draws from selected roster') + require('src.battle.Gen3AI').OPS.count_usable_party_mons(vm,{1}) + T.eq(vm.result,0,'both selected mons on field leave no AI replacement') + -- Party-wide effects must not heal or count the excluded save-party mon. + for _,mon in ipairs(save.party)do mon.status='BRN'end + require('src.battle.Gen3MoveEffects').primary.HEAL_BELL_EFFECT(b,b.player) + T.eq(first.status,'BRN','actual Heal Bell leaves excluded mon alone') + T.eq(second.status,nil,'actual Heal Bell heals selected second mon') + T.eq(third.status,nil,'actual Heal Bell heals selected lead') + first.status=nil + local count=require('src.battle.Gen3MoveEffects').full.BEAT_UP_EFFECT.hitCount( + {battle=b,user=b.player,target=b.enemy}) + T.eq(count,2,'real Beat Up hit count excludes healthy save-party outsider') + if V.isGen5()then + for _,mon in ipairs(save.party)do mon.status='BRN'end + require('src.battle.Gen5MoveEffects').record(b,'BW_HEAL_BELL').run({ + battle=b,user=b.player,target=b.player, + move={id='OWN_SCOPE_BELL',name='BELL',power=0,type='NORMAL'}, + }) + T.eq(first.status,'BRN','actual Gen5 effect leaves excluded mon alone') + T.eq(second.status,nil,'actual Gen5 effect heals selected second mon') + T.eq(third.status,nil,'actual Gen5 effect heals selected lead') + end + -- Native EXP.ALL includes exactly this battle's party, even if stale + -- participation bookkeeping still names the excluded save-party mon. + b.queue={};b.participants={[first]=true,[second]=true,[third]=true} + save.inventory.EXP_ALL=1 + local excludedExp=first.exp + b:awardExp(b.enemy) + local award=run.loader.exports.scope_probe.award + T.check(award~=nil,'actual native EXP hook executes') + if award then + T.eq(award.participants,2,'actual EXP divisor contains selected participants') + T.eq(#award.alive,2,'actual EXP living roster excludes outsider') + end + T.eq(first.exp,excludedExp,'actual EXP.ALL cannot pay excluded outsider') + second.hp,third.hp=0,0 + T.check(first.hp>0,'excluded save-party member remains healthy') + b.result=nil;b:playerMonFainted() + T.eq(b.result,'lose','actual native defeat ignores excluded healthy party member') + T.eq(save.party,original,'battle never replaces real save.party') + T.eq(save.party[1],first,'battle never reorders excluded save-party member') + T.eq(save.party[2],second,'battle never reorders selected save-party member') + T.eq(save.party[3],third,'battle retains every original party mon') + for _,invalid in ipairs{{},{1,1},{0},{4},{1.5},{[1]=1,[3]=2},{1,unexpected=true},'bad'}do + local party,indices=require('src.battle.PlayerPartyScope').select(game,invalid) + T.eq(party,nil,'invalid native selection falls back to complete party') + T.eq(indices,nil,'invalid selection keeps no misleading remap') + end + run.release() +end +V.set(old) +T.finish('older-host real trainer party selection, UI, AI, party effects, EXP and defeat')