diff --git a/CLAUDE.md b/CLAUDE.md index eefa00ed..c8299f56 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,13 +53,14 @@ chomp/ │ │ ├── OkayCPU.sol # Mid-tier opponent │ │ ├── CPUMoveManager.sol # Wraps Engine.execute for CPU-driven battles │ │ └── ICPU.sol -│ ├── effects/ # Effect system (status effects, stat boosts, battlefield) +│ ├── effects/ # Effect system (status effects, battlefield) │ │ ├── IEffect.sol # Effect interface with lifecycle hooks │ │ ├── BasicEffect.sol │ │ ├── StaminaRegen.sol -│ │ ├── StatBoosts.sol │ │ ├── status/ # Status effects (Burn, Frostbite, Panic, Sleep, Zap) + StatusEffectLib │ │ ├── battlefield/ # Battlefield effects (Overclock) +│ │ # NOTE: stat boosts are inlined into the Engine (see "Stat Boosts" below); the math +│ │ # helpers live in src/lib/StatBoostLib.sol, there is no StatBoosts effect contract. │ ├── game-layer/ # Team / mon registry, gacha, exp, facets, quests, gifts │ │ ├── GachaTeamRegistry.sol # Concrete leaf: composes the abstracts below │ │ ├── MonOwnership.sol # monsOwned set + ownership view/check helpers @@ -220,6 +221,30 @@ Effects implement `IEffect` with a bitmap indicating which lifecycle steps they Effects can be per-mon (local) or global (battlefield-wide). The `StaminaRegen` effect is a global default that regenerates 1 stamina per turn. +### Stat Boosts (inlined into the Engine) + +Stat modifiers are **not** a separate effect contract — they are native Engine functions: +`addStatBoost` / `addKeyedStatBoost` / `removeStatBoost` / `removeKeyedStatBoost` / `clearAllStatBoosts` +(declared in `IEngine`). Moves, abilities, and shared effects (`BurnStatus`, `FrostbiteStatus`, +`Overclock`, `UpOnly`, `Tinderclaws`, …) call `engine.addStatBoost(...)` directly during `execute`. +The packing/aggregation math lives in `src/lib/StatBoostLib.sol`. (Historically this was an external +`StatBoosts` effect that the Engine called back into ~10–15× per application; inlining removed those +round-trips.) + +How it works: +- Each boost **source** is one packed entry stored in the mon's normal effect mapping under the + `STAT_BOOST_ADDRESS` sentinel (steps bitmap `STAT_BOOST_STEPS` = `OnMonSwitchOut | ALWAYS_APPLIES`). + Sources are keyed by `msg.sender` (or `msg.sender` + a salt string), so each move/ability/effect + stacks independently and can remove its own boost. Boosts are **multiplicative** per source; `Temp` + boosts are dropped automatically on switch-out (`_inlineStatBoostSwitchOut`), `Perm` ones persist. +- Boosts apply only to the 5 stat deltas: `Speed`, `Attack`, `Defense`, `SpecialAttack`, + `SpecialDefense`. There is **no globalKV snapshot** — the Engine telescopes off the live `monState` + delta (`new boosted − base − currentDelta`) and fires `OnUpdateMonState` like any other delta write. +- **Ownership invariant:** the stat-boost system is the *sole* writer of those 5 deltas. External + `updateMonState` calls with `Speed`..`SpecialDefense` **revert with `StatRequiresStatBoost`** — to + change a stat you must go through `add`/`removeStatBoost`, never `updateMonState`. (`Hp`, `Stamina`, + `IsKnockedOut`, `ShouldSkipTurn` remain writable via `updateMonState`.) + ### Type System 16 types: Yin, Yang, Earth, Liquid, Fire, Metal, Ice, Nature, Lightning, Mythic, Air, Math, Cyber, Wild, Cosmic, None. Type effectiveness is calculated by `ITypeCalculator`. @@ -300,8 +325,8 @@ Both per-mon mappings share the same 16-mon bucketing so `_applyExpAndFacetDraws ### Storage Architecture - `BattleData` and `BattleConfig` are stored per battle key (derived from player addresses) -- `MonState` tracks deltas from base stats (hpDelta, staminaDelta, etc.) -- Effects stored in per-mon mappings with stride-based indexing (64 slots per mon) +- `MonState` tracks deltas from base stats (hpDelta, staminaDelta, etc.). The 5 stat deltas are written only by the inlined stat-boost path (see "Stat Boosts"); other deltas via `updateMonState`. +- Effects stored in per-mon mappings with stride-based indexing (64 slots per mon). Stat-boost sources reuse these same mappings under the `STAT_BOOST_ADDRESS` sentinel. - Heavy use of bit packing for gas efficiency (KO bitmaps, effect counts, active mon indices) - Transient storage used for per-transaction state (`battleKeyForWrite`, `tempRNG`) - `GachaTeamRegistry`'s storage is the union of its abstract bases; each base owns its own mappings/constants so the leaf is integration-only. Reordering the inheritance list would shift slot layout — keep the order in `GachaTeamRegistry.sol` stable across deploys. @@ -424,7 +449,7 @@ Effects fall into several categories depending on scope: - **Status effects** (`src/effects/status/`): Extend `StatusEffect` which enforces one-status-per-mon via a KV flag. Shared across mons — deployed once, injected into moves via constructor parameters. (e.g., `BurnStatus`, `FrostbiteStatus`, `SleepStatus`) - **Battlefield effects** (`src/effects/battlefield/`): Extend `BasicEffect`, use `targetIndex=2` for global scope. (e.g., `Overclock`) -- **Shared utility effects** (`src/effects/`): Deployed once, used by many contracts. (e.g., `StatBoosts` for stat modifiers, `StaminaRegen` for per-turn recovery) +- **Shared utility effects** (`src/effects/`): Deployed once, used by many contracts. (e.g., `StaminaRegen` for per-turn recovery). NOTE: stat modifiers are **not** an effect — they are inlined Engine functions (`addStatBoost`/`removeStatBoost`/…); see "Stat Boosts" above. - **Mon-local effects** (`src/mons//`): Abilities or move-effect hybrids that only apply to one mon. These live in the mon's directory, not in `src/effects/`. To implement a new effect: diff --git a/script/EngineAndPeriphery.s.sol b/script/EngineAndPeriphery.s.sol index a4a4986c..13748cc8 100644 --- a/script/EngineAndPeriphery.s.sol +++ b/script/EngineAndPeriphery.s.sol @@ -20,7 +20,6 @@ import {SimplePM} from "../src/hooks/SimplePM.sol"; import {ReturnerGift} from "../src/game-layer/ReturnerGift.sol"; // Shared effects -import {StatBoosts} from "../src/effects/StatBoosts.sol"; import {BurnStatus} from "../src/effects/status/BurnStatus.sol"; import {FrostbiteStatus} from "../src/effects/status/FrostbiteStatus.sol"; import {PanicStatus} from "../src/effects/status/PanicStatus.sol"; @@ -117,10 +116,9 @@ contract EngineAndPeriphery is Script { function deployGameFundamentals() public { - StatBoosts statBoosts = new StatBoosts(); - deployedContracts.push(DeployData({name: "STAT BOOSTS", contractAddress: address(statBoosts)})); + // Stat boosts are now inlined into the Engine (no standalone StatBoosts contract to deploy). - Overclock overclock = new Overclock(statBoosts); + Overclock overclock = new Overclock(); deployedContracts.push(DeployData({name: "OVERCLOCK", contractAddress: address(overclock)})); SleepStatus sleepStatus = new SleepStatus(); @@ -129,10 +127,10 @@ contract EngineAndPeriphery is Script { PanicStatus panicStatus = new PanicStatus(); deployedContracts.push(DeployData({name: "PANIC STATUS", contractAddress: address(panicStatus)})); - FrostbiteStatus frostbiteStatus = new FrostbiteStatus(statBoosts); + FrostbiteStatus frostbiteStatus = new FrostbiteStatus(); deployedContracts.push(DeployData({name: "FROSTBITE STATUS", contractAddress: address(frostbiteStatus)})); - BurnStatus burnStatus = new BurnStatus(statBoosts); + BurnStatus burnStatus = new BurnStatus(); deployedContracts.push(DeployData({name: "BURN STATUS", contractAddress: address(burnStatus)})); ZapStatus zapStatus = new ZapStatus(); diff --git a/script/SetupMons.s.sol b/script/SetupMons.s.sol index f7d2bb66..543fd22e 100644 --- a/script/SetupMons.s.sol +++ b/script/SetupMons.s.sol @@ -8,7 +8,6 @@ import {MonStats} from "../src/Structs.sol"; import {Type} from "../src/Enums.sol"; import {IEffect} from "../src/effects/IEffect.sol"; -import {StatBoosts} from "../src/effects/StatBoosts.sol"; import {Overclock} from "../src/effects/battlefield/Overclock.sol"; import {BullRush} from "../src/mons/aurox/BullRush.sol"; import {GildedRecovery} from "../src/mons/aurox/GildedRecovery.sol"; @@ -123,7 +122,7 @@ contract SetupMons is Script { address[4] memory addrs; { - addrs[0] = address(new EternalGrudge(StatBoosts(vm.envAddress("STAT_BOOSTS")))); + addrs[0] = address(new EternalGrudge()); deployedContracts[0] = DeployData({name: "Eternal Grudge", contractAddress: addrs[0]}); } { @@ -172,7 +171,6 @@ contract SetupMons is Script { DeployData[] memory deployedContracts = new DeployData[](4); // Cache commonly used addresses - address statboosts = vm.envAddress("STAT_BOOSTS"); address typecalculator = vm.envAddress("TYPE_CALCULATOR"); address[4] memory addrs; @@ -182,7 +180,7 @@ contract SetupMons is Script { deployedContracts[0] = DeployData({name: "Chain Expansion", contractAddress: addrs[0]}); } { - addrs[1] = address(new Initialize(StatBoosts(statboosts))); + addrs[1] = address(new Initialize()); deployedContracts[1] = DeployData({name: "Initialize", contractAddress: addrs[1]}); } { @@ -190,7 +188,7 @@ contract SetupMons is Script { deployedContracts[2] = DeployData({name: "Hit And Dip", contractAddress: addrs[2]}); } { - addrs[3] = address(new Interweaving(StatBoosts(statboosts))); + addrs[3] = address(new Interweaving()); deployedContracts[3] = DeployData({name: "Interweaving", contractAddress: addrs[3]}); } @@ -226,17 +224,14 @@ contract SetupMons is Script { function deployMalalien(GachaTeamRegistry registry) internal returns (DeployData[] memory) { DeployData[] memory deployedContracts = new DeployData[](2); - // Cache commonly used addresses - address statboosts = vm.envAddress("STAT_BOOSTS"); - address[2] memory addrs; { - addrs[0] = address(new TripleThink(StatBoosts(statboosts))); + addrs[0] = address(new TripleThink()); deployedContracts[0] = DeployData({name: "Triple Think", contractAddress: addrs[0]}); } { - addrs[1] = address(new ActusReus(StatBoosts(statboosts))); + addrs[1] = address(new ActusReus()); deployedContracts[1] = DeployData({name: "Actus Reus", contractAddress: addrs[1]}); } @@ -273,7 +268,6 @@ contract SetupMons is Script { DeployData[] memory deployedContracts = new DeployData[](5); // Cache commonly used addresses - address statboosts = vm.envAddress("STAT_BOOSTS"); address typecalculator = vm.envAddress("TYPE_CALCULATOR"); address[5] memory addrs; @@ -287,7 +281,7 @@ contract SetupMons is Script { deployedContracts[1] = DeployData({name: "Unbounded Strike", contractAddress: addrs[1]}); } { - addrs[2] = address(new Loop(Baselight(addrs[0]), StatBoosts(statboosts))); + addrs[2] = address(new Loop(Baselight(addrs[0]))); deployedContracts[2] = DeployData({name: "Loop", contractAddress: addrs[2]}); } { @@ -295,7 +289,7 @@ contract SetupMons is Script { deployedContracts[3] = DeployData({name: "Brightback", contractAddress: addrs[3]}); } { - addrs[4] = address(new Renormalize(Baselight(addrs[0]), StatBoosts(statboosts), Loop(addrs[2]))); + addrs[4] = address(new Renormalize(Baselight(addrs[0]), Loop(addrs[2]))); deployedContracts[4] = DeployData({name: "Renormalize", contractAddress: addrs[4]}); } @@ -434,7 +428,7 @@ contract SetupMons is Script { address[4] memory addrs; { - addrs[0] = address(new Deadlift(StatBoosts(vm.envAddress("STAT_BOOSTS")))); + addrs[0] = address(new Deadlift()); deployedContracts[0] = DeployData({name: "Deadlift", contractAddress: addrs[0]}); } { @@ -484,13 +478,12 @@ contract SetupMons is Script { // Cache commonly used addresses address burnstatus = vm.envAddress("BURN_STATUS"); - address statboosts = vm.envAddress("STAT_BOOSTS"); address typecalculator = vm.envAddress("TYPE_CALCULATOR"); address[5] memory addrs; { - addrs[0] = address(new HoneyBribe(StatBoosts(statboosts))); + addrs[0] = address(new HoneyBribe()); deployedContracts[0] = DeployData({name: "Honey Bribe", contractAddress: addrs[0]}); } { @@ -506,7 +499,7 @@ contract SetupMons is Script { deployedContracts[3] = DeployData({name: "Q5", contractAddress: addrs[3]}); } { - addrs[4] = address(new Tinderclaws(IEffect(burnstatus), StatBoosts(statboosts))); + addrs[4] = address(new Tinderclaws(IEffect(burnstatus))); deployedContracts[4] = DeployData({name: "Tinderclaws", contractAddress: addrs[4]}); } @@ -619,7 +612,7 @@ contract SetupMons is Script { deployedContracts[3] = DeployData({name: "Bull Rush", contractAddress: addrs[3]}); } { - addrs[4] = address(new UpOnly(StatBoosts(vm.envAddress("STAT_BOOSTS")))); + addrs[4] = address(new UpOnly()); deployedContracts[4] = DeployData({name: "Up Only", contractAddress: addrs[4]}); } @@ -736,7 +729,7 @@ contract SetupMons is Script { deployedContracts[3] = DeployData({name: "Overflow", contractAddress: addrs[3]}); } { - addrs[4] = address(new SaviorComplex(StatBoosts(vm.envAddress("STAT_BOOSTS")))); + addrs[4] = address(new SaviorComplex()); deployedContracts[4] = DeployData({name: "Savior Complex", contractAddress: addrs[4]}); } @@ -779,7 +772,7 @@ contract SetupMons is Script { deployedContracts[0] = DeployData({name: "Hard Reset", contractAddress: addrs[0]}); } { - addrs[1] = address(new Chronoffense(StatBoosts(vm.envAddress("STAT_BOOSTS")))); + addrs[1] = address(new Chronoffense()); deployedContracts[1] = DeployData({name: "Chronoffense", contractAddress: addrs[1]}); } { diff --git a/snapshots/EngineOptimizationTest.json b/snapshots/EngineOptimizationTest.json index c6109fbe..c8a77275 100644 --- a/snapshots/EngineOptimizationTest.json +++ b/snapshots/EngineOptimizationTest.json @@ -1,4 +1,4 @@ { - "ExternalStaminaRegen": "410294", - "InlineStaminaRegen": "1053626" + "ExternalStaminaRegen": "416961", + "InlineStaminaRegen": "1060256" } \ No newline at end of file diff --git a/snapshots/FullyOptimizedInlineGasTest.json b/snapshots/FullyOptimizedInlineGasTest.json index 88b38a4e..4a4dc5e3 100644 --- a/snapshots/FullyOptimizedInlineGasTest.json +++ b/snapshots/FullyOptimizedInlineGasTest.json @@ -1,23 +1,23 @@ { - "Fast_Battle1_coldGas": "1691495", - "Fast_Battle1_coldSload": "252", + "Fast_Battle1_coldGas": "1445192", + "Fast_Battle1_coldSload": "255", "Fast_Battle1_noop": "24", - "Fast_Battle1_nzToNz": "78", - "Fast_Battle1_totalSload": "1563", - "Fast_Battle1_totalSstore": "132", - "Fast_Battle1_zToNz": "30", - "Fast_Battle2_coldGas": "1635917", - "Fast_Battle2_coldSload": "258", + "Fast_Battle1_nzToNz": "72", + "Fast_Battle1_totalSload": "1407", + "Fast_Battle1_totalSstore": "123", + "Fast_Battle1_zToNz": "27", + "Fast_Battle2_coldGas": "1362316", + "Fast_Battle2_coldSload": "259", "Fast_Battle2_noop": "31", - "Fast_Battle2_nzToNz": "99", - "Fast_Battle2_totalSload": "1635", - "Fast_Battle2_totalSstore": "151", - "Fast_Battle2_zToNz": "20", - "Fast_Battle3_coldGas": "1220797", - "Fast_Battle3_coldSload": "252", - "Fast_Battle3_noop": "37", - "Fast_Battle3_nzToNz": "89", - "Fast_Battle3_totalSload": "1563", - "Fast_Battle3_totalSstore": "132", - "Fast_Battle3_zToNz": "4" + "Fast_Battle2_nzToNz": "87", + "Fast_Battle2_totalSload": "1489", + "Fast_Battle2_totalSstore": "137", + "Fast_Battle2_zToNz": "18", + "Fast_Battle3_coldGas": "1047852", + "Fast_Battle3_coldSload": "255", + "Fast_Battle3_noop": "38", + "Fast_Battle3_nzToNz": "78", + "Fast_Battle3_totalSload": "1407", + "Fast_Battle3_totalSstore": "123", + "Fast_Battle3_zToNz": "5" } \ No newline at end of file diff --git a/snapshots/MatchmakerTest.json b/snapshots/MatchmakerTest.json index 86d241af..9ba18a19 100644 --- a/snapshots/MatchmakerTest.json +++ b/snapshots/MatchmakerTest.json @@ -1,5 +1,5 @@ { - "Accept1": "341848", - "Accept2": "34250", - "Propose1": "197406" + "Accept1": "344188", + "Accept2": "34385", + "Propose1": "197541" } \ No newline at end of file diff --git a/snapshots/StartBattleGasTest.json b/snapshots/StartBattleGasTest.json index 2e1bf1a9..1bc32c1e 100644 --- a/snapshots/StartBattleGasTest.json +++ b/snapshots/StartBattleGasTest.json @@ -1,23 +1,23 @@ { - "StartBattle_Cold_coldGas": "1419128", - "StartBattle_Cold_coldSload": "119", - "StartBattle_Cold_noop": "12", + "StartBattle_Cold_coldGas": "1419479", + "StartBattle_Cold_coldSload": "120", + "StartBattle_Cold_noop": "13", "StartBattle_Cold_nzToNz": "5", - "StartBattle_Cold_totalSload": "158", - "StartBattle_Cold_totalSstore": "81", + "StartBattle_Cold_totalSload": "159", + "StartBattle_Cold_totalSstore": "82", "StartBattle_Cold_zToNz": "64", - "StartBattle_WarmSame_coldGas": "267111", - "StartBattle_WarmSame_coldSload": "129", - "StartBattle_WarmSame_noop": "68", + "StartBattle_WarmSame_coldGas": "267462", + "StartBattle_WarmSame_coldSload": "130", + "StartBattle_WarmSame_noop": "69", "StartBattle_WarmSame_nzToNz": "4", - "StartBattle_WarmSame_totalSload": "167", - "StartBattle_WarmSame_totalSstore": "81", + "StartBattle_WarmSame_totalSload": "168", + "StartBattle_WarmSame_totalSstore": "82", "StartBattle_WarmSame_zToNz": "6", - "StartBattle_WarmSteady_coldGas": "267111", - "StartBattle_WarmSteady_coldSload": "129", - "StartBattle_WarmSteady_noop": "44", + "StartBattle_WarmSteady_coldGas": "267462", + "StartBattle_WarmSteady_coldSload": "130", + "StartBattle_WarmSteady_noop": "45", "StartBattle_WarmSteady_nzToNz": "28", - "StartBattle_WarmSteady_totalSload": "167", - "StartBattle_WarmSteady_totalSstore": "81", + "StartBattle_WarmSteady_totalSload": "168", + "StartBattle_WarmSteady_totalSstore": "82", "StartBattle_WarmSteady_zToNz": "6" } \ No newline at end of file diff --git a/src/Constants.sol b/src/Constants.sol index 6dc3f96b..e49ccae6 100644 --- a/src/Constants.sol +++ b/src/Constants.sol @@ -42,6 +42,15 @@ uint256 constant EFFECT_COUNT_MASK = 0x3F; // 6 bits = max count of 63 address constant TOMBSTONE_ADDRESS = address(0xdead); +// Sentinel effect address used for inlined stat-boost entries. Boost sources are stored in the +// normal per-mon effect mappings under this address; the Engine recognizes it and runs the +// inlined stat-boost switch-out logic instead of making an external IEffect call (mirrors the +// address(0) StaminaRegen inline path). It is never a real deployed contract. +address constant STAT_BOOST_ADDRESS = address(0x57B); // "STB" - stat boost +// Steps bitmap stored on inlined stat-boost effect entries: ALWAYS_APPLIES | OnMonSwitchOut (bit 5). +// Matches the legacy StatBoosts.getStepsBitmap() (0x8020) so view/round-trip behavior is unchanged. +uint16 constant STAT_BOOST_STEPS = 0x8020; + // Sentinel ruleset address: when passed as battle.ruleset, the Engine adds // inline StaminaRegen as a global effect without calling an external contract. address constant INLINE_STAMINA_REGEN_RULESET = address(0x57A); // "STA"mina diff --git a/src/Engine.sol b/src/Engine.sol index caa22ab3..194bfebf 100644 --- a/src/Engine.sol +++ b/src/Engine.sol @@ -11,6 +11,7 @@ import {IEngine} from "./IEngine.sol"; import {IAbility} from "./abilities/IAbility.sol"; import {ICommitManager} from "./commit-manager/ICommitManager.sol"; import {MappingAllocator} from "./lib/MappingAllocator.sol"; +import {StatBoostLib} from "./lib/StatBoostLib.sol"; import {StaminaRegenLogic} from "./lib/StaminaRegenLogic.sol"; import {TimeoutCheckParams, ValidatorLogic} from "./lib/ValidatorLogic.sol"; import {IMatchmaker} from "./matchmaker/IMatchmaker.sol"; @@ -53,6 +54,10 @@ contract Engine is IEngine, MappingAllocator { // Errors error NoWriteAllowed(); error WrongCaller(); + // The 5 stat deltas (Speed/Attack/Defense/SpecialAttack/SpecialDefense) are owned exclusively by + // the inlined stat-boost system; they can only be written via add/removeStatBoost so the boost + // aggregation (which telescopes off the live delta) can't be silently clobbered. + error StatRequiresStatBoost(); error MatchmakerNotAuthorized(); error MatchmakerError(); error MovesNotSet(); @@ -175,6 +180,7 @@ contract Engine is IEngine, MappingAllocator { config.packedP1EffectsCount = 0; config.koBitmaps = 0; config.globalKVCount = 0; + config.playerEffectStepsUnion = 0; // teamIndices narrowed from Battle.uint96; phantom-team writes truncate to match. battleData[battleKey] = BattleData({ @@ -996,13 +1002,15 @@ contract Engine is IEngine, MappingAllocator { monState.shouldSkipTurn = (valueToAdd % 2) == 1; } - // Trigger OnUpdateMonState lifecycle hook only if any per-mon effect could listen. - // Skipping saves the abi.encode(4-tuple) allocation + _runEffects shell overhead when no - // OnUpdateMonState consumers are registered on this mon (the common case). + // Trigger OnUpdateMonState lifecycle hook only if some player effect actually listens at it + // (battle-wide union) AND this mon has effects. OnUpdateMonState has a single listener in the + // whole game (Dreamcatcher), so the union bit is unset in almost every battle — skipping the + // abi.encode(4-tuple) + _runEffects shell entirely. Stat-boost delta writes hit this path a lot. uint256 updateMonStateCount = playerIndex == 0 ? _getMonEffectCount(config.packedP0EffectsCount, monIndex) : _getMonEffectCount(config.packedP1EffectsCount, monIndex); - if (updateMonStateCount > 0) { + if (updateMonStateCount > 0 + && (config.playerEffectStepsUnion & uint16(1 << uint8(EffectStep.OnUpdateMonState))) != 0) { _runEffects( battleKey, tempRNG, @@ -1021,6 +1029,13 @@ contract Engine is IEngine, MappingAllocator { if (battleKeyForWrite == bytes32(0)) { revert NoWriteAllowed(); } + // Speed(2)..SpecialDefense(6) are the stat-boost-owned deltas — reject direct writes so they + // can only change through add/removeStatBoost (the internal stat-boost path bypasses this by + // calling _updateMonStateInternal directly). Hp/Stamina/IsKnockedOut/ShouldSkipTurn stay open. + uint256 idx = uint256(stateVarIndex); + if (idx >= uint256(MonStateIndexName.Speed) && idx <= uint256(MonStateIndexName.SpecialDefense)) { + revert StatRequiresStatBoost(); + } _updateMonStateInternal(playerIndex, monIndex, stateVarIndex, valueToAdd); } @@ -1119,6 +1134,12 @@ contract Engine is IEngine, MappingAllocator { // Add to the appropriate effects mapping based on targetIndex BattleConfig storage config = battleConfig[storageKeyForWrite]; + // Record which steps any PLAYER effect now listens at, so the per-mon step pipelines + // can skip the _runEffects shell when nothing listens (see playerEffectStepsUnion). + if (targetIndex != 2) { + config.playerEffectStepsUnion |= stepsBitmap; + } + if (targetIndex == 2) { // Global effects use simple sequential indexing uint256 effectIndex = config.globalEffectsLength; @@ -1221,11 +1242,312 @@ contract Engine is IEngine, MappingAllocator { eff.effect = IEffect(TOMBSTONE_ADDRESS); } + // --------------------------------------------------------------------------------------------- + // Inlined stat boosts + // + // Formerly the standalone `StatBoosts` effect contract. Boost sources are stored in the normal + // per-mon effect mappings under the STAT_BOOST_ADDRESS sentinel (stepsBitmap = STAT_BOOST_STEPS), + // and the aggregated multiplier snapshot lives in globalKV — both already recycled across battles + // by the MappingAllocator-managed storageKey, so no new storage is introduced. Callers (moves, + // abilities, shared effects) invoke these directly during execute, so the boost-source key is + // still derived from msg.sender exactly as it was when StatBoosts saw the caller. The math lives + // in StatBoostLib; here we only touch storage and fire the OnUpdateMonState pipeline via + // _updateMonStateInternal (matching the legacy updateMonState path). + // --------------------------------------------------------------------------------------------- + + /// @notice Apply a stat-boost source keyed by msg.sender (no salt). Merges into an existing + /// same-source/same-permanence entry if present. + function addStatBoost( + uint256 targetIndex, + uint256 monIndex, + StatBoostToApply[] calldata statBoostsToApply, + StatBoostFlag boostFlag + ) external { + if (battleKeyForWrite == bytes32(0)) revert NoWriteAllowed(); + uint168 key = StatBoostLib.generateKeyNoSalt(targetIndex, monIndex, msg.sender); + _addStatBoostWithKey(targetIndex, monIndex, statBoostsToApply, boostFlag == StatBoostFlag.Perm, key); + } + + /// @notice Apply a stat-boost source keyed by (msg.sender, salt string) so one caller can hold + /// multiple independent boost instances on the same mon. + function addKeyedStatBoost( + uint256 targetIndex, + uint256 monIndex, + StatBoostToApply[] calldata statBoostsToApply, + StatBoostFlag boostFlag, + string calldata keyToUse + ) external { + if (battleKeyForWrite == bytes32(0)) revert NoWriteAllowed(); + uint168 key = StatBoostLib.generateKey(targetIndex, monIndex, msg.sender, keyToUse); + _addStatBoostWithKey(targetIndex, monIndex, statBoostsToApply, boostFlag == StatBoostFlag.Perm, key); + } + + /// @notice Remove the msg.sender-keyed stat-boost source of the given permanence (if any) and + /// recompute the mon's boosted stats. + function removeStatBoost(uint256 targetIndex, uint256 monIndex, StatBoostFlag boostFlag) external { + if (battleKeyForWrite == bytes32(0)) revert NoWriteAllowed(); + uint168 key = StatBoostLib.generateKeyNoSalt(targetIndex, monIndex, msg.sender); + _removeStatBoostWithKey(targetIndex, monIndex, key, boostFlag == StatBoostFlag.Perm); + } + + /// @notice Remove a (msg.sender, salt)-keyed stat-boost source. + function removeKeyedStatBoost( + uint256 targetIndex, + uint256 monIndex, + StatBoostFlag boostFlag, + string calldata keyToUse + ) external { + if (battleKeyForWrite == bytes32(0)) revert NoWriteAllowed(); + uint168 key = StatBoostLib.generateKey(targetIndex, monIndex, msg.sender, keyToUse); + _removeStatBoostWithKey(targetIndex, monIndex, key, boostFlag == StatBoostFlag.Perm); + } + + /// @notice Remove every stat-boost source on a mon and reset its stats to base values. + function clearAllStatBoosts(uint256 targetIndex, uint256 monIndex) external { + if (battleKeyForWrite == bytes32(0)) revert NoWriteAllowed(); + BattleConfig storage config = battleConfig[storageKeyForWrite]; + uint96 packedCounts = targetIndex == 0 ? config.packedP0EffectsCount : config.packedP1EffectsCount; + mapping(uint256 => EffectInstance) storage effects = targetIndex == 0 ? config.p0Effects : config.p1Effects; + uint256 monEffectCount = _getMonEffectCount(packedCounts, monIndex); + uint256 baseSlot = _getEffectSlotIndex(monIndex, 0); + + uint256 removeCount; + for (uint256 i; i < monEffectCount; ++i) { + EffectInstance storage eff = effects[baseSlot + i]; + if (address(eff.effect) == STAT_BOOST_ADDRESS) { + eff.effect = IEffect(TOMBSTONE_ADDRESS); + unchecked { ++removeCount; } + } + } + if (removeCount == 0) return; + + // Reset to base by applying with empty aggregation. + uint32[5] memory baseStats = _getStatBoostBaseStats(config, targetIndex, monIndex); + uint32[5] memory numBoostsPerStat; + uint256[5] memory accumulatedNumeratorPerStat; + _applyStatBoosts(config, targetIndex, monIndex, baseStats, numBoostsPerStat, accumulatedNumeratorPerStat); + } + + function _getStatBoostBaseStats(BattleConfig storage config, uint256 targetIndex, uint256 monIndex) + private + view + returns (uint32[5] memory stats) + { + MonStats storage monStats = _getTeamMon(config, targetIndex, monIndex).stats; + stats[0] = monStats.attack; + stats[1] = monStats.defense; + stats[2] = monStats.specialAttack; + stats[3] = monStats.specialDefense; + stats[4] = monStats.speed; + } + + function _addStatBoostWithKey( + uint256 targetIndex, + uint256 monIndex, + StatBoostToApply[] calldata statBoostsToApply, + bool isPerm, + uint168 key + ) private { + BattleConfig storage config = battleConfig[storageKeyForWrite]; + uint32[5] memory baseStats = _getStatBoostBaseStats(config, targetIndex, monIndex); + + uint96 packedCounts = targetIndex == 0 ? config.packedP0EffectsCount : config.packedP1EffectsCount; + mapping(uint256 => EffectInstance) storage effects = targetIndex == 0 ? config.p0Effects : config.p1Effects; + uint256 monEffectCount = _getMonEffectCount(packedCounts, monIndex); + uint256 baseSlot = _getEffectSlotIndex(monIndex, 0); + + bool found; + uint256 foundSlot; + bytes32 existingData; + uint32[5] memory numBoostsPerStat; + uint256[5] memory accumulatedNumeratorPerStat; + + // Single pass: find the matching key AND aggregate every OTHER boost source on the mon. + for (uint256 i; i < monEffectCount; ++i) { + uint256 slotIndex = baseSlot + i; + EffectInstance storage eff = effects[slotIndex]; + if (address(eff.effect) != STAT_BOOST_ADDRESS) continue; + bytes32 data = eff.data; + (bool effIsPerm, uint168 existingKey, uint8[5] memory bp, uint8[5] memory bc, bool[5] memory im) = + StatBoostLib.unpackBoostData(data); + if (existingKey == key && effIsPerm == isPerm) { + found = true; + foundSlot = slotIndex; + existingData = data; + continue; // excluded from aggregation; merged version is added below + } + StatBoostLib.accumulateBoosts(baseStats, bp, bc, im, numBoostsPerStat, accumulatedNumeratorPerStat); + } + + // Compute the new/merged source and add its contribution. + bytes32 newData; + { + uint8[5] memory finalPercents; + uint8[5] memory finalCounts; + bool[5] memory finalIsMul; + if (found) { + (, , uint8[5] memory ep, uint8[5] memory ec, bool[5] memory em) = + StatBoostLib.unpackBoostData(existingData); + (finalPercents, finalCounts, finalIsMul) = + StatBoostLib.mergeExistingAndNewBoosts(ep, ec, em, statBoostsToApply); + newData = StatBoostLib.packBoostDataWithArrays(key, isPerm, finalPercents, finalCounts, finalIsMul); + } else { + newData = StatBoostLib.packBoostData(key, isPerm, statBoostsToApply); + (, , finalPercents, finalCounts, finalIsMul) = StatBoostLib.unpackBoostData(newData); + } + StatBoostLib.accumulateBoosts( + baseStats, finalPercents, finalCounts, finalIsMul, numBoostsPerStat, accumulatedNumeratorPerStat + ); + } + + // Persist the source entry. + if (found) { + effects[foundSlot].data = newData; + } else { + _addStatBoostEffectSlot(config, targetIndex, monIndex, packedCounts, effects, monEffectCount, newData); + } + + _applyStatBoosts(config, targetIndex, monIndex, baseStats, numBoostsPerStat, accumulatedNumeratorPerStat); + } + + function _removeStatBoostWithKey(uint256 targetIndex, uint256 monIndex, uint168 key, bool isPerm) private { + BattleConfig storage config = battleConfig[storageKeyForWrite]; + uint32[5] memory baseStats = _getStatBoostBaseStats(config, targetIndex, monIndex); + + uint96 packedCounts = targetIndex == 0 ? config.packedP0EffectsCount : config.packedP1EffectsCount; + mapping(uint256 => EffectInstance) storage effects = targetIndex == 0 ? config.p0Effects : config.p1Effects; + uint256 monEffectCount = _getMonEffectCount(packedCounts, monIndex); + uint256 baseSlot = _getEffectSlotIndex(monIndex, 0); + + bool found; + uint256 foundSlot; + uint32[5] memory numBoostsPerStat; + uint256[5] memory accumulatedNumeratorPerStat; + + for (uint256 i; i < monEffectCount; ++i) { + uint256 slotIndex = baseSlot + i; + EffectInstance storage eff = effects[slotIndex]; + if (address(eff.effect) != STAT_BOOST_ADDRESS) continue; + (bool effIsPerm, uint168 existingKey, uint8[5] memory bp, uint8[5] memory bc, bool[5] memory im) = + StatBoostLib.unpackBoostData(eff.data); + if (existingKey == key && effIsPerm == isPerm) { + found = true; + foundSlot = slotIndex; + continue; // removed: excluded from aggregation + } + StatBoostLib.accumulateBoosts(baseStats, bp, bc, im, numBoostsPerStat, accumulatedNumeratorPerStat); + } + + if (!found) return; + effects[foundSlot].effect = IEffect(TOMBSTONE_ADDRESS); + _applyStatBoosts(config, targetIndex, monIndex, baseStats, numBoostsPerStat, accumulatedNumeratorPerStat); + } + + /// @dev Write a fresh stat-boost source into the next free per-mon effect slot. Mirrors the + /// player-effect storage block in _addEffectInternal (count bump + dirty bit) but writes + /// the sentinel address and fixed steps bitmap directly — no external IEffect calls. + function _addStatBoostEffectSlot( + BattleConfig storage config, + uint256 targetIndex, + uint256 monIndex, + uint96 packedCounts, + mapping(uint256 => EffectInstance) storage effects, + uint256 monEffectCount, + bytes32 data + ) private { + uint256 slotIndex = _getEffectSlotIndex(monIndex, monEffectCount); + EffectInstance storage effectSlot = effects[slotIndex]; + effectSlot.effect = IEffect(STAT_BOOST_ADDRESS); + effectSlot.stepsBitmap = STAT_BOOST_STEPS; + effectSlot.data = data; + uint96 newCounts = _setMonEffectCount(packedCounts, monIndex, monEffectCount + 1); + if (targetIndex == 0) { + config.packedP0EffectsCount = newCounts; + effectsDirtyBitmap |= (1 << (1 + monIndex)); + } else { + config.packedP1EffectsCount = newCounts; + effectsDirtyBitmap |= (1 << (9 + monIndex)); + } + } + + /// @dev Re-apply a mon's aggregated stat boosts by telescoping its monState deltas. The + /// stat-boost system is the sole writer of the 5 stat-delta fields, so the current delta + /// *is* the previous boost contribution (cleared == sentinel == 0): old boosted stat = + /// base + currentDelta. We compute the new boosted stat and feed the difference through + /// _updateMonStateInternal, which fires OnUpdateMonState for listeners exactly as before. + /// No globalKV snapshot is kept — being inside the Engine we read the delta back directly. + function _applyStatBoosts( + BattleConfig storage config, + uint256 targetIndex, + uint256 monIndex, + uint32[5] memory baseStats, + uint32[5] memory numBoostsPerStat, + uint256[5] memory accumulatedNumeratorPerStat + ) private { + uint32[5] memory newBoostedStats = + StatBoostLib.finalizeBoostedStats(baseStats, numBoostsPerStat, accumulatedNumeratorPerStat); + MonState storage st = _getMonState(config, targetIndex, monIndex); + + for (uint256 i; i < 5; ++i) { + // old boosted = base + current stat-boost delta (sentinel reads as 0 / "no boost") + int32 valueToAdd = int32(newBoostedStats[i]) - int32(baseStats[i]) - _statBoostCurrentDelta(st, i); + if (valueToAdd != 0) { + _updateMonStateInternal(targetIndex, monIndex, StatBoostLib.statBoostIndexToMonStateIndex(i), valueToAdd); + } + } + } + + /// @dev Current stat-boost delta for boost-index i (0:atk,1:def,2:spatk,3:spdef,4:speed), + /// treating the cleared sentinel as 0. + function _statBoostCurrentDelta(MonState storage st, uint256 i) private view returns (int32) { + int32 d; + if (i == 0) d = st.attackDelta; + else if (i == 1) d = st.defenceDelta; + else if (i == 2) d = st.specialAttackDelta; + else if (i == 3) d = st.specialDefenceDelta; + else d = st.speedDelta; + return d == CLEARED_MON_STATE_SENTINEL ? int32(0) : d; + } + + /// @dev Switch-out handling for stat-boost entries: in a single pass drop EVERY temp source on + /// the mon (they all expire on switch-out) and re-aggregate the surviving permanent sources, + /// applying the result once. _runEffects only routes here when it hits a temp entry; that + /// first hit does all the work and tombstones its siblings, so the remaining temp slots are + /// skipped by the loop's tombstone guard (collapses the legacy per-instance O(n^2) recompute). + function _inlineStatBoostSwitchOut(BattleConfig storage config, uint256 targetIndex, uint256 monIndex) private { + mapping(uint256 => EffectInstance) storage effects = targetIndex == 0 ? config.p0Effects : config.p1Effects; + uint96 packedCounts = targetIndex == 0 ? config.packedP0EffectsCount : config.packedP1EffectsCount; + uint256 monEffectCount = _getMonEffectCount(packedCounts, monIndex); + uint256 baseSlot = _getEffectSlotIndex(monIndex, 0); + uint32[5] memory baseStats = _getStatBoostBaseStats(config, targetIndex, monIndex); + uint32[5] memory numBoostsPerStat; + uint256[5] memory accumulatedNumeratorPerStat; + + for (uint256 i; i < monEffectCount; ++i) { + EffectInstance storage eff = effects[baseSlot + i]; + if (address(eff.effect) != STAT_BOOST_ADDRESS) continue; + (bool effIsPerm, , uint8[5] memory bp, uint8[5] memory bc, bool[5] memory im) = + StatBoostLib.unpackBoostData(eff.data); + if (!effIsPerm) { + eff.effect = IEffect(TOMBSTONE_ADDRESS); // temp sources all expire on switch-out + continue; + } + StatBoostLib.accumulateBoosts(baseStats, bp, bc, im, numBoostsPerStat, accumulatedNumeratorPerStat); + } + + _applyStatBoosts(config, targetIndex, monIndex, baseStats, numBoostsPerStat, accumulatedNumeratorPerStat); + } + function setGlobalKV(uint64 key, uint192 value) external { - bytes32 battleKey = battleKeyForWrite; - if (battleKey == bytes32(0)) { + if (battleKeyForWrite == bytes32(0)) { revert NoWriteAllowed(); } + _setGlobalKV(key, value); + } + + /// @dev Internal globalKV writer (assumes caller has gated on battleKeyForWrite). Shared by the + /// external setGlobalKV and the inlined stat-boost snapshot path. + function _setGlobalKV(uint64 key, uint192 value) private { bytes32 storageKey = storageKeyForWrite; BattleConfig storage config = battleConfig[storageKey]; uint40 timestamp = config.startTimestamp; @@ -1296,7 +1618,10 @@ contract Engine is IEngine, MappingAllocator { uint256 monEffectCount = playerIndex == 0 ? _getMonEffectCount(config.packedP0EffectsCount, monIndex) : _getMonEffectCount(config.packedP1EffectsCount, monIndex); - if (monEffectCount > 0) { + // PreDamage has a single listener game-wide (Adaptor), so the union bit is unset in almost + // every battle — skip the pipeline (and the tempPreDamage round-trip) entirely. + if (monEffectCount > 0 + && (config.playerEffectStepsUnion & uint16(1 << uint8(EffectStep.PreDamage))) != 0) { tempPreDamage = damage; _runEffects( battleKeyForWrite, tempRNG, playerIndex, playerIndex, EffectStep.PreDamage, abi.encode(source), type(uint256).max @@ -1321,8 +1646,10 @@ contract Engine is IEngine, MappingAllocator { // Lock in winner immediately if this KO ends the game _checkAndSetWinnerIfGameOver(config, playerIndex); } - // Only run the AfterDamage hook pipeline if any per-mon effects could listen. - if (monEffectCount > 0) { + // Only run the AfterDamage hook pipeline if some player effect listens at it AND this mon + // has effects. + if (monEffectCount > 0 + && (config.playerEffectStepsUnion & uint16(1 << uint8(EffectStep.AfterDamage))) != 0) { _runEffects( battleKeyForWrite, tempRNG, @@ -1387,11 +1714,11 @@ contract Engine is IEngine, MappingAllocator { config, attackerPlayerIndex, attackerMonIndex, defenderPlayerIndex, defenderMonIndex ); - // Type effectiveness via TypeCalcLib (internal pure, no external call) - Mon storage defenderMon = _getTeamMon(config, defenderPlayerIndex, defenderMonIndex); - uint32 scaledBasePower = TypeCalcLib.getTypeEffectiveness(moveType, defenderMon.stats.type1, basePower); - if (defenderMon.stats.type2 != Type.None) { - scaledBasePower = TypeCalcLib.getTypeEffectiveness(moveType, defenderMon.stats.type2, scaledBasePower); + // Type effectiveness via TypeCalcLib (internal pure, no external call). Reuse the defender + // types already loaded into ctx instead of re-resolving the defender Mon from storage. + uint32 scaledBasePower = TypeCalcLib.getTypeEffectiveness(moveType, ctx.defenderType1, basePower); + if (ctx.defenderType2 != Type.None) { + scaledBasePower = TypeCalcLib.getTypeEffectiveness(moveType, ctx.defenderType2, scaledBasePower); } // Shared damage formula (same function the external path uses) @@ -1934,6 +2261,16 @@ contract Engine is IEngine, MappingAllocator { return; } + // Inline execution for stat-boost sentinel entries. Only OnMonSwitchOut is in their steps + // bitmap; a temp boost is dropped on switch-out and the mon's stats recomputed from the + // remaining permanent sources (mirrors the legacy StatBoosts.onMonSwitchOut path). + if (address(effect) == STAT_BOOST_ADDRESS) { + if (!StatBoostLib.isPerm(data)) { + _inlineStatBoostSwitchOut(config, effectIndex, monIndex); + } + return; + } + // Run the effect and get result (bytes32 updatedExtraData, bool removeAfterRun) = _executeEffectHook( battleKeyForWrite, @@ -2290,7 +2627,8 @@ contract Engine is IEngine, MappingAllocator { uint256 effectCount = playerIndex == 0 ? _getMonEffectCount(config.packedP0EffectsCount, monIndex) : _getMonEffectCount(config.packedP1EffectsCount, monIndex); - if (effectCount > 0) { + if (effectCount > 0 + && (config.playerEffectStepsUnion & uint16(1 << uint8(EffectStep.OnUpdateMonState))) != 0) { _runEffects( battleKeyForWrite, tempRNG, @@ -2827,12 +3165,16 @@ contract Engine is IEngine, MappingAllocator { } function getGlobalKV(bytes32 battleKey, uint64 key) external view returns (uint192) { - bytes32 storageKey = _resolveStorageKey(battleKey); + return _getGlobalKVValue(_resolveStorageKey(battleKey), key); + } + + /// @dev Internal timestamp-gated globalKV reader. Returns 0 for stale values left over from a + /// prior battle that reused this storageKey. Shared by getGlobalKV and the inlined + /// stat-boost snapshot path. + function _getGlobalKVValue(bytes32 storageKey, uint64 key) private view returns (uint192) { bytes32 packed = globalKV[storageKey][key]; - // Extract timestamp (upper 64 bits) and value (lower 192 bits) uint64 storedTimestamp = uint64(uint256(packed) >> 192); uint64 currentTimestamp = uint64(battleConfig[storageKey].startTimestamp); - // If timestamps don't match, return 0 (stale value from different battle) if (storedTimestamp != currentTimestamp) { return 0; } diff --git a/src/IEngine.sol b/src/IEngine.sol index c1e6849c..60343e9e 100644 --- a/src/IEngine.sol +++ b/src/IEngine.sol @@ -26,6 +26,28 @@ interface IEngine { function removeEffect(uint256 targetIndex, uint256 monIndex, uint256 effectIndex) external; function editEffect(uint256 targetIndex, uint256 effectIndex, bytes32 newExtraData) external; function setGlobalKV(uint64 key, uint192 value) external; + // Inlined stat boosts (formerly the StatBoosts effect contract). Keyed by msg.sender. + function addStatBoost( + uint256 targetIndex, + uint256 monIndex, + StatBoostToApply[] calldata statBoostsToApply, + StatBoostFlag boostFlag + ) external; + function addKeyedStatBoost( + uint256 targetIndex, + uint256 monIndex, + StatBoostToApply[] calldata statBoostsToApply, + StatBoostFlag boostFlag, + string calldata keyToUse + ) external; + function removeStatBoost(uint256 targetIndex, uint256 monIndex, StatBoostFlag boostFlag) external; + function removeKeyedStatBoost( + uint256 targetIndex, + uint256 monIndex, + StatBoostFlag boostFlag, + string calldata keyToUse + ) external; + function clearAllStatBoosts(uint256 targetIndex, uint256 monIndex) external; function dealDamage(uint256 playerIndex, uint256 monIndex, int32 damage) external; function dispatchStandardAttack( uint256 attackerPlayerIndex, diff --git a/src/Structs.sol b/src/Structs.sol index 1bb91725..617e9120 100644 --- a/src/Structs.sol +++ b/src/Structs.sol @@ -109,6 +109,12 @@ struct BattleConfig { uint8 globalKVCount; // 8 — live entry count in the current battle's globalKV key buffer uint104 p0Salt; uint104 p1Salt; + // OR of every player (per-mon) effect's stepsBitmap added this battle. Lets the hot step + // pipelines (PreDamage / AfterDamage / OnUpdateMonState) skip the whole _runEffects shell when + // NO player effect listens at that step — e.g. battles without a Dreamcatcher (OnUpdateMonState) + // or Adaptor (PreDamage) listener, which is the common case. Over-approximate: never cleared on + // removal (safe — at worst runs a pipeline that finds nothing, as today). Packs into slot 3. + uint16 playerEffectStepsUnion; // 16 MoveDecision p0Move; MoveDecision p1Move; // Stored at startBattle so Engine.getBattle can passthrough to level/exp/facet getters. diff --git a/src/effects/StatBoosts.sol b/src/effects/StatBoosts.sol deleted file mode 100644 index 06b69d7c..00000000 --- a/src/effects/StatBoosts.sol +++ /dev/null @@ -1,635 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0 -pragma solidity ^0.8.0; - -import {MonStateIndexName, StatBoostFlag, StatBoostType} from "../Enums.sol"; -import {EffectInstance, MonStats, StatBoostToApply} from "../Structs.sol"; - -import {IEngine} from "../IEngine.sol"; -import {BasicEffect} from "./BasicEffect.sol"; -import {IEffect} from "./IEffect.sol"; - -/** - * Usage Notes: - * - Each effect instance stores ONE boost source in bytes32 format - * - Multiple boosts = multiple effect instances - * - Snapshot (aggregated multipliers) stored in globalKV - * - * Extra Data Layout (bytes32): - * [8 bits isPerm | 168 bits key | 80 bits stat data] - * stat data = 5 stats × 16 bits: [8 boostPercent | 7 boostCount | 1 isMultiply] - * - * Snapshot stored in globalKV with key: keccak256(targetIndex, monIndex, address(this)) - * Snapshot layout (uint256): - * [32 empty (255-224) | 32 atk (223-192) | 32 def (191-160) | 32 spatk (159-128) | 32 spdef (127-96) | 32 speed (95-64) | 64 empty (63-0)] - */ - -contract StatBoosts is BasicEffect { - uint256 public constant DENOM = 100; - // Per-instance boost count is stored in a 7-bit field (see layout below). Cap merge increments - // at this value so the packed write can't bleed into the boostPercent field above it, and so - // the in-memory uint8 increment can't revert at 256. - uint8 public constant MAX_BOOST_COUNT_PER_INSTANCE = 127; - // Apply-time clamp on the boosted stat: keeps the int32 cast safe and gives downstream - // damage math headroom against uint32 overflow. - uint32 public constant MAX_BOOSTED_STAT = uint32(type(int32).max); - // Layout: [8 bits isPerm | 168 bits key | 80 bits stat data] - uint256 private constant PERM_FLAG_OFFSET = 248; // 256 - 8 = 248 - uint256 private constant KEY_OFFSET = 80; - uint256 private constant KEY_MASK = (1 << 168) - 1; - - function name() public pure override returns (string memory) { - return "Stat Boost"; - } - - // Steps: OnMonSwitchOut - function getStepsBitmap() external pure override returns (uint16) { - return 0x8020; - } - - // Removes all temporary boosts on mon switch out - function onMonSwitchOut( - IEngine engine, - bytes32 battleKey, - uint256, - bytes32 extraData, - uint256 targetIndex, - uint256 monIndex, - uint256, - uint256 - ) external override returns (bytes32, bool) { - // Check if this is a temp boost (isPerm flag is 0) - bool isPerm = _isPerm(extraData); - if (!isPerm) { - // This is a temp boost, remove it and recalculate stats - // Pass excludeTempBoosts=true since all temp boosts are being removed - _recalculateAndApplyStats(engine, battleKey, targetIndex, monIndex, true); - return (extraData, true); // Remove this effect - } - return (extraData, false); - } - - function _isPerm(bytes32 data) internal pure returns (bool) { - return uint8(uint256(data) >> PERM_FLAG_OFFSET) != 0; - } - - function _snapshotKey(uint256 targetIndex, uint256 monIndex) internal view returns (uint64) { - return uint64(uint256(keccak256(abi.encode(targetIndex, monIndex, address(this))))); - } - - // Pack boost instance with isPerm flag - // Layout: [8 bits isPerm | 168 bits key | 80 bits stat data] - function _packBoostData(uint168 key, bool isPerm, StatBoostToApply[] memory statBoostsToApply) - internal - pure - returns (bytes32) - { - uint256 packed = isPerm ? (uint256(1) << PERM_FLAG_OFFSET) : 0; - packed |= uint256(key) << KEY_OFFSET; - - for (uint256 i = 0; i < statBoostsToApply.length; i++) { - uint256 statIndex = _monStateIndexToStatBoostIndex(statBoostsToApply[i].stat); - uint256 offset = statIndex * 16; - bool isMultiply = statBoostsToApply[i].boostType == StatBoostType.Multiply; - uint256 boostInstance = - (uint256(statBoostsToApply[i].boostPercent) << 8) | (1 << 1) | (isMultiply ? 1 : 0); - packed |= boostInstance << offset; - } - return bytes32(packed); - } - - // Extracts only isPerm and key without allocating arrays (for key matching) - function _unpackBoostHeader(bytes32 data) internal pure returns (bool isPerm, uint168 key) { - uint256 packed = uint256(data); - isPerm = uint8(packed >> PERM_FLAG_OFFSET) != 0; - key = uint168((packed >> KEY_OFFSET) & KEY_MASK); - } - - // Full unpack with fixed-size arrays (no dynamic allocation) - function _unpackBoostData(bytes32 data) - internal - pure - returns ( - bool isPerm, - uint168 key, - uint8[5] memory boostPercents, - uint8[5] memory boostCounts, - bool[5] memory isMultiply - ) - { - uint256 packed = uint256(data); - isPerm = uint8(packed >> PERM_FLAG_OFFSET) != 0; - key = uint168((packed >> KEY_OFFSET) & KEY_MASK); - for (uint256 i = 0; i < 5; i++) { - uint256 offset = i * 16; - uint256 boostInstance = (packed >> offset) & 0xFFFF; - boostPercents[i] = uint8(boostInstance >> 8); - boostCounts[i] = uint8((boostInstance >> 1) & 0x7F); - isMultiply[i] = (boostInstance & 0x1) == 1; - } - } - - function _generateKeyNoSalt(uint256 targetIndex, uint256 monIndex, address caller) - internal - pure - returns (uint168) - { - // Layout: [160 bits address | 7 bits monIndex | 1 bit targetIndex] - return uint168((uint256(uint160(caller)) << 8) | (monIndex << 1) | targetIndex); - } - - function _generateKey(uint256 targetIndex, uint256 monIndex, address caller, string memory salt) - internal - pure - returns (uint168) - { - return uint168(uint256(keccak256(abi.encode(targetIndex, monIndex, caller, salt)))); - } - - // Accumulate boost contributions into running totals (modifies arrays in place). - // Multiplication is unchecked: high stack counts wrap mod 2^256 instead of reverting. The - // resulting numerator may be garbage past the safe range; the apply step clamps the final - // boosted stat to int32, so the wrap is observable as "unexpected stat value" but never as - // a revert or as out-of-range delta arithmetic downstream. - function _accumulateBoosts( - uint32[5] memory baseStats, - uint8[5] memory boostPercents, - uint8[5] memory boostCounts, - bool[5] memory isMultiply, - uint32[5] memory numBoostsPerStat, - uint256[5] memory accumulatedNumeratorPerStat - ) internal pure { - for (uint256 k = 0; k < 5; k++) { - if (boostCounts[k] == 0) continue; - uint256 existingStatValue = - (accumulatedNumeratorPerStat[k] == 0) ? baseStats[k] : accumulatedNumeratorPerStat[k]; - uint256 scalingFactor = isMultiply[k] ? DENOM + boostPercents[k] : DENOM - boostPercents[k]; - unchecked { - accumulatedNumeratorPerStat[k] = existingStatValue * (scalingFactor ** boostCounts[k]); - } - numBoostsPerStat[k] += boostCounts[k]; - } - } - - function _denomPower(uint256 exp) internal pure returns (uint256) { - if (exp == 0) return 1; - if (exp == 1) return 100; - if (exp == 2) return 10000; - if (exp == 3) return 1000000; - if (exp == 4) return 100000000; - if (exp == 5) return 10000000000; - if (exp == 6) return 1000000000000; - if (exp == 7) return 100000000000000; - // Fallback for larger exponents — unchecked so high total stack counts don't revert. - // Pairs with the unchecked numerator multiply in _accumulateBoosts; the apply step - // clamps the final stat to int32 either way. - // 100 = 2^2 * 25, so 100^exp wraps to 0 mod 2^256 once exp >= 128. Substitute 1 in - // that case so the apply-time division can't revert with division-by-zero — the - // resulting raw value is garbage but the [1, MAX_BOOSTED_STAT] clamp contains it. - unchecked { - uint256 result = DENOM ** exp; - return result == 0 ? 1 : result; - } - } - - function _packBoostSnapshot(uint32[5] memory unpackedSnapshot) internal pure returns (uint192) { - return uint192( - (uint256(unpackedSnapshot[0]) << 160) | (uint256(unpackedSnapshot[1]) << 128) - | (uint256(unpackedSnapshot[2]) << 96) | (uint256(unpackedSnapshot[3]) << 64) - | (uint256(unpackedSnapshot[4]) << 32) - ); - } - - // Apply stat deltas from pre-aggregated boost data (avoids re-iterating effects) - function _applyStatsFromAggregatedData( - IEngine engine, - bytes32 battleKey, - uint256 targetIndex, - uint256 monIndex, - uint32[5] memory baseStats, - uint32[5] memory numBoostsPerStat, - uint256[5] memory accumulatedNumeratorPerStat - ) internal { - uint64 snapshotKey = _snapshotKey(targetIndex, monIndex); - uint192 prevSnapshot = engine.getGlobalKV(battleKey, snapshotKey); - uint32[5] memory oldBoostedStats = _unpackBoostSnapshot(prevSnapshot, baseStats); - - // Calculate final values. Clamp to int32 max so the int32 cast at delta time can't wrap - // and the engine's `monState.Delta + valueToAdd` arithmetic stays in range. - uint32[5] memory newBoostedStats; - for (uint256 i = 0; i < 5; i++) { - if (numBoostsPerStat[i] > 0) { - uint256 raw = accumulatedNumeratorPerStat[i] / _denomPower(numBoostsPerStat[i]); - // Clamp to [1, MAX_BOOSTED_STAT]. Lower bound matters because the snapshot uses - // 0 as a "no snapshot" sentinel (filled with baseStats on read); after an - // unchecked-wrap turn that produced 0, storing 0 would break delta telescoping - // and let monState.Delta drift outside [base..., MAX_BOOSTED_STAT - base]. - if (raw > MAX_BOOSTED_STAT) { - newBoostedStats[i] = MAX_BOOSTED_STAT; - } else if (raw == 0) { - newBoostedStats[i] = 1; - } else { - newBoostedStats[i] = uint32(raw); - } - } else { - newBoostedStats[i] = baseStats[i]; - } - } - - // Apply deltas - for (uint256 i = 0; i < 5; i++) { - int32 delta = int32(newBoostedStats[i]) - int32(oldBoostedStats[i]); - if (delta != 0) { - engine.updateMonState(targetIndex, monIndex, _statBoostIndexToMonStateIndex(i), delta); - } - } - - // Update snapshot in globalKV - engine.setGlobalKV(snapshotKey, _packBoostSnapshot(newBoostedStats)); - } - - // Unpack snapshot, using provided base stats to fill in zeros (avoids redundant ENGINE call) - function _unpackBoostSnapshot(uint192 boostSnapshot, uint32[5] memory baseStats) - internal - pure - returns (uint32[5] memory snapshotPerStat) - { - snapshotPerStat[0] = uint32((boostSnapshot >> 160) & 0xFFFFFFFF); - snapshotPerStat[1] = uint32((boostSnapshot >> 128) & 0xFFFFFFFF); - snapshotPerStat[2] = uint32((boostSnapshot >> 96) & 0xFFFFFFFF); - snapshotPerStat[3] = uint32((boostSnapshot >> 64) & 0xFFFFFFFF); - snapshotPerStat[4] = uint32((boostSnapshot >> 32) & 0xFFFFFFFF); - for (uint256 i; i < 5; i++) { - if (snapshotPerStat[i] == 0) { - snapshotPerStat[i] = baseStats[i]; - } - } - } - - // Mapping: Attack(3)->0, Defense(4)->1, SpecialAttack(5)->2, SpecialDefense(6)->3, Speed(2)->4 - // Uses arithmetic to calculate index - // WARNING: This assumes MonStateIndexName enum ordering: Hp(0), Stamina(1), Speed(2), Attack(3), Defense(4), SpecialAttack(5), SpecialDefense(6), ... - // If the enum is reordered, this function will break! - function _monStateIndexToStatBoostIndex(MonStateIndexName statIndex) internal pure returns (uint256) { - uint256 idx = uint256(statIndex); - // Speed (2) maps to 4, Attack-SpecialDefense (3-6) map to 0-3 - if (idx == 2) return 4; - return idx - 3; // Attack(3)->0, Defense(4)->1, SpAtk(5)->2, SpDef(6)->3 - } - - // Reverse mapping: 0->Attack(3), 1->Defense(4), 2->SpecialAttack(5), 3->SpecialDefense(6), 4->Speed(2) - // WARNING: This assumes MonStateIndexName enum ordering (see above) - function _statBoostIndexToMonStateIndex(uint256 statBoostIndex) internal pure returns (MonStateIndexName) { - if (statBoostIndex == 4) return MonStateIndexName.Speed; - return MonStateIndexName(statBoostIndex + 3); - } - - function _getMonStatSubset(IEngine engine, bytes32 battleKey, uint256 playerIndex, uint256 monIndex) - internal - view - returns (uint32[5] memory stats) - { - MonStats memory monStats = engine.getMonStatsForBattle(battleKey, playerIndex, monIndex); - stats[0] = monStats.attack; - stats[1] = monStats.defense; - stats[2] = monStats.specialAttack; - stats[3] = monStats.specialDefense; - stats[4] = monStats.speed; - } - - // Recalculate stats by iterating through all StatBoosts effects - // If excludeTempBoosts is true, skip temp boosts (used during onMonSwitchOut when temp boosts are being removed) - function _recalculateAndApplyStats( - IEngine engine, - bytes32 battleKey, - uint256 targetIndex, - uint256 monIndex, - bool excludeTempBoosts - ) internal { - uint64 snapshotKey = _snapshotKey(targetIndex, monIndex); - uint192 prevSnapshot = engine.getGlobalKV(battleKey, snapshotKey); - - (EffectInstance[] memory effects,) = engine.getEffects(battleKey, targetIndex, monIndex); - - // Get base stats once and pass to _unpackBoostSnapshot to avoid duplicate ENGINE call - uint32[5] memory stats = _getMonStatSubset(engine, battleKey, targetIndex, monIndex); - uint32[5] memory oldBoostedStats = _unpackBoostSnapshot(prevSnapshot, stats); - uint32[5] memory newBoostedStats; - uint32[5] memory numBoostsPerStat; - uint256[5] memory accumulatedNumeratorPerStat; - - // Iterate through all StatBoosts effects and aggregate - for (uint256 i = 0; i < effects.length; i++) { - if (address(effects[i].effect) == address(this)) { - ( - bool isPerm, - , - uint8[5] memory boostPercents, - uint8[5] memory boostCounts, - bool[5] memory isMultiply - ) = _unpackBoostData(effects[i].data); - // Skip temp boosts if excludeTempBoosts is true - if (excludeTempBoosts && !isPerm) continue; - _accumulateBoosts( - stats, boostPercents, boostCounts, isMultiply, numBoostsPerStat, accumulatedNumeratorPerStat - ); - } - } - - // Calculate final values. Clamp to int32 max so the int32 cast at delta time can't wrap - // and the engine's `monState.Delta + valueToAdd` arithmetic stays in range. - for (uint256 i = 0; i < 5; i++) { - if (numBoostsPerStat[i] > 0) { - uint256 raw = accumulatedNumeratorPerStat[i] / _denomPower(numBoostsPerStat[i]); - // Clamp to [1, MAX_BOOSTED_STAT]. Lower bound matters because the snapshot uses - // 0 as a "no snapshot" sentinel (filled with baseStats on read); after an - // unchecked-wrap turn that produced 0, storing 0 would break delta telescoping - // and let monState.Delta drift outside [base..., MAX_BOOSTED_STAT - base]. - if (raw > MAX_BOOSTED_STAT) { - newBoostedStats[i] = MAX_BOOSTED_STAT; - } else if (raw == 0) { - newBoostedStats[i] = 1; - } else { - newBoostedStats[i] = uint32(raw); - } - } else { - newBoostedStats[i] = stats[i]; - } - } - - // Apply deltas - for (uint256 i = 0; i < 5; i++) { - int32 delta = int32(newBoostedStats[i]) - int32(oldBoostedStats[i]); - if (delta != 0) { - engine.updateMonState(targetIndex, monIndex, _statBoostIndexToMonStateIndex(i), delta); - } - } - - // Update snapshot in globalKV (reuse cached snapshotKey) - engine.setGlobalKV(snapshotKey, _packBoostSnapshot(newBoostedStats)); - } - - function _mergeExistingAndNewBoosts( - uint8[5] memory existingBoostPercents, - uint8[5] memory existingBoostCounts, - bool[5] memory existingIsMultiply, - StatBoostToApply[] memory newBoostsToApply - ) - internal - pure - returns (uint8[5] memory mergedBoostPercents, uint8[5] memory mergedBoostCounts, bool[5] memory mergedIsMultiply) - { - mergedBoostPercents = existingBoostPercents; - mergedBoostCounts = existingBoostCounts; - mergedIsMultiply = existingIsMultiply; - for (uint256 i; i < newBoostsToApply.length; i++) { - uint256 statIndex = _monStateIndexToStatBoostIndex(newBoostsToApply[i].stat); - if (existingBoostPercents[statIndex] != 0) { - if (mergedBoostCounts[statIndex] < MAX_BOOST_COUNT_PER_INSTANCE) { - mergedBoostCounts[statIndex]++; - } - } else { - mergedBoostPercents[statIndex] = newBoostsToApply[i].boostPercent; - mergedBoostCounts[statIndex] = 1; - mergedIsMultiply[statIndex] = newBoostsToApply[i].boostType == StatBoostType.Multiply; - } - } - return (mergedBoostPercents, mergedBoostCounts, mergedIsMultiply); - } - - function _packBoostDataWithArrays( - uint168 key, - bool isPerm, - uint8[5] memory boostPercents, - uint8[5] memory boostCounts, - bool[5] memory isMultiply - ) internal pure returns (bytes32) { - uint256 packed = isPerm ? (uint256(1) << PERM_FLAG_OFFSET) : 0; - packed |= uint256(key) << KEY_OFFSET; - - for (uint256 i = 0; i < 5; i++) { - uint256 offset = i * 16; - uint256 boostInstance = - (uint256(boostPercents[i]) << 8) | (uint256(boostCounts[i]) << 1) | (isMultiply[i] ? 1 : 0); - packed |= boostInstance << offset; - } - return bytes32(packed); - } - - function addStatBoosts( - IEngine engine, - uint256 targetIndex, - uint256 monIndex, - StatBoostToApply[] memory statBoostsToApply, - StatBoostFlag boostFlag - ) public { - uint168 key = _generateKeyNoSalt(targetIndex, monIndex, msg.sender); - _addStatBoostsWithKey(engine, targetIndex, monIndex, statBoostsToApply, boostFlag == StatBoostFlag.Perm, key); - } - - function addKeyedStatBoosts( - IEngine engine, - uint256 targetIndex, - uint256 monIndex, - StatBoostToApply[] memory statBoostsToApply, - StatBoostFlag boostFlag, - string memory keyToUse - ) public { - uint168 key = _generateKey(targetIndex, monIndex, msg.sender, keyToUse); - _addStatBoostsWithKey(engine, targetIndex, monIndex, statBoostsToApply, boostFlag == StatBoostFlag.Perm, key); - } - - function _addStatBoostsWithKey( - IEngine engine, - uint256 targetIndex, - uint256 monIndex, - StatBoostToApply[] memory statBoostsToApply, - bool isPerm, - uint168 key - ) internal { - - // Single-pass: find existing boost AND aggregate all OTHER boosts - // We exclude the matching effect from aggregation so we can add the merged/new version - bytes32 battleKey = engine.battleKeyForWrite(); - (EffectInstance[] memory effects, uint256[] memory indices) = engine.getEffects(battleKey, targetIndex, monIndex); - uint32[5] memory baseStats = _getMonStatSubset(engine, battleKey, targetIndex, monIndex); - - bool found; - uint256 foundEffectIndex; - bytes32 existingData; - uint32[5] memory numBoostsPerStat; - uint256[5] memory accumulatedNumeratorPerStat; - - // Single pass: find matching key AND aggregate all OTHER StatBoost effects - for (uint256 i = 0; i < effects.length; i++) { - if (address(effects[i].effect) == address(this)) { - ( - bool effIsPerm, - uint168 existingKey, - uint8[5] memory boostPercents, - uint8[5] memory boostCounts, - bool[5] memory isMultiply - ) = _unpackBoostData(effects[i].data); - - // Check if this is the effect we're searching for - if (existingKey == key && effIsPerm == isPerm) { - found = true; - foundEffectIndex = indices[i]; - existingData = effects[i].data; - // DON'T add to aggregation - we'll add the merged version later - continue; - } - - // Aggregate this effect's boosts - _accumulateBoosts( - baseStats, boostPercents, boostCounts, isMultiply, numBoostsPerStat, accumulatedNumeratorPerStat - ); - } - } - - // Compute the new/merged boost data and add its contribution to aggregation - bytes32 newData; - { - uint8[5] memory finalPercents; - uint8[5] memory finalCounts; - bool[5] memory finalIsMultiply; - - if (found) { - // Merge with existing boost - ( - , - , - uint8[5] memory existingBoostPercents, - uint8[5] memory existingBoostCounts, - bool[5] memory existingIsMultiply - ) = _unpackBoostData(existingData); - (finalPercents, finalCounts, finalIsMultiply) = _mergeExistingAndNewBoosts( - existingBoostPercents, existingBoostCounts, existingIsMultiply, statBoostsToApply - ); - newData = _packBoostDataWithArrays(key, isPerm, finalPercents, finalCounts, finalIsMultiply); - } else { - // Pack new boost data and extract its components for aggregation - newData = _packBoostData(key, isPerm, statBoostsToApply); - (, , finalPercents, finalCounts, finalIsMultiply) = _unpackBoostData(newData); - } - - // Add the new/merged boost's contribution to aggregation - _accumulateBoosts( - baseStats, finalPercents, finalCounts, finalIsMultiply, numBoostsPerStat, accumulatedNumeratorPerStat - ); - } - - // Update effect storage - if (found) { - engine.editEffect(targetIndex, foundEffectIndex, newData); - } else { - engine.addEffect(targetIndex, monIndex, IEffect(address(this)), newData); - } - - // Apply stats using already-computed aggregation (no second iteration needed) - _applyStatsFromAggregatedData( - engine, battleKey, targetIndex, monIndex, baseStats, numBoostsPerStat, accumulatedNumeratorPerStat - ); - } - - function removeStatBoosts(IEngine engine, uint256 targetIndex, uint256 monIndex, StatBoostFlag boostFlag) public { - uint168 key = _generateKeyNoSalt(targetIndex, monIndex, msg.sender); - _removeStatBoostsWithKey(engine, targetIndex, monIndex, key, boostFlag == StatBoostFlag.Perm); - } - - function removeKeyedStatBoosts( - IEngine engine, - uint256 targetIndex, - uint256 monIndex, - StatBoostFlag boostFlag, - string memory stringToUse - ) public { - uint168 key = _generateKey(targetIndex, monIndex, msg.sender, stringToUse); - _removeStatBoostsWithKey(engine, targetIndex, monIndex, key, boostFlag == StatBoostFlag.Perm); - } - - function _removeStatBoostsWithKey( - IEngine engine, - uint256 targetIndex, - uint256 monIndex, - uint168 key, - bool isPerm - ) internal { - - // Single-pass: find existing boost AND aggregate all OTHER boosts (excluding the one to remove) - bytes32 battleKey = engine.battleKeyForWrite(); - (EffectInstance[] memory effects, uint256[] memory indices) = engine.getEffects(battleKey, targetIndex, monIndex); - uint32[5] memory baseStats = _getMonStatSubset(engine, battleKey, targetIndex, monIndex); - - bool found; - uint256 foundEffectIndex; - uint32[5] memory numBoostsPerStat; - uint256[5] memory accumulatedNumeratorPerStat; - - // Single pass: find matching key AND aggregate all OTHER StatBoost effects - for (uint256 i = 0; i < effects.length; i++) { - if (address(effects[i].effect) == address(this)) { - ( - bool effIsPerm, - uint168 existingKey, - uint8[5] memory boostPercents, - uint8[5] memory boostCounts, - bool[5] memory isMultiply - ) = _unpackBoostData(effects[i].data); - - // Check if this is the effect we're removing - if (existingKey == key && effIsPerm == isPerm) { - found = true; - foundEffectIndex = indices[i]; - // DON'T add to aggregation - we're removing this effect - continue; - } - - // Aggregate this effect's boosts - _accumulateBoosts( - baseStats, boostPercents, boostCounts, isMultiply, numBoostsPerStat, accumulatedNumeratorPerStat - ); - } - } - - if (found) { - // Remove the effect - engine.removeEffect(targetIndex, monIndex, foundEffectIndex); - // Apply stats using already-computed aggregation (no second iteration needed) - _applyStatsFromAggregatedData( - engine, battleKey, targetIndex, monIndex, baseStats, numBoostsPerStat, accumulatedNumeratorPerStat - ); - } - } - - /// @notice Clears all stat boosts for a mon and resets stats to base values - /// @param engine The engine instance - /// @param targetIndex The player index - /// @param monIndex The mon index - function clearAllBoostsForMon(IEngine engine, uint256 targetIndex, uint256 monIndex) external { - bytes32 battleKey = engine.battleKeyForWrite(); - (EffectInstance[] memory effects, uint256[] memory indices) = engine.getEffects(battleKey, targetIndex, monIndex); - uint32[5] memory baseStats = _getMonStatSubset(engine, battleKey, targetIndex, monIndex); - - // Single pass: collect indices to remove in reverse order - // We iterate forward but store in reverse to enable proper removal - uint256 removeCount = 0; - for (uint256 i = effects.length; i > 0; i--) { - if (address(effects[i - 1].effect) == address(this)) { - // Remove immediately while iterating in reverse (avoids index shifting) - engine.removeEffect(targetIndex, monIndex, indices[i - 1]); - removeCount++; - } - } - - if (removeCount == 0) { - return; - } - - // Reset stats to base values by applying with empty aggregation - uint32[5] memory numBoostsPerStat; - uint256[5] memory accumulatedNumeratorPerStat; - _applyStatsFromAggregatedData( - engine, battleKey, targetIndex, monIndex, baseStats, numBoostsPerStat, accumulatedNumeratorPerStat - ); - } -} diff --git a/src/effects/battlefield/Overclock.sol b/src/effects/battlefield/Overclock.sol index 4a444e37..778f66e4 100644 --- a/src/effects/battlefield/Overclock.sol +++ b/src/effects/battlefield/Overclock.sol @@ -7,7 +7,6 @@ import "../../Structs.sol"; import {IEngine} from "../../IEngine.sol"; import {BasicEffect} from "../BasicEffect.sol"; import {IEffect} from "../IEffect.sol"; -import {StatBoosts} from "../StatBoosts.sol"; contract Overclock is BasicEffect { uint256 public constant DEFAULT_DURATION = 3; @@ -15,12 +14,6 @@ contract Overclock is BasicEffect { uint8 public constant SPEED_PERCENT = 25; uint8 public constant SP_DEF_PERCENT = 25; - StatBoosts immutable STAT_BOOST; - - constructor(StatBoosts _STAT_BOOSTS) { - STAT_BOOST = _STAT_BOOSTS; - } - function name() public pure override returns (string memory) { return "Overclock"; } @@ -67,12 +60,12 @@ contract Overclock is BasicEffect { boostPercent: SP_DEF_PERCENT, boostType: StatBoostType.Divide }); - STAT_BOOST.addStatBoosts(engine, playerIndex, monIndex, statBoosts, StatBoostFlag.Temp); + engine.addStatBoost(playerIndex, monIndex, statBoosts, StatBoostFlag.Temp); } function _removeStatChange(IEngine engine, uint256 playerIndex, uint256 monIndex) internal { // Reset stat boosts (speed buff / sp def debuff) - STAT_BOOST.removeStatBoosts(engine, playerIndex, monIndex, StatBoostFlag.Temp); + engine.removeStatBoost(playerIndex, monIndex, StatBoostFlag.Temp); } function onApply( diff --git a/src/effects/status/BurnStatus.sol b/src/effects/status/BurnStatus.sol index 01e3f130..b3bccf3c 100644 --- a/src/effects/status/BurnStatus.sol +++ b/src/effects/status/BurnStatus.sol @@ -5,7 +5,6 @@ import "../../Enums.sol"; import {StatBoostToApply, EffectInstance} from "../../Structs.sol"; import {IEngine} from "../../IEngine.sol"; -import {StatBoosts} from "../StatBoosts.sol"; import {StatusEffect} from "./StatusEffect.sol"; import {StatusEffectLib} from "./StatusEffectLib.sol"; @@ -19,12 +18,6 @@ contract BurnStatus is StatusEffect { int32 public constant DEG2_DAMAGE_DENOM = 8; int32 public constant DEG3_DAMAGE_DENOM = 4; - StatBoosts immutable STAT_BOOSTS; - - constructor(StatBoosts statBoosts) { - STAT_BOOSTS = statBoosts; - } - function name() public pure override returns (string memory) { return "Burn"; } @@ -83,7 +76,7 @@ contract BurnStatus is StatusEffect { boostPercent: ATTACK_PERCENT, boostType: StatBoostType.Divide }); - STAT_BOOSTS.addStatBoosts(engine, targetIndex, monIndex, statBoosts, StatBoostFlag.Perm); + engine.addStatBoost(targetIndex, monIndex, statBoosts, StatBoostFlag.Perm); } else { (EffectInstance[] memory effects, uint256[] memory indices) = engine.getEffects(battleKey, targetIndex, monIndex); uint256 indexOfBurnEffect; @@ -118,7 +111,7 @@ contract BurnStatus is StatusEffect { super.onRemove(engine, battleKey, bytes32(0), targetIndex, monIndex, p0ActiveMonIndex, p1ActiveMonIndex); // Reset the attack reduction - STAT_BOOSTS.removeStatBoosts(engine, targetIndex, monIndex, StatBoostFlag.Perm); + engine.removeStatBoost(targetIndex, monIndex, StatBoostFlag.Perm); // Reset the burn degree engine.setGlobalKV(getKeyForMonIndex(targetIndex, monIndex), 0); diff --git a/src/effects/status/FrostbiteStatus.sol b/src/effects/status/FrostbiteStatus.sol index 78188d12..6b4308b5 100644 --- a/src/effects/status/FrostbiteStatus.sol +++ b/src/effects/status/FrostbiteStatus.sol @@ -4,7 +4,6 @@ pragma solidity ^0.8.0; import "../../Enums.sol"; import {StatBoostToApply} from "../../Structs.sol"; import {IEngine} from "../../IEngine.sol"; -import {StatBoosts} from "../StatBoosts.sol"; import {StatusEffect} from "./StatusEffect.sol"; @@ -13,12 +12,6 @@ contract FrostbiteStatus is StatusEffect { int32 constant DAMAGE_DENOM = 16; uint8 constant SP_ATTACK_PERCENT = 50; - StatBoosts immutable STAT_BOOST; - - constructor(StatBoosts _STAT_BOOSTS) { - STAT_BOOST = _STAT_BOOSTS; - } - function name() public pure override returns (string memory) { return "Frostbite"; } @@ -52,7 +45,7 @@ contract FrostbiteStatus is StatusEffect { boostPercent: SP_ATTACK_PERCENT, boostType: StatBoostType.Divide }); - STAT_BOOST.addStatBoosts(engine, targetIndex, monIndex, statBoosts, StatBoostFlag.Perm); + engine.addStatBoost(targetIndex, monIndex, statBoosts, StatBoostFlag.Perm); // Do not update data return (extraData, false); @@ -70,7 +63,7 @@ contract FrostbiteStatus is StatusEffect { super.onRemove(engine, battleKey, data, targetIndex, monIndex, p0ActiveMonIndex, p1ActiveMonIndex); // Reset the special attack reduction - STAT_BOOST.removeStatBoosts(engine, targetIndex, monIndex, StatBoostFlag.Perm); + engine.removeStatBoost(targetIndex, monIndex, StatBoostFlag.Perm); } function onRoundEnd( diff --git a/src/lib/StatBoostLib.sol b/src/lib/StatBoostLib.sol new file mode 100644 index 00000000..8b7290d5 --- /dev/null +++ b/src/lib/StatBoostLib.sol @@ -0,0 +1,218 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity ^0.8.0; + +import {MonStateIndexName, StatBoostType} from "../Enums.sol"; +import {StatBoostToApply} from "../Structs.sol"; + +/** + * @notice Pure math + packing helpers for the Engine's inlined stat-boost system. This is the + * former StatBoosts effect contract's stateless core, lifted into a library so the Engine + * can apply boosts natively (no external call round-trips). The packed per-source data + * layout and multiplicative aggregation/clamp semantics are identical to the legacy effect. + * + * Per-source packed boost data (bytes32): + * [8 bits isPerm | 168 bits key | 80 bits stat data] + * stat data = 5 stats × 16 bits: [8 boostPercent | 7 boostCount | 1 isMultiply] + * + * The legacy aggregated globalKV snapshot is gone: the Engine telescopes off the live monState + * stat deltas instead (it is the sole writer of those fields), so no separate snapshot is stored. + */ +library StatBoostLib { + uint256 internal constant DENOM = 100; + // Per-instance boost count is stored in a 7-bit field. Cap merge increments here so the packed + // write can't bleed into the boostPercent field above it, and the in-memory uint8 can't revert. + uint8 internal constant MAX_BOOST_COUNT_PER_INSTANCE = 127; + // Apply-time clamp on the boosted stat: keeps the int32 cast safe and gives downstream damage + // math headroom against uint32 overflow. + uint32 internal constant MAX_BOOSTED_STAT = uint32(type(int32).max); + + uint256 private constant PERM_FLAG_OFFSET = 248; // 256 - 8 + uint256 private constant KEY_OFFSET = 80; + uint256 private constant KEY_MASK = (1 << 168) - 1; + + function isPerm(bytes32 data) internal pure returns (bool) { + return uint8(uint256(data) >> PERM_FLAG_OFFSET) != 0; + } + + // Pack a fresh boost instance from the caller-supplied boosts. + function packBoostData(uint168 key, bool perm, StatBoostToApply[] memory statBoostsToApply) + internal + pure + returns (bytes32) + { + uint256 packed = perm ? (uint256(1) << PERM_FLAG_OFFSET) : 0; + packed |= uint256(key) << KEY_OFFSET; + for (uint256 i = 0; i < statBoostsToApply.length; i++) { + uint256 statIndex = monStateIndexToStatBoostIndex(statBoostsToApply[i].stat); + uint256 offset = statIndex * 16; + bool isMul = statBoostsToApply[i].boostType == StatBoostType.Multiply; + uint256 boostInstance = + (uint256(statBoostsToApply[i].boostPercent) << 8) | (1 << 1) | (isMul ? 1 : 0); + packed |= boostInstance << offset; + } + return bytes32(packed); + } + + function unpackBoostHeader(bytes32 data) internal pure returns (bool perm, uint168 key) { + uint256 packed = uint256(data); + perm = uint8(packed >> PERM_FLAG_OFFSET) != 0; + key = uint168((packed >> KEY_OFFSET) & KEY_MASK); + } + + function unpackBoostData(bytes32 data) + internal + pure + returns (bool perm, uint168 key, uint8[5] memory boostPercents, uint8[5] memory boostCounts, bool[5] memory isMul) + { + uint256 packed = uint256(data); + perm = uint8(packed >> PERM_FLAG_OFFSET) != 0; + key = uint168((packed >> KEY_OFFSET) & KEY_MASK); + for (uint256 i = 0; i < 5; i++) { + uint256 offset = i * 16; + uint256 boostInstance = (packed >> offset) & 0xFFFF; + boostPercents[i] = uint8(boostInstance >> 8); + boostCounts[i] = uint8((boostInstance >> 1) & 0x7F); + isMul[i] = (boostInstance & 0x1) == 1; + } + } + + function packBoostDataWithArrays( + uint168 key, + bool perm, + uint8[5] memory boostPercents, + uint8[5] memory boostCounts, + bool[5] memory isMul + ) internal pure returns (bytes32) { + uint256 packed = perm ? (uint256(1) << PERM_FLAG_OFFSET) : 0; + packed |= uint256(key) << KEY_OFFSET; + for (uint256 i = 0; i < 5; i++) { + uint256 offset = i * 16; + uint256 boostInstance = + (uint256(boostPercents[i]) << 8) | (uint256(boostCounts[i]) << 1) | (isMul[i] ? 1 : 0); + packed |= boostInstance << offset; + } + return bytes32(packed); + } + + function generateKeyNoSalt(uint256 targetIndex, uint256 monIndex, address caller) + internal + pure + returns (uint168) + { + // Layout: [160 bits address | 7 bits monIndex | 1 bit targetIndex] + return uint168((uint256(uint160(caller)) << 8) | (monIndex << 1) | targetIndex); + } + + function generateKey(uint256 targetIndex, uint256 monIndex, address caller, string memory salt) + internal + pure + returns (uint168) + { + return uint168(uint256(keccak256(abi.encode(targetIndex, monIndex, caller, salt)))); + } + + // Accumulate boost contributions into running totals (modifies arrays in place). Multiplication + // is unchecked: high stack counts wrap mod 2^256 instead of reverting; the apply step clamps the + // final boosted stat to int32, so the wrap is observable as a weird stat value but never a revert. + function accumulateBoosts( + uint32[5] memory baseStats, + uint8[5] memory boostPercents, + uint8[5] memory boostCounts, + bool[5] memory isMul, + uint32[5] memory numBoostsPerStat, + uint256[5] memory accumulatedNumeratorPerStat + ) internal pure { + for (uint256 k = 0; k < 5; k++) { + if (boostCounts[k] == 0) continue; + uint256 existingStatValue = + (accumulatedNumeratorPerStat[k] == 0) ? baseStats[k] : accumulatedNumeratorPerStat[k]; + uint256 scalingFactor = isMul[k] ? DENOM + boostPercents[k] : DENOM - boostPercents[k]; + unchecked { + accumulatedNumeratorPerStat[k] = existingStatValue * (scalingFactor ** boostCounts[k]); + } + numBoostsPerStat[k] += boostCounts[k]; + } + } + + function denomPower(uint256 exp) internal pure returns (uint256) { + if (exp == 0) return 1; + if (exp == 1) return 100; + if (exp == 2) return 10000; + if (exp == 3) return 1000000; + if (exp == 4) return 100000000; + if (exp == 5) return 10000000000; + if (exp == 6) return 1000000000000; + if (exp == 7) return 100000000000000; + // Fallback for larger exponents — unchecked so high total stack counts don't revert. 100 = + // 2^2 * 25, so 100^exp wraps to 0 mod 2^256 once exp >= 128; substitute 1 so the apply-time + // division can't divide by zero — the resulting raw value is garbage but the clamp contains it. + unchecked { + uint256 result = DENOM ** exp; + return result == 0 ? 1 : result; + } + } + + // Compute final boosted stats from aggregated numerators, clamping each to [1, MAX_BOOSTED_STAT]. + // Stats with no boosts fall back to baseStats. Lower bound matters: the snapshot uses 0 as a + // "no snapshot" sentinel, so a wrapped-to-0 result must store 1 to keep delta telescoping intact. + function finalizeBoostedStats( + uint32[5] memory baseStats, + uint32[5] memory numBoostsPerStat, + uint256[5] memory accumulatedNumeratorPerStat + ) internal pure returns (uint32[5] memory newBoostedStats) { + for (uint256 i = 0; i < 5; i++) { + if (numBoostsPerStat[i] > 0) { + uint256 raw = accumulatedNumeratorPerStat[i] / denomPower(numBoostsPerStat[i]); + if (raw > MAX_BOOSTED_STAT) { + newBoostedStats[i] = MAX_BOOSTED_STAT; + } else if (raw == 0) { + newBoostedStats[i] = 1; + } else { + newBoostedStats[i] = uint32(raw); + } + } else { + newBoostedStats[i] = baseStats[i]; + } + } + } + + function mergeExistingAndNewBoosts( + uint8[5] memory existingBoostPercents, + uint8[5] memory existingBoostCounts, + bool[5] memory existingIsMul, + StatBoostToApply[] memory newBoostsToApply + ) + internal + pure + returns (uint8[5] memory mergedBoostPercents, uint8[5] memory mergedBoostCounts, bool[5] memory mergedIsMul) + { + mergedBoostPercents = existingBoostPercents; + mergedBoostCounts = existingBoostCounts; + mergedIsMul = existingIsMul; + for (uint256 i; i < newBoostsToApply.length; i++) { + uint256 statIndex = monStateIndexToStatBoostIndex(newBoostsToApply[i].stat); + if (existingBoostPercents[statIndex] != 0) { + if (mergedBoostCounts[statIndex] < MAX_BOOST_COUNT_PER_INSTANCE) { + mergedBoostCounts[statIndex]++; + } + } else { + mergedBoostPercents[statIndex] = newBoostsToApply[i].boostPercent; + mergedBoostCounts[statIndex] = 1; + mergedIsMul[statIndex] = newBoostsToApply[i].boostType == StatBoostType.Multiply; + } + } + } + + // Attack(3)->0, Defense(4)->1, SpecialAttack(5)->2, SpecialDefense(6)->3, Speed(2)->4. + // WARNING: assumes MonStateIndexName ordering Hp(0), Stamina(1), Speed(2), Attack(3)... + function monStateIndexToStatBoostIndex(MonStateIndexName statIndex) internal pure returns (uint256) { + uint256 idx = uint256(statIndex); + if (idx == 2) return 4; // Speed + return idx - 3; + } + + function statBoostIndexToMonStateIndex(uint256 statBoostIndex) internal pure returns (MonStateIndexName) { + if (statBoostIndex == 4) return MonStateIndexName.Speed; + return MonStateIndexName(statBoostIndex + 3); + } +} diff --git a/src/mons/aurox/UpOnly.sol b/src/mons/aurox/UpOnly.sol index 482aa733..b858dac7 100644 --- a/src/mons/aurox/UpOnly.sol +++ b/src/mons/aurox/UpOnly.sol @@ -10,18 +10,11 @@ import {IEngine} from "../../IEngine.sol"; import {IAbility} from "../../abilities/IAbility.sol"; import {BasicEffect} from "../../effects/BasicEffect.sol"; import {IEffect} from "../../effects/IEffect.sol"; -import {StatBoosts} from "../../effects/StatBoosts.sol"; contract UpOnly is IAbility, BasicEffect { uint8 public constant ATTACK_BOOST_PERCENT = 10; // 10% attack boost per hit - StatBoosts immutable STAT_BOOSTS; - - constructor(StatBoosts _STAT_BOOSTS) { - STAT_BOOSTS = _STAT_BOOSTS; - } - // IAbility implementation function name() public pure override(IAbility, BasicEffect) returns (string memory) { return "Up Only"; @@ -56,7 +49,7 @@ contract UpOnly is IAbility, BasicEffect { boostPercent: ATTACK_BOOST_PERCENT, boostType: StatBoostType.Multiply }); - STAT_BOOSTS.addStatBoosts(engine, targetIndex, monIndex, statBoosts, StatBoostFlag.Perm); + engine.addStatBoost(targetIndex, monIndex, statBoosts, StatBoostFlag.Perm); return (extraData, false); } diff --git a/src/mons/ekineki/SaviorComplex.sol b/src/mons/ekineki/SaviorComplex.sol index 79b3cd45..af3c2276 100644 --- a/src/mons/ekineki/SaviorComplex.sol +++ b/src/mons/ekineki/SaviorComplex.sol @@ -6,19 +6,12 @@ import {MonStateIndexName, StatBoostFlag, StatBoostType} from "../../Enums.sol"; import {StatBoostToApply} from "../../Structs.sol"; import {IEngine} from "../../IEngine.sol"; import {IAbility} from "../../abilities/IAbility.sol"; -import {StatBoosts} from "../../effects/StatBoosts.sol"; contract SaviorComplex is IAbility { uint8 public constant STAGE_1_BOOST = 15; // 1 KO'd uint8 public constant STAGE_2_BOOST = 25; // 2 KO'd uint8 public constant STAGE_3_BOOST = 30; // 3+ KO'd - StatBoosts immutable STAT_BOOSTS; - - constructor(StatBoosts _STAT_BOOSTS) { - STAT_BOOSTS = _STAT_BOOSTS; - } - function name() external pure returns (string memory) { return "Savior Complex"; } @@ -58,7 +51,7 @@ contract SaviorComplex is IAbility { boostPercent: boostPercent, boostType: StatBoostType.Multiply }); - STAT_BOOSTS.addStatBoosts(engine, playerIndex, monIndex, statBoosts, StatBoostFlag.Temp); + engine.addStatBoost(playerIndex, monIndex, statBoosts, StatBoostFlag.Temp); // Mark as triggered (once per game) engine.setGlobalKV(_getSaviorComplexKey(playerIndex), 1); diff --git a/src/mons/embursa/HoneyBribe.sol b/src/mons/embursa/HoneyBribe.sol index 12e63b8a..a7750516 100644 --- a/src/mons/embursa/HoneyBribe.sol +++ b/src/mons/embursa/HoneyBribe.sol @@ -7,7 +7,6 @@ import "../../Enums.sol"; import { StatBoostToApply, MoveMeta } from "../../Structs.sol"; import {IEngine} from "../../IEngine.sol"; -import {StatBoosts} from "../../effects/StatBoosts.sol"; import {IMoveSet} from "../../moves/IMoveSet.sol"; import {HeatBeaconLib} from "./HeatBeaconLib.sol"; @@ -16,12 +15,6 @@ contract HoneyBribe is IMoveSet { uint256 public constant MAX_DIVISOR = 3; uint8 public constant SP_DEF_PERCENT = 50; - StatBoosts immutable STAT_BOOSTS; - - constructor(StatBoosts _STAT_BOOSTS) { - STAT_BOOSTS = _STAT_BOOSTS; - } - function name() public pure override returns (string memory) { return "Honey Bribe"; } @@ -83,7 +76,7 @@ contract HoneyBribe is IMoveSet { boostPercent: SP_DEF_PERCENT, boostType: StatBoostType.Divide }); - STAT_BOOSTS.addStatBoosts(engine, defenderPlayerIndex, defenderMonIndex, statBoosts, StatBoostFlag.Temp); + engine.addStatBoost(defenderPlayerIndex, defenderMonIndex, statBoosts, StatBoostFlag.Temp); // Update the bribe level _increaseBribeLevel(engine, battleKey, attackerPlayerIndex, attackerMonIndex); diff --git a/src/mons/embursa/Tinderclaws.sol b/src/mons/embursa/Tinderclaws.sol index 4fb595cd..3e92f8f2 100644 --- a/src/mons/embursa/Tinderclaws.sol +++ b/src/mons/embursa/Tinderclaws.sol @@ -10,7 +10,6 @@ import {IEngine} from "../../IEngine.sol"; import {EffectInstance, IEffect, MoveDecision, StatBoostToApply} from "../../Structs.sol"; import {IAbility} from "../../abilities/IAbility.sol"; import {BasicEffect} from "../../effects/BasicEffect.sol"; -import {StatBoosts} from "../../effects/StatBoosts.sol"; import {StatusEffectLib} from "../../effects/status/StatusEffectLib.sol"; contract Tinderclaws is IAbility, BasicEffect { @@ -18,11 +17,8 @@ contract Tinderclaws is IAbility, BasicEffect { uint8 constant SP_ATTACK_BOOST_PERCENT = 50; IEffect immutable BURN_STATUS; - StatBoosts immutable STAT_BOOSTS; - - constructor(IEffect _BURN_STATUS, StatBoosts _STAT_BOOSTS) { + constructor(IEffect _BURN_STATUS) { BURN_STATUS = _BURN_STATUS; - STAT_BOOSTS = _STAT_BOOSTS; } function name() public pure override(IAbility, BasicEffect) returns (string memory) { @@ -100,11 +96,11 @@ contract Tinderclaws is IAbility, BasicEffect { boostPercent: SP_ATTACK_BOOST_PERCENT, boostType: StatBoostType.Multiply }); - STAT_BOOSTS.addStatBoosts(engine, targetIndex, monIndex, statBoosts, StatBoostFlag.Perm); + engine.addStatBoost(targetIndex, monIndex, statBoosts, StatBoostFlag.Perm); return (bytes32(uint256(1)), false); } else if (!isBurned && hasBoost) { // Remove SpATK boost - STAT_BOOSTS.removeStatBoosts(engine, targetIndex, monIndex, StatBoostFlag.Perm); + engine.removeStatBoost(targetIndex, monIndex, StatBoostFlag.Perm); return (bytes32(0), false); } diff --git a/src/mons/ghouliath/EternalGrudge.sol b/src/mons/ghouliath/EternalGrudge.sol index fcfeb482..bf7a7f13 100644 --- a/src/mons/ghouliath/EternalGrudge.sol +++ b/src/mons/ghouliath/EternalGrudge.sol @@ -7,19 +7,12 @@ import "../../Enums.sol"; import { StatBoostToApply, MoveMeta } from "../../Structs.sol"; import {IEngine} from "../../IEngine.sol"; -import {StatBoosts} from "../../effects/StatBoosts.sol"; import {IMoveSet} from "../../moves/IMoveSet.sol"; contract EternalGrudge is IMoveSet { uint8 public constant ATTACK_DEBUFF_PERCENT = 50; uint8 public constant SP_ATTACK_DEBUFF_PERCENT = 50; - StatBoosts immutable STAT_BOOSTS; - - constructor(StatBoosts _STAT_BOOSTS) { - STAT_BOOSTS = _STAT_BOOSTS; - } - function name() public pure override returns (string memory) { return "Eternal Grudge"; } @@ -46,7 +39,7 @@ contract EternalGrudge is IMoveSet { boostPercent: SP_ATTACK_DEBUFF_PERCENT, boostType: StatBoostType.Divide }); - STAT_BOOSTS.addStatBoosts(engine, defenderPlayerIndex, defenderMonIndex, statBoosts, StatBoostFlag.Temp); + engine.addStatBoost(defenderPlayerIndex, defenderMonIndex, statBoosts, StatBoostFlag.Temp); // KO self by dealing just enough damage int32 currentDamage = diff --git a/src/mons/iblivion/Loop.sol b/src/mons/iblivion/Loop.sol index ff16f18c..738df4a3 100644 --- a/src/mons/iblivion/Loop.sol +++ b/src/mons/iblivion/Loop.sol @@ -8,7 +8,6 @@ import "../../Structs.sol"; import {IEngine} from "../../IEngine.sol"; import {IMoveSet} from "../../moves/IMoveSet.sol"; -import {StatBoosts} from "../../effects/StatBoosts.sol"; import {Baselight} from "./Baselight.sol"; import {MoveMeta} from "../../Structs.sol"; @@ -29,11 +28,8 @@ contract Loop is IMoveSet { uint8 public constant BOOST_PERCENT_LEVEL_3 = 40; Baselight immutable BASELIGHT; - StatBoosts immutable STAT_BOOSTS; - - constructor(Baselight _BASELIGHT, StatBoosts _STAT_BOOSTS) { + constructor(Baselight _BASELIGHT) { BASELIGHT = _BASELIGHT; - STAT_BOOSTS = _STAT_BOOSTS; } function name() public pure override returns (string memory) { @@ -119,7 +115,7 @@ contract Loop is IMoveSet { }); // Use Temp flag so boosts are removed on switch out - STAT_BOOSTS.addStatBoosts(engine, attackerPlayerIndex, attackerMonIndex, statBoosts, StatBoostFlag.Temp); + engine.addStatBoost(attackerPlayerIndex, attackerMonIndex, statBoosts, StatBoostFlag.Temp); } function stamina(IEngine, bytes32, uint256, uint256) public pure returns (uint32) { diff --git a/src/mons/iblivion/Renormalize.sol b/src/mons/iblivion/Renormalize.sol index 8fa34f41..ccf2da94 100644 --- a/src/mons/iblivion/Renormalize.sol +++ b/src/mons/iblivion/Renormalize.sol @@ -8,7 +8,6 @@ import "../../Structs.sol"; import {IEngine} from "../../IEngine.sol"; import {IMoveSet} from "../../moves/IMoveSet.sol"; -import {StatBoosts} from "../../effects/StatBoosts.sol"; import {Baselight} from "./Baselight.sol"; import {Loop} from "./Loop.sol"; @@ -23,12 +22,10 @@ import {MoveMeta} from "../../Structs.sol"; */ contract Renormalize is IMoveSet { Baselight immutable BASELIGHT; - StatBoosts immutable STAT_BOOSTS; Loop immutable LOOP; - constructor(Baselight _BASELIGHT, StatBoosts _STAT_BOOSTS, Loop _LOOP) { + constructor(Baselight _BASELIGHT, Loop _LOOP) { BASELIGHT = _BASELIGHT; - STAT_BOOSTS = _STAT_BOOSTS; LOOP = _LOOP; } @@ -52,7 +49,7 @@ contract Renormalize is IMoveSet { LOOP.clearLoopActive(engine, attackerPlayerIndex, attackerMonIndex); // Clear all StatBoost effects and reset stats to base values - STAT_BOOSTS.clearAllBoostsForMon(engine, attackerPlayerIndex, attackerMonIndex); + engine.clearAllStatBoosts(attackerPlayerIndex, attackerMonIndex); } function stamina(IEngine, bytes32, uint256, uint256) public pure returns (uint32) { diff --git a/src/mons/inutia/Initialize.sol b/src/mons/inutia/Initialize.sol index 5e720626..c7d4cfe1 100644 --- a/src/mons/inutia/Initialize.sol +++ b/src/mons/inutia/Initialize.sol @@ -8,19 +8,12 @@ import { StatBoostToApply, MoveMeta } from "../../Structs.sol"; import {IEngine} from "../../IEngine.sol"; import {BasicEffect} from "../../effects/BasicEffect.sol"; -import {StatBoosts} from "../../effects/StatBoosts.sol"; import {IMoveSet} from "../../moves/IMoveSet.sol"; contract Initialize is IMoveSet, BasicEffect { uint8 public constant ATTACK_BUFF_PERCENT = 50; uint8 public constant SP_ATTACK_BUFF_PERCENT = 50; - StatBoosts immutable STAT_BOOSTS; - - constructor(StatBoosts _STAT_BOOSTS) { - STAT_BOOSTS = _STAT_BOOSTS; - } - function name() public pure override(IMoveSet, BasicEffect) returns (string memory) { return "Initialize"; } @@ -64,7 +57,7 @@ contract Initialize is IMoveSet, BasicEffect { boostPercent: ATTACK_BUFF_PERCENT, boostType: StatBoostType.Multiply }); - STAT_BOOSTS.addStatBoosts(engine, playerIndex, monIndex, statBoosts, StatBoostFlag.Temp); + engine.addStatBoost(playerIndex, monIndex, statBoosts, StatBoostFlag.Temp); } function stamina(IEngine, bytes32, uint256, uint256) public pure returns (uint32) { diff --git a/src/mons/inutia/Interweaving.sol b/src/mons/inutia/Interweaving.sol index 562f0353..c0ca73d7 100644 --- a/src/mons/inutia/Interweaving.sol +++ b/src/mons/inutia/Interweaving.sol @@ -8,17 +8,10 @@ import {IEngine} from "../../IEngine.sol"; import {IAbility} from "../../abilities/IAbility.sol"; import {BasicEffect} from "../../effects/BasicEffect.sol"; import {IEffect} from "../../effects/IEffect.sol"; -import {StatBoosts} from "../../effects/StatBoosts.sol"; contract Interweaving is IAbility, BasicEffect { uint8 public constant DECREASE_PERCENTAGE = 15; - StatBoosts immutable STAT_BOOST; - - constructor(StatBoosts _STAT_BOOSTS) { - STAT_BOOST = _STAT_BOOSTS; - } - function name() public pure override(IAbility, BasicEffect) returns (string memory) { return "Interweaving"; } @@ -34,7 +27,7 @@ contract Interweaving is IAbility, BasicEffect { boostPercent: DECREASE_PERCENTAGE, boostType: StatBoostType.Divide }); - STAT_BOOST.addStatBoosts(engine, otherPlayerIndex, otherPlayerActiveMonIndex, statBoosts, StatBoostFlag.Temp); + engine.addStatBoost(otherPlayerIndex, otherPlayerActiveMonIndex, statBoosts, StatBoostFlag.Temp); // Check if the effect has already been set for this mon (EffectInstance[] memory effects, ) = engine.getEffects(battleKey, playerIndex, monIndex); @@ -66,7 +59,7 @@ contract Interweaving is IAbility, BasicEffect { boostPercent: DECREASE_PERCENTAGE, boostType: StatBoostType.Divide }); - STAT_BOOST.addStatBoosts(engine, otherPlayerIndex, otherPlayerActiveMonIndex, statBoosts, StatBoostFlag.Temp); + engine.addStatBoost(otherPlayerIndex, otherPlayerActiveMonIndex, statBoosts, StatBoostFlag.Temp); return (bytes32(0), false); } } diff --git a/src/mons/malalien/ActusReus.sol b/src/mons/malalien/ActusReus.sol index a2e7fcb0..f124924d 100644 --- a/src/mons/malalien/ActusReus.sol +++ b/src/mons/malalien/ActusReus.sol @@ -10,19 +10,12 @@ import {EffectInstance, StatBoostToApply} from "../../Structs.sol"; import {IAbility} from "../../abilities/IAbility.sol"; import {BasicEffect} from "../../effects/BasicEffect.sol"; import {IEffect} from "../../effects/IEffect.sol"; -import {StatBoosts} from "../../effects/StatBoosts.sol"; contract ActusReus is IAbility, BasicEffect { uint8 public constant SPEED_DEBUFF_PERCENT = 50; bytes32 public constant INDICTMENT = bytes32("INDICTMENT"); - StatBoosts immutable STAT_BOOSTS; - - constructor(StatBoosts _STAT_BOOSTS) { - STAT_BOOSTS = _STAT_BOOSTS; - } - function name() public pure override(IAbility, BasicEffect) returns (string memory) { return "Actus Reus"; } @@ -83,7 +76,7 @@ contract ActusReus is IAbility, BasicEffect { boostPercent: SPEED_DEBUFF_PERCENT, boostType: StatBoostType.Divide }); - STAT_BOOSTS.addStatBoosts(engine, otherPlayerIndex, otherPlayerActiveMonIndex, statBoosts, StatBoostFlag.Temp); + engine.addStatBoost(otherPlayerIndex, otherPlayerActiveMonIndex, statBoosts, StatBoostFlag.Temp); return (bytes32(0), false); } } diff --git a/src/mons/malalien/TripleThink.sol b/src/mons/malalien/TripleThink.sol index b1adbbb5..ab0ca940 100644 --- a/src/mons/malalien/TripleThink.sol +++ b/src/mons/malalien/TripleThink.sol @@ -7,18 +7,11 @@ import "../../Enums.sol"; import { StatBoostToApply, MoveMeta } from "../../Structs.sol"; import {IEngine} from "../../IEngine.sol"; -import {StatBoosts} from "../../effects/StatBoosts.sol"; import {IMoveSet} from "../../moves/IMoveSet.sol"; contract TripleThink is IMoveSet { uint8 public constant SP_ATTACK_BUFF_PERCENT = 75; - StatBoosts immutable STAT_BOOSTS; - - constructor(StatBoosts _STAT_BOOSTS) { - STAT_BOOSTS = _STAT_BOOSTS; - } - function name() public pure override returns (string memory) { return "Triple Think"; } @@ -39,7 +32,7 @@ contract TripleThink is IMoveSet { boostPercent: SP_ATTACK_BUFF_PERCENT, boostType: StatBoostType.Multiply }); - STAT_BOOSTS.addStatBoosts(engine, attackerPlayerIndex, attackerMonIndex, statBoosts, StatBoostFlag.Temp); + engine.addStatBoost(attackerPlayerIndex, attackerMonIndex, statBoosts, StatBoostFlag.Temp); } function stamina(IEngine, bytes32, uint256, uint256) public pure returns (uint32) { diff --git a/src/mons/nirvamma/Chronoffense.sol b/src/mons/nirvamma/Chronoffense.sol index 9f79500e..745b9435 100644 --- a/src/mons/nirvamma/Chronoffense.sol +++ b/src/mons/nirvamma/Chronoffense.sol @@ -8,7 +8,6 @@ import {MoveMeta, StatBoostToApply} from "../../Structs.sol"; import {IEngine} from "../../IEngine.sol"; import {IEffect} from "../../effects/IEffect.sol"; -import {StatBoosts} from "../../effects/StatBoosts.sol"; import {IMoveSet} from "../../moves/IMoveSet.sol"; contract Chronoffense is IMoveSet { @@ -16,12 +15,6 @@ contract Chronoffense is IMoveSet { uint32 public constant BP_CAP = 999; uint8 public constant BOOST_PERCENT = 25; - StatBoosts immutable STAT_BOOSTS; - - constructor(StatBoosts _STAT_BOOSTS) { - STAT_BOOSTS = _STAT_BOOSTS; - } - function name() public pure returns (string memory) { return "Chronoffense"; } @@ -59,7 +52,7 @@ contract Chronoffense is IMoveSet { boostPercent: BOOST_PERCENT, boostType: StatBoostType.Multiply }); - STAT_BOOSTS.addStatBoosts(engine, attackerPlayerIndex, attackerMonIndex, boosts, StatBoostFlag.Temp); + engine.addStatBoost(attackerPlayerIndex, attackerMonIndex, boosts, StatBoostFlag.Temp); return; } diff --git a/src/mons/pengym/Deadlift.sol b/src/mons/pengym/Deadlift.sol index 50772bac..9f010b8a 100644 --- a/src/mons/pengym/Deadlift.sol +++ b/src/mons/pengym/Deadlift.sol @@ -7,19 +7,12 @@ import "../../Enums.sol"; import { StatBoostToApply, MoveMeta } from "../../Structs.sol"; import {IEngine} from "../../IEngine.sol"; -import {StatBoosts} from "../../effects/StatBoosts.sol"; import {IMoveSet} from "../../moves/IMoveSet.sol"; contract Deadlift is IMoveSet { uint8 public constant ATTACK_BUFF_PERCENT = 50; uint8 public constant DEF_BUFF_PERCENT = 50; - StatBoosts immutable STAT_BOOSTS; - - constructor(StatBoosts _STAT_BOOSTS) { - STAT_BOOSTS = _STAT_BOOSTS; - } - function name() public pure override returns (string memory) { return "Deadlift"; } @@ -45,7 +38,7 @@ contract Deadlift is IMoveSet { boostPercent: DEF_BUFF_PERCENT, boostType: StatBoostType.Multiply }); - STAT_BOOSTS.addStatBoosts(engine, attackerPlayerIndex, attackerMonIndex, statBoosts, StatBoostFlag.Temp); + engine.addStatBoost(attackerPlayerIndex, attackerMonIndex, statBoosts, StatBoostFlag.Temp); } function stamina(IEngine, bytes32, uint256, uint256) public pure returns (uint32) { diff --git a/test/EngineTest.sol b/test/EngineTest.sol index 29f5d95c..880df271 100644 --- a/test/EngineTest.sol +++ b/test/EngineTest.sol @@ -1213,8 +1213,10 @@ contract EngineTest is Test, BattleHelper { // (We have a check for 2 instead of 1 to avoid confusing it with the base case state) _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey, SWITCH_MOVE_INDEX, 0, uint16(1), 0); - // Assert that the temporary stat boost effect is updated to 2 because the roundEnd hook also runs - assertEq(engine.getMonStateForBattle(battleKey, 0, 1, MonStateIndexName.Attack), 2); + // Both hooks apply the same-keyed +100% Attack multiply (they merge): onApply takes base 1 + // -> 2 (delta +1), onRoundEnd -> 4 (delta +3). The +3 confirms BOTH hooks ran on the + // post-switch mon (delta would be +1 if only onApply had run). + assertEq(engine.getMonStateForBattle(battleKey, 0, 1, MonStateIndexName.Attack), 3); } function test_moveKOSupersedesRoundEndEffectKOForGameEnd() public { diff --git a/test/FullyOptimizedInlineGasTest.sol b/test/FullyOptimizedInlineGasTest.sol index 59ccf62e..7dc1816c 100644 --- a/test/FullyOptimizedInlineGasTest.sol +++ b/test/FullyOptimizedInlineGasTest.sol @@ -25,7 +25,6 @@ import {StatBoostsMove} from "./mocks/StatBoostsMove.sol"; import {BurnStatus} from "../src/effects/status/BurnStatus.sol"; import {FrostbiteStatus} from "../src/effects/status/FrostbiteStatus.sol"; -import {StatBoosts} from "../src/effects/StatBoosts.sol"; import {IEngineHook} from "../src/IEngineHook.sol"; @@ -300,10 +299,9 @@ contract FullyOptimizedInlineGasTest is BattleHelper, SignedCommitHelper, GasMea mon.stats.specialAttack = 10; mon.moves = new uint256[](4); - StatBoosts statBoosts = new StatBoosts(); - IMoveSet burnMove = new EffectAttack(new BurnStatus(statBoosts), EffectAttack.Args({TYPE: Type.Fire, STAMINA_COST: 1, PRIORITY: 1})); - IMoveSet frostbiteMove = new EffectAttack(new FrostbiteStatus(statBoosts), EffectAttack.Args({TYPE: Type.Fire, STAMINA_COST: 1, PRIORITY: 1})); - IMoveSet statBoostMove = new StatBoostsMove(statBoosts); + IMoveSet burnMove = new EffectAttack(new BurnStatus(), EffectAttack.Args({TYPE: Type.Fire, STAMINA_COST: 1, PRIORITY: 1})); + IMoveSet frostbiteMove = new EffectAttack(new FrostbiteStatus(), EffectAttack.Args({TYPE: Type.Fire, STAMINA_COST: 1, PRIORITY: 1})); + IMoveSet statBoostMove = new StatBoostsMove(); IMoveSet damageMove = new CustomAttack(ITypeCalculator(address(typeCalc)), CustomAttack.Args({TYPE: Type.Fire, BASE_POWER: 10, ACCURACY: 100, STAMINA_COST: 1, PRIORITY: 1})); mon.moves[0] = uint256(uint160(address(burnMove))); mon.moves[1] = uint256(uint160(address(frostbiteMove))); diff --git a/test/InlineMoveParityTest.sol b/test/InlineMoveParityTest.sol index 7ffaeaf4..c576b140 100644 --- a/test/InlineMoveParityTest.sol +++ b/test/InlineMoveParityTest.sol @@ -16,7 +16,6 @@ import {DefaultMatchmaker} from "../src/matchmaker/DefaultMatchmaker.sol"; import {BattleHelper} from "./abstract/BattleHelper.sol"; import {BurnStatus} from "../src/effects/status/BurnStatus.sol"; import {FrostbiteStatus} from "../src/effects/status/FrostbiteStatus.sol"; -import {StatBoosts} from "../src/effects/StatBoosts.sol"; /// @title Inline Move Parity Tests /// @notice Verifies that inline packed moves work correctly in the Engine @@ -26,7 +25,6 @@ contract InlineMoveParityTest is Test, BattleHelper { MockRandomnessOracle mockOracle; TestTeamRegistry defaultRegistry; DefaultMatchmaker matchmaker; - StatBoosts statBoosts; // Inline validation constants uint256 constant MONS_PER_TEAM = 1; @@ -38,7 +36,6 @@ contract InlineMoveParityTest is Test, BattleHelper { commitManager = new DefaultCommitManager(engine); defaultRegistry = new TestTeamRegistry(); matchmaker = new DefaultMatchmaker(engine); - statBoosts = new StatBoosts(); } /// @notice Pack an inline move value from components @@ -246,7 +243,7 @@ contract InlineMoveParityTest is Test, BattleHelper { /// @notice Test inline move with effect: effect is applied when RNG hits function test_inlineMoveWithEffect_appliesEffect() public { - BurnStatus burnStatus = new BurnStatus(statBoosts); + BurnStatus burnStatus = new BurnStatus(); // Pack: basePower=50, Special(1), default priority, Fire(4), stamina=1, effectAccuracy=100, effect=burn uint256 inlineMove = _packMove(50, 1, 0, 4, 1, 100, address(burnStatus)); @@ -294,7 +291,7 @@ contract InlineMoveParityTest is Test, BattleHelper { /// @notice Test basePower=0 inline move (like ChillOut) deals no damage but applies effect function test_inlineBasePowerZero_noEffectDamage_appliesEffect() public { - FrostbiteStatus frostbiteStatus = new FrostbiteStatus(statBoosts); + FrostbiteStatus frostbiteStatus = new FrostbiteStatus(); // Pack ChillOut: basePower=0, Other(3), default priority, Ice(6), stamina=0, effectAccuracy=100, effect=frostbite uint256 chillOutPacked = _packMove(0, 3, 0, 6, 0, 100, address(frostbiteStatus)); diff --git a/test/RealMonReplayGasTest.t.sol b/test/RealMonReplayGasTest.t.sol index 94f231b9..b56e9795 100644 --- a/test/RealMonReplayGasTest.t.sol +++ b/test/RealMonReplayGasTest.t.sol @@ -14,7 +14,6 @@ import {IGachaRNG} from "../src/rng/IGachaRNG.sol"; import {IEngine} from "../src/IEngine.sol"; import {TypeCalculator} from "../src/types/TypeCalculator.sol"; -import {StatBoosts} from "../src/effects/StatBoosts.sol"; import {BurnStatus} from "../src/effects/status/BurnStatus.sol"; import {FrostbiteStatus} from "../src/effects/status/FrostbiteStatus.sol"; import {PanicStatus} from "../src/effects/status/PanicStatus.sol"; @@ -71,16 +70,14 @@ contract RealMonReplayGasTest is Test, SetupMons, BatchHelper { function _deployStack() internal { engine = new Engine(4, 4, 1); TypeCalculator tc = new TypeCalculator(); - StatBoosts sb = new StatBoosts(); - Overclock oc = new Overclock(sb); + Overclock oc = new Overclock(); SleepStatus sleepStatus = new SleepStatus(); PanicStatus panicStatus = new PanicStatus(); - FrostbiteStatus frost = new FrostbiteStatus(sb); - BurnStatus burn = new BurnStatus(sb); + FrostbiteStatus frost = new FrostbiteStatus(); + BurnStatus burn = new BurnStatus(); ZapStatus zap = new ZapStatus(); gachaReg = new GachaTeamRegistry(4, 4, IEngine(address(engine)), IGachaRNG(address(0))); vm.setEnv("TYPE_CALCULATOR", vm.toString(address(tc))); - vm.setEnv("STAT_BOOSTS", vm.toString(address(sb))); vm.setEnv("OVERCLOCK", vm.toString(address(oc))); vm.setEnv("SLEEP_STATUS", vm.toString(address(sleepStatus))); vm.setEnv("PANIC_STATUS", vm.toString(address(panicStatus))); diff --git a/test/effects/EffectTest.sol b/test/effects/EffectTest.sol index 86b657b5..829298e8 100644 --- a/test/effects/EffectTest.sol +++ b/test/effects/EffectTest.sol @@ -24,7 +24,6 @@ import {BattleHelper} from "../abstract/BattleHelper.sol"; // Import effects import {DefaultRuleset} from "../../src/DefaultRuleset.sol"; import {StaminaRegen} from "../../src/effects/StaminaRegen.sol"; -import {StatBoosts} from "../../src/effects/StatBoosts.sol"; import {BurnStatus} from "../../src/effects/status/BurnStatus.sol"; import {FrostbiteStatus} from "../../src/effects/status/FrostbiteStatus.sol"; import {PanicStatus} from "../../src/effects/status/PanicStatus.sol"; @@ -40,6 +39,7 @@ import {ATTACK_PARAMS} from "../../src/moves/StandardAttackStructs.sol"; import {OnUpdateMonStateHealEffect} from "../mocks/OnUpdateMonStateHealEffect.sol"; import {EffectAbility} from "../mocks/EffectAbility.sol"; import {ReduceSpAtkMove} from "../mocks/ReduceSpAtkMove.sol"; +import {DirectStatWriteMove} from "../mocks/DirectStatWriteMove.sol"; contract EffectTest is Test, BattleHelper { DefaultCommitManager commitManager; @@ -49,7 +49,6 @@ contract EffectTest is Test, BattleHelper { MockRandomnessOracle mockOracle; TestTeamRegistry defaultRegistry; - StatBoosts statBoosts; StandardAttackFactory standardAttackFactory; FrostbiteStatus frostbiteStatus; SleepStatus sleepStatus; @@ -88,11 +87,10 @@ contract EffectTest is Test, BattleHelper { standardAttackFactory = new StandardAttackFactory(typeCalc); // Deploy all effects - statBoosts = new StatBoosts(); - frostbiteStatus = new FrostbiteStatus(statBoosts); + frostbiteStatus = new FrostbiteStatus(); sleepStatus = new SleepStatus(); panicStatus = new PanicStatus(); - burnStatus = new BurnStatus(statBoosts); + burnStatus = new BurnStatus(); zapStatus = new ZapStatus(); matchmaker = new DefaultMatchmaker(engine); } @@ -832,4 +830,54 @@ contract EffectTest is Test, BattleHelper { // Verify that the OnUpdateMonState effect triggered and healed Bob by 5 HP assertEq(bobHpAfter, 5, "Bob should be healed by 5 HP when SpATK is reduced"); } + + // Stats are owned by the inlined stat-boost system: a direct updateMonState on a stat delta + // (here Attack, via a move) must revert so it can't silently clobber the boost aggregation. + function test_directStatWriteIsRejected() public { + DirectStatWriteMove badMove = new DirectStatWriteMove(); + uint256[] memory moves = new uint256[](1); + moves[0] = uint256(uint160(address(badMove))); + Mon memory mon = Mon({ + stats: MonStats({ + hp: 20, + stamina: 10, + speed: 5, + attack: 10, + defense: 10, + specialAttack: 10, + specialDefense: 10, + type1: Type.Math, + type2: Type.None + }), + moves: moves, + ability: 0 + }); + Mon[] memory team = new Mon[](1); + team[0] = mon; + defaultRegistry.setTeam(ALICE, team); + defaultRegistry.setTeam(BOB, team); + + bytes32 battleKey = + _startBattle(oneMonOneMoveValidator, engine, mockOracle, defaultRegistry, matchmaker, address(commitManager)); + + // Switch both mons in (turnId 0 -> 1). + _commitRevealExecuteForAliceAndBob( + engine, commitManager, battleKey, SWITCH_MOVE_INDEX, SWITCH_MOVE_INDEX, uint16(0), uint16(0) + ); + + // Move turn (turnId 1, odd): BOB commits, ALICE reveals, then BOB's reveal auto-executes — + // the move's direct stat write makes execute revert with StatRequiresStatBoost. + uint8 mv = 0; + uint104 salt = 0; + uint16 ed = 0; + vm.startPrank(BOB); + commitManager.commitMove(battleKey, keccak256(abi.encodePacked(mv, salt, ed))); + vm.startPrank(ALICE); + commitManager.revealMove(battleKey, mv, salt, ed, true); + vm.startPrank(BOB); + vm.expectRevert(Engine.StatRequiresStatBoost.selector); + commitManager.revealMove(battleKey, mv, salt, ed, true); + vm.stopPrank(); + engine.resetCallContext(); + } } diff --git a/test/effects/StatBoosts.t.sol b/test/effects/StatBoosts.t.sol index 6c410ca8..05c32a45 100644 --- a/test/effects/StatBoosts.t.sol +++ b/test/effects/StatBoosts.t.sol @@ -17,7 +17,6 @@ import {MockRandomnessOracle} from "../mocks/MockRandomnessOracle.sol"; import {TestTeamRegistry} from "../mocks/TestTeamRegistry.sol"; import {TestTypeCalculator} from "../mocks/TestTypeCalculator.sol"; -import {StatBoosts} from "../../src/effects/StatBoosts.sol"; import {StatBoostsMove} from "../mocks/StatBoostsMove.sol"; import {DefaultMatchmaker} from "../../src/matchmaker/DefaultMatchmaker.sol"; @@ -34,7 +33,6 @@ contract StatBoostsTest is Test, BattleHelper { MockRandomnessOracle mockOracle; TestTeamRegistry defaultRegistry; IValidator validator; - StatBoosts statBoosts; StatBoostsMove statBoostMove; DefaultMatchmaker matchmaker; @@ -49,8 +47,7 @@ contract StatBoostsTest is Test, BattleHelper { commitManager = new DefaultCommitManager(IEngine(address(engine))); // Create the StatBoosts effect and move - statBoosts = new StatBoosts(); - statBoostMove = new StatBoostsMove(statBoosts); + statBoostMove = new StatBoostsMove(); matchmaker = new DefaultMatchmaker(engine); } @@ -151,7 +148,7 @@ contract StatBoostsTest is Test, BattleHelper { (EffectInstance[] memory effects, ) = engine.getEffects(battleKey, 0, 0); bool foundEffect = false; for (uint256 i = 0; i < effects.length; i++) { - if (keccak256(abi.encodePacked(effects[i].effect.name())) == keccak256(abi.encodePacked("Stat Boost"))) { + if (address(effects[i].effect) == STAT_BOOST_ADDRESS) { foundEffect = true; break; } @@ -196,7 +193,7 @@ contract StatBoostsTest is Test, BattleHelper { (effects, ) = engine.getEffects(battleKey, 0, 1); foundEffect = false; for (uint256 i = 0; i < effects.length; i++) { - if (keccak256(abi.encodePacked(effects[i].effect.name())) == keccak256(abi.encodePacked("Stat Boost"))) { + if (address(effects[i].effect) == STAT_BOOST_ADDRESS) { foundEffect = true; break; } @@ -313,8 +310,7 @@ contract StatBoostsTest is Test, BattleHelper { bool foundStatEffect = false; for (uint256 j = 0; j < statEffects.length; j++) { if ( - keccak256(abi.encodePacked(statEffects[j].effect.name())) - == keccak256(abi.encodePacked("Stat Boost")) + address(statEffects[j].effect) == STAT_BOOST_ADDRESS ) { foundStatEffect = true; break; @@ -338,7 +334,7 @@ contract StatBoostsTest is Test, BattleHelper { (EffectInstance[] memory effectsAfterSwitch, ) = engine.getEffects(battleKey, 0, 1); for (uint256 i = 0; i < effectsAfterSwitch.length; i++) { assertFalse( - keccak256(abi.encodePacked(effectsAfterSwitch[i].effect.name())) == keccak256(abi.encodePacked("Stat Boost")), + address(effectsAfterSwitch[i].effect) == STAT_BOOST_ADDRESS, "No Stat Boost effects should remain after switching out" ); } @@ -346,7 +342,7 @@ contract StatBoostsTest is Test, BattleHelper { function test_permanentTempStatBoostInteraction() public { StandardAttackFactory attackFactory = new StandardAttackFactory(typeCalc); - SpAtkDebuffEffect spAtkDebuff = new SpAtkDebuffEffect(statBoosts); + SpAtkDebuffEffect spAtkDebuff = new SpAtkDebuffEffect(); // Create teams with two mons each uint256[] memory moves = new uint256[](2); diff --git a/test/mocks/DirectStatWriteMove.sol b/test/mocks/DirectStatWriteMove.sol new file mode 100644 index 00000000..87e140f9 --- /dev/null +++ b/test/mocks/DirectStatWriteMove.sol @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity ^0.8.0; + +import "../../src/Enums.sol"; +import "../../src/Structs.sol"; + +import {IEngine} from "../../src/IEngine.sol"; +import {IMoveSet} from "../../src/moves/IMoveSet.sol"; + +/** + * @title DirectStatWriteMove + * @notice Move that attempts to write a stat delta directly via updateMonState. Used to assert the + * Engine rejects direct stat writes (stats are owned by the inlined stat-boost system). + */ +contract DirectStatWriteMove is IMoveSet { + function name() external pure returns (string memory) { + return "Direct Stat Write"; + } + + function move(IEngine engine, bytes32, uint256 attackerPlayerIndex, uint256 attackerMonIndex, uint256, uint16, uint256) + external + { + // Forbidden: stat deltas may only be changed through add/removeStatBoost. + engine.updateMonState(attackerPlayerIndex, attackerMonIndex, MonStateIndexName.Attack, 1); + } + + function priority(IEngine, bytes32, uint256) public pure returns (uint32) { + return 0; + } + + function stamina(IEngine, bytes32, uint256, uint256) public pure returns (uint32) { + return 0; + } + + function moveType(IEngine, bytes32) public pure returns (Type) { + return Type.Math; + } + + function moveClass(IEngine, bytes32) public pure returns (MoveClass) { + return MoveClass.Other; + } + + function basePower(bytes32) external pure returns (uint32) { + return 0; + } + + function extraDataType() public pure returns (ExtraDataType) { + return ExtraDataType.None; + } + + function getMeta(IEngine engine, bytes32 battleKey, uint256 attackerPlayerIndex, uint256 attackerMonIndex) + external + pure + returns (MoveMeta memory) + { + return MoveMeta({ + moveType: moveType(engine, battleKey), + moveClass: moveClass(engine, battleKey), + extraDataType: extraDataType(), + priority: priority(engine, battleKey, attackerPlayerIndex), + stamina: stamina(engine, battleKey, attackerPlayerIndex, attackerMonIndex), + basePower: 0 + }); + } +} diff --git a/test/mocks/OneTurnStatBoost.sol b/test/mocks/OneTurnStatBoost.sol index 0e25e370..7d65b667 100644 --- a/test/mocks/OneTurnStatBoost.sol +++ b/test/mocks/OneTurnStatBoost.sol @@ -19,23 +19,30 @@ contract OneTurnStatBoost is BasicEffect { return 0x05; } - // Adds a bonus + // Adds a bonus. Both hooks apply the same-keyed +100% Attack multiply, so they merge: one + // application takes base 1 -> 2 (delta +1), the second -> 4 (delta +3). The test asserts +3, + // distinguishing "both hooks ran" from "only onApply ran" (+1). + function _boost() private pure returns (StatBoostToApply[] memory boosts) { + boosts = new StatBoostToApply[](1); + boosts[0] = StatBoostToApply({stat: MonStateIndexName.Attack, boostPercent: 100, boostType: StatBoostType.Multiply}); + } + function onApply(IEngine engine, bytes32, uint256, bytes32, uint256 targetIndex, uint256 monIndex, uint256, uint256) external override returns (bytes32 updatedExtraData, bool removeAfterRun) { - engine.updateMonState(targetIndex, monIndex, MonStateIndexName.Attack, 1); + engine.addStatBoost(targetIndex, monIndex, _boost(), StatBoostFlag.Perm); return (bytes32(0), false); } - // Adds another bonus + // Adds another bonus (merges with the onApply boost), then removes this effect. function onRoundEnd(IEngine engine, bytes32, uint256, bytes32, uint256 targetIndex, uint256 monIndex, uint256, uint256) external override returns (bytes32 updatedExtraData, bool removeAfterRun) { - engine.updateMonState(targetIndex, monIndex, MonStateIndexName.Attack, 1); + engine.addStatBoost(targetIndex, monIndex, _boost(), StatBoostFlag.Perm); return (bytes32(0), true); } } diff --git a/test/mocks/ReduceSpAtkMove.sol b/test/mocks/ReduceSpAtkMove.sol index 83acb798..b4a86a82 100644 --- a/test/mocks/ReduceSpAtkMove.sol +++ b/test/mocks/ReduceSpAtkMove.sol @@ -24,8 +24,13 @@ contract ReduceSpAtkMove is IMoveSet { // Get the opposing player's index uint256 opposingPlayerIndex = (attackerPlayerIndex + 1) % 2; - // Reduce the opposing mon's SpecialAttack by 1 - engine.updateMonState(opposingPlayerIndex, defenderMonIndex, MonStateIndexName.SpecialAttack, -1); + // Reduce the opposing mon's SpecialAttack via the stat-boost system. Stats can only be + // written through stat boosts now; a 10% divide on a base of 10 lands exactly -1, matching + // the legacy direct updateMonState(SpecialAttack, -1), and still fires OnUpdateMonState. + StatBoostToApply[] memory boosts = new StatBoostToApply[](1); + boosts[0] = + StatBoostToApply({stat: MonStateIndexName.SpecialAttack, boostPercent: 10, boostType: StatBoostType.Divide}); + engine.addStatBoost(opposingPlayerIndex, defenderMonIndex, boosts, StatBoostFlag.Temp); } function priority(IEngine, bytes32, uint256) public pure returns (uint32) { diff --git a/test/mocks/SpAtkDebuffEffect.sol b/test/mocks/SpAtkDebuffEffect.sol index 869fbfc0..2521a6af 100644 --- a/test/mocks/SpAtkDebuffEffect.sol +++ b/test/mocks/SpAtkDebuffEffect.sol @@ -6,17 +6,10 @@ import {IEngine} from "../../src/IEngine.sol"; import {StatBoostToApply} from "../../src/Structs.sol"; import {StatusEffect} from "../../src/effects/status/StatusEffect.sol"; -import {StatBoosts} from "../../src/effects/StatBoosts.sol"; contract SpAtkDebuffEffect is StatusEffect { uint8 constant SP_ATTACK_PERCENT = 50; - StatBoosts immutable STAT_BOOST; - - constructor(StatBoosts _STAT_BOOSTS) { - STAT_BOOST = _STAT_BOOSTS; - } - function name() public pure override returns (string memory) { return "SpAtk Debuff"; } @@ -50,7 +43,7 @@ contract SpAtkDebuffEffect is StatusEffect { boostPercent: SP_ATTACK_PERCENT, boostType: StatBoostType.Divide }); - STAT_BOOST.addStatBoosts(engine, targetIndex, monIndex, statBoosts, StatBoostFlag.Perm); + engine.addStatBoost(targetIndex, monIndex, statBoosts, StatBoostFlag.Perm); // Do not update data return (extraData, false); @@ -68,6 +61,6 @@ contract SpAtkDebuffEffect is StatusEffect { super.onRemove(engine, battleKey, data, targetIndex, monIndex, p0ActiveMonIndex, p1ActiveMonIndex); // Reset the special attack reduction - STAT_BOOST.removeStatBoosts(engine, targetIndex, monIndex, StatBoostFlag.Perm); + engine.removeStatBoost(targetIndex, monIndex, StatBoostFlag.Perm); } } diff --git a/test/mocks/StatBoostsMove.sol b/test/mocks/StatBoostsMove.sol index f46ec6e7..fc6433e8 100644 --- a/test/mocks/StatBoostsMove.sol +++ b/test/mocks/StatBoostsMove.sol @@ -9,15 +9,7 @@ import "../../src/Structs.sol"; import {IEngine} from "../../src/IEngine.sol"; import {IMoveSet} from "../../src/moves/IMoveSet.sol"; -import {StatBoosts} from "../../src/effects/StatBoosts.sol"; - contract StatBoostsMove is IMoveSet { - StatBoosts immutable STAT_BOOSTS; - - constructor(StatBoosts _STAT_BOOSTS) { - STAT_BOOSTS = _STAT_BOOSTS; - } - function name() external pure returns (string memory) { return ""; } @@ -44,7 +36,7 @@ contract StatBoostsMove is IMoveSet { boostPercent: uint8(uint32(boostAmount)), boostType: boostType }); - STAT_BOOSTS.addStatBoosts(engine, playerIndex, monIndex, statBoosts, StatBoostFlag.Temp); + engine.addStatBoost(playerIndex, monIndex, statBoosts, StatBoostFlag.Temp); } function priority(IEngine, bytes32, uint256) public pure returns (uint32) { diff --git a/test/mocks/TempStatBoostEffect.sol b/test/mocks/TempStatBoostEffect.sol index 3c842db4..a61e5137 100644 --- a/test/mocks/TempStatBoostEffect.sol +++ b/test/mocks/TempStatBoostEffect.sol @@ -14,26 +14,21 @@ contract TempStatBoostEffect is BasicEffect { return ""; } - // Steps: OnApply, OnMonSwitchOut + // Steps: OnApply function getStepsBitmap() external pure override returns (uint16) { - return 0x21; + return 0x01; } + // Applies a TEMPORARY +100% Attack multiply (base 1 -> 2, i.e. delta +1). The engine drops temp + // stat boosts natively on switch-out, so this no longer needs its own OnMonSwitchOut hook. function onApply(IEngine engine, bytes32, uint256, bytes32, uint256 targetIndex, uint256 monIndex, uint256, uint256) external override returns (bytes32 updatedExtraData, bool removeAfterRun) { - engine.updateMonState(targetIndex, monIndex, MonStateIndexName.Attack, 1); + StatBoostToApply[] memory boosts = new StatBoostToApply[](1); + boosts[0] = StatBoostToApply({stat: MonStateIndexName.Attack, boostPercent: 100, boostType: StatBoostType.Multiply}); + engine.addStatBoost(targetIndex, monIndex, boosts, StatBoostFlag.Temp); return (bytes32(0), false); } - - function onMonSwitchOut(IEngine engine, bytes32, uint256, bytes32, uint256 targetIndex, uint256 monIndex, uint256, uint256) - external - override - returns (bytes32 updatedExtraData, bool removeAfterRun) - { - engine.updateMonState(targetIndex, monIndex, MonStateIndexName.Attack, 1); - return (bytes32(0), true); - } } diff --git a/test/mons/AuroxTest.sol b/test/mons/AuroxTest.sol index bcd566fd..b7ca167b 100644 --- a/test/mons/AuroxTest.sol +++ b/test/mons/AuroxTest.sol @@ -13,7 +13,6 @@ import {Engine} from "../../src/Engine.sol"; import {MonStateIndexName, MoveClass, Type} from "../../src/Enums.sol"; import {IEngine} from "../../src/IEngine.sol"; import {IEffect} from "../../src/effects/IEffect.sol"; -import {StatBoosts} from "../../src/effects/StatBoosts.sol"; import {BurnStatus} from "../../src/effects/status/BurnStatus.sol"; import {FrostbiteStatus} from "../../src/effects/status/FrostbiteStatus.sol"; import {DefaultMatchmaker} from "../../src/matchmaker/DefaultMatchmaker.sol"; @@ -52,7 +51,6 @@ contract AuroxTest is Test, BattleHelper { TestTypeCalculator typeCalc; MockRandomnessOracle mockOracle; TestTeamRegistry defaultRegistry; - StatBoosts statBoosts; DefaultMatchmaker matchmaker; StandardAttackFactory attackFactory; @@ -62,7 +60,6 @@ contract AuroxTest is Test, BattleHelper { defaultRegistry = new TestTeamRegistry(); engine = new Engine(0, 0, 0); commitManager = new DefaultCommitManager(IEngine(address(engine))); - statBoosts = new StatBoosts(); matchmaker = new DefaultMatchmaker(engine); attackFactory = new StandardAttackFactory(ITypeCalculator(address(typeCalc))); } @@ -97,7 +94,7 @@ contract AuroxTest is Test, BattleHelper { } function test_gildedRecoveryHealsWithStatus() public { - FrostbiteStatus frostbiteStatus = new FrostbiteStatus(statBoosts); + FrostbiteStatus frostbiteStatus = new FrostbiteStatus(); GildedRecovery gildedRecovery = new GildedRecovery(); uint32 maxHp = 100; @@ -573,7 +570,7 @@ contract AuroxTest is Test, BattleHelper { uint32 maxAtk = 100; uint32 maxDef = 100; - UpOnly upOnly = new UpOnly(statBoosts); + UpOnly upOnly = new UpOnly(); StandardAttack attack = attackFactory.createAttack( ATTACK_PARAMS({ BASE_POWER: maxHp / 2, @@ -735,8 +732,8 @@ contract AuroxTest is Test, BattleHelper { function test_volatilePunchDealsDamageAndTriggersStatusEffects() public { uint32 maxHp = 100; - BurnStatus burnStatus = new BurnStatus(statBoosts); - FrostbiteStatus frostbiteStatus = new FrostbiteStatus(statBoosts); + BurnStatus burnStatus = new BurnStatus(); + FrostbiteStatus frostbiteStatus = new FrostbiteStatus(); VolatilePunch volatilePunch = new VolatilePunch( typeCalc, burnStatus, frostbiteStatus ); diff --git a/test/mons/EkinekiTest.sol b/test/mons/EkinekiTest.sol index 9bcca4ab..610ab86f 100644 --- a/test/mons/EkinekiTest.sol +++ b/test/mons/EkinekiTest.sol @@ -13,7 +13,6 @@ import {Engine} from "../../src/Engine.sol"; import {MonStateIndexName, MoveClass, Type} from "../../src/Enums.sol"; import {IEngine} from "../../src/IEngine.sol"; import {IEffect} from "../../src/effects/IEffect.sol"; -import {StatBoosts} from "../../src/effects/StatBoosts.sol"; import {DefaultMatchmaker} from "../../src/matchmaker/DefaultMatchmaker.sol"; import {StandardAttack} from "../../src/moves/StandardAttack.sol"; import {StandardAttackFactory} from "../../src/moves/StandardAttackFactory.sol"; @@ -49,7 +48,6 @@ contract EkinekiTest is Test, BattleHelper { TestTypeCalculator typeCalc; MockRandomnessOracle mockOracle; TestTeamRegistry defaultRegistry; - StatBoosts statBoosts; DefaultMatchmaker matchmaker; StandardAttackFactory attackFactory; @@ -59,7 +57,6 @@ contract EkinekiTest is Test, BattleHelper { defaultRegistry = new TestTeamRegistry(); engine = new Engine(0, 0, 0); commitManager = new DefaultCommitManager(IEngine(address(engine))); - statBoosts = new StatBoosts(); matchmaker = new DefaultMatchmaker(engine); attackFactory = new StandardAttackFactory(ITypeCalculator(address(typeCalc))); } @@ -166,7 +163,7 @@ contract EkinekiTest is Test, BattleHelper { uint32 maxHp = 100; SneakAttack sneakAttack = new SneakAttack(ITypeCalculator(address(typeCalc))); - SaviorComplex saviorComplex = new SaviorComplex(statBoosts); + SaviorComplex saviorComplex = new SaviorComplex(); uint256[] memory moves = new uint256[](1); moves[0] = uint256(uint160(address(sneakAttack))); @@ -213,7 +210,7 @@ contract EkinekiTest is Test, BattleHelper { uint32 maxHp = 100; SneakAttack sneakAttack = new SneakAttack(ITypeCalculator(address(typeCalc))); - SaviorComplex saviorComplex = new SaviorComplex(statBoosts); + SaviorComplex saviorComplex = new SaviorComplex(); uint256[] memory moves = new uint256[](1); moves[0] = uint256(uint160(address(sneakAttack))); @@ -265,7 +262,7 @@ contract EkinekiTest is Test, BattleHelper { uint32 maxHp = 200; SneakAttack sneakAttack = new SneakAttack(ITypeCalculator(address(typeCalc))); - SaviorComplex saviorComplex = new SaviorComplex(statBoosts); + SaviorComplex saviorComplex = new SaviorComplex(); uint256[] memory moves = new uint256[](1); moves[0] = uint256(uint160(address(sneakAttack))); @@ -316,7 +313,7 @@ contract EkinekiTest is Test, BattleHelper { uint32 maxHp = 200; NineNineNine nineNineNine = new NineNineNine(); - SaviorComplex saviorComplex = new SaviorComplex(statBoosts); + SaviorComplex saviorComplex = new SaviorComplex(); // Create a predictable attack (0 vol, 0 default crit) to isolate crit boost StandardAttack testAttack = attackFactory.createAttack( @@ -385,7 +382,7 @@ contract EkinekiTest is Test, BattleHelper { function test_saviorComplexBoostsOnKO() public { uint32 maxHp = 100; - SaviorComplex saviorComplex = new SaviorComplex(statBoosts); + SaviorComplex saviorComplex = new SaviorComplex(); // Create a strong attack that will KO in one hit StandardAttack koAttack = attackFactory.createAttack( @@ -487,7 +484,7 @@ contract EkinekiTest is Test, BattleHelper { function test_saviorComplexTriggersOncePerGame() public { uint32 maxHp = 100; - SaviorComplex saviorComplex = new SaviorComplex(statBoosts); + SaviorComplex saviorComplex = new SaviorComplex(); StandardAttack koAttack = attackFactory.createAttack( ATTACK_PARAMS({ @@ -598,7 +595,7 @@ contract EkinekiTest is Test, BattleHelper { function test_saviorComplexNoBoostWithZeroKOs() public { uint32 maxHp = 100; - SaviorComplex saviorComplex = new SaviorComplex(statBoosts); + SaviorComplex saviorComplex = new SaviorComplex(); StandardAttack koAttack = attackFactory.createAttack( ATTACK_PARAMS({ diff --git a/test/mons/EmbursaTest.sol b/test/mons/EmbursaTest.sol index 06632865..cf9c3b27 100644 --- a/test/mons/EmbursaTest.sol +++ b/test/mons/EmbursaTest.sol @@ -26,7 +26,6 @@ import {TestTypeCalculator} from "../mocks/TestTypeCalculator.sol"; import {StandardAttackFactory} from "../../src/moves/StandardAttackFactory.sol"; import {ATTACK_PARAMS} from "../../src/moves/StandardAttackStructs.sol"; -import {StatBoosts} from "../../src/effects/StatBoosts.sol"; import {BurnStatus} from "../../src/effects/status/BurnStatus.sol"; import {DefaultMatchmaker} from "../../src/matchmaker/DefaultMatchmaker.sol"; import {HeatBeacon} from "../../src/mons/embursa/HeatBeacon.sol"; @@ -137,8 +136,7 @@ contract EmbursaTest is Test, BattleHelper { HeatBeacon heatBeacon = new HeatBeacon(IEffect(address(dummyStatus))); Q5 q5 = new Q5(typeCalc); SetAblaze setAblaze = new SetAblaze(typeCalc, IEffect(address(dummyStatus))); - StatBoosts statBoosts = new StatBoosts(); - HoneyBribe honeyBribe = new HoneyBribe(statBoosts); + HoneyBribe honeyBribe = new HoneyBribe(); IMoveSet koMove = attackFactory.createAttack( ATTACK_PARAMS({ @@ -300,7 +298,7 @@ contract EmbursaTest is Test, BattleHelper { _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey, 0, NO_OP_MOVE_INDEX, 0, 0); _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey, 3, 4, 0, 0); (effects, ) = engine.getEffects(battleKey, 1, 0); - assertEq(address(effects[1].effect), address(statBoosts), "StatBoosts should be applied to Bob's mon"); + assertEq(address(effects[1].effect), STAT_BOOST_ADDRESS, "StatBoosts should be applied to Bob's mon"); assertEq( engine.getMonStateForBattle(battleKey, 0, 0, MonStateIndexName.IsKnockedOut), 1, @@ -544,9 +542,8 @@ contract EmbursaTest is Test, BattleHelper { * - If burn is applied externally, SpATK boost is still granted at end of round */ function test_tinderclaws_selfBurnOnMove() public { - StatBoosts statBoosts = new StatBoosts(); - BurnStatus burnStatus = new BurnStatus(statBoosts); - Tinderclaws tinderclaws = new Tinderclaws(IEffect(address(burnStatus)), statBoosts); + BurnStatus burnStatus = new BurnStatus(); + Tinderclaws tinderclaws = new Tinderclaws(IEffect(address(burnStatus))); uint256[] memory moves = new uint256[](1); moves[0] = uint256(uint160(address(attackFactory.createAttack( @@ -634,9 +631,8 @@ contract EmbursaTest is Test, BattleHelper { } function test_tinderclaws_restingRemovesBurn() public { - StatBoosts statBoosts = new StatBoosts(); - BurnStatus burnStatus = new BurnStatus(statBoosts); - Tinderclaws tinderclaws = new Tinderclaws(IEffect(address(burnStatus)), statBoosts); + BurnStatus burnStatus = new BurnStatus(); + Tinderclaws tinderclaws = new Tinderclaws(IEffect(address(burnStatus))); uint256[] memory moves = new uint256[](1); moves[0] = uint256(uint160(address(attackFactory.createAttack( diff --git a/test/mons/GhouliathTest.sol b/test/mons/GhouliathTest.sol index c94c1ce6..f2ad0738 100644 --- a/test/mons/GhouliathTest.sol +++ b/test/mons/GhouliathTest.sol @@ -15,7 +15,6 @@ import {IEngine} from "../../src/IEngine.sol"; import {IEffect} from "../../src/effects/IEffect.sol"; import {IMoveSet} from "../../src/moves/IMoveSet.sol"; -import {StatBoosts} from "../../src/effects/StatBoosts.sol"; import {StandardAttackFactory} from "../../src/moves/StandardAttackFactory.sol"; import {ATTACK_PARAMS} from "../../src/moves/StandardAttackStructs.sol"; import {ITypeCalculator} from "../../src/types/ITypeCalculator.sol"; @@ -43,7 +42,6 @@ contract GhouliathTest is Test, BattleHelper { IMoveSet osteoporosis; WitherAway witherAway; PanicStatus panicStatus; - StatBoosts statBoosts; EternalGrudge eternalGrudge; StandardAttackFactory standardAttackFactory; DefaultMatchmaker matchmaker; @@ -68,8 +66,7 @@ contract GhouliathTest is Test, BattleHelper { panicStatus = new PanicStatus(); witherAway = new WitherAway(ITypeCalculator(address(typeCalc)), IEffect(address(panicStatus))); - statBoosts = new StatBoosts(); - eternalGrudge = new EternalGrudge(statBoosts); + eternalGrudge = new EternalGrudge(); matchmaker = new DefaultMatchmaker(engine); } diff --git a/test/mons/IblivionTest.sol b/test/mons/IblivionTest.sol index e8bd5659..6f5d4056 100644 --- a/test/mons/IblivionTest.sol +++ b/test/mons/IblivionTest.sol @@ -11,7 +11,6 @@ import {Engine} from "../../src/Engine.sol"; import {MonStateIndexName, Type} from "../../src/Enums.sol"; import {DefaultValidator} from "../../src/DefaultValidator.sol"; import {IEngine} from "../../src/IEngine.sol"; -import {StatBoosts} from "../../src/effects/StatBoosts.sol"; import {StandardAttackFactory} from "../../src/moves/StandardAttackFactory.sol"; import {ITypeCalculator} from "../../src/types/ITypeCalculator.sol"; import {BattleHelper} from "../abstract/BattleHelper.sol"; @@ -39,7 +38,6 @@ contract IblivionTest is Test, BattleHelper { MockRandomnessOracle mockOracle; TestTeamRegistry defaultRegistry; DefaultValidator validator; - StatBoosts statBoost; StandardAttackFactory attackFactory; DefaultMatchmaker matchmaker; @@ -59,7 +57,6 @@ contract IblivionTest is Test, BattleHelper { IEngine(address(engine)), DefaultValidator.Args({MONS_PER_TEAM: 2, MOVES_PER_MON: 4, TIMEOUT_DURATION: 10}) ); commitManager = new DefaultCommitManager(IEngine(address(engine))); - statBoost = new StatBoosts(); attackFactory = new StandardAttackFactory(ITypeCalculator(address(typeCalc))); matchmaker = new DefaultMatchmaker(engine); @@ -67,8 +64,8 @@ contract IblivionTest is Test, BattleHelper { baselight = new Baselight(); brightback = new Brightback(ITypeCalculator(address(typeCalc)), baselight); unboundedStrike = new UnboundedStrike(ITypeCalculator(address(typeCalc)), baselight); - loop = new Loop(baselight, statBoost); - renormalize = new Renormalize(baselight, statBoost, loop); + loop = new Loop(baselight); + renormalize = new Renormalize(baselight, loop); } // ============ Baselight Ability Tests ============ @@ -864,7 +861,7 @@ contract IblivionTest is Test, BattleHelper { * 5. Verify: stats remain at base values (no change from the failed removal attempt) */ function test_renormalizeClearsStatusEffectStatBoosts() public { - BurnStatus burnStatus = new BurnStatus(statBoost); + BurnStatus burnStatus = new BurnStatus(); MockEffectRemover effectRemover = new MockEffectRemover(); // Create a 0-damage attack that inflicts burn with 100% accuracy diff --git a/test/mons/InutiaTest.sol b/test/mons/InutiaTest.sol index 65ac03b7..947dc19b 100644 --- a/test/mons/InutiaTest.sol +++ b/test/mons/InutiaTest.sol @@ -12,7 +12,6 @@ import {DefaultValidator} from "../../src/DefaultValidator.sol"; import {IEngine} from "../../src/IEngine.sol"; import "../../src/Structs.sol"; import {IEffect} from "../../src/effects/IEffect.sol"; -import {StatBoosts} from "../../src/effects/StatBoosts.sol"; import {StandardAttackFactory} from "../../src/moves/StandardAttackFactory.sol"; import {ATTACK_PARAMS} from "../../src/moves/StandardAttackStructs.sol"; import {ITypeCalculator} from "../../src/types/ITypeCalculator.sol"; @@ -34,7 +33,6 @@ contract InutiaTest is Test, BattleHelper { MockRandomnessOracle mockOracle; TestTeamRegistry defaultRegistry; Interweaving interweaving; - StatBoosts statBoost; StandardAttackFactory attackFactory; DefaultMatchmaker matchmaker; @@ -44,8 +42,7 @@ contract InutiaTest is Test, BattleHelper { defaultRegistry = new TestTeamRegistry(); engine = new Engine(0, 0, 0); commitManager = new DefaultCommitManager(IEngine(address(engine))); - statBoost = new StatBoosts(); - interweaving = new Interweaving(statBoost); + interweaving = new Interweaving(); attackFactory = new StandardAttackFactory(ITypeCalculator(address(typeCalc))); matchmaker = new DefaultMatchmaker(engine); } @@ -141,7 +138,7 @@ contract InutiaTest is Test, BattleHelper { } function test_initialize() public { - Initialize initialize = new Initialize(statBoost); + Initialize initialize = new Initialize(); // Create a validator with 2 mons and 1 move per mon DefaultValidator validator = new DefaultValidator( diff --git a/test/mons/MalalienTest.sol b/test/mons/MalalienTest.sol index 569b76d6..e1105d64 100644 --- a/test/mons/MalalienTest.sol +++ b/test/mons/MalalienTest.sol @@ -21,7 +21,6 @@ import {MockRandomnessOracle} from "../mocks/MockRandomnessOracle.sol"; import {TestTeamRegistry} from "../mocks/TestTeamRegistry.sol"; import {TestTypeCalculator} from "../mocks/TestTypeCalculator.sol"; -import {StatBoosts} from "../../src/effects/StatBoosts.sol"; import {StandardAttackFactory} from "../../src/moves/StandardAttackFactory.sol"; import {ATTACK_PARAMS} from "../../src/moves/StandardAttackStructs.sol"; @@ -37,7 +36,6 @@ contract MalalienTest is Test, BattleHelper { TestTeamRegistry defaultRegistry; ActusReus actusReus; StandardAttackFactory attackFactory; - StatBoosts statBoosts; DefaultMatchmaker matchmaker; function setUp() public { @@ -46,8 +44,7 @@ contract MalalienTest is Test, BattleHelper { defaultRegistry = new TestTeamRegistry(); engine = new Engine(0, 0, 0); commitManager = new DefaultCommitManager(IEngine(address(engine))); - statBoosts = new StatBoosts(); - actusReus = new ActusReus(statBoosts); + actusReus = new ActusReus(); attackFactory = new StandardAttackFactory(ITypeCalculator(address(typeCalc))); matchmaker = new DefaultMatchmaker(engine); } @@ -180,7 +177,7 @@ contract MalalienTest is Test, BattleHelper { IEngine(address(engine)), DefaultValidator.Args({MONS_PER_TEAM: 2, MOVES_PER_MON: 1, TIMEOUT_DURATION: 10}) ); uint256[] memory moves = new uint256[](1); - TripleThink tripleThink = new TripleThink(statBoosts); + TripleThink tripleThink = new TripleThink(); moves[0] = uint256(uint160(address(tripleThink))); Mon memory mon = Mon({ stats: MonStats({ diff --git a/test/mons/NirvammaTest.sol b/test/mons/NirvammaTest.sol index 157a75e6..307a1cc6 100644 --- a/test/mons/NirvammaTest.sol +++ b/test/mons/NirvammaTest.sol @@ -13,7 +13,6 @@ import {Engine} from "../../src/Engine.sol"; import {MonStateIndexName, MoveClass, Type} from "../../src/Enums.sol"; import {IEngine} from "../../src/IEngine.sol"; import {IEffect} from "../../src/effects/IEffect.sol"; -import {StatBoosts} from "../../src/effects/StatBoosts.sol"; import {BurnStatus} from "../../src/effects/status/BurnStatus.sol"; import {FrostbiteStatus} from "../../src/effects/status/FrostbiteStatus.sol"; import {ZapStatus} from "../../src/effects/status/ZapStatus.sol"; @@ -40,7 +39,6 @@ contract NirvammaTest is Test, BattleHelper { TestTeamRegistry defaultRegistry; DefaultMatchmaker matchmaker; StandardAttackFactory attackFactory; - StatBoosts statBoosts; function setUp() public { typeCalc = new TestTypeCalculator(); @@ -50,7 +48,6 @@ contract NirvammaTest is Test, BattleHelper { commitManager = new DefaultCommitManager(IEngine(address(engine))); matchmaker = new DefaultMatchmaker(engine); attackFactory = new StandardAttackFactory(ITypeCalculator(address(typeCalc))); - statBoosts = new StatBoosts(); } function _ping(uint32 power) internal returns (StandardAttack) { @@ -270,7 +267,7 @@ contract NirvammaTest is Test, BattleHelper { // ===== Chronoffense ===== function _setupChronoffense() internal returns (bytes32 battleKey, Chronoffense chrono) { - chrono = new Chronoffense(statBoosts); + chrono = new Chronoffense(); StandardAttack ping = _ping(10); uint256[] memory nirvammaMoves = new uint256[](2); @@ -433,8 +430,8 @@ contract NirvammaTest is Test, BattleHelper { } function test_modalBolt_perModeDispatchAndTracking() public { - BurnStatus burn = new BurnStatus(statBoosts); - FrostbiteStatus frost = new FrostbiteStatus(statBoosts); + BurnStatus burn = new BurnStatus(); + FrostbiteStatus frost = new FrostbiteStatus(); ZapStatus zap = new ZapStatus(); (bytes32 battleKey, ModalBolt modalBolt) = _setupModalBolt(burn, frost, zap); @@ -458,8 +455,8 @@ contract NirvammaTest is Test, BattleHelper { } function test_modalBolt_lockoutBehavior() public { - BurnStatus burn = new BurnStatus(statBoosts); - FrostbiteStatus frost = new FrostbiteStatus(statBoosts); + BurnStatus burn = new BurnStatus(); + FrostbiteStatus frost = new FrostbiteStatus(); ZapStatus zap = new ZapStatus(); (bytes32 battleKey, ModalBolt modalBolt) = _setupModalBolt(burn, frost, zap); diff --git a/test/mons/PengymTest.sol b/test/mons/PengymTest.sol index 3ff3f6b5..bbeb2c5b 100644 --- a/test/mons/PengymTest.sol +++ b/test/mons/PengymTest.sol @@ -25,7 +25,6 @@ import {MockRandomnessOracle} from "../mocks/MockRandomnessOracle.sol"; import {TestTeamRegistry} from "../mocks/TestTeamRegistry.sol"; import {TestTypeCalculator} from "../mocks/TestTypeCalculator.sol"; -import {StatBoosts} from "../../src/effects/StatBoosts.sol"; import {StandardAttack} from "../../src/moves/StandardAttack.sol"; import {StandardAttackFactory} from "../../src/moves/StandardAttackFactory.sol"; import {ATTACK_PARAMS} from "../../src/moves/StandardAttackStructs.sol"; @@ -46,7 +45,6 @@ contract PengymTest is Test, BattleHelper { PostWorkout postWorkout; PanicStatus panicStatus; FrostbiteStatus frostbiteStatus; - StatBoosts statBoost; DefaultMatchmaker matchmaker; function setUp() public { @@ -61,8 +59,7 @@ contract PengymTest is Test, BattleHelper { attackFactory = new StandardAttackFactory(ITypeCalculator(address(typeCalc))); postWorkout = new PostWorkout(); panicStatus = new PanicStatus(); - statBoost = new StatBoosts(); - frostbiteStatus = new FrostbiteStatus(statBoost); + frostbiteStatus = new FrostbiteStatus(); matchmaker = new DefaultMatchmaker(engine); } diff --git a/test/mons/VolthareTest.sol b/test/mons/VolthareTest.sol index 3f222e21..323ef9a2 100644 --- a/test/mons/VolthareTest.sol +++ b/test/mons/VolthareTest.sol @@ -21,7 +21,6 @@ import {MockRandomnessOracle} from "../mocks/MockRandomnessOracle.sol"; import {TestTeamRegistry} from "../mocks/TestTeamRegistry.sol"; import {TestTypeCalculator} from "../mocks/TestTypeCalculator.sol"; -import {StatBoosts} from "../../src/effects/StatBoosts.sol"; import {ZapStatus} from "../../src/effects/status/ZapStatus.sol"; import {Overclock} from "../../src/effects/battlefield/Overclock.sol"; @@ -45,7 +44,6 @@ contract VolthareTest is Test, BattleHelper { DefaultValidator validator; PreemptiveShock preemptiveShock; Overclock overclock; - StatBoosts statBoost; StandardAttackFactory attackFactory; DefaultMatchmaker matchmaker; @@ -58,8 +56,7 @@ contract VolthareTest is Test, BattleHelper { IEngine(address(engine)), DefaultValidator.Args({MONS_PER_TEAM: 2, MOVES_PER_MON: 0, TIMEOUT_DURATION: 10}) ); commitManager = new DefaultCommitManager(IEngine(address(engine))); - statBoost = new StatBoosts(); - overclock = new Overclock(statBoost); + overclock = new Overclock(); preemptiveShock = new PreemptiveShock(ITypeCalculator(address(typeCalc))); attackFactory = new StandardAttackFactory(ITypeCalculator(address(typeCalc))); matchmaker = new DefaultMatchmaker(engine);