diff --git a/CLAUDE.md b/CLAUDE.md index 5a056ce3..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`. @@ -254,8 +279,10 @@ playerData[address] (1 slot per player): bit 254 isWhitelistedAsOpponent (admin-set; replaces a separate mapping) bit 253 isHardCpu (only meaningful when bit 254 is set) bits 250-252 streakDay (1..STREAK_FLAT_BONUS_MAX; 0 = no streak yet) + bits 224-249 (reserved) bits 192-223 lastQuestCompletedDay (uint32 calendar day) - bits 128-159 lastFirstGameTimestamp (uint32 seconds since epoch) + bits 160-191 lastSeenTimestamp (uint32 seconds; last battle of ANY kind — drives streak grace/reset) + bits 128-159 lastFirstGameTimestamp (uint32 seconds; last streak-bonus game — gates the 24h cooldown) bits 0-127 pointsBalance (uint128) packedExpForMon[player][monId / 16]: 16 mons × 16 bits each, capped at 65535. @@ -263,10 +290,15 @@ facetData[player][monId / 16]: 16 mons × 16 bits each (bits 0-11 unlockedBitmap, bits 12-15 assignedFacetId). ``` -Streak is timestamp-driven (not calendar-day): a battle counts as "first of day" -when ≥24h have passed since `lastFirstGameTimestamp`. A gap >36h -(`STREAK_GRACE_WINDOW`) resets `streakDay` to 1; otherwise it ratchets up -toward the cap of `STREAK_FLAT_BONUS_MAX` (= 5). +Streak is timestamp-driven (not calendar-day): a battle qualifies for the streak bonus +when ≥24h have passed since `lastFirstGameTimestamp` (the last *bonus-earning* game). On a +qualifying battle the ratchet-vs-reset decision is measured from `lastSeenTimestamp` (the +last battle of *any* kind, advanced every battle): a gap >36h (`STREAK_GRACE_WINDOW`) of +genuine inactivity resets `streakDay` to 1, otherwise it ratchets up toward the cap of +`STREAK_FLAT_BONUS_MAX` (= 5). Splitting the two anchors is deliberate — measuring the +reset from the bonus anchor instead would strand players who play slightly more often than +once per 24h (their sub-24h plays advance no anchor, so the next day reads a phantom ~46h +gap and resets the streak forever). Both per-mon mappings share the same 16-mon bucketing so `_applyExpAndFacetDraws` walks the team in one pass and coalesces SSTOREs by bucket. @@ -293,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. @@ -417,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/OPT_PLAN.md b/OPT_PLAN.md deleted file mode 100644 index ad353844..00000000 --- a/OPT_PLAN.md +++ /dev/null @@ -1,472 +0,0 @@ -# OPT_PLAN — Batched Execute Gas Optimization - -## 1. Goal - -Amortize per-turn cold-storage access in `Engine.execute()` by: -1. Submitting each turn's signed moves on-chain immediately to a per-turn buffer (no execute). -2. Executing **all currently buffered turns** in one tx with engine state held in **transient shadow storage**, flushed to persistent storage once at the end. - -Secondary goal: route `Engine` state access through helpers so the single-turn path can also use the shadow layer. - ---- - -## 2. Mechanism - -### 2.1 Per-turn submission (PvP) - -`SignedCommitManager.submitTurnMoves(battleKey, TurnSubmission entry)`: -- Uniform shape every turn: **two EIP-712 signatures** (committer + revealer), committer preimage in calldata. Roles derived from `turnId % 2` (matching `getCommitAuthForDualSigned`). -- Switch turns use the same shape. The non-acting player signs a `NO_OP` (move 126); engine ignores their half at batch time using the live `playerSwitchForTurnFlag`. -- Manager hashes committer preimage, verifies committer sig over `SignedCommit{committerMoveHash, …}` and revealer sig over `DualSignedReveal{committerMoveHash, …}`, writes to `moveBuffer[storageKey][turnId]`. **No execute runs.** -- Updates `lastSubmitTimestamp` for timeout tracking. - -**Why two sigs.** Without a committer sig, a malicious revealer could pick any preimage `P*`, sign `DualSignedReveal{committerMoveHash: keccak(P*), …}`, and submit unilaterally — the contract would play `P*` as the committer's move with no committer involvement. Today's `executeWithDualSignedMoves` blocks this only via `msg.sender == committer`, which is fragile and not relayer-friendly. Phase 0 (§9) lifts the same fix into the existing function before any batching ships, so both paths share one security model. - -### 2.2 Per-batch execute - -`Engine.executeBatch(battleKey)`: -- Anyone can call (sigs were checked at submission). -- Reads every currently buffered entry `[startTurn, startTurn + numTurnsBuffered)`, runs each in sequence inside transient shadow storage, flushes once at end. -- The **transient mirror** of `turnId` advances inside the loop. Persistent `BattleData.turnId` advances only during the final flush. -- Batch execution always consumes the full pending buffer. There is no partial-batch mode in v1. -- Processed buffer slots are not cleared — the unbounded mapping leaves them for on-chain replay. Slot reuse across battles comes from `MappingAllocator`. - -### 2.3 Fallback / stalls - -Fully separate write paths. Legacy `DefaultCommitManager.commitMove`/`revealMove` writes `config.p0Move` etc. and triggers `execute()` immediately; the batched path never reads that storage. A battle can alternate between modes turn-by-turn. Timeout via `Engine.end()` covers full stalls. - ---- - -## 3. Buffer layout - -One 256-bit slot per turn: - -```solidity -// [ p0MoveIndex (8) | p0ExtraData (16) | p0Salt (104) | p1MoveIndex (8) | p1ExtraData (16) | p1Salt (104) ] -struct PackedTurnEntry { - uint8 p0MoveIndex; - uint16 p0ExtraData; - uint104 p0Salt; - uint8 p1MoveIndex; - uint16 p1ExtraData; - uint104 p1Salt; -} - -mapping(bytes32 storageKey => mapping(uint64 turnId => PackedTurnEntry)) moveBuffer; -``` - -Steady-state cost per turn: 1 SSTORE (5k, nonzero→nonzero from prior battle's slot reuse) + 1 SLOAD inside batch (2.1k) = ~7.1k. - -Buffer validity is tracked by two packed `uint8` counters: -- `numTurnsBuffered`: number of currently pending buffered turns. -- `numTurnsExecuted`: cumulative number of buffered turns consumed for the current battle/storage key. - -Submit rule: -- If `numTurnsBuffered == 0`, the manager first syncs `numTurnsExecuted` to the engine's current `BattleData.turnId`. This keeps the batched buffer compatible with legacy single-turn execution when the battle alternates modes. -- A new entry must have `entry.turnId == numTurnsExecuted + numTurnsBuffered`. -- After storing the entry, increment `numTurnsBuffered`. - -Execute rule: -- `executeBatch` requires `numTurnsBuffered > 0`. -- It attempts the full pending range of `numTurnsBuffered` turns, starting at `numTurnsExecuted`. -- At flush, persistent `BattleData.turnId` becomes the shadowed turn id, `numTurnsExecuted += executedTurns`, and `numTurnsBuffered = 0`. - -This means stale slots from a prior battle or earlier batch cannot be treated as valid pending moves: only the contiguous range described by `(numTurnsExecuted, numTurnsBuffered)` is live. - -**Width changes (clean break):** -- `extraData`: 240 → 16 bits. Audit confirmed all production consumers read ≤8 bits. Narrow `IMoveSet.move()`'s `extraData` param to `uint16`; repack test helpers (`_packStatBoost`, `StatBoostsMove` mock). -- `Salt`: 256 → 104 bits. 2^104 brute-force resistance is sufficient for the seconds-to-minutes commit-reveal window. - ---- - -## 4. API - -### 4.1 Submission - -```solidity -struct TurnSubmission { - uint64 turnId; - // Committer preimage: - uint8 committerMoveIndex; - uint16 committerExtraData; - uint104 committerSalt; - // Revealer reveal: - uint8 revealerMoveIndex; - uint16 revealerExtraData; - uint104 revealerSalt; - // Sigs: - bytes committerSig; // EIP-712 over SignedCommit{committerMoveHash, battleKey, turnId} - bytes revealerSig; // EIP-712 over DualSignedReveal -} - -// Existing SignedCommitLib struct, reused unchanged. -struct SignedCommit { - bytes32 moveHash; - bytes32 battleKey; - uint64 turnId; -} - -struct DualSignedReveal { - bytes32 battleKey; - uint64 turnId; - bytes32 committerMoveHash; // keccak(committerMoveIndex, committerSalt, committerExtraData) - uint8 revealerMoveIndex; - uint16 revealerExtraData; - uint104 revealerSalt; -} - -function submitTurnMoves(bytes32 battleKey, TurnSubmission calldata entry) external; -``` - -Manager flow: -1. Battle is in dual-signed mode and not over. -2. `entry.turnId` equals next append position. -3. Derive `(committer, revealer)` from `turnId % 2`. -4. `committerMoveHash = keccak(committerMoveIndex, committerSalt, committerExtraData)`. -5. Recover `committerSig` over `SignedCommit{committerMoveHash, battleKey, turnId}`; require equality with `committer`. -6. Recover `revealerSig` over `DualSignedReveal{committerMoveHash, …}`; require equality with `revealer`. -7. Map fields to `(p0, p1)` by parity; SSTORE `PackedTurnEntry`. - -### 4.2 Batch execute - -```solidity -function executeBatch(bytes32 battleKey) external; -``` - -1. Read `startTurn = numTurnsExecuted`; require `numTurnsBuffered > 0`. -2. Hydrate shadow. -3. For each pending buffered turn: read buffer slot, populate per-turn move/salt transient, run `_executeOneTurn()`, break on game-over. -4. Flush shadow → storage. -5. Set `numTurnsBuffered = 0` and increment `numTurnsExecuted` by the number of turns actually executed. - ---- - -## 5. Transient shadow storage - -### 5.1 Shadowed state - -| Storage | Shadow form | -|---|---| -| `MonState` (per mon) | Per-`(playerIndex, monIndex)` mirror, lazy-loaded. Dirty bit per slot. | -| `koBitmaps` (16 bits in `BattleConfig` slot 2) | `uint16` mirror, loaded flag. | -| `winnerIndex` / `prevPlayerSwitchForTurnFlag` / `playerSwitchForTurnFlag` / `activeMonIndex` / `turnId` / `lastExecuteTimestamp` | Single packed `uint256` mirror. | -| Effect list slots (`globalEffects[i]`, `pXEffects[i]`) | Fixed numeric transient keys, mirrors the full `EffectInstance` (`effect`, `stepsBitmap`, `data`). | -| `packedP0EffectsCount` / `packedP1EffectsCount` / `globalEffectsLength` | Three small mirrors, flushed with effect-list shadow. | -| `globalKV[storageKey][key]` | Per-`key` mirror, lazy-loaded. | -| `BattleConfig.p0Move` / `p1Move` / salts | Re-populated per sub-turn from buffer slot. | - -Hydrate strategy: -- **Eager**: `BattleData` slot 1 + `BattleConfig` slot 2 (always touched). -- **Lazy**: `MonState`, effect slots/counts, `globalKV` (sparse — pay only for slots touched). - -Loaded-flag strategy: -- **Bitmap** for fixed-shape slots (MonState, effects, slot-2 packed fields). -- **Per-key transient hash-set** for `globalKV` (dynamic keys). - -### 5.1.1 Effect shadow key layout - -Effects are bounded and already partitioned, so use numeric transient keys and bitmaps instead of hashed keys. - -Assumptions: -- Up to 8 mons per side. -- Up to 8 effects per mon. -- Up to 16 global effects. - -Flat effect-slot keys: - -```solidity -uint256 constant EFFECTS_PER_MON = 8; -uint256 constant MONS_PER_SIDE = 8; -uint256 constant MAX_GLOBAL_EFFECTS = 16; - -uint256 constant EFFECT_P0_OFFSET = 0; // keys 0..63 -uint256 constant EFFECT_P1_OFFSET = 64; // keys 64..127 -uint256 constant EFFECT_GLOBAL_OFFSET = 128; // keys 128..143 - -function _effectShadowKey(uint256 targetIndex, uint256 monIndex, uint256 localEffectIndex) - internal - pure - returns (uint256) -{ - if (targetIndex == 2) return EFFECT_GLOBAL_OFFSET + localEffectIndex; - uint256 sideOffset = targetIndex == 0 ? EFFECT_P0_OFFSET : EFFECT_P1_OFFSET; - return sideOffset + monIndex * EFFECTS_PER_MON + localEffectIndex; -} -``` - -For player effects, `localEffectIndex` is `0..7` and the storage slot remains -`_getEffectSlotIndex(monIndex, localEffectIndex)`. For global effects, `monIndex` is ignored and -`localEffectIndex` is the global effect index. - -Loaded/dirty bitmaps: - -```solidity -uint256 transient effectSlotLoadedBitmap; -uint256 transient effectSlotDirtyBitmap; - -function _effectBit(uint256 key) internal pure returns (uint256) { - return 1 << key; -} -``` - -Shadow values can use numeric transient key regions, one region per `EffectInstance` field: - -```solidity -uint256 constant T_EFFECT_ADDR_BASE = 0x1000; -uint256 constant T_EFFECT_STEPS_BASE = 0x2000; -uint256 constant T_EFFECT_DATA_BASE = 0x3000; - -// tstore(T_EFFECT_ADDR_BASE + key, address(effect)) -// tstore(T_EFFECT_STEPS_BASE + key, stepsBitmap) -// tstore(T_EFFECT_DATA_BASE + key, data) -``` - -Counts use a separate compact key space: - -```solidity -// 0 = globalEffectsLength -// 1..8 = p0 mon counts -// 9..16 = p1 mon counts -function _effectCountKey(uint256 targetIndex, uint256 monIndex) internal pure returns (uint256) { - if (targetIndex == 2) return 0; - if (targetIndex == 0) return 1 + monIndex; - return 9 + monIndex; -} -``` - -Use separate loaded/dirty bitmaps for counts. Flush scans only dirty effect-slot bits in `0..143` and dirty count bits in `0..16`, so flush work is bounded and independent of calldata shape. - -### 5.2 Helper boundary - -Mirrored helpers in `Engine.sol`: - -```solidity -function _shadowReadMonState(BattleConfig storage cfg, uint256 playerIndex, uint256 monIndex) internal returns (MonState memory); -function _shadowWriteMonState(uint256 playerIndex, uint256 monIndex, MonState memory state) internal; -function _shadowReadKV(bytes32 storageKey, uint64 key) internal returns (uint192); -function _shadowWriteKV(bytes32 storageKey, uint64 key, uint192 value) internal; -function _shadowReadEffectSlot(uint256 effectList, uint256 monIndex, uint256 slotIndex) internal returns (EffectInstance memory); -function _shadowWriteEffectSlot(uint256 effectList, uint256 monIndex, uint256 slotIndex, EffectInstance memory eff) internal; -function _shadowReadEffectCount(uint256 effectList, uint256 monIndex) internal returns (uint256); -function _shadowWriteEffectCount(uint256 effectList, uint256 monIndex, uint256 count) internal; -``` - -When `_shadowActive == false`, helpers SLOAD/SSTORE storage directly. When `true`, they read/write the transient mirror with lazy-load and dirty-bit bookkeeping. - -External `IEngine` writers (`updateMonState`, `dealDamage`, `addEffect`, `removeEffect`, `editEffect`, `setGlobalKV`, `switchActiveMon`, `dispatchStandardAttack`, `setMove`) and external readers (`getMonStateForBattle`, `getEffects`, `getGlobalKV`, etc.) all route through these helpers. The `battleKeyForWrite != bytes32(0)` gate stays. - -Effect-list shadowing must preserve these same-batch visibility rules: -- `addEffect` writes a full shadow `EffectInstance` and increments the shadow count, so later effect loops / `getEffects` calls in the same batch see the new effect. -- `editEffect` updates shadow `data`; later hooks see the edited value. -- `removeEffect` tombstones the shadow `effect` address and keeps the slot index stable; later loops skip it. -- `_handleEffects` loads counts and slots from shadow, not storage, and keeps the existing `effectsDirtyBitmap` pattern so effects added while iterating can extend the current loop when today’s logic would. -- `getEffects` builds its return arrays from shadow while `_shadowActive == true`, so external moves/effects that inspect active effects observe the live batch state. - -### 5.3 Batch loop - -``` -executeBatch(battleKey): - storageKey = _getStorageKey(battleKey) - storageKeyForWrite = storageKey - battleKeyForWrite = battleKey - _shadowActive = true - - _hydrateBattleData(battleKey) - _hydrateConfigSlot2(storageKey) - - startTurn = numTurnsExecuted - turnsToExecute = numTurnsBuffered - for t in [startTurn .. startTurn + turnsToExecute): - bufferEntry = _readMoveBufferSlot(storageKey, t) - _populateTurnMoveTransient(bufferEntry) - _executeOneTurn() - if winnerIndex != 2: break - _resetPerTurnTransients() - - _flushBattleData(battleKey) - _flushConfigSlot2(storageKey) - _flushDirtyMonStates(storageKey) - _flushDirtyEffectSlots(storageKey) - _flushDirtyGlobalKV(storageKey) - _flushBufferCounters(executedTurns) - - _shadowActive = false -``` - -Per sub-turn, `tempRNG = keccak(p0Salt, p1Salt)` (or single signed salt for switch turns). Engine hooks (`onRoundStart`, `onRoundEnd`) fire per sub-turn and read shadow state via the routed getters. - ---- - -## 6. Forced switches and game-over - -### 6.1 Forced switch (KO without game-over) - -Both players sign for every turn. The non-acting player signs `NO_OP`. At batch time, the engine reads the live `playerSwitchForTurnFlag` (cheap — in shadow state) and dispatches: -- `flag == 2`: process both halves. -- `flag == 0`: process p0 only, ignore p1's NO_OP. -- `flag == 1`: mirror. - -A player who maliciously signs a non-NO_OP on a turn they shouldn't act has bound themselves cryptographically, but the engine ignores the move. A player who refuses to sign stalls the batched flow; legacy single-turn paths remain as fallback. - -Submission validates only cheap invariants (battle exists, not over at last flush, append position, sig). It does **not** project `playerSwitchForTurnFlag`, since that would require replaying every unprocessed turn. - -### 6.2 Game-over mid-batch - -`_executeInternal` already breaks when `winnerIndex != 2`. Same check stops the batch loop. Because batch execution consumes the full pending buffer, any unexecuted buffered entries after game-over remain in storage for replay but are no longer live; `numTurnsBuffered` is set to zero at flush. - -### 6.3 Status-induced skip-turn - -`shouldSkipTurn` already auto-clears in `_handleMove`. No special batch handling. - ---- - -## 7. CPU mode (trusted-state batched) - -Same per-turn buffer + `executeBatch` as PvP. CPU manager packs `(Alice move, computed CPU move)` into the same `PackedTurnEntry` layout. **Zero engine changes.** - -### 7.1 Trusted state hint - -Alice supplies the projected post-prior-turn `CPUContext` in calldata. Not verified. Lying never benefits Alice — it makes the CPU's chosen move suboptimal against her, which she absorbs. This replaces the dozen-plus cold SLOADs `engine.getCPUContext(battleKey)` does today with a single calldata struct. - -### 7.2 No signature - -Alice calls directly from her wallet. Manager checks `msg.sender == alice` (same as today's `CPUMoveManager.selectMove`). The tx is the proof — no relay path needed for a single-human flow. - -### 7.3 Off-chain protocol - -Each turn, locally on Alice's client: -1. Hold current `CPUContext`-shaped state. Turn 0 = post-`startBattle` state; later turns = output of last local sim. -2. Pick Alice's move. -3. Run the transpiled engine locally to produce the post-turn state, used as next turn's hint. -4. Submit on-chain with the **current-turn** hint. - -### 7.4 Submission - -```solidity -function selectMoveWithStateHint( - bytes32 battleKey, - uint8 aliceMoveIndex, - uint16 aliceExtraData, - uint104 aliceSalt, - CPUContext calldata projectedState -) external; -``` - -1. Read/sync the next append `turnId` from `numTurnsExecuted + numTurnsBuffered` using the same buffer counter rules as PvP. -2. Require `msg.sender == alice`. -3. Route on `projectedState.playerSwitchForTurnFlag` (single-player vs two-player CPU branch). -4. `ICPU(cpuAddr).calculateMove(projectedState, aliceMoveIndex, aliceExtraData)` → `(cpuMove, cpuExtra)`. CPU reads from calldata only. -5. Derive CPU salt: `uint104(uint256(keccak256(abi.encode(block.timestamp, aliceSalt, turnId))))`. Emit `CPUTurnSalt(battleKey, turnId, timestamp)` so off-chain replay can reconstruct it. `turnId` in the hash prevents collision when Alice submits multiple CPU turns in the same block. -6. Pack into `PackedTurnEntry` and SSTORE into `moveBuffer[storageKey][turnId]`. - -`executeBatch` is shared with PvP — the engine doesn't know whether the buffer came from PvP or CPU submissions. - -### 7.5 Coexistence - -Battles select via the `moveManager` they're started with: -- `signedCommitManager` (extended) → PvP batched -- `cpuMoveManager` (extended) → CPU batched -- Today's unmodified managers → legacy single-turn paths - -Today's `CPUMoveManager.selectMove` stays callable for any battle that doesn't opt into batching. - ---- - -## 8. Migration - -Add new entry points alongside existing ones. No "batch mode" flag on a battle — `executeBatch` works on any battle that has buffered turns. - -Touched contracts: -- `Engine.sol`: `executeBatch` + shadow-transient layer + helper routing + flag-based per-turn dispatch. -- `IEngine.sol`: new function signatures. -- `SignedCommitManager.sol`: `submitTurnMoves` (sharing existing EIP-712 domain). -- `CPUMoveManager.sol`: `selectMoveWithStateHint`. -- `IMoveSet.sol`: narrow `extraData` to `uint16`. ~40 mon files take mechanical edits. - -Validator/legality is unchanged: signature recovery proves player intent (or `msg.sender == alice` for CPU); state-dependent illegality silently no-ops in `_handleMove`. Timeout reads `lastSubmitTimestamp` and `lastExecuteTimestamp` — whichever is more recent. - ---- - -## 9. Phased rollout - -**Phase 0 — Dual-sig security fix (preflight, ships first, independent of batching).** The existing `executeWithDualSignedMoves` relies on `msg.sender == committer` as the committer's binding. Without that check, a malicious revealer could sign `DualSignedReveal{committerMoveHash: keccak(P*), …}` for any preimage `P*` they choose and submit unilaterally — the contract would happily compute `committerMoveHash = keccak(P*)`, recover the revealer's sig, and play `P*` as the committer's move. The check is load-bearing today, but it's also fragile: any future evolution of the flow that drops or weakens it (relayers, batching, alt entry points) silently re-opens the hole. - -Fix: require an explicit committer signature over the existing `SignedCommit{moveHash, battleKey, turnId}` struct (already used by `commitWithSignature`). - -- Modify `executeWithDualSignedMoves` to take an additional `bytes calldata committerSignature` parameter. -- Recover `committerSignature` over `SignedCommit{committerMoveHash, battleKey, turnId}`; require equality with `committer`. -- Drop the `msg.sender == committer` check; the function becomes relayer-friendly (anyone with both sigs + the preimage can submit). -- Breaking signature change. Update all callers (tests, `BattleHelper`, anything off-chain that calls this function) in the same PR. No deployed callers in production yet. -- New tests: missing committer sig reverts; wrong committer signer reverts; submission by a third party with both valid sigs succeeds; revealer cannot submit a self-chosen committer preimage (regression). - -This phase ships before any batching work. It hardens the existing flow on its own merits and unifies the security model so the batched path in Phase 2 inherits the same shape (§4.1) without surprises. - -**Phase 0.1 — Instrumentation refresh.** `test/BatchInstrumentationTest.sol` already wires `vm.startStateDiffRecording` for the clean damage-trade case. Add scenarios: effect-heavy turn (status DOT + StatBoosts active), forced-switch turn, multi-mon turn. Lock final batch-size guidance. - -**Phase 0.5 — Helper extraction (no behavior change).** Replace direct `MonState`/`globalKV`/effect-data SLOAD/SSTORE in `Engine.sol` with §5.2 helpers, with `_shadowActive` permanently `false`. Snapshot diff should be roughly flat. - -**Phase 1 — Single-turn shadow.** Implement transient mirrors + lazy-load/dirty-flag bookkeeping. Wire helpers to consult `_shadowActive`. Add `executeShadowed(bytes32 battleKey)` that does `execute()`'s work inside the shadow layer (hydrate → run one turn → flush). Existing test suite should pass against it. B=1 will be slightly *worse* than today's `execute()` due to bookkeeping overhead; expected. - -**Phase 2 — PvP per-turn submission + batch execute.** Extend `SignedCommitManager` with `submitTurnMoves`. Add per-turn move buffer mapping and `numTurnsBuffered` / `numTurnsExecuted` counters. Add `Engine.executeBatch` with flag-based dispatch (§6.1), requiring execution of all currently buffered turns. Equivalence tests + gas snapshots. - -**Phase 2.5 — CPU mode.** Extend `CPUMoveManager` with `selectMoveWithStateHint` (§7.4). Reuse Phase-2 buffer + `executeBatch`. Equivalence test: 24-turn CPU game via legacy `selectMove × 24` vs `selectMoveWithStateHint × 24 + executeBatch × 3` produces identical end state. - -**Phase 3 — Transpiler parity (deferred).** Local TS engine continues running single-turn `execute()` against hydrated state. Eventual batched parity desired but not v1. - -**Phase 4 — Optional cutover.** If `executeShadowed` (B=1) is gas-neutral or better, consider redirecting. Otherwise keep the legacy fast path. - ---- - -## 10. Test surface - -New `BattleHelper` helpers: -- `_submitTurnMoves(battleKey, turnId, p0Move, p1Move)` — synthesizes signatures and calls `submitTurnMoves`. -- `_executeBuffered(battleKey)` — calls `executeBatch` for all currently buffered turns. - -New tests: -- **Submission validation**: wrong committer signer, wrong revealer signer (parity), wrong turnId, wrong battleKey, replay, committer preimage hash mismatch, missing committer sig (regression for unilateral-revealer attack), missing revealer sig. -- **Buffer ordering**: out-of-order rejected; batch executes in turnId order. -- **Switch-turn dispatch**: `flag == 0` and `flag == 1` ignore the non-acting half; non-acting player signing a non-NO_OP has no effect. -- **Equivalence (core gate)**: B turns through legacy path vs `submitTurnMoves × B + executeBatch` produce byte-identical state. -- **Game-over short-circuit** mid-batch: remaining stored buffer entries are no longer live after `numTurnsBuffered` resets to zero. -- **Effect lifecycle parity**: BurnStatus DOT over a 4-turn batch matches per-turn execution. -- **Multi-batch in one battle**: submit 4 then execute, submit 4 then execute, submit 6 then execute — `turnId`, `numTurnsBuffered`, and `numTurnsExecuted` advance correctly. -- **Shadow flush**: post-batch `getMonStateForBattle` / `getGlobalKV` / `getEffects` match equivalent per-turn execution. -- **CPU equivalence**: 24-turn CPU game via legacy vs trusted-state batched produces identical end state. - -Existing tests stay untouched — they use the legacy entry points. - -Targeted equivalence tests for v1; differential fuzzing as a follow-up. - -### 10.1 Effect-shadow correctness tests - -Correctness target: for any scripted turn sequence, batched execution produces the same final battle state and the same mid-execution observations as legacy single-turn execution would produce after each turn. - -Use a small purpose-built mock effect/move suite instead of relying only on production mons: - -- `AddEffectOnRun`: during a hook, calls `engine.addEffect` to append another effect to the same list. -- `EditSelfOnRun`: calls `engine.editEffect` on its own slot and increments a counter in `data`. -- `RemoveSelfOnRun`: returns `removeAfterRun = true`. -- `RemoveOtherOnRun`: calls `engine.removeEffect` for another slot. -- `InspectEffectsOnRun`: calls `engine.getEffects` during the batch and records/validates the visible list. -- `SingletonAbilityRegister`: exercises ability-triggered self-registration through `_activateAbility`. - -Required cases: - -- **Add visibility:** an effect added on sub-turn `T` is visible to `getEffects` and to `_handleEffects` on sub-turn `T+1`. -- **Add during iteration:** when an effect adds another effect while `_handleEffects` is iterating, the shadow count + `effectsDirtyBitmap` behavior matches legacy storage behavior. -- **Edit visibility:** data written by `editEffect` or returned from a hook is visible to later hooks in the same batch. -- **Remove visibility:** a removed effect is tombstoned in shadow, skipped by later `_handleEffects`, and omitted from `getEffects`, with slot indices preserved. -- **OnRemove callback:** removing an effect with `OnRemove` sees shadowed active mon indices and can perform shadowed writes. -- **Singleton/idempotency:** ability self-registration checks the shadow list, so repeated activation in one batch does not duplicate an effect. -- **Global effects:** repeat add/edit/remove/getEffects cases for global effects, including index `15` to cover the `MAX_GLOBAL_EFFECTS = 16` boundary. -- **Per-player boundaries:** cover p0 mon 0, p0 mon 7, p1 mon 0, and p1 mon 7 to exercise numeric key offsets. -- **Capacity:** adding a ninth effect to one mon or a seventeenth global effect fails/no-ops according to the chosen production behavior, and never corrupts adjacent shadow keys. -- **Flush parity:** after batch flush, storage `EffectInstance` slots and counts match the legacy run byte-for-byte, including tombstones. - -Test shape: - -1. Start two identical battles. -2. Run the same scripted turns through legacy single-turn execution in battle A. -3. Submit all turns, execute one full batch in battle B. -4. Compare `BattleData`, mon states, `globalKV`, `getEffects` for all relevant lists, and any mock-recorded observations. diff --git a/STRONG_CPU.md b/STRONG_CPU.md deleted file mode 100644 index 64cb6673..00000000 --- a/STRONG_CPU.md +++ /dev/null @@ -1,359 +0,0 @@ -# BetterCPU Aggressive Mode + Difficulty Escalation - -## Context - -`BetterCPU` (`src/cpu/BetterCPU.sol`) is a heuristic CPU whose decision tree is identical for every player it has ever faced. We want it to *learn* from a player within a session: if a human beats it, the next battle the CPU plays harder — picks a lead that punishes the player's lead, raises its tolerance for incoming damage before bailing out, and on a KO swaps to the best offensive matchup instead of the safest sponge. The CPU cycles through three escalating tiers — **Hell** (baseline), **Tartarus** (harder), **Diyu** (hardest) — and naturally drops back down again after the player breaks the streak. State is keyed by the human's address. - ---- - -## Part 1 — Mode ladder (Hell / Tartarus / Diyu) - -### Storage (BetterCPU.sol) - -One slot per human address. Use the full 256 bits to leave headroom for Part 2. - -``` -mapping(address => uint256) public playerState; - -// bits 0-7 : 8-bit rolling history (1 = CPU win, 0 = CPU loss); LSB = newest -// bits 8-9 : mode (0 HELL, 1 TARTARUS, 2 DIYU) -// bit 10 : diyuPriorLoss flag (CPU has already lost once in current DIYU stint) -// bits 11-14 : history length, capped at 8 -// bits 15-...: reserved (Part 2 uses bits 15-22) -``` - -### Mode constants - -``` -uint8 constant MODE_HELL = 0; -uint8 constant MODE_TARTARUS = 1; -uint8 constant MODE_DIYU = 2; - -uint256 constant SEVERE_DAMAGE_PCT_HELL = 30; -uint256 constant SEVERE_DAMAGE_PCT_TARTARUS = 50; -uint256 constant SEVERE_DAMAGE_PCT_DIYU = 60; // +10% over TARTARUS (Part 2, H3) -``` - -(`SEVERE_DAMAGE_PCT` constant at line 36 is removed; threshold becomes a parameter to `_evaluateDefensiveSwitch`, picked per mode.) - -### End-of-turn hook (CPUMoveManager.sol) - -Add to `CPUMoveManager`: - -``` -function _afterTurn(bytes32 battleKey, address p0) internal virtual {} -``` - -Refactor `selectMove` so all three execute paths fall through to a single `_afterTurn(battleKey, p0)` call before returning. The early returns above the dispatch (`msg.sender != p0`, `winnerIndex != 2`) skip the hook by construction, so when `_afterTurn` runs, the pre-execute `winnerIndex` was 2 — no "already recorded" guard needed. - -Cost: ~10 gas/turn for every CPU subclass — accepted. Verify `BetterCPUInlineGasTest.sol` and `EngineGasTest.sol` snapshots after. - -### State machine (BetterCPU._afterTurn) - -``` -function _afterTurn(bytes32 battleKey, address p0) internal override { - (, uint8 winnerIndex,) = ENGINE.getCPURouteContext(battleKey); - if (winnerIndex == 2) return; - _recordResult(battleKey, p0, winnerIndex == 1); // CPU is p1 -} -``` - -Transition rules (full ladder — symmetric promote/demote with one sticky bit in DIYU): - -``` -HELL + CPU won → HELL -HELL + CPU lost → TARTARUS - -TARTARUS + CPU won → HELL -TARTARUS + CPU lost → DIYU (diyuPriorLoss = 0) - -DIYU + CPU won → TARTARUS (diyuPriorLoss = 0) -DIYU + CPU lost, diyuPriorLoss == 0 → DIYU (diyuPriorLoss = 1) -DIYU + CPU lost, diyuPriorLoss == 1 → HELL (diyuPriorLoss = 0) -``` - -DIYU is not a sink: two cumulative wins from a DIYU starting point bring the CPU back to HELL through TARTARUS, and two losses in DIYU also reset to HELL (the player has clearly figured the CPU out — cool off and try a fresh angle next time). One DIYU loss earns the CPU a second crack at the same tier; the `diyuPriorLoss` bit is the cheapest way to express that without a multi-bit consecutive-loss counter. - -`winnerIndex` convention confirmed at Engine.sol:769 (`(winner == data.p0) ? 0 : 1`). - -### Mode-aware behavior - -Read mode once at the top of `calculateMove`: - -``` -uint8 mode = uint8((playerState[ctx.p0] >> 8) & 0x3); -bool aggressive = (mode == MODE_TARTARUS || mode == MODE_DIYU); -bool diyu = (mode == MODE_DIYU); -``` - -Plumb `aggressive` into the three branch helpers: - -- **`_selectLead(battleKey, opponentMonExtraData, switches, aggressive)`** (line 383) - Replace `score = offensiveScore - defensiveScore` with - `score = aggressive ? (3 * offensiveScore - defensiveScore) : (offensiveScore - defensiveScore)`. - -- **`_selectBestSwitch(battleKey, opponentMonIndex, opponentMoveIndex, switches, aggressive)`** (line 444) - When `aggressive`, replace the "least damage taken" loop with an offensive-matchup loop. Extract a helper `_offensiveMatchupScore(battleKey, candidateMonIndex, opponentMonIndex)` mirroring the offensive half of `_selectLead` (sum of `TYPE_CALC.getTypeEffectiveness(candType_i, oppType_j, 10)` over both type pairs). Pick max. Keep the existing `canEstimate=false` early-return. - Two call sites: - - line 86 (P1 KO revenge) → pass `aggressive`. - - line 201 (P6 fallback when no usable moves left) → pass `false`. Stuck-out-of-moves is not a revenge scenario. - -- **`_evaluateDefensiveSwitch(... , uint256 severeDamagePct)`** (line 616, threshold at line 653) - Take threshold as a parameter. Caller picks by mode: - `diyu ? SEVERE_DAMAGE_PCT_DIYU : (aggressive ? SEVERE_DAMAGE_PCT_TARTARUS : SEVERE_DAMAGE_PCT_HELL)`. - -- **`_findBestDamageMove`**: NO CHANGE. - -### Tartarus chaos roll - -To make Tartarus harder to game-plan against without disturbing Diyu's deterministic scariness, Tartarus gets a **1/10 chance per turn to bypass the decision tree and pick uniformly from all valid options** for the current context. - -At the very top of `calculateMove`, after `_calculateValidMoves` and mode resolution but before turn-0 / P1-KO branching: - -``` -if (mode == MODE_TARTARUS) { - uint256 rng = _getRNG(battleKey); - if (rng % 10 == 0) { - return _pickRandomValidOption(rng, noOp, moves, switches); - } -} -``` - -`_pickRandomValidOption` picks from the union `noOp ++ moves ++ switches`. Because `_calculateValidMoves` already filters by context (turn 0 / KO'd → no moves array; insufficient stamina → moves excluded), the union *is* the valid action set — no extra context-awareness needed. - -``` -function _pickRandomValidOption( - uint256 rng, - RevealedMove[] memory noOp, - RevealedMove[] memory moves, - RevealedMove[] memory switches -) internal pure returns (uint128, uint16) { - uint256 total = noOp.length + moves.length + switches.length; - // Use upper bits for selection so the 1/10 trigger and the index don't share entropy. - uint256 idx = (rng >> 8) % total; - if (idx < noOp.length) return (noOp[idx].moveIndex, noOp[idx].extraData); - idx -= noOp.length; - if (idx < moves.length) return (moves[idx].moveIndex, moves[idx].extraData); - idx -= moves.length; - return (switches[idx].moveIndex, switches[idx].extraData); -} -``` - -Notes: -- HELL stays fully deterministic. DIYU also stays fully deterministic — chaos would dilute D3/D4. -- The chaos roll *can* land on turn 0, randomizing the lead 10% of the time in Tartarus. Acceptable trade-off. -- `_getRNG` already exists (uses `_sampleRNG` + `nonceToUse++`); the chaos roll adds one RNG sample per Tartarus turn (~free). - -### Tests (test/BetterCPUTest.sol) - -Three goals — *not* full behavioral coverage: -1. **Baseline preserved** — covered by the existing BetterCPU test suite; the refactor must keep those green. No new "HELL still works" tests needed. -2. **Aggressive ramp visible** — one test per feature showing the harder mode picks differently from the easier mode on the *same* synthetic team. Without the paired baseline run, the test could pass vacuously. -3. **Never revert** — state machine doesn't deadlock; helpers handle sentinel HP, fresh mons, and empty option lanes without reverting; CPU doesn't suicide. - -Naming: `testRamp_*` (ramp visible), `testSafety_*` (revert / suicide guard), `testStateMachine_*`, `testChaosRoll_*`. - -#### State machine — 3 tests - -- `testStateMachine_LadderClimbAndReset` — end-to-end integration through real battles: HELL → lose → TARTARUS → lose → DIYU → lose (flag set, mode unchanged) → lose → HELL. *Catches*: any broken transition, plus the `selectMove` → `_afterTurn` → `_recordResult` wiring. Subsumes per-transition unit tests. -- `testStateMachine_DiyuWinDropsToTartarus` — parameterized over `diyuPriorLoss ∈ {0, 1}`; assert mode = TARTARUS and flag = 0 after a DIYU win in either starting state. *Catches*: stuck in DIYU after a win; flag not cleared on demote. -- `testSafety_DrawDoesNotMutateState` — `winnerIndex == 2` early return; mode/flag/history all unchanged. *Catches*: corruption on incomplete or drawn battles. - -#### Tartarus ramp — 2 paired tests + 1 safety - -Each paired test runs HELL and TARTARUS on the same synthetic team and asserts both picks. - -- `testRamp_LeadOffensiveVsDefensive` — team has candidate A (defensive-best, resists opp types, low offense) and B (SE offense, weak defense) constructed so HELL's `off - def` picks A and TARTARUS's `3*off - def` picks B. *Catches*: aggressive lead-score multiplier not wired in. -- `testRamp_DefensiveThresholdRaised` — incoming damage = 45% of max HP, materially-better switch candidate exists. HELL switches; TARTARUS stays. *Catches*: severe-damage threshold not mode-aware. -- `testSafety_AfterTurnSkipsOnDraw` — battle ends in draw mid-stream; assert `playerState[ALICE]` unchanged. *Catches*: state corruption on `winnerIndex == 2` execute path. (Belongs here rather than state machine because it crosses the full `selectMove` flow.) - -#### Tartarus chaos roll — 2 tests - -- `testChaosRoll_ModeAndTriggerGating` — table-driven over (mode, RNG % 10): only `(TARTARUS, 0)` yields the chaos pick; `(HELL, 0)`, `(DIYU, 0)`, and all `(*, non-zero)` rows use the heuristic. Constructed so heuristic pick X ≠ chaos pick Y, so non-firing is observable. Also exercises `MockCPURNG` wiring through `_getRNG`. *Catches*: chaos firing in wrong mode, firing on wrong trigger value, RNG plumbing broken. -- `testSafety_ChaosRollPicksFromUnionWithoutRevert` — context with `noOp.length = 1, moves.length = 2, switches.length = 3`. Force chaos to pick from each lane (3 RNG values targeting `idx ∈ {0, 1, 4}`). Assert the returned option matches the expected lane element each time. *Catches*: index-out-of-bounds in `_pickRandomValidOption`, lane stitching off-by-one. - ---- - -## Part 2 — Diyu mode - -Diyu sits above Tartarus and converts the lookahead the CPU already has into actual punishment. Tartarus reweights existing helpers; Diyu adds *new* capabilities that exploit information we already read but don't currently use. - -### What we have to work with - -**Free per-turn information** (already in CPUContext / IEngine getters): -- The player's revealed move this turn (`playerMoveIndex`, `playerExtraData`) — this is the lookahead. -- Every mon's full stats (HP, atk, def, spA, spD, speed, types) and stat deltas. -- Every mon's 4 move slots and their metadata (basePower, type, class, priority, stamina). -- Stamina, status, stat-delta, KO bitmaps for both sides. -- Turn count. - -**What we don't have:** -- The player's *next* turn's intent. -- Any way to reason across battles other than what we persist. - -**Persistent budget per player:** 256-bit slot, ~15 bits used by Part 1, ~241 free. - -### Diyu feature set - -Three independently-shippable features. - -#### (D3) Win-condition lock-in — *free, no storage* - -Two layered tweaks to defensive-switch evaluation in Diyu mode: - -1. **Raised severe-damage threshold.** Pass `SEVERE_DAMAGE_PCT_DIYU` (60% — `SEVERE_DAMAGE_PCT_TARTARUS + 10`) into `_evaluateDefensiveSwitch`. Smaller incoming hits no longer scare the CPU into swapping out of a setup mon. -2. **KO-bypass.** Inside `_evaluateDefensiveSwitch`, before checking the threshold, peek at our outgoing damage estimate against the opponent's current HP. If our best damage estimate would KO opp **within ±10% tolerance** (`bestOutDmg >= oppCurrentHp * 90 / 100`) **and** `_weGoFirst(...)` is true, short-circuit `return (false, 0)` regardless of `damagePctToUs`. Mirror P2's KO check but compare with the looser threshold — a sweeper that's *almost* going to KO shouldn't get pulled. - -Reuses `_findBestDamageMove`'s damage estimate (already computed by the caller) and the existing `_weGoFirst` helper. No new external calls. - -#### (D4) Reveal-move free-turn detection — *free, no storage* - -Currently P3 catches `playerMoveIndex == SWITCH_MOVE_INDEX` and P4 catches `playerMoveIndex == NO_OP_MOVE_INDEX`. Add a Diyu-only branch ("P4.5") that catches **opponent revealing a Self/Other move with `basePower == 0`** — i.e. a setup, heal, hazard, or buff move that doesn't threaten us this turn. The move's class and base power are already cached in `_evaluateDefensiveSwitch`'s opener; lift that decode just above P5 so P4.5 can read it. - -In P4.5, run a decision tree biased by "momentum": - -``` -momentum = (ourAliveCount > theirAliveCount) - || (ourAliveCount == theirAliveCount && ourActiveStamina >= theirActiveStamina) -``` - -Order of options: -1. **KO** — already caught by P2 upstream; nothing extra here. -2. **Switch-in move** (if configured for active mon and not yet used) — same as P3/P4. -3. **2HKO check**: if `bestEstimatedDamage * 2 >= oppCurrentHp`, take the damage move (we can finish in 2 turns and the free turn pays for itself). -4. **Setup move** (if configured for active mon, not yet used this switch-in, and `momentum` is true) — see (D5). -5. **Offensive/defensive switch**: run `_offensiveMatchupScore` against the opponent's active mon; pick the candidate that beats the matchup. Falls through to P5/P6 if no candidate clears the existing matchup score by ≥ `SWITCH_THRESHOLD`. -6. **Default**: best damage move (P6 fallback). - -The KO short-circuit from (D3) still applies inside P5 if we fall through. - -#### (D5) Per-mon setup-move config — *minimal storage, reuses `monConfig`* - -Extend the existing `monConfig[monIndex][key]` map with a new key: - -``` -uint256 constant CONFIG_SETUP_MOVE = 2; // stores (moveIndex + 1); 0 = unset -``` - -Set once at deploy via `setMonConfig(monIndex, CONFIG_SETUP_MOVE, slot + 1)` — sparse, one cold SLOAD per active-mon-turn, exact same shape as the existing `CONFIG_PREFERRED_MOVE` / `CONFIG_SWITCH_IN_MOVE` entries. - -Once-per-switch-in usage tracking: add a sibling bitmap to `switchInMoveUsedBitmap`. To avoid bloating per-battle storage, **pack both into one slot**: - -``` -mapping(bytes32 => uint256) public cpuMoveUsedBitmap; -// bits 0-7 : switch-in move used per monIndex (was switchInMoveUsedBitmap) -// bits 8-15 : setup move used per monIndex -``` - -Rename the existing field to `cpuMoveUsedBitmap` and update all touch sites (`|= 1 << monIndex`, `& 1 << monIndex`, `&= ~(1 << monIndex)`) to take a base offset (0 for switch-in, 8 for setup). MONS_PER_TEAM caps at 8 per the validator, so both lanes fit cleanly. - -**Clear semantics on re-entry.** When the CPU switches a mon back into play (the four existing clear sites: turn-0 lead, P1 KO revenge, P5 defensive switch, P6 no-moves fallback), clear the switch-in lane (`bit monIndex`) unconditionally — same as today. Clear the setup lane (`bit 8 + monIndex`) **only if the incoming mon's current HP is above 50% of its max HP**. A low-HP mon coming back in is probably about to die; spending its turn on a stat-boost / hazard / heal is a bad trade, so we leave the "setup already used" bit set and let the D4 tree fall through to step 5/6 (matchup switch / damage). - -Extract a helper so the gate lives in one place: - -``` -function _clearMoveUsedBitsOnSwitchIn(bytes32 battleKey, uint256 monIdx) internal { - uint256 bitmap = cpuMoveUsedBitmap[battleKey]; - bitmap &= ~(uint256(1) << monIdx); // always clear switch-in lane - uint32 maxHp = ENGINE.getMonValueForBattle(battleKey, 1, monIdx, MonStateIndexName.Hp); - int32 hpDelta = ENGINE.getMonStateForBattle(battleKey, 1, monIdx, MonStateIndexName.Hp); - if (hpDelta == CLEARED_MON_STATE_SENTINEL) hpDelta = 0; - int256 currentHp = int256(uint256(maxHp)) + int256(hpDelta); - if (currentHp * 2 > int256(uint256(maxHp))) { // strictly above 50% - bitmap &= ~(uint256(1) << (monIdx + 8)); // clear setup lane - } - cpuMoveUsedBitmap[battleKey] = bitmap; -} -``` - -Cost: one extra `getMonValueForBattle` + one `getMonStateForBattle` per CPU switch (≤4× per battle in normal play, plus once at turn 0). Acceptable. Replaces the four inline `switchInMoveUsedBitmap[battleKey] &= ~(1 << uint256(extraData))` lines. - -**Storage cleanup follow-up** (optional, separate PR): `cpuMoveUsedBitmap` currently accumulates one SSTORE-shaped slot per battle forever. Following the same pattern as `Engine` and `DefaultMatchmaker`, BetterCPU can extend `MappingAllocator` and key the bitmap through `_getStorageKey(battleKey)`, then free the key on battle end via the existing `_afterTurn` hook (or an `OnBattleEnd` engine hook). Out of scope for Part 2 — call it out and ship it as a cleanup if/when storage churn becomes a concern. - -**Setup move slots** (from `script/SetupMons.s.sol`): - -| Mon | Move | Slot | Why | -|---|---|---|---| -| Inutia | Initialize | 1 | +50% Atk/SpAtk, transfers on switch — already once-per-switch-in by design | -| Malalien | Triple Think | 0 | +75% SpAtk, strongest single-turn setup in roster | -| Pengym | Deadlift | 1 | +50% Atk/Def, hybrid | -| Iblivion | Loop | 1 | +15/30/40% all stats by Baselight stacks (turn 2+) | -| Aurox | Iron Wall | 2 | Damage regen + 25% HP first-use; pairs with Bull Rush recoil | -| Ekineki | Nine Nine Nine | 2 | 90% crit next turn; one-shot offensive amplifier | -| Embursa | Heat Beacon | 2 | +1 priority next turn + burn opp; tag for priority follow-up | -| Nirvamma | Hard Reset | 0 | Trap: opp's next rest is punished, our next rest swaps; momentum tool | - -Mons not in the table have no setup move — (D4) skips step 4 and falls through to step 5/6. - -### Storage layout after Part 1 + Part 2 - -``` -playerState[address]: - bits 8-9 : mode (0 HELL, 1 TARTARUS, 2 DIYU) - bit 10 : diyuPriorLoss flag - -monConfig[monIndex][key]: - key 0 = CONFIG_PREFERRED_MOVE (unchanged) - key 1 = CONFIG_SWITCH_IN_MOVE (unchanged) - key 2 = CONFIG_SETUP_MOVE (new) - -cpuMoveUsedBitmap[battleKey]: - bits 0-7 : switch-in used per monIndex - bits 8-15 : setup used per monIndex -``` - -### Diyu-only behavior switches - -In `calculateMove`, after computing `mode` and `aggressive`: - -``` -bool diyu = (mode == MODE_DIYU); -``` - -- (D3) Inside `_evaluateDefensiveSwitch`: caller picks the Diyu threshold; helper additionally short-circuits if Diyu and outgoing-damage estimate within 10% of `oppCurrentHp` and `_weGoFirst`. -- (D4) Between P4 and P5 (new "P4.5"): if `diyu` and the opponent's revealed move is Self/Other with `basePower == 0`, enter the free-turn decision tree above. -- (D5) The (D4) tree consults `monConfig[activeMonIndex][CONFIG_SETUP_MOVE]` and the upper lane of `cpuMoveUsedBitmap[battleKey]`. - -### Tests for Diyu (additive) - -State-machine tests for Diyu are consolidated in Part 1's `testStateMachine_LadderClimbAndReset` — don't duplicate. The tests below are Diyu-only behaviors. Each ramp test runs paired TARTARUS + DIYU on the same synthetic team. - -#### (D3) Win-condition lock-in — 3 tests - -- `testRamp_DiyuRaisedThresholdAt55Pct` — incoming 55%, no outgoing-KO conditions. TARTARUS switches (>50); DIYU stays (<60). *Catches*: threshold not raised, or raised too far. -- `testRamp_DiyuKOBypassStaysInForTheKO` — outgoing damage ≥ 90% of opp current HP, incoming 65%, CPU outspeeds. TARTARUS switches; DIYU stays and attacks. *Catches*: KO-bypass missing or wrong tolerance. -- `testSafety_DiyuKOBypassRequiresOutspeed` — same as above, opp outspeeds. DIYU switches. *Catches*: CPU staying in for a KO it won't reach — suicide. - -#### (D4) Free-turn detection — 3 tests - -- `testRamp_DiyuFreeTurnGating` — table-driven over (mode, opp move): - - (DIYU, Other bp=0) → enters D4 - - (DIYU, Physical bp>0) → skips D4, normal P5 - - (TARTARUS, Other bp=0) → skips D4 - Constructed so D4 outcome differs from P5 outcome on this team. *Catches*: class/bp gate wrong, mode gate wrong. -- `testRamp_DiyuFreeTurnSetupVsMatchupSwitch` — opp reveals Other bp=0, setup move configured for active mon, `bestDmg * 2 < oppCurrentHp`. Two runs on the same setup: - - momentum=true (CPU 4 alive, opp 3) → DIYU plays setup move. - - momentum=false (CPU 3 alive, opp 4) and switch candidate beats current matchup by ≥ `SWITCH_THRESHOLD` → DIYU switches. - *Catches*: tree ordering inverted, momentum computation wrong, switch candidate ranking broken. -- `testSafety_DiyuFreeTurn_2HKOBeatsSetup` — momentum=true, setup configured, but `bestDmg * 2 >= oppCurrentHp`. DIYU plays best-damage; setup lane bit NOT set after the turn. *Catches*: setup priority blocking a winning damage trade. - -#### (D5) Setup bitmap + HP-gated clear — 3 tests - -- `testDiyu_SetupOncePerSwitchIn` — turn N setup plays (lane bit `8 + monIdx` set). Turn N+1: same opp 0-power reveal, momentum still true, no switch in between. DIYU does NOT replay setup; falls through to step 5/6. *Catches*: bit not being set, or D4 step 4 not checking it. -- `testRamp_HpGatedClearAbove50ClearsBelowDoesNot` — single test, two scenarios on the same setup: - - Mon switches out at full HP, switches back at 80% HP → setup bit cleared, setup playable again on next free-turn. - - Mon takes damage to 50% before switching back → bit stays set, setup not playable. - *Catches*: HP gate inverted, missing, or boundary off-by-one (strict `>` not `≥`). -- `testSafety_SwitchInClearsAndFreshMonNoRevert` — two assertions: - - Switch-in lane (`bit monIdx`) clears on every re-entry regardless of HP (verify at 10% HP); setup lane is independently gated. - - Fresh mon (never switched in, HP delta == `CLEARED_MON_STATE_SENTINEL`) runs through `_clearMoveUsedBitsOnSwitchIn` without reverting. - *Catches*: switch-in lane accidentally tied to HP gate; sentinel HP delta not handled. - ---- - -## Verification - -1. `forge build` (timeout 360000ms — see CLAUDE memory note on build timeout) -2. `forge test --match-contract BetterCPUTest -vvv` -3. Full suite: `forge test` to confirm no regression in other CPU tests, engine tests, or gas snapshots -4. Inspect `snapshots/BetterCPUInlineGasTest.json` and `snapshots/EngineGasTest.json` — `_afterTurn` adds ~10 gas/turn across all CPUs; flag if larger. The (D4) lookahead branch adds another ~30 gas in Diyu only; flag if it leaks into the HELL/TARTARUS paths. \ No newline at end of file diff --git a/docs/MOVE_VM_DESIGN.md b/docs/MOVE_VM_DESIGN.md new file mode 100644 index 00000000..bcc09e5e --- /dev/null +++ b/docs/MOVE_VM_DESIGN.md @@ -0,0 +1,180 @@ +# Move-VM Design Sketch + +Builds on [`MOVE_VM_RESEARCH.md`](./MOVE_VM_RESEARCH.md). **Premise:** `StatBoosts` and `AttackCalculator` +are absorbed into the engine as syscalls (this clears the single biggest dependency gate — see the +research doc §"real ceiling"). Under that premise, this sketches what the move VM looks like. + +**Goal:** a move/ability becomes a **VM program** (data) run by a **fixed interpreter** whose only +side-effects are engine syscalls. Then the entire battle is ZK-provable by proving *one interpreter* +over committed program-data + inputs, instead of proving arbitrary user EVM bytecode. + +> ⚠️ **Read §7 (Validation verdict) first.** Transpiling the 12 hardest/representative moves to the v0 +> ISA below exposed that `DAMAGE`/`STAT_BOOST` are **not** clean macro-opcodes — they're 190-/635-line +> stateful subsystems, and the RNG is a **keccak chain**, not a sequential entropy pull. The "restricted +> VM" is honest only for the ~25–35% **state-machine** slice; for the damage/boost/status majority it +> requires a general `KECCAK` + subsystem-opcodes. §8 explores the **no-keccak** variation that could fix +> this; §9 details what it buys in the 1-tx CPU flow. + +--- + +## 1. Shape + +- A move/ability = a **program blob** (the VM's bytecode), stored in the registry / committed into the + battle hash — *not* a deployed Solidity contract. +- The engine hosts a **fixed interpreter** that loads and runs a program. Side-effects happen **only** + through engine syscalls (the "engine-only callbacks" invariant — which also guarantees deterministic + replay, the property the 1-tx flow already leans on). +- For ZK: the interpreter is the circuit; the program + the battle's input vector + the start/end state + commitments are the public inputs. Arbitrary user moves are handled because we prove the *interpreter*, + not each move's bytecode. + +## 2. Instruction set (ISA) + +- **ALU:** add/sub/mul/div/mod; **256-bit *wrapping* mul/exp** (StatBoosts depends on intentional + overflow + a downstream clamp); bit-ops (and/or/xor/shl/shr/mask); signed-`int32`-aware comparisons; + **keccak** as a first-class op (RNG stream-splitting + KV-key derivation — the dominant in-circuit cost). +- **Memory:** a small fixed register file + bounded scratch memory (moves build `uint32[5]` stat arrays, + `StatBoostToApply[]`, `DamageCalcContext`). Statically sized. +- **Control flow:** conditional branch; **bounded loops** with a static iteration cap (= max effects/mon, + max team size). No unbounded loops, no recursion. +- **Syscalls (the ~21 engine ops as opcodes):** + - *reads:* `getMonValueForBattle`, `getMonStateForBattle`, `getMonStatsForBattle`, `getEffects`, + `getEffectData`, `getGlobalKV`, `getMoveDecisionForBattleState`, `getActiveMonIndexForBattleState`, + `getTurnIdForBattleState`, `getKOBitmap`, `getTeamSize`, `getDamageCalcContext`, `getPreDamage`/`tempRNG`. + - *writes:* `updateMonState`, `dealDamage`, `addEffect`/`removeEffect`/`editEffect`, `setGlobalKV`, + `switchActiveMon`, `setPreDamage`. +- **Macro-opcodes (now engine features):** `DAMAGE` (AttackCalculator) and `STAT_BOOST` (StatBoosts) — + single opcodes the interpreter implements natively, proven once rather than re-derived per move. + +## 3. Effect lifecycle + +- A program declares its `stepsBitmap` — which of the 10 `EffectStep`s it hooks (`OnApply`, `RoundStart`, + `RoundEnd`, `OnRemove`, `OnMonSwitchIn`, `OnMonSwitchOut`, `AfterDamage`, `AfterMove`, + `OnUpdateMonState`, `PreDamage`). +- The engine's existing scheduler invokes the program at each hooked step with + `(battleKey, targetIndex, monIndex, extraData)`; the program returns updated `extraData` (+ remove flag). +- **Persistent state** = the per-effect `bytes32 extraData` (threaded across turns) + `globalKV`. The VM + never touches storage directly — only via the read/write syscalls. + +## 4. Hard cases → explicit opcodes or escape hatch + +| Case | Handling | +|---|---| +| **Gachachacha** (attacker-context swap) | a `DAMAGE`-variant opcode taking an explicit attacker side ("compute damage as side X") | +| **HardReset** (global-singleton, cross-team, RNG switches) | global effect (`targetIndex 2`) + cross-team syscalls + a `bytes32` state machine. *The explicit "can the VM express this?" stress test.* | +| **ChainExpansion** (per-switch summon) | `OnMonSwitchIn` hook + a charge counter in `extraData` | +| **Tinderclaws / Somniphobia** (read opponent's committed move) | the existing `getMoveDecisionForBattleState` syscall — but note this makes the player's move-decision a *committed input* to the proof | +| **anything else** | a **`kind: custom` escape hatch** — stays a native EVM contract and simply isn't ZK-provable yet. First tail candidates: the above. | + +## 5. Phased plan + +- **Phase 0 — prerequisite (valuable independent of ZK):** inline `StatBoosts` + `AttackCalculator` into + the engine as syscalls; migrate the ~12 StatBoosts callers + the damage callers. Enforces the + engine-only-callbacks invariant for the whole stat system. +- **Phase 1 — ISA + off-chain interpreter:** define the instruction set and build a *reference* + interpreter off-chain (extend `transpiler/`'s runtime — it already lowers arithmetic / keccak / + control flow). +- **Phase 2 — transpile + equivalence:** auto-transpile the ~85% (trivial + moderate) moves to VM + programs; validate by running each VM program against its Solidity move and diffing state, using the + existing per-mon test suites as oracles. +- **Phase 3 — on-chain interpreter:** the engine runs VM programs for migrated moves; the hard ~15% + stays behind the `kind: custom` escape hatch. +- **Phase 4 — ZK:** the interpreter as a circuit; prove battles; settle with proof + final-state commit. + +## 6. Gates (de-risk on the subset before scaling) + +1. **`STAT_BOOST` + `DAMAGE` as economical circuit opcodes** (incl. the unchecked-wrap exponentiation). + **Prototype these FIRST** — 12+ moves and the whole stat system route through them; if they can't be + proven cheaply the VM is dead on arrival regardless of ISA elegance. +2. **keccak/turn cost in-circuit** — the dominant driver (RNG splitting + every KV key). Measure on the + subset; decide "how many keccaks/turn can we afford." +3. **Escape-hatch tail** — confirm it stays small and that those moves being non-ZK-provable is acceptable. + +**Prototype subset** (from the research): `aurox + ekineki + embursa` + **`HardReset`** as the stress test +— exercises every feature class (trivial wrappers, RNG branching, globalKV state machines, the +NineNineNine turn-counter, Tinderclaws's intent-read, UpOnly/HoneyBribe as StatBoosts clients) plus the +hardest control-flow shape. + +--- + +## 7. Validation verdict (transpiling the 12 hardest moves to the v0 ISA) + +We transpiled the 8 hardest + 4 representative moves to the ISA above and checked each against source. +The optimistic framing of §§1–6 does **not** survive contact with the code: + +- **The "restricted VM" is honest only for the ~3/12 state-machine slice** (≈25–35% of all moves): + bit-packed effect state, countdown timers, KO checks, global-singleton cross-team effects + (HardReset, ActusReus, RiseFromTheGrave). These transpile cleanly and ~mechanically. This is a real, + useful design space — the *novel control-flow* moves a user most wants to author. +- **The damage/boost/status majority (~80%) breaks "restricted."** It is served only by admitting: + - `DAMAGE` — not a primitive: `AttackCalculator` is ~190 LOC (accuracy/crit/volatility/variance + type + calc + `dealDamage`). + - `STAT_BOOST` — not a primitive: `StatBoosts` is **635 LOC** — iterates the full effect array, unpacks + 5-stat packed words, reads/writes a globalKV snapshot, **unchecked-wrapping exponentiation**, clamps. + - a **general `KECCAK`** — the v0 ISA pretended keccak was "key-derivation only," but the RNG is itself + keccak-chained (below). A general hash opcode is not a restricted VM. +- **The keccak is load-bearing decorrelation, not lazy stream-splitting** (this kills the easy + player-entropy assumption): `AttackCalculator.mixRngForAttacker = keccak256(rng, attackerIndex)` exists + so mirror mons don't roll identical accuracy/crit (`:17`); the effect-trigger reroll + `keccak256(rng)` (`:34`) decorrelates effect procs from damage rolls. +- **Mechanical transpilation is fatally ambiguous for damaging moves:** `keccak256(rng)` (fork RNG) and + `keccak256(prefix, idx)` (derive a KV key) are *syntactically identical* — a transpiler cannot tell + them apart. So a Sol→VM pass is mechanical only for the control-flow tail; for damage/boost/status it + degenerates to "recognize the three blessed libraries and emit macro calls." + +**Strategic fork (unchanged by the prototype's outcome):** +1. **Scope the VM to the state-machine tail** + leave damage/boost/status as native EVM behind a stable + ABI. Honest and ships, but the common moves stay un-provable. +2. **Rewrite the three shared subsystems** (`AttackCalculator`, `StatBoosts`, `StatusEffectLib`) to a + circuit-friendly form — the **no-keccak variation** below — so the majority becomes provable. Real + project, real correctness risk. + +## 8. No-keccak variation (the thing that makes ZK economics work) + +keccak is the dominant in-circuit cost. Both keccak sources are removable **without losing the property +they provide**: + +- **RNG → a player-committed entropy *stream*; each roll pops the next word.** This *preserves* + decorrelation **by construction**: p0's accuracy/crit/vol/effect rolls pop distinct words, p1's pop + later words, so mirror mons pull *different* entropy natively — `mixRngForAttacker` and the + effect-reroll keccaks become unnecessary (their purpose, not their mechanism, is what matters). + Requirement: a **fixed, deterministic consumption order** (engine logic already determines how many + rolls each move makes), so the off-chain sim and the circuit consume the stream identically. Scope this + to the CPU/ZK path; PvP keeps its keccak RNG (it isn't proven anyway). + - *Cost:* rewrite `AttackCalculator` to sequential pops and re-validate, against the existing + damage-determinism + mirror-match `forge` tests, that decorrelation survives bit-for-bit. +- **KV keys → direct bit-packing, not keccak.** Keys namespace globalKV from a *tiny* input space + (a constant prefix + playerIndex 0–1 + monIndex 0–5). Assign each namespace a small id and pack + `(nsId << k) | indices` into the uint64 key — collision-free, deterministic, zero hashing. Replaces + `StatusEffectLib.getKeyForMonIndex` / `StatBoosts._snapshotKey` / every `_key()` helper. +- **State commitment → a ZK-friendly hash (Poseidon), not keccak.** The circuit proves a parallel + Poseidon-Merkle state (start/end commitments + read/write paths); the EVM keeps its keccak storage only + for the cheap on-chain verify+settle. No keccak inside the proven computation. + +**Result:** a **keccak-free native circuit.** `DAMAGE`/`STAT_BOOST` stay large but become *pure +arithmetic/state* subsystems (the 635-LOC boost aggregator is mostly mul/exp/array-walk — provable), and +the dominant cost (Gate 2, keccak/turn) is **eliminated**. That is the difference between an intractable +zkEVM-scale proof and a tractable app-specific one. The smallest experiment that settles whether the +decorrelation survives the rewrite: **port `VolatilePunch` end-to-end and run it against the existing +damage tests** (§6 prototype). + +## 9. Gains in the 1-tx CPU batched flow (the only place this pays) + +The CPU 1-tx flow (shipped: `CPUMoveManager.executeGame`, ~2.88M for a 26-turn game) is the unique flow +where ZK fully collapses — no commit-reveal interactivity, and the player **already executes the whole +game off-chain** to compute moves, so they already hold the trace and only need to *also* prove it. + +With the no-keccak native circuit: + +| | today (1-tx, on-chain exec) | with no-keccak ZK | +|---|---|---| +| on-chain execution | ~2.88M (run 26 turns) | **~0.5M** = proof verify (~250–300k) + final-state write (~200–300k) | +| calldata / DA | full move + per-turn salt stream | a proof + start/end + input-commitment (moves need not be posted, only committed) | +| off-chain | player simulates (free) | player simulates **+ proves** — now cheap: app-specific, **no keccak**, ~arithmetic + Poseidon-Merkle | + +So the per-game gain is **~2.4M on-chain** plus a DA cut, against an off-chain proving cost that the +no-keccak circuit drops from "dwarfs the gas" (zkEVM) to "seconds of player-side compute." This is the +case — single-player, gas genuinely costs money — where ZK plausibly nets *positive*, conditional on the +two open gates: (1) `STAT_BOOST`/`DAMAGE` proving cost even keccak-free, and (2) the `VolatilePunch` +prototype confirming decorrelation survives the entropy-stream rewrite. PvP gains nothing here — its +move-hiding floor is untouched and it keeps native EVM + keccak RNG. diff --git a/docs/MOVE_VM_RESEARCH.md b/docs/MOVE_VM_RESEARCH.md new file mode 100644 index 00000000..66380bfa --- /dev/null +++ b/docs/MOVE_VM_RESEARCH.md @@ -0,0 +1,105 @@ +# Move-VM Feasibility Research + +**Question:** could C.H.O.M.P.'s user-created moves/abilities be re-expressed as programs for a +restricted, circuit-friendly **"move VM"** — a fixed instruction set whose only side-effects are calls +back into the battle engine — so the whole battle becomes ZK-provable via one interpreter circuit +instead of proving arbitrary EVM bytecode (the current blocker to cheap ZK)? + +**Method:** parallel per-mon catalog of every custom `.sol` move/ability across all 13 mons (engine ops, +computation, control flow, reentrant reads, external deps, effect-persistence, expressivity hardness), +then an adversarial synthesis. Findings verified against source on the load-bearing claims. + +--- + +## Verdict (TL;DR) + +A **pure fixed-op VM is insufficient.** The realistic target is a small **bounded-Turing core**: + +- a fixed ALU — add/sub/mul/div/mod, bit-ops, **keccak as a first-class op**, and **256-bit *wrapping* + mul/exp** (StatBoosts relies on intentional overflow + a downstream clamp); +- **bounded loops** with a static iteration cap (effect-array scans, KO popcount, team scans — no + unbounded loops, no recursion); +- **scratch memory** (moves build `uint32[5]` stat arrays, `StatBoostToApply[]`, `DamageCalcContext`); +- **persistent effect state** — `bytes32 extraData` per instance threaded across turns (~30% of items); +- a **lifecycle dispatcher** over the 10 `EffectStep`s, selected by the 16-bit `getStepsBitmap`, with + **consistent reentrant battle-state views** mid-hook; +- the **~21 engine syscalls** as opcodes; +- two **macro opcodes** — `DAMAGE` (AttackCalculator) and `STAT_BOOST` (StatBoosts) — or the circuit + must inline ~825 LOC of EVM-exact arithmetic incl. intentional overflow. + +**Recommendation: prototype on a subset; don't commit to a full VM yet; don't shelve.** The two +make-or-break gates are (1) StatBoosts + AttackCalculator as economical in-circuit primitives, and +(2) keccak-per-turn prover cost (keccak, not arithmetic, is the dominant in-circuit driver here). + +--- + +## 1. Required syscall set (≈21 engine ops = the VM's primitives) + +**Reads:** `getMonValueForBattle`, `getMonStateForBattle`, `getMonStatsForBattle`, `getEffects`, +`getEffectData`, `getGlobalKV`, `getMoveDecisionForBattleState`, `getActiveMonIndexForBattleState`, +`getTurnIdForBattleState`, `getKOBitmap`, `getTeamSize`, `getDamageCalcContext`, `getPreDamage`/`tempRNG`. + +**Writes:** `updateMonState`, `dealDamage`, `dispatchStandardAttack`, `addEffect`/`removeEffect`/`editEffect`, +`setGlobalKV`, `switchActiveMon`, `setPreDamage`. + +**Trusted library subroutines that must become macro-opcodes (not opaque callouts):** +`AttackCalculator._calculateDamage*` (≈190 LOC, calls `ITypeCalculator` + RNG/accuracy/crit/variance), +`StatBoosts.addStatBoosts/removeStatBoosts/clearAll` (≈635 LOC), `ITypeCalculator.getTypeEffectiveness`, +`SwitchTargetLib.findRandomNonKOed`, keccak key-derivation libs (`StatusEffectLib`, `HeatBeaconLib`, `NineNineNineLib`). + +## 2. Coverage (~55 items across 13 mons) + +- **~40% trivial** — `StandardAttack` wrapper + one side effect. +- **~45% moderate** — RNG branching, globalKV state machines, bounded loops, heal-with-clamp, single reentrant read. +- **~15% HARD**, each with a specific blocking feature: + +| Move/ability | Blocking feature | +|---|---| +| **HardReset** (nirvamma) | Global-singleton effect `(2,0)` w/ caster identity in extraData; fires only on opponent NO_OP; heals/damages *both* teams in one hook; RNG-seeded forced switches; 2-bit state machine. **Hardest single item.** | +| **Gachachacha** (sofabbi) | RNG 3-way dispatch where one branch **swaps the attacker context fed to the damage calc** (move computes damage *as the opponent*); basePower is sometimes attacker HP, sometimes defender HP. Breaks "move computes its own fixed basePower." | +| **ActusReus** (malalien) | Conditional debuff on opponent-KO; reentrant live-KO read; triple-hook lifecycle; opaque 1-bit flag in bytes32; routes through StatBoosts. | +| **Initialize** (inutia) | GlobalKV lock-on-execute + clear-on-switchout + propagate-buff-on-switchin; cross-mon buff routing. | +| **ChainExpansion** (inutia) | Per-opponent-switch "summon" effect: decrement charges, heal/damage by type, self-remove when exhausted. Needs event scheduling, not move-and-return. | +| **Tinderclaws** (embursa) | `getMoveDecisionForBattleState` mid-hook to gate self-burn on the player's *intent this turn* (REST/SWITCH/normal). | +| **PostWorkout** (pengym) | `onMonSwitchOut` recovers a status-effect *address* from globalKV, scans `getEffects` to find+remove it, grants stamina. | +| **SneakAttack** (ekineki) | Manual `DamageCalcContext` from 9 reentrant reads + persists-as-effect; needs an 8-point consistent reentrant view. | + +**The real ceiling — `StatBoosts`** (≈635 LOC; clients: TripleThink, ActusReus, Deadlift, Loop, +Initialize, Interweaving, HoneyBribe, Tinderclaws, EternalGrudge, UpOnly, SaviorComplex, Chronoffense). +It iterates the live effect set, **accumulates with unchecked wrapping exponentiation** +(`scalingFactor ** boostCounts[k]`), divides by a denom-power, and packs a globalKV snapshot. Its result +depends on the *entire live effect set* and on EVM-exact 256-bit wrap semantics. It must be a first-class +VM opcode — there is no clean "treat it as opaque" middle. + +## 3. Transpiler reuse + +The existing `transpiler/` (Sol→TS) is the **front half for free and almost none of the hard half**: +- **Carries over (~1/3, mechanical):** lexer/parser/AST + arithmetic/bitop/keccak codegen + control-flow + lowering. Trivial + most moderate moves transpile near-mechanically. +- **Does NOT carry over (the expensive 2/3):** no storage model (but globalKV + effect `extraData` + persistence is *the* state mechanism); dynamic dispatch via a **runtime registry of concrete + instances** (a circuit needs static, compile-time-flattened dispatch); no macro-opcode mapping for + AttackCalculator/StatBoosts; no effect-lifecycle scheduling (that's engine behavior, not in-function). + +## 4. Recommendation & gates + +**Prototype scope:** `aurox + ekineki + embursa` (covers trivial wrappers, RNG branching, globalKV state +machines, the NineNineNine turn-counter pattern, Tinderclaws's hard intent-read, and UpOnly/HoneyBribe as +StatBoosts clients) **+ `HardReset` as the explicit "can the VM even express this?" stress test.** + +**Gate 1 — macro-opcodes:** implement `DAMAGE` (AttackCalculator) and `STAT_BOOST` (StatBoosts) as +in-circuit primitives *first*, incl. the unchecked-wrap exponentiation. If these can't be proven +economically, the VM is dead on arrival — 12+ moves and the whole stat-boost system route through them. + +**Gate 2 — keccak economics:** measure keccaks/turn (RNG stream-splitting + every KV key) in-circuit; +that, not arithmetic, is the dominant cost. Decide "how many keccaks/turn can we afford" on the subset +before scaling. + +**Long tail behind a `kind: custom` escape hatch:** a move the VM can't represent stays native EVM and +simply isn't ZK-provable yet. First candidates: Gachachacha's attacker-context swap, ChainExpansion's +summon scheduling. + +Key source refs: `src/IEngine.sol` (syscalls), `src/effects/StatBoosts.sol:153-248` (aggregation + +unchecked exponentiation ceiling), `src/moves/AttackCalculator.sol:38-127` (damage macro), +`src/mons/nirvamma/HardReset.sol:41-163` (hardest item), `src/mons/sofabbi/Gachachacha.sol:47-62` +(attacker swap), `src/Enums.sol` (EffectStep lifecycle the dispatcher must cover). diff --git a/processing/generateMonsTypeScript.py b/processing/generateMonsTypeScript.py index 850d1f6b..65287706 100644 --- a/processing/generateMonsTypeScript.py +++ b/processing/generateMonsTypeScript.py @@ -64,6 +64,14 @@ def build_sprite_config( }, } +# Maps move name → attack-overlay placement. Absent means the default 'target' +# (overlay drawn on the opposing mon); 'canvas-center' lifts it to the middle +# of the battle canvas. Renderer-only per-move metadata with no CSV column — +# same kind of override as MOVE_SPRITE_VARIANTS, keyed by exact move name. +MOVE_OVERLAY_PLACEMENT: Dict[str, str] = { + "Gachachacha": "canvas-center", +} + def _resolve_sprite_for_key( key: str, @@ -255,6 +263,9 @@ def parse_int_or_unknown(val: str) -> int | str: "description": row["DevDescription"], "inputType": row.get("InputType", "none").strip() or "none", } + placement = MOVE_OVERLAY_PLACEMENT.get(move_name) + if placement: + move_data["overlayPlacement"] = placement sprite, matched_keys = build_attack_sprite( move_name, attack_spritesheet_data, non_standard_spritesheet_data ) @@ -411,6 +422,11 @@ def generate_typescript_const(data: Dict[int, Dict[str, Any]], output_file: str) readonly selfKO: SpriteAnimationConfig; }}; +// Where a move's attack overlay renders. Default 'target' overlays the sprite +// on the opposing mon; 'canvas-center' lifts it to the middle of the battle +// canvas (works across desktop/mobile/large without per-breakpoint coords). +export type AttackOverlayPlacement = 'target' | 'canvas-center'; + export type Move = {{ readonly address: LowercaseHex; readonly name: string; @@ -423,6 +439,7 @@ def generate_typescript_const(data: Dict[int, Dict[str, Any]], output_file: str) readonly description: string; readonly inputType: MoveInputType; readonly sprite?: SpriteAnimationConfig | MoveSpriteVariants; + readonly overlayPlacement?: AttackOverlayPlacement; }}; export type Mon = {{ diff --git a/processing/parse_desync_report.py b/processing/parse_desync_report.py new file mode 100644 index 00000000..026226e3 --- /dev/null +++ b/processing/parse_desync_report.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +"""Convert a battle desync report (markdown) into faithful-replay test data. + +Usage: + python processing/parse_desync_report.py test/fixtures/desync_reports/.md [--json] + +Emits a Solidity snippet (team monIds + per-slot MonStats + the deduped `Turn[]` move sequence) +ready to paste into a `RealMonReplayGasTest`-style replay test, so any real prod game becomes a +gas + equivalence regression. `--json` emits the structured form instead. + +The report gives leveled/facffeted stats (which differ from base CSV stats) + the per-turn +moveIndex/salt/extraData with the exact prod salts. Stamina is not logged, so it defaults to 5 +(the game default); the replay test can also pull real stamina from the registry if preferred. +Duplicate turnIds (a resubmission/desync artifact) are deduped to the first occurrence. +""" +import csv +import json +import re +import sys +from pathlib import Path + +# Type enum index -> Solidity Type member (Enums.sol order). +TYPE_NAMES = [ + "Yin", "Yang", "Earth", "Liquid", "Fire", "Metal", "Ice", "Nature", + "Lightning", "Mythic", "Air", "Math", "Cyber", "Wild", "Cosmic", "None", +] + +REPO = Path(__file__).resolve().parent.parent +DEFAULT_STAMINA = 5 + + +def load_name_to_id(): + name_to_id = {} + with open(REPO / "drool" / "mons.csv") as f: + for row in csv.DictReader(f): + name_to_id[row["Name"].strip().lower()] = int(row["Id"]) + return name_to_id + + +def parse_int(tok): + # Strip JS BigInt 'n' suffix and any commas. + return int(re.sub(r"[n,]", "", tok.strip())) + + +def parse_report(text, name_to_id): + players = {0: [], 1: []} + cur_player = None + mon_re = re.compile( + r"-\s*\d+:\s*(\w+)\s*\{hp:(\d+),\s*atk:(\d+),\s*def:(\d+),\s*spAtk:(\d+)," + r"\s*spDef:(\d+),\s*spe:(\d+),\s*type:([\d/]+)\}" + ) + for line in text.splitlines(): + ph = re.match(r"###\s*Player\s*(\d)", line) + if ph: + cur_player = int(ph.group(1)) + continue + m = mon_re.search(line) + if m and cur_player is not None: + name, hp, atk, df, spa, spd, spe, types = m.groups() + t = [int(x) for x in types.split("/")] + t1, t2 = t[0], (t[1] if len(t) > 1 else 15) # 15 = None + players[cur_player].append({ + "name": name, "monId": name_to_id[name.lower()], + "hp": int(hp), "atk": int(atk), "def": int(df), + "spAtk": int(spa), "spDef": int(spd), "spe": int(spe), + "type1": t1, "type2": t2, + }) + + # Turns: split on "## Turn", parse each block's turnId + p0/p1 sub-blocks. + turns = {} + blocks = re.split(r"^##\s+Turn\b", text, flags=re.MULTILINE)[1:] + for b in blocks: + tid_m = re.search(r"turnId:\s*(\d+)", b) + if not tid_m: + continue + tid = int(tid_m.group(1)) + if tid in turns: # dedupe resubmissions: keep first occurrence + continue + turn = {"turnId": tid} + for side in ("p0", "p1"): + # Match "p0:\n moveIndex: Xn\n salt: Yn\n extraData: Zn" OR "p0: {}" + sub = re.search( + rf"{side}:\s*\n\s*moveIndex:\s*(\d+)n?\s*\n\s*salt:\s*(\d+)n?\s*\n\s*extraData:\s*(\d+)n?", + b, + ) + if sub: + turn[side] = { + "present": True, + "moveIndex": int(sub.group(1)), + "salt": int(sub.group(2)), + "extraData": int(sub.group(3)), + } + else: + turn[side] = {"present": False, "moveIndex": 126, "salt": 0, "extraData": 0} + turns[tid] = turn + return players, [turns[k] for k in sorted(turns)] + + +def to_solidity(players, turns): + out = [] + p0_ids = ", ".join(str(m["monId"]) for m in players[0]) + p1_ids = ", ".join(str(m["monId"]) for m in players[1]) + out.append(f" uint256[{len(players[0])}] P0_IDS = [uint256({p0_ids.split(', ',1)[0]}), {p0_ids.split(', ',1)[1]}];") + out.append(f" uint256[{len(players[1])}] P1_IDS = [uint256({p1_ids.split(', ',1)[0]}), {p1_ids.split(', ',1)[1]}];") + out.append("") + for pi, fn in ((0, "_p0Stats"), (1, "_p1Stats")): + out.append(f" function {fn}() internal pure returns (MonStats[{len(players[pi])}] memory s) {{") + for i, m in enumerate(players[pi]): + t1 = f"Type.{TYPE_NAMES[m['type1']]}" + t2 = f"Type.{TYPE_NAMES[m['type2']]}" + out.append( + f" s[{i}] = _mk({m['hp']}, {DEFAULT_STAMINA}, {m['spe']}, {m['atk']}, " + f"{m['def']}, {m['spAtk']}, {m['spDef']}, {t1}, {t2}); // {m['name']}" + ) + out.append(" }") + out.append("") + out.append(f" function _plan() internal pure returns (Turn[] memory t) {{") + out.append(f" t = new Turn[]({len(turns)});") + for i, tn in enumerate(turns): + p0, p1 = tn["p0"], tn["p1"] + out.append( + f" t[{i}] = Turn({p0['moveIndex']},{p0['extraData']},{p0['salt']},{str(p0['present']).lower()}, " + f"{p1['moveIndex']},{p1['extraData']},{p1['salt']},{str(p1['present']).lower()});" + ) + out.append(" }") + return "\n".join(out) + + +def main(): + if len(sys.argv) < 2: + print(__doc__) + sys.exit(1) + path = sys.argv[1] + text = Path(path).read_text() + players, turns = parse_report(text, load_name_to_id()) + if "--json" in sys.argv: + print(json.dumps({"players": players, "turns": turns}, indent=2)) + else: + print(f"// Generated from {path} ({len(turns)} turns, deduped)") + print(to_solidity(players, turns)) + + +if __name__ == "__main__": + main() 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/BetterCPUInlineGasTest.json b/snapshots/BetterCPUInlineGasTest.json index 56b5eac7..e556b4b3 100644 --- a/snapshots/BetterCPUInlineGasTest.json +++ b/snapshots/BetterCPUInlineGasTest.json @@ -1,8 +1,8 @@ { - "Flag0_P0ForcedSwitch": "25377", - "Turn0_Lead": "107260", - "Turn1_BothAttack": "240701", - "Turn2_BothAttack": "214777", - "Turn3_BothAttack": "210801", - "Turn4_BothAttack": "210805" + "Flag0_P0ForcedSwitch": "25427", + "Turn0_Lead": "107648", + "Turn1_BothAttack": "243027", + "Turn2_BothAttack": "217103", + "Turn3_BothAttack": "213127", + "Turn4_BothAttack": "213131" } \ No newline at end of file diff --git a/snapshots/EngineGasTest.json b/snapshots/EngineGasTest.json deleted file mode 100644 index cf70da84..00000000 --- a/snapshots/EngineGasTest.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "B1_Execute": "912113", - "B1_Setup": "850985", - "B2_Execute": "659466", - "B2_Setup": "307623", - "Battle1_Execute": "443036", - "Battle1_Setup": "826189", - "Battle2_Execute": "364327", - "Battle2_Setup": "245514", - "External_Execute": "451382", - "External_Setup": "816904", - "FirstBattle": "2920585", - "Inline_Execute": "317825", - "Inline_Setup": "227355", - "Intermediary stuff": "45252", - "SecondBattle": "2957006", - "Setup 1": "1712677", - "Setup 2": "312571", - "Setup 3": "353891", - "ThirdBattle": "2293275" -} \ No newline at end of file diff --git a/snapshots/EngineOptimizationTest.json b/snapshots/EngineOptimizationTest.json index d710c64e..c8a77275 100644 --- a/snapshots/EngineOptimizationTest.json +++ b/snapshots/EngineOptimizationTest.json @@ -1,4 +1,4 @@ { - "ExternalStaminaRegen": "389950", - "InlineStaminaRegen": "1035668" + "ExternalStaminaRegen": "416961", + "InlineStaminaRegen": "1060256" } \ No newline at end of file diff --git a/snapshots/FullyOptimizedInlineGasTest.json b/snapshots/FullyOptimizedInlineGasTest.json index 180cde78..4a4dc5e3 100644 --- a/snapshots/FullyOptimizedInlineGasTest.json +++ b/snapshots/FullyOptimizedInlineGasTest.json @@ -1,8 +1,23 @@ { - "Fast_Battle1": "1895389", - "Fast_Battle2": "1792891", - "Fast_Battle3": "1314750", - "Fast_Setup_1": "1345979", - "Fast_Setup_2": "219252", - "Fast_Setup_3": "215455" + "Fast_Battle1_coldGas": "1445192", + "Fast_Battle1_coldSload": "255", + "Fast_Battle1_noop": "24", + "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": "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/InlineEngineGasTest.json b/snapshots/InlineEngineGasTest.json deleted file mode 100644 index 7536bb1d..00000000 --- a/snapshots/InlineEngineGasTest.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "B1_Execute": "896745", - "B1_Setup": "782990", - "B2_Execute": "621601", - "B2_Setup": "286671", - "Battle1_Execute": "398480", - "Battle1_Setup": "758186", - "Battle2_Execute": "317777", - "Battle2_Setup": "226783", - "FirstBattle": "2606959", - "SecondBattle": "2604950", - "Setup 1": "1636824", - "Setup 2": "321759", - "Setup 3": "317965", - "ThirdBattle": "1979658" -} \ No newline at end of file diff --git a/snapshots/MatchmakerTest.json b/snapshots/MatchmakerTest.json index 41df196f..9ba18a19 100644 --- a/snapshots/MatchmakerTest.json +++ b/snapshots/MatchmakerTest.json @@ -1,5 +1,5 @@ { - "Accept1": "343446", - "Accept2": "34250", - "Propose1": "197406" + "Accept1": "344188", + "Accept2": "34385", + "Propose1": "197541" } \ No newline at end of file diff --git a/snapshots/StandardAttackPvPGasTest.json b/snapshots/StandardAttackPvPGasTest.json index 8909a4ad..5ca868cb 100644 --- a/snapshots/StandardAttackPvPGasTest.json +++ b/snapshots/StandardAttackPvPGasTest.json @@ -1,7 +1,7 @@ { - "Turn0_Lead": "71144", - "Turn1_BothAttack": "121432", - "Turn2_BothAttack": "81643", - "Turn3_BothAttack": "81682", - "Turn4_BothAttack": "81698" + "Turn0_Lead": "65611", + "Turn1_BothAttack": "116105", + "Turn2_BothAttack": "76318", + "Turn3_BothAttack": "76338", + "Turn4_BothAttack": "76328" } \ No newline at end of file diff --git a/snapshots/StartBattleGasTest.json b/snapshots/StartBattleGasTest.json new file mode 100644 index 00000000..1bc32c1e --- /dev/null +++ b/snapshots/StartBattleGasTest.json @@ -0,0 +1,23 @@ +{ + "StartBattle_Cold_coldGas": "1419479", + "StartBattle_Cold_coldSload": "120", + "StartBattle_Cold_noop": "13", + "StartBattle_Cold_nzToNz": "5", + "StartBattle_Cold_totalSload": "159", + "StartBattle_Cold_totalSstore": "82", + "StartBattle_Cold_zToNz": "64", + "StartBattle_WarmSame_coldGas": "267462", + "StartBattle_WarmSame_coldSload": "130", + "StartBattle_WarmSame_noop": "69", + "StartBattle_WarmSame_nzToNz": "4", + "StartBattle_WarmSame_totalSload": "168", + "StartBattle_WarmSame_totalSstore": "82", + "StartBattle_WarmSame_zToNz": "6", + "StartBattle_WarmSteady_coldGas": "267462", + "StartBattle_WarmSteady_coldSload": "130", + "StartBattle_WarmSteady_noop": "45", + "StartBattle_WarmSteady_nzToNz": "28", + "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/DefaultValidator.sol b/src/DefaultValidator.sol index 4f1022d3..831afae9 100644 --- a/src/DefaultValidator.sol +++ b/src/DefaultValidator.sol @@ -126,14 +126,17 @@ contract DefaultValidator is IValidator { view returns (bool) { - // Use batch context to minimize external calls (reduces SLOADs significantly) - ValidationContext memory vctx = ENGINE.getValidationContext(battleKey); - uint256 activeMonIndex = (playerIndex == 0) ? vctx.p0ActiveMonIndex : vctx.p1ActiveMonIndex; - bool isActiveMonKnockedOut = (playerIndex == 0) ? vctx.p0ActiveMonKnockedOut : vctx.p1ActiveMonKnockedOut; + // Inline validation is the production path; the default validator is no longer perf-critical, + // so it reads granular getters directly (the batched getValidationContext was removed to shrink + // the engine's external surface). Only the acting player's data is needed. + uint64 turnId = uint64(ENGINE.getTurnIdForBattleState(battleKey)); + uint256 activeMonIndex = ENGINE.getActiveMonIndexForBattleState(battleKey)[playerIndex]; + bool isActiveMonKnockedOut = + ENGINE.getMonStateForBattle(battleKey, playerIndex, activeMonIndex, MonStateIndexName.IsKnockedOut) == 1; // Use library for basic validation (, bool isNoOp, bool isSwitch, bool isRegularMove, bool basicValid) = - ValidatorLogic.validatePlayerMoveBasics(moveIndex, vctx.turnId, isActiveMonKnockedOut, MOVES_PER_MON); + ValidatorLogic.validatePlayerMoveBasics(moveIndex, turnId, isActiveMonKnockedOut, MOVES_PER_MON); if (!basicValid) { return false; @@ -147,67 +150,25 @@ contract DefaultValidator is IValidator { // Switch validation if (isSwitch) { uint256 monToSwitchIndex = uint256(extraData); - return _validateSwitchInternalWithContext(battleKey, playerIndex, monToSwitchIndex, vctx); + bool isTargetKnockedOut = + ENGINE.getMonStateForBattle(battleKey, playerIndex, monToSwitchIndex, MonStateIndexName.IsKnockedOut) == 1; + return ValidatorLogic.validateSwitch(turnId, activeMonIndex, monToSwitchIndex, isTargetKnockedOut, MONS_PER_TEAM); } // Regular move validation if (isRegularMove) { - return _validateSpecificMoveSelectionWithContext(battleKey, moveIndex, playerIndex, extraData, activeMonIndex, vctx); + uint32 baseStamina = ENGINE.getMonStatsForBattle(battleKey, playerIndex, activeMonIndex).stamina; + int32 staminaDelta = + ENGINE.getMonStateForBattle(battleKey, playerIndex, activeMonIndex, MonStateIndexName.Stamina); + uint256 rawMoveSlot = ENGINE.getMoveForMonForBattle(battleKey, playerIndex, activeMonIndex, moveIndex); + return ValidatorLogic.validateSpecificMoveSelection( + ENGINE, battleKey, rawMoveSlot, playerIndex, activeMonIndex, extraData, baseStamina, staminaDelta + ); } return true; } - // Internal version using ValidationContext to avoid redundant SLOADs - function _validateSwitchInternalWithContext( - bytes32 battleKey, - uint256 playerIndex, - uint256 monToSwitchIndex, - ValidationContext memory vctx - ) internal view returns (bool) { - uint256 activeMonIndex = (playerIndex == 0) ? vctx.p0ActiveMonIndex : vctx.p1ActiveMonIndex; - - // Still need external call to check if switch target is KO'd (not in context) - bool isTargetKnockedOut = - ENGINE.getMonStateForBattle(battleKey, playerIndex, monToSwitchIndex, MonStateIndexName.IsKnockedOut) == 1; - - return ValidatorLogic.validateSwitch( - vctx.turnId, - activeMonIndex, - monToSwitchIndex, - isTargetKnockedOut, - MONS_PER_TEAM - ); - } - - // Internal version using ValidationContext for stamina check - function _validateSpecificMoveSelectionWithContext( - bytes32 battleKey, - uint256 moveIndex, - uint256 playerIndex, - uint16 extraData, - uint256 activeMonIndex, - ValidationContext memory vctx - ) internal view returns (bool) { - // Use pre-fetched stamina values from context - uint32 baseStamina = (playerIndex == 0) ? vctx.p0ActiveMonBaseStamina : vctx.p1ActiveMonBaseStamina; - int32 staminaDelta = (playerIndex == 0) ? vctx.p0ActiveMonStaminaDelta : vctx.p1ActiveMonStaminaDelta; - - // Still need external call to get the move (can't batch all moves) - uint256 rawMoveSlot = ENGINE.getMoveForMonForBattle(battleKey, playerIndex, activeMonIndex, moveIndex); - - return ValidatorLogic.validateSpecificMoveSelection( - ENGINE, - battleKey, - rawMoveSlot, - playerIndex, - activeMonIndex, - extraData, - baseStamina, - staminaDelta - ); - } - /* Check switch for turn flag: diff --git a/src/Engine.sol b/src/Engine.sol index b3757bdc..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"; @@ -42,16 +43,21 @@ contract Engine is IEngine, MappingAllocator { uint256 public transient tempRNG; // Used to provide RNG during execute() tx uint256 private transient koOccurredFlag; // Set when a KO occurs, checked by _handleEffects/_handleMove int32 private transient tempPreDamage; // Running damage during PreDamage hook pipeline; mutated via setPreDamage - // Current-turn move + salt data exposed to external effects (ZapStatus, SleepStatus, StaminaRegen, etc.) - // A non-zero encoded move is the "transient is populated for this call" signal. - uint256 private transient _turnP0MoveEncoded; - uint256 private transient _turnP1MoveEncoded; - uint104 private transient _turnP0Salt; - uint104 private transient _turnP1Salt; + // Current-turn move + salt data exposed to external effects (ZapStatus, SleepStatus, StaminaRegen, etc.). + // Packed per player into ONE transient slot: [salt: bits 0-103 | encoded move: bits 104-127]. + // The encoded move is always nonzero when populated (it carries IS_REAL_TURN_BIT) and salt is only + // ever written paired with it, so `packed != 0` is exactly the "transient is populated this call" + // signal — and the salt read collapses from two transient loads to one. + uint256 private transient _turnP0Packed; + uint256 private transient _turnP1Packed; // 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(); @@ -116,7 +122,7 @@ contract Engine is IEngine, MappingAllocator { pairHashNonces[pairHash] += 1; // Ensure that the matchmaker validates the match for both players - if (!matchmaker.validateMatch(battleKey, battle.p0) || !matchmaker.validateMatch(battleKey, battle.p1)) { + if (!matchmaker.validateMatch(battleKey, battle.p0, battle.p1)) { revert MatchmakerError(); } @@ -174,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({ @@ -182,7 +189,6 @@ contract Engine is IEngine, MappingAllocator { p0TeamIndex: uint16(battle.p0TeamIndex), p1TeamIndex: uint16(battle.p1TeamIndex), winnerIndex: 2, // Initialize to 2 (uninitialized/no winner) - prevPlayerSwitchForTurnFlag: 0, playerSwitchForTurnFlag: 2, // Set flag to be 2 which means both players act activeMonIndex: 0, // Defaults to 0 (both players start with mon index 0) turnId: 0, @@ -302,7 +308,7 @@ contract Engine is IEngine, MappingAllocator { revert MovesNotSet(); } - return _executeInternal(battleKey, storageKey); + return _executeInternal(battleKey, storageKey, true); } /// @notice Combined setMove + setMove + execute for gas optimization @@ -328,16 +334,16 @@ contract Engine is IEngine, MappingAllocator { revert WrongCaller(); } - // Populate transient directly. _executeInternal sees non-zero _turnP0MoveEncoded and skips the + // Populate transient directly. _executeInternal sees non-zero _turnP0Packed and skips the // mirror-from-storage step. No SSTORE happens; transient auto-clears at tx end in prod. uint8 p0StoredMoveIndex = p0MoveIndex < SWITCH_MOVE_INDEX ? p0MoveIndex + MOVE_INDEX_OFFSET : p0MoveIndex; uint8 p1StoredMoveIndex = p1MoveIndex < SWITCH_MOVE_INDEX ? p1MoveIndex + MOVE_INDEX_OFFSET : p1MoveIndex; - _turnP0MoveEncoded = (uint256(p0StoredMoveIndex) | uint256(IS_REAL_TURN_BIT)) | (uint256(p0ExtraData) << 8); - _turnP1MoveEncoded = (uint256(p1StoredMoveIndex) | uint256(IS_REAL_TURN_BIT)) | (uint256(p1ExtraData) << 8); - _turnP0Salt = p0Salt; - _turnP1Salt = p1Salt; + _turnP0Packed = + _packTurn((uint256(p0StoredMoveIndex) | uint256(IS_REAL_TURN_BIT)) | (uint256(p0ExtraData) << 8), p0Salt); + _turnP1Packed = + _packTurn((uint256(p1StoredMoveIndex) | uint256(IS_REAL_TURN_BIT)) | (uint256(p1ExtraData) << 8), p1Salt); - return _executeInternal(battleKey, storageKey); + return _executeInternal(battleKey, storageKey, true); } /// @notice Combined single-player setMove + execute for forced switch turns @@ -364,14 +370,95 @@ contract Engine is IEngine, MappingAllocator { uint8 storedMoveIndex = moveIndex < SWITCH_MOVE_INDEX ? moveIndex + MOVE_INDEX_OFFSET : moveIndex; uint256 encoded = (uint256(storedMoveIndex) | uint256(IS_REAL_TURN_BIT)) | (uint256(extraData) << 8); if (playerIndex == 0) { - _turnP0MoveEncoded = encoded; - _turnP0Salt = salt; + _turnP0Packed = _packTurn(encoded, salt); } else { - _turnP1MoveEncoded = encoded; - _turnP1Salt = salt; + _turnP1Packed = _packTurn(encoded, salt); } - return _executeInternal(battleKey, storageKey); + return _executeInternal(battleKey, storageKey, true); + } + + /// @notice Execute every buffered turn in `entries` in one tx by looping `_executeInternal` + /// with DIRECT storage. The EVM keeps each slot warm across sub-turns, so cold SLOADs + /// are paid once per batch and amortized for free — no transient shadow layer. + /// @dev Only callable by the registered moveManager. Each `entries[i]` packs: + /// [p0Move 8 | p0Extra 16 | p0Salt 104 | p1Move 8 | p1Extra 16 | p1Salt 104] + /// Flag-based dispatch reads the live `playerSwitchForTurnFlag` to pick the acting half + /// (the non-acting player's half is a NO_OP the engine ignores). Returns the number of + /// sub-turns actually executed and the winner (zero address if the game continues). + function executeBatchedTurns(bytes32 battleKey, uint256[] calldata entries) + external + returns (uint64 executed, address winner) + { + bytes32 storageKey = _getStorageKey(battleKey); + storageKeyForWrite = storageKey; + BattleConfig storage config = battleConfig[storageKey]; + if (msg.sender != config.moveManager) { + revert WrongCaller(); + } + + for (uint256 i = 0; i < entries.length; i++) { + uint256 entry = entries[i]; + uint8 p0Move = uint8(entry); + uint16 p0Extra = uint16(entry >> 8); + uint104 p0Salt = uint104(entry >> 24); + uint8 p1Move = uint8(entry >> 128); + uint16 p1Extra = uint16(entry >> 136); + uint104 p1Salt = uint104(entry >> 152); + + // Live flag read (direct storage, warm after the first sub-turn). + uint8 flag = battleData[battleKey].playerSwitchForTurnFlag; + if (flag == 2) { + uint8 p0Stored = p0Move < SWITCH_MOVE_INDEX ? p0Move + MOVE_INDEX_OFFSET : p0Move; + uint8 p1Stored = p1Move < SWITCH_MOVE_INDEX ? p1Move + MOVE_INDEX_OFFSET : p1Move; + _turnP0Packed = _packTurn((uint256(p0Stored) | uint256(IS_REAL_TURN_BIT)) | (uint256(p0Extra) << 8), p0Salt); + _turnP1Packed = _packTurn((uint256(p1Stored) | uint256(IS_REAL_TURN_BIT)) | (uint256(p1Extra) << 8), p1Salt); + } else if (flag == 0) { + uint8 p0Stored = p0Move < SWITCH_MOVE_INDEX ? p0Move + MOVE_INDEX_OFFSET : p0Move; + _turnP0Packed = _packTurn((uint256(p0Stored) | uint256(IS_REAL_TURN_BIT)) | (uint256(p0Extra) << 8), p0Salt); + } else { + uint8 p1Stored = p1Move < SWITCH_MOVE_INDEX ? p1Move + MOVE_INDEX_OFFSET : p1Move; + _turnP1Packed = _packTurn((uint256(p1Stored) | uint256(IS_REAL_TURN_BIT)) | (uint256(p1Extra) << 8), p1Salt); + } + + winner = _executeInternal(battleKey, storageKey, false); + executed++; + if (winner != address(0)) { + break; + } + + // Reset per-turn transients so the next sub-turn starts like a fresh legacy tx. + _turnP0Packed = 0; + _turnP1Packed = 0; + tempRNG = 0; + koOccurredFlag = 0; + tempPreDamage = 0; + effectsDirtyBitmap = 0; + } + // The batched flow emits NO per-turn events (each sub-turn passes emitEvents=false to + // _executeInternal: no EngineExecute, no MonMoves). The submitter already holds every + // move + salt from the batch calldata, so the per-turn logs would be pure overhead + // (~1.6k gas/turn for MonMoves). Indexers reconstruct from the call's calldata. + } + + /// @notice Public storageKey resolver so external move managers can key per-turn buffers on + /// the engine's slot-reused storageKey (warm SSTOREs on subsequent battles). + function getStorageKey(bytes32 battleKey) external view returns (bytes32) { + return _getStorageKey(battleKey); + } + + /// @notice Minimal context for the batched submit flow: only the fields submitTurnMoves needs. + function getSubmitContext(bytes32 battleKey) + external + view + returns (address p0, address p1, uint64 turnId, uint8 winnerIndex, bytes32 storageKey) + { + storageKey = _resolveStorageKey(battleKey); + BattleData storage data = battleData[battleKey]; + p0 = data.p0; + p1 = data.p1; + turnId = data.turnId; + winnerIndex = data.winnerIndex; } /// @dev Decodes a transient-encoded move (layout: [extraData:16 | packedMoveIndex:8]) into a @@ -382,6 +469,13 @@ contract Engine is IEngine, MappingAllocator { m.extraData = uint16(encoded >> 8); } + /// @dev Packs a current-turn (encoded move, salt) pair into one transient word: + /// [salt: bits 0-103 | encoded move: bits 104-127]. Companion to the unpacks in + /// _getCurrentTurnMove / _getCurrentTurnSalt. + function _packTurn(uint256 encoded, uint104 salt) private pure returns (uint256) { + return uint256(salt) | (encoded << 104); + } + /// @dev Returns the current turn's MoveDecision for `playerIndex`. During an active /// execute, reads from transient storage (populated at the start of _executeInternal). function _getCurrentTurnMove(BattleConfig storage config, uint256 playerIndex) @@ -389,25 +483,26 @@ contract Engine is IEngine, MappingAllocator { view returns (MoveDecision memory) { - uint256 encoded = playerIndex == 0 ? _turnP0MoveEncoded : _turnP1MoveEncoded; + uint256 encoded = (playerIndex == 0 ? _turnP0Packed : _turnP1Packed) >> 104; if (encoded != 0) { return _decodeMove(encoded); } return playerIndex == 0 ? config.p0Move : config.p1Move; } - /// @dev Salt companion to `_getCurrentTurnMove`. + /// @dev Salt companion to `_getCurrentTurnMove`. One transient load covers both the + /// "populated?" check and the salt extraction. function _getCurrentTurnSalt(BattleConfig storage config, uint256 playerIndex) internal view returns (uint104) { - uint256 encoded = playerIndex == 0 ? _turnP0MoveEncoded : _turnP1MoveEncoded; - if (encoded != 0) { - return playerIndex == 0 ? _turnP0Salt : _turnP1Salt; + uint256 packed = playerIndex == 0 ? _turnP0Packed : _turnP1Packed; + if (packed != 0) { + return uint104(packed); } return playerIndex == 0 ? config.p0Salt : config.p1Salt; } /// @notice Internal execution logic shared by execute() and executeWithMoves() /// @return winner address(0) if the battle is still in progress, otherwise the winning player's address. - function _executeInternal(bytes32 battleKey, bytes32 storageKey) internal returns (address winner) { + function _executeInternal(bytes32 battleKey, bytes32 storageKey, bool emitEvents) internal returns (address winner) { // Load storage vars BattleData storage battle = battleData[battleKey]; BattleConfig storage config = battleConfig[storageKey]; @@ -420,16 +515,13 @@ contract Engine is IEngine, MappingAllocator { // `cameFromDirectMoveInput` detects whether transient was pre-populated by executeWithMoves // or executeWithSingleMove // (non-zero at entry) vs. a plain execute() call (transient is zero, helpers fall back to storage). - bool cameFromDirectMoveInput = _turnP0MoveEncoded != 0 || _turnP1MoveEncoded != 0; + bool cameFromDirectMoveInput = _turnP0Packed != 0 || _turnP1Packed != 0; // Set up turn / player vars uint256 turnId = battle.turnId; uint256 playerSwitchForTurnFlag = 2; uint256 priorityPlayerIndex; - // Store the prev player switch for turn flag - battle.prevPlayerSwitchForTurnFlag = battle.playerSwitchForTurnFlag; - // Set the battle key for the stack frame // (gets cleared at the end of the transaction) battleKeyForWrite = battleKey; @@ -450,9 +542,14 @@ contract Engine is IEngine, MappingAllocator { // inside _handleMove. Per-lane packedMoveIndex == 0 means that player did not // submit (e.g. non-acting side on a switch-only follow-up turn); if both lanes // are zero the emit is skipped entirely. + // Batched flows (executeBatchedTurns) pass emitEvents=false: the submitter already + // holds every move + salt (they're in the batch/submit calldata), so the per-turn + // MonMoves log is pure overhead there (~1.6k gas/turn) and is skipped. MoveDecision memory p0TurnMove = _getCurrentTurnMove(config, 0); MoveDecision memory p1TurnMove = _getCurrentTurnMove(config, 1); - _emitMonMoves(battleKey, config, battle, p0TurnMove, p1TurnMove); + if (emitEvents) { + _emitMonMoves(battleKey, config, battle, p0TurnMove, p1TurnMove); + } // If only a single player has a move to submit, then we don't trigger any effects // (Basically this only handles switching mons for now) @@ -709,8 +806,10 @@ contract Engine is IEngine, MappingAllocator { winner = (battle.winnerIndex == 0) ? battle.p0 : battle.p1; _handleGameOver(battleKey, winner); - // Still emit execute event - emit EngineExecute(battleKey); + // Single-execute paths emit EngineExecute + MonMoves; the batched flow passes + // emitEvents=false (no per-turn and no batch-level emit — the submitter already + // holds the full move list from the batch calldata). + if (emitEvents) emit EngineExecute(battleKey); return winner; } @@ -728,9 +827,9 @@ contract Engine is IEngine, MappingAllocator { config.p0Move.packedMoveIndex = 0; config.p1Move.packedMoveIndex = 0; } - battle.lastExecuteTimestamp = uint48(block.timestamp); + battle.lastExecuteTimestamp = uint40(block.timestamp); - emit EngineExecute(battleKey); + if (emitEvents) emit EngineExecute(battleKey); } /// @notice Clears transient storage that otherwise persists across multiple execute()/executeWithMoves() @@ -744,12 +843,16 @@ contract Engine is IEngine, MappingAllocator { /// Note: this loses `setMove`'s `isForCurrentBattle` cache hit (Engine.sol:1454) on the next setMove, /// adding one warm SLOAD per call. Production never calls this so the regression is test-only. function resetCallContext() external { - _turnP0MoveEncoded = 0; - _turnP1MoveEncoded = 0; - _turnP0Salt = 0; - _turnP1Salt = 0; + _turnP0Packed = 0; + _turnP1Packed = 0; battleKeyForWrite = bytes32(0); storageKeyForWrite = bytes32(0); + // Per-turn transients that `executeBatchedTurns` resets between sub-turns; cleared here too + // so each call starts like a fresh tx (these auto-clear at tx end in prod). + tempRNG = 0; + koOccurredFlag = 0; + tempPreDamage = 0; + effectsDirtyBitmap = 0; } function end(bytes32 battleKey) external { @@ -899,20 +1002,23 @@ 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, playerIndex, playerIndex, EffectStep.OnUpdateMonState, - abi.encode(playerIndex, monIndex, stateVarIndex, valueToAdd) + abi.encode(playerIndex, monIndex, stateVarIndex, valueToAdd), + type(uint256).max ); } } @@ -923,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); } @@ -1021,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; @@ -1123,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; @@ -1198,10 +1618,13 @@ 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) + battleKeyForWrite, tempRNG, playerIndex, playerIndex, EffectStep.PreDamage, abi.encode(source), type(uint256).max ); damage = tempPreDamage; tempPreDamage = 0; @@ -1223,15 +1646,18 @@ 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, playerIndex, playerIndex, EffectStep.AfterDamage, - abi.encode(damage, source) + abi.encode(damage, source), + type(uint256).max ); } } @@ -1288,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) @@ -1452,7 +1878,7 @@ contract Engine is IEngine, MappingAllocator { function setMove(bytes32 battleKey, uint256 playerIndex, uint8 moveIndex, uint104 salt, uint16 extraData) external { - bool isInsideExecute = _turnP0MoveEncoded != 0 || _turnP1MoveEncoded != 0; + bool isInsideExecute = _turnP0Packed != 0 || _turnP1Packed != 0; bool isForCurrentBattle = battleKeyForWrite == battleKey; bytes32 storageKey = isForCurrentBattle ? storageKeyForWrite : _getStorageKey(battleKey); @@ -1471,11 +1897,9 @@ contract Engine is IEngine, MappingAllocator { uint8 storedMoveIndex = moveIndex < SWITCH_MOVE_INDEX ? moveIndex + MOVE_INDEX_OFFSET : moveIndex; uint256 encoded = (uint256(storedMoveIndex) | uint256(IS_REAL_TURN_BIT)) | (uint256(extraData) << 8); if (playerIndex == 0) { - _turnP0MoveEncoded = encoded; - _turnP0Salt = salt; + _turnP0Packed = _packTurn(encoded, salt); } else { - _turnP1MoveEncoded = encoded; - _turnP1Salt = salt; + _turnP1Packed = _packTurn(encoded, salt); } } else { // Out-of-execute setMove (commit manager revealing across txs) - must persist to storage @@ -1557,20 +1981,20 @@ contract Engine is IEngine, MappingAllocator { // Go through each effect to see if it should be cleared after a switch, // If so, remove the effect and the extra data if (!currentMonState.isKnockedOut) { - _runEffects(battleKey, tempRNG, playerIndex, playerIndex, EffectStep.OnMonSwitchOut, ""); + _runEffects(battleKey, tempRNG, playerIndex, playerIndex, EffectStep.OnMonSwitchOut, "", type(uint256).max); // Then run the global on mon switch out hook as well - _runEffects(battleKey, tempRNG, 2, playerIndex, EffectStep.OnMonSwitchOut, ""); + _runEffects(battleKey, tempRNG, 2, playerIndex, EffectStep.OnMonSwitchOut, "", type(uint256).max); } // Update to new active mon (we assume validateSwitch already resolved and gives us a valid target) battle.activeMonIndex = _setActiveMonIndex(battle.activeMonIndex, playerIndex, monToSwitchIndex); // Run onMonSwitchIn hook for local effects - _runEffects(battleKey, tempRNG, playerIndex, playerIndex, EffectStep.OnMonSwitchIn, ""); + _runEffects(battleKey, tempRNG, playerIndex, playerIndex, EffectStep.OnMonSwitchIn, "", type(uint256).max); // Run onMonSwitchIn hook for global effects - _runEffects(battleKey, tempRNG, 2, playerIndex, EffectStep.OnMonSwitchIn, ""); + _runEffects(battleKey, tempRNG, 2, playerIndex, EffectStep.OnMonSwitchIn, "", type(uint256).max); // Run ability for the newly switched in mon as long as it's not KO'ed and as long as it's not turn 0, (execute() has a special case to run activateOnSwitch after both moves are handled) if (battle.turnId != 0 && !_getMonState(config, playerIndex, monToSwitchIndex).isKnockedOut) { @@ -1731,7 +2155,8 @@ contract Engine is IEngine, MappingAllocator { uint256 effectIndex, uint256 playerIndex, EffectStep round, - bytes memory extraEffectsData + bytes memory extraEffectsData, + uint256 effectsCountHint ) internal { BattleData storage battle = battleData[battleKey]; BattleConfig storage config = battleConfig[storageKeyForWrite]; @@ -1742,22 +2167,27 @@ contract Engine is IEngine, MappingAllocator { uint256 monIndex = (playerIndex == 2) ? 0 : _unpackActiveMonIndex(battle.activeMonIndex, playerIndex); - // Pre-compute loop metadata once (baseSlot, dirtyBit, effectsCount) + // Pre-compute loop metadata once (baseSlot, dirtyBit) // Bit 0: global, Bits 1-8: P0 mons 0-7, Bits 9-16: P1 mons 0-7 uint256 baseSlot; uint256 dirtyBit; - uint256 effectsCount; if (effectIndex == 2) { dirtyBit = 1; - effectsCount = config.globalEffectsLength; } else if (effectIndex == 0) { baseSlot = _getEffectSlotIndex(monIndex, 0); dirtyBit = 1 << (1 + monIndex); - effectsCount = _getMonEffectCount(config.packedP0EffectsCount, monIndex); } else { baseSlot = _getEffectSlotIndex(monIndex, 0); dirtyBit = 1 << (9 + monIndex); - effectsCount = _getMonEffectCount(config.packedP1EffectsCount, monIndex); + } + + // Callers whose resolved count is for THIS list (the active mon, or the global list) thread it + // in to skip the initial read; everyone else passes the sentinel and we resolve it here. Note: + // callers like updateMonState/dealDamage hold a count for a possibly-benched mon, so they MUST + // pass the sentinel — the count below is always for `monIndex` (this player's active mon). + uint256 effectsCount = effectsCountHint; + if (effectsCount == type(uint256).max) { + effectsCount = _loadEffectsCount(config, effectIndex, monIndex); } // Iterate directly over storage, skipping tombstones @@ -1831,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, @@ -1975,10 +2415,12 @@ contract Engine is IEngine, MappingAllocator { return playerSwitchForTurnFlag; } - // Short-circuit if no effects exist for this target (skip both effects and KO check) - bool hasEffects; + // Short-circuit if no effects exist for this target (skip both effects and KO check). + // The count we resolve here is for the active mon (or the global list), which is exactly + // the list _runEffects iterates — so we thread it in to skip _runEffects' own initial read. + uint256 effectsCount; if (effectIndex == 2) { - hasEffects = config.globalEffectsLength > 0; + effectsCount = config.globalEffectsLength; } else { uint256 monIndex = _unpackActiveMonIndex(battle.activeMonIndex, playerIndex); @@ -1990,15 +2432,14 @@ contract Engine is IEngine, MappingAllocator { } // Check effect count for this mon - uint256 effectCount = (effectIndex == 0) + effectsCount = (effectIndex == 0) ? _getMonEffectCount(config.packedP0EffectsCount, monIndex) : _getMonEffectCount(config.packedP1EffectsCount, monIndex); - hasEffects = effectCount > 0; } - if (hasEffects) { - // Run the effects - _runEffects(battleKey, rng, effectIndex, playerIndex, round, ""); + if (effectsCount > 0) { + // Run the effects (thread the resolved count so _runEffects skips its own initial read) + _runEffects(battleKey, rng, effectIndex, playerIndex, round, "", effectsCount); } // Only check for Game Over / KO if a KO actually occurred since last check @@ -2186,14 +2627,16 @@ 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, playerIndex, playerIndex, EffectStep.OnUpdateMonState, - abi.encode(playerIndex, monIndex, MonStateIndexName.Stamina, int32(1)) + abi.encode(playerIndex, monIndex, MonStateIndexName.Stamina, int32(1)), + type(uint256).max ); } } @@ -2527,10 +2970,6 @@ contract Engine is IEngine, MappingAllocator { p1Levels = TeamLevelInfo({monIds: p1MonIds, exp: p1Exp, levels: p1LevelArr}); } - function getBattleValidator(bytes32 battleKey) external view returns (IValidator) { - return battleConfig[_resolveStorageKey(battleKey)].validator; - } - /// @notice Validates a player move, handling both inline validation (when validator is address(0)) and external validators /// @dev This allows callers like CPU to validate moves without needing to handle the address(0) case themselves function validatePlayerMoveForBattle(bytes32 battleKey, uint256 moveIndex, uint256 playerIndex, uint16 extraData) @@ -2678,15 +3117,6 @@ contract Engine is IEngine, MappingAllocator { return _readMonStateDelta(config, playerIndex, monIndex, stateVarIndex); } - function getMonStateForStorageKey( - bytes32 storageKey, - uint256 playerIndex, - uint256 monIndex, - MonStateIndexName stateVarIndex - ) external view returns (int32) { - return _readMonStateDelta(battleConfig[storageKey], playerIndex, monIndex, stateVarIndex); - } - function _readMonStateDelta( BattleConfig storage config, uint256 playerIndex, @@ -2734,17 +3164,17 @@ contract Engine is IEngine, MappingAllocator { return result; } - function getPlayerSwitchForTurnFlagForBattleState(bytes32 battleKey) external view returns (uint256) { - return battleData[battleKey].playerSwitchForTurnFlag; + function getGlobalKV(bytes32 battleKey, uint64 key) external view returns (uint192) { + return _getGlobalKVValue(_resolveStorageKey(battleKey), key); } - function getGlobalKV(bytes32 battleKey, uint64 key) external view returns (uint192) { - bytes32 storageKey = _resolveStorageKey(battleKey); + /// @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; } @@ -2760,6 +3190,40 @@ contract Engine is IEngine, MappingAllocator { return _getEffectsForTarget(storageKey, targetIndex, monIndex); } + /// @notice Targeted single-effect lookup. Scans a mon's (or the global) effect list for + /// `effectAddr` and returns its slot index + data, WITHOUT materializing the full + /// `EffectInstance[]` array. For abilities / move-effects that only need one known + /// effect (idempotency guards, reading own state) this avoids the array build + ABI + /// round-trip that dominates `getEffects()`. `effectIndex` matches the index that + /// `editEffect` expects (absolute slot for players, list index for global). + function getEffectData(bytes32 battleKey, uint256 targetIndex, uint256 monIndex, address effectAddr) + external + view + returns (bool exists, uint256 effectIndex, bytes32 data) + { + BattleConfig storage config = battleConfig[_resolveStorageKey(battleKey)]; + if (targetIndex == 2) { + uint256 len = config.globalEffectsLength; + for (uint256 i; i < len;) { + EffectInstance storage e = config.globalEffects[i]; + if (address(e.effect) == effectAddr) return (true, i, e.data); + unchecked { ++i; } + } + return (false, 0, bytes32(0)); + } + uint96 packedCounts = targetIndex == 0 ? config.packedP0EffectsCount : config.packedP1EffectsCount; + uint256 monEffectCount = _getMonEffectCount(packedCounts, monIndex); + uint256 baseSlot = _getEffectSlotIndex(monIndex, 0); + mapping(uint256 => EffectInstance) storage effects = targetIndex == 0 ? config.p0Effects : config.p1Effects; + for (uint256 i; i < monEffectCount;) { + uint256 slotIndex = baseSlot + i; + EffectInstance storage e = effects[slotIndex]; + if (address(e.effect) == effectAddr) return (true, slotIndex, e.data); + unchecked { ++i; } + } + return (false, 0, bytes32(0)); + } + function getWinner(bytes32 battleKey) external view returns (address) { BattleData storage data = battleData[battleKey]; uint8 winnerIndex = data.winnerIndex; @@ -2769,10 +3233,6 @@ contract Engine is IEngine, MappingAllocator { return (winnerIndex == 0) ? data.p0 : data.p1; } - function getStartTimestamp(bytes32 battleKey) external view returns (uint256) { - return battleConfig[_resolveStorageKey(battleKey)].startTimestamp; - } - function getLastExecuteTimestamp(bytes32 battleKey) external view returns (uint48) { return battleData[battleKey].lastExecuteTimestamp; } @@ -2781,14 +3241,6 @@ contract Engine is IEngine, MappingAllocator { return _getKOBitmap(battleConfig[_resolveStorageKey(battleKey)], playerIndex); } - function getPrevPlayerSwitchForTurnFlagForBattleState(bytes32 battleKey) external view returns (uint256) { - return battleData[battleKey].prevPlayerSwitchForTurnFlag; - } - - function getMoveManager(bytes32 battleKey) external view returns (address) { - return battleConfig[_resolveStorageKey(battleKey)].moveManager; - } - function getBattleContext(bytes32 battleKey) external view returns (BattleContext memory ctx) { bytes32 storageKey = _resolveStorageKey(battleKey); BattleData storage data = battleData[battleKey]; @@ -2800,7 +3252,6 @@ contract Engine is IEngine, MappingAllocator { ctx.winnerIndex = data.winnerIndex; ctx.turnId = data.turnId; ctx.playerSwitchForTurnFlag = data.playerSwitchForTurnFlag; - ctx.prevPlayerSwitchForTurnFlag = data.prevPlayerSwitchForTurnFlag; ctx.p0ActiveMonIndex = uint8(data.activeMonIndex & 0xFF); ctx.p1ActiveMonIndex = uint8(data.activeMonIndex >> 8); ctx.validator = address(config.validator); @@ -2896,97 +3347,6 @@ contract Engine is IEngine, MappingAllocator { ); } - function getValidationContext(bytes32 battleKey) external view returns (ValidationContext memory ctx) { - bytes32 storageKey = _resolveStorageKey(battleKey); - BattleData storage data = battleData[battleKey]; - BattleConfig storage config = battleConfig[storageKey]; - - ctx.turnId = data.turnId; - ctx.playerSwitchForTurnFlag = data.playerSwitchForTurnFlag; - - // Get active mon indices - uint256 p0MonIndex = _unpackActiveMonIndex(data.activeMonIndex, 0); - uint256 p1MonIndex = _unpackActiveMonIndex(data.activeMonIndex, 1); - ctx.p0ActiveMonIndex = uint8(p0MonIndex); - ctx.p1ActiveMonIndex = uint8(p1MonIndex); - - // Get KO status for active mons - MonState storage p0State = config.p0States[p0MonIndex]; - MonState storage p1State = config.p1States[p1MonIndex]; - ctx.p0ActiveMonKnockedOut = p0State.isKnockedOut; - ctx.p1ActiveMonKnockedOut = p1State.isKnockedOut; - - // Get stamina info for active mons - Mon storage p0Mon = config.p0Team[p0MonIndex]; - Mon storage p1Mon = config.p1Team[p1MonIndex]; - ctx.p0ActiveMonBaseStamina = p0Mon.stats.stamina; - ctx.p0ActiveMonStaminaDelta = - p0State.staminaDelta == CLEARED_MON_STATE_SENTINEL ? int32(0) : p0State.staminaDelta; - ctx.p1ActiveMonBaseStamina = p1Mon.stats.stamina; - ctx.p1ActiveMonStaminaDelta = - p1State.staminaDelta == CLEARED_MON_STATE_SENTINEL ? int32(0) : p1State.staminaDelta; - } - - /// @notice Cheap route-only getter for CPUMoveManager.selectMove. Returns just the fields - /// needed to authenticate the caller, detect game-over, and route on the switch flag. - /// One SLOAD (p0/winnerIndex/playerSwitchForTurnFlag all live in the same BattleData - /// slot) — skips the storage-key hash, config pointer, team-sizes/KO-bitmap unpacks, - /// and p1's active-mon + move-slot reads that the full CPUContext performs. - function getCPURouteContext(bytes32 battleKey) - external - view - returns (address p0, uint8 winnerIndex, uint8 playerSwitchForTurnFlag) - { - BattleData storage data = battleData[battleKey]; - p0 = data.p0; - winnerIndex = data.winnerIndex; - playerSwitchForTurnFlag = data.playerSwitchForTurnFlag; - } - - /// @notice Batch getter for the CPU move-selection hot path. Assumes the CPU is p1. - /// @dev Consolidates everything CPUMoveManager.selectMove and CPU.calculateValidMoves need, - /// including p1's active mon move slots, in a single staticcall. - function getCPUContext(bytes32 battleKey) external view returns (CPUContext memory ctx) { - bytes32 storageKey = _resolveStorageKey(battleKey); - BattleData storage data = battleData[battleKey]; - BattleConfig storage config = battleConfig[storageKey]; - - ctx.battleKey = battleKey; - ctx.p0 = data.p0; - ctx.p1 = data.p1; - ctx.validator = address(config.validator); - ctx.winnerIndex = data.winnerIndex; - ctx.playerSwitchForTurnFlag = data.playerSwitchForTurnFlag; - ctx.turnId = data.turnId; - - uint256 p0MonIndex = _unpackActiveMonIndex(data.activeMonIndex, 0); - uint256 p1MonIndex = _unpackActiveMonIndex(data.activeMonIndex, 1); - ctx.p0ActiveMonIndex = uint8(p0MonIndex); - ctx.p1ActiveMonIndex = uint8(p1MonIndex); - - uint8 teamSizes = config.teamSizes; - ctx.p0TeamSize = teamSizes & 0x0F; - ctx.p1TeamSize = teamSizes >> 4; - - uint16 koBitmaps = config.koBitmaps; - ctx.p0KOBitmap = uint8(koBitmaps & 0xFF); - ctx.p1KOBitmap = uint8(koBitmaps >> 8); - - Mon storage p1Active = config.p1Team[p1MonIndex]; - MonState storage p1State = config.p1States[p1MonIndex]; - ctx.cpuActiveMonBaseStamina = p1Active.stats.stamina; - ctx.cpuActiveMonStaminaDelta = - p1State.staminaDelta == CLEARED_MON_STATE_SENTINEL ? int32(0) : p1State.staminaDelta; - ctx.cpuActiveMonKnockedOut = p1State.isKnockedOut; - - uint256[] storage moves = p1Active.moves; - uint256 len = moves.length; - if (len > 4) len = 4; - for (uint256 i; i < len; ++i) { - ctx.cpuActiveMonMoveSlots[i] = moves[i]; - } - } - /// @notice Returns the MonState array for one side of a battle. Used by registry-side /// quest opcodes that aggregate over MonState fields (e.g. MIN/MAX_HP_DELTA) so /// they pay 1 extcall + N internal SLOADs instead of N separate getMonStateForBattle diff --git a/src/IEngine.sol b/src/IEngine.sol index 946686a6..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, @@ -55,13 +77,20 @@ interface IEngine { function executeWithSingleMove(bytes32 battleKey, uint8 moveIndex, uint104 salt, uint16 extraData) external returns (address winner); + function executeBatchedTurns(bytes32 battleKey, uint256[] calldata entries) + external + returns (uint64 executed, address winner); function resetCallContext() external; // Getters function pairHashNonces(bytes32 pairHash) external view returns (uint256); function computeBattleKey(address p0, address p1) external view returns (bytes32 battleKey, bytes32 pairHash); function computePriorityPlayerIndex(bytes32 battleKey, uint256 rng) external view returns (uint256); - function getMoveManager(bytes32 battleKey) external view returns (address); + function getStorageKey(bytes32 battleKey) external view returns (bytes32); + function getSubmitContext(bytes32 battleKey) + external + view + returns (address p0, address p1, uint64 turnId, uint8 winnerIndex, bytes32 storageKey); function getBattle(bytes32 battleKey) external view returns (BattleConfigView memory, BattleData memory); function getMonValueForBattle( bytes32 battleKey, @@ -79,12 +108,6 @@ interface IEngine { uint256 monIndex, MonStateIndexName stateVarIndex ) external view returns (int32); - function getMonStateForStorageKey( - bytes32 storageKey, - uint256 playerIndex, - uint256 monIndex, - MonStateIndexName stateVarIndex - ) external view returns (int32); function getMoveForMonForBattle(bytes32 battleKey, uint256 playerIndex, uint256 monIndex, uint256 moveIndex) external view @@ -97,9 +120,7 @@ interface IEngine { function getTeamSize(bytes32 battleKey, uint256 playerIndex) external view returns (uint256); function getTurnIdForBattleState(bytes32 battleKey) external view returns (uint256); function getActiveMonIndexForBattleState(bytes32 battleKey) external view returns (uint256[] memory); - function getPlayerSwitchForTurnFlagForBattleState(bytes32 battleKey) external view returns (uint256); function getGlobalKV(bytes32 battleKey, uint64 key) external view returns (uint192); - function getBattleValidator(bytes32 battleKey) external view returns (IValidator); function validatePlayerMoveForBattle(bytes32 battleKey, uint256 moveIndex, uint256 playerIndex, uint16 extraData) external returns (bool); @@ -107,11 +128,13 @@ interface IEngine { external view returns (EffectInstance[] memory, uint256[] memory); + function getEffectData(bytes32 battleKey, uint256 targetIndex, uint256 monIndex, address effectAddr) + external + view + returns (bool exists, uint256 effectIndex, bytes32 data); function getWinner(bytes32 battleKey) external view returns (address); - function getStartTimestamp(bytes32 battleKey) external view returns (uint256); function getLastExecuteTimestamp(bytes32 battleKey) external view returns (uint48); function getKOBitmap(bytes32 battleKey, uint256 playerIndex) external view returns (uint256); - function getPrevPlayerSwitchForTurnFlagForBattleState(bytes32 battleKey) external view returns (uint256); function getBattleContext(bytes32 battleKey) external view returns (BattleContext memory); function getCommitContext(bytes32 battleKey) external view returns (CommitContext memory); function getCommitAuthForDualSigned(bytes32 battleKey) @@ -122,12 +145,6 @@ interface IEngine { external view returns (DamageCalcContext memory); - function getValidationContext(bytes32 battleKey) external view returns (ValidationContext memory); - function getCPUContext(bytes32 battleKey) external view returns (CPUContext memory); - function getCPURouteContext(bytes32 battleKey) - external - view - returns (address p0, uint8 winnerIndex, uint8 playerSwitchForTurnFlag); function getBattleEndContext(bytes32 battleKey) external view returns (BattleEndContext memory); function getMonStatesForSide(bytes32 battleKey, uint256 playerIndex) external diff --git a/src/Structs.sol b/src/Structs.sol index 94c63491..617e9120 100644 --- a/src/Structs.sol +++ b/src/Structs.sol @@ -73,19 +73,23 @@ struct MoveDecision { } // Stored by the Engine, tracks immutable battle data and battle state. -// Slot 0: p1 (160) + turnId (64) + p0TeamIndex (16) + p1TeamIndex (16) = 256 bits exactly. -// teamIndices are narrowed from Battle.uint96 at startBattle; phantom-team writes truncate to match. +// Slot 0 — IMMUTABLE during play (only written at startBattle): +// p1 (160) + p0TeamIndex (16) + p1TeamIndex (16) = 192 bits used. +// Slot 1 — EVERY per-turn mutation lands here, so a single SSTORE/turn covers all of them: +// p0 (160) + winnerIndex (8) + playerSwitchForTurnFlag (8) + +// activeMonIndex (16) + lastExecuteTimestamp (40) + turnId (16) = 248 bits (8 bits slack). +// turnId narrowed uint64->uint16 (65,535 turns is far beyond any real game); timestamp +// uint48->uint40 (year 36800 cap) to make room in slot 1. struct BattleData { address p1; - uint64 turnId; uint16 p0TeamIndex; uint16 p1TeamIndex; address p0; uint8 winnerIndex; // 2 = uninitialized (no winner), 0 = p0 winner, 1 = p1 winner - uint8 prevPlayerSwitchForTurnFlag; uint8 playerSwitchForTurnFlag; uint16 activeMonIndex; // Packed: lower 8 bits = player0, upper 8 bits = player1 - uint48 lastExecuteTimestamp; // Written at end of every execute() — packed with flags in slot 1 to avoid extra SSTORE + uint40 lastExecuteTimestamp; // Written at end of every execute() — packed in slot 1 with turnId + uint16 turnId; } // Stored by the Engine for a battle, is overwritten after a battle is over @@ -105,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. @@ -235,6 +245,21 @@ struct RevealedMove { uint104 salt; } +// Per-turn submission for `SignedCommitManager.submitTurnMoves` (batched flow). SINGLE-SIG model: +// the committer is `msg.sender` (no committer signature), and the revealer signs `DualSignedReveal` +// which pins the committer's move hash — so the committer can't change their move post-hoc and +// can't be impersonated (msg.sender == committer). On-chain stores the packed (p0,p1) projection. +struct TurnSubmission { + uint64 turnId; + uint8 committerMoveIndex; + uint16 committerExtraData; + uint104 committerSalt; + uint8 revealerMoveIndex; + uint16 revealerExtraData; + uint104 revealerSalt; + bytes revealerSig; +} + // Used for StatBoosts struct StatBoostToApply { MonStateIndexName stat; @@ -256,7 +281,6 @@ struct BattleContext { uint8 winnerIndex; // 2 = uninitialized (no winner), 0 = p0 winner, 1 = p1 winner uint64 turnId; uint8 playerSwitchForTurnFlag; - uint8 prevPlayerSwitchForTurnFlag; uint8 p0ActiveMonIndex; uint8 p1ActiveMonIndex; address validator; @@ -293,22 +317,6 @@ struct DamageCalcContext { Type defenderType2; } -// Batch context for move validation to reduce external calls (5+ -> 1) -struct ValidationContext { - uint64 turnId; - uint8 playerSwitchForTurnFlag; - // Per-player data - uint8 p0ActiveMonIndex; - uint8 p1ActiveMonIndex; - bool p0ActiveMonKnockedOut; - bool p1ActiveMonKnockedOut; - // Stamina info for move validation (for active mons) - uint32 p0ActiveMonBaseStamina; - int32 p0ActiveMonStaminaDelta; - uint32 p1ActiveMonBaseStamina; - int32 p1ActiveMonStaminaDelta; -} - // Bundled move metadata returned by IMoveSet.getMeta. Batches the five separate // getters (moveType / moveClass / priority / stamina / basePower) + extraDataType into // one staticcall. MoveSlotLib.decodeMeta handles both inline moves (pure bit ops) and @@ -323,7 +331,8 @@ struct MoveMeta { } // Batch context for CPU move selection. The CPU is always p1 in this codebase, -// so `cpuActiveMon*` fields mirror p1's active mon state. Returned by Engine.getCPUContext. +// so `cpuActiveMon*` fields mirror p1's active mon state. Assembled CPU-side by +// CPUMoveManager._buildCPUContext from granular engine getters. // // MoveMeta is intentionally NOT included here — only BetterCPU needs decoded metadata, and // even BetterCPU doesn't need it on turn 0 / flag==0 paths. Putting it in the shared diff --git a/src/commit-manager/SignedCommitManager.sol b/src/commit-manager/SignedCommitManager.sol index aa0831fa..d58dbe52 100644 --- a/src/commit-manager/SignedCommitManager.sol +++ b/src/commit-manager/SignedCommitManager.sol @@ -3,7 +3,7 @@ pragma solidity ^0.8.0; import {IEngine} from "../IEngine.sol"; import {IValidator} from "../IValidator.sol"; -import {CommitContext, PlayerDecisionData} from "../Structs.sol"; +import {CommitContext, PlayerDecisionData, TurnSubmission} from "../Structs.sol"; import {ECDSA} from "../lib/ECDSA.sol"; import {EIP712} from "../lib/EIP712.sol"; import {DefaultCommitManager} from "./DefaultCommitManager.sol"; @@ -53,13 +53,14 @@ contract SignedCommitManager is DefaultCommitManager, EIP712 { version = "1"; } - /// @notice Executes a turn using dual-signed moves from both players (gas-optimized) - /// @dev Both players sign off-chain — committer over `SignedCommit{committerMoveHash, …}` - /// and revealer over `DualSignedReveal{committerMoveHash, …, revealerMove…}`. Anyone - /// can submit (relayer-friendly) since both signatures are required and bind each - /// player independently. Without the explicit committer signature, a malicious - /// revealer could pick any preimage `P*`, sign `DualSignedReveal{keccak(P*), …}` - /// and play `P*` as the committer's move — the committer signature closes that. + /// @notice Executes a turn in one transaction (gas-optimized, SINGLE-SIG): the committer is + /// `msg.sender` and the revealer's signature carries their move. + /// @dev No committer signature — the committer is bound by `msg.sender == committer`. The + /// revealer signs `DualSignedReveal{committerMoveHash, …}`, which pins the committer's move + /// hash, so the committer is locked to this exact move (a different preimage would break the + /// revealer's signature) and a malicious revealer cannot play a forged committer move (they + /// are not `msg.sender == committer`). Saves one ecrecover + one 65-byte signature vs the + /// dual-sig variant. Trade-off: NOT relayer-friendly — the committer must send their own tx. /// @param battleKey The battle identifier /// @param committerMoveIndex The committer's move index /// @param committerSalt The committer's salt @@ -67,8 +68,6 @@ contract SignedCommitManager is DefaultCommitManager, EIP712 { /// @param revealerMoveIndex The revealer's move index /// @param revealerSalt The revealer's salt /// @param revealerExtraData The revealer's extra data - /// @param committerSignature EIP-712 signature from the committer over - /// SignedCommit(committerMoveHash, battleKey, turnId) /// @param revealerSignature EIP-712 signature from the revealer over /// DualSignedReveal(battleKey, turnId, committerMoveHash, revealerMove…) function executeWithDualSignedMoves( @@ -79,26 +78,16 @@ contract SignedCommitManager is DefaultCommitManager, EIP712 { uint8 revealerMoveIndex, uint104 revealerSalt, uint16 revealerExtraData, - bytes calldata committerSignature, bytes calldata revealerSignature ) external { (address committer, address revealer, uint64 turnId) = ENGINE.getCommitAuthForDualSigned(battleKey); - bytes32 committerMoveHash = keccak256(abi.encodePacked(committerMoveIndex, committerSalt, committerExtraData)); - - // Scoped to keep `commit`/`reveal` structs from sharing stack space across recoveries. - { - SignedCommitLib.SignedCommit memory commit = SignedCommitLib.SignedCommit({ - moveHash: committerMoveHash, - battleKey: battleKey, - turnId: turnId - }); - bytes32 commitDigest = _hashTypedData(SignedCommitLib.hashSignedCommit(commit)); - if (ECDSA.recoverCalldata(commitDigest, committerSignature) != committer) { - revert InvalidSignature(); - } + if (msg.sender != committer) { + revert NotCommitter(); } + bytes32 committerMoveHash = keccak256(abi.encodePacked(committerMoveIndex, committerSalt, committerExtraData)); + { SignedCommitLib.DualSignedReveal memory reveal = SignedCommitLib.DualSignedReveal({ battleKey: battleKey, @@ -234,4 +223,170 @@ contract SignedCommitManager is DefaultCommitManager, EIP712 { emit MoveCommit(battleKey, committer); } + + // --------------------------------------------------------------------- + // Batched per-turn submission (single-sig: msg.sender == committer) + // --------------------------------------------------------------------- + + error WrongTurnId(); + error EmptyBuffer(); + error NotCommitter(); + + /// @notice Packed per-turn move buffer keyed by the engine's `storageKey` (slot reuse across + /// battles → steady-state warm nz->nz SSTOREs). Layout per slot: + /// [p0Move 8 | p0Extra 16 | p0Salt 104 | p1Move 8 | p1Extra 16 | p1Salt 104] + mapping(bytes32 storageKey => mapping(uint64 turnId => uint256 packed)) public moveBuffer; + + /// @notice Packed counters per storageKey: + /// bits 0-63 numTurnsExecuted | bits 64-127 numTurnsBuffered | bits 128-191 lastSubmitTimestamp + mapping(bytes32 storageKey => uint256) public bufferCounters; + + /// @notice Append a per-turn entry to the buffer. The committer (msg.sender) supplies their + /// preimage directly; the revealer's signature pins the committer's move hash. No + /// engine execution — `executeBuffered` later drains the whole buffer in one tx. + function submitTurnMoves(bytes32 battleKey, TurnSubmission calldata entry) external { + _submitTurnMoves(battleKey, entry); + } + + /// @notice Append a per-turn entry and drain the whole buffer in the same transaction. + /// @dev Convenience for the final submission of a batch: the committer (msg.sender) submits + /// their entry and pays for execution in one call, saving a standalone `executeBuffered` + /// transaction (one fewer 21k base cost + one fewer engine context lookup). + function submitTurnMovesAndExecute(bytes32 battleKey, TurnSubmission calldata entry) external { + bytes32 storageKey = _submitTurnMoves(battleKey, entry); + _executeBuffered(battleKey, storageKey); + } + + function _submitTurnMoves(bytes32 battleKey, TurnSubmission calldata entry) internal returns (bytes32 storageKey) { + address ctxP0; + address ctxP1; + uint64 ctxTurnId; + uint8 ctxWinnerIndex; + (ctxP0, ctxP1, ctxTurnId, ctxWinnerIndex, storageKey) = ENGINE.getSubmitContext(battleKey); + + if (ctxWinnerIndex != 2) { + revert BattleAlreadyComplete(); + } + + uint256 packedCounters = bufferCounters[storageKey]; + uint64 numExecuted = uint64(packedCounters); + uint64 numBuffered = uint64(packedCounters >> 64); + if (numBuffered == 0) { + // First of a new batch: sync to the engine's live turnId (seamless legacy<->batched). + numExecuted = ctxTurnId; + } + if (entry.turnId != numExecuted + numBuffered) { + revert WrongTurnId(); + } + + (address committer, address revealer) = entry.turnId % 2 == 0 ? (ctxP0, ctxP1) : (ctxP1, ctxP0); + + // SINGLE-SIG: committer is msg.sender (no committer signature). Cheaper than dual-sig by + // one ecrecover + one 65-byte sig; the revealer sig below still pins committerMoveHash so + // the committer cannot change their move and cannot be impersonated. + if (msg.sender != committer) { + revert NotCommitter(); + } + + bytes32 committerMoveHash = + keccak256(abi.encodePacked(entry.committerMoveIndex, entry.committerSalt, entry.committerExtraData)); + + { + SignedCommitLib.DualSignedReveal memory reveal = SignedCommitLib.DualSignedReveal({ + battleKey: battleKey, + turnId: entry.turnId, + committerMoveHash: committerMoveHash, + revealerMoveIndex: entry.revealerMoveIndex, + revealerSalt: entry.revealerSalt, + revealerExtraData: entry.revealerExtraData + }); + bytes32 digest = _hashTypedData(SignedCommitLib.hashDualSignedReveal(reveal)); + if (ECDSA.recoverCalldata(digest, entry.revealerSig) != revealer) { + revert InvalidSignature(); + } + } + + uint256 packed; + if (entry.turnId % 2 == 0) { + packed = _packBufferedTurn( + entry.committerMoveIndex, entry.committerExtraData, entry.committerSalt, + entry.revealerMoveIndex, entry.revealerExtraData, entry.revealerSalt + ); + } else { + packed = _packBufferedTurn( + entry.revealerMoveIndex, entry.revealerExtraData, entry.revealerSalt, + entry.committerMoveIndex, entry.committerExtraData, entry.committerSalt + ); + } + + moveBuffer[storageKey][entry.turnId] = packed; + unchecked { + bufferCounters[storageKey] = + uint256(numExecuted) | (uint256(numBuffered + 1) << 64) | (uint256(uint64(block.timestamp)) << 128); + } + } + + /// @notice Drain every currently buffered turn in one transaction. Anyone can call. + function executeBuffered(bytes32 battleKey) external { + _executeBuffered(battleKey, ENGINE.getStorageKey(battleKey)); + } + + function _executeBuffered(bytes32 battleKey, bytes32 storageKey) internal { + uint256 packedCounters = bufferCounters[storageKey]; + uint64 numExecuted = uint64(packedCounters); + uint64 numBuffered = uint64(packedCounters >> 64); + if (numBuffered == 0) { + revert EmptyBuffer(); + } + + uint256[] memory entries = new uint256[](numBuffered); + for (uint64 i = 0; i < numBuffered; i++) { + entries[i] = moveBuffer[storageKey][numExecuted + i]; + } + (uint64 executedThisBatch,) = ENGINE.executeBatchedTurns(battleKey, entries); + + unchecked { + bufferCounters[storageKey] = + uint256(numExecuted + executedThisBatch) | (uint256(uint64(block.timestamp)) << 128); + } + } + + function getBufferStatus(bytes32 battleKey) + external + view + returns (uint64 numExecuted, uint64 numBuffered, uint64 lastSubmitTimestamp) + { + uint256 packed = bufferCounters[ENGINE.getStorageKey(battleKey)]; + numExecuted = uint64(packed); + numBuffered = uint64(packed >> 64); + lastSubmitTimestamp = uint64(packed >> 128); + } + + function getBufferedTurn(bytes32 battleKey, uint64 turnId) + external + view + returns (uint8 p0Move, uint16 p0Extra, uint104 p0Salt, uint8 p1Move, uint16 p1Extra, uint104 p1Salt) + { + return _unpackBufferedTurn(moveBuffer[ENGINE.getStorageKey(battleKey)][turnId]); + } + + function _packBufferedTurn( + uint8 p0Move, uint16 p0Extra, uint104 p0Salt, uint8 p1Move, uint16 p1Extra, uint104 p1Salt + ) internal pure returns (uint256 packed) { + packed = uint256(p0Move) | (uint256(p0Extra) << 8) | (uint256(p0Salt) << 24) + | (uint256(p1Move) << 128) | (uint256(p1Extra) << 136) | (uint256(p1Salt) << 152); + } + + function _unpackBufferedTurn(uint256 packed) + internal + pure + returns (uint8 p0Move, uint16 p0Extra, uint104 p0Salt, uint8 p1Move, uint16 p1Extra, uint104 p1Salt) + { + p0Move = uint8(packed); + p0Extra = uint16(packed >> 8); + p0Salt = uint104(packed >> 24); + p1Move = uint8(packed >> 128); + p1Extra = uint16(packed >> 136); + p1Salt = uint104(packed >> 152); + } } diff --git a/src/cpu/CPU.sol b/src/cpu/CPU.sol index c3f8d079..7a599fb3 100644 --- a/src/cpu/CPU.sol +++ b/src/cpu/CPU.sol @@ -48,7 +48,7 @@ abstract contract CPU is CPUMoveManager, ICPU, ICPURNG, IMatchmaker { public returns (RevealedMove[] memory noOp, RevealedMove[] memory moves, RevealedMove[] memory switches) { - (noOp, moves, switches,) = _calculateValidMoves(ENGINE.getCPUContext(battleKey)); + (noOp, moves, switches,) = _calculateValidMoves(_buildCPUContext(battleKey)); } /** @@ -318,7 +318,7 @@ abstract contract CPU is CPUMoveManager, ICPU, ICPURNG, IMatchmaker { ); } - function validateMatch(bytes32, address) external pure returns (bool) { + function validateMatch(bytes32, address, address) external pure returns (bool) { return true; } } diff --git a/src/cpu/CPUMoveManager.sol b/src/cpu/CPUMoveManager.sol index 52b0c109..cebda2b3 100644 --- a/src/cpu/CPUMoveManager.sol +++ b/src/cpu/CPUMoveManager.sol @@ -23,11 +23,12 @@ abstract contract CPUMoveManager { } function selectMove(bytes32 battleKey, uint8 moveIndex, uint104 salt, uint16 extraData) external { - // Cheap routing staticcall: one SLOAD for p0 / winnerIndex / playerSwitchForTurnFlag. - // When the turn is "p0 forced switch" (flag == 0) or the game is already over we return - // without ever paying for the full CPUContext (which would load team sizes, KO bitmaps, - // p1's active mon state, and all four move slots — none of which we'd use). - (address p0, uint8 winnerIndex, uint8 playerSwitchForTurnFlag) = ENGINE.getCPURouteContext(battleKey); + // Routing read: p0 / winnerIndex / playerSwitchForTurnFlag off the BattleContext (the dedicated + // getCPURouteContext getter was removed to shrink the engine surface). When the turn is "p0 + // forced switch" (flag == 0) or the game is already over we return without building the full CPUContext. + BattleContext memory rctx = ENGINE.getBattleContext(battleKey); + (address p0, uint8 winnerIndex, uint8 playerSwitchForTurnFlag) = + (rctx.p0, rctx.winnerIndex, rctx.playerSwitchForTurnFlag); if (msg.sender != p0) { revert NotP0(); @@ -42,7 +43,7 @@ abstract contract CPUMoveManager { winner = ENGINE.executeWithSingleMove(battleKey, moveIndex, salt, extraData); } else { // P1's turn or both players move: CPU calculates its move. Fetch the full context now. - CPUContext memory ctx = ENGINE.getCPUContext(battleKey); + CPUContext memory ctx = _buildCPUContext(battleKey); (uint128 cpuMoveIndex, uint16 cpuExtraData) = ICPU(address(this)).calculateMove(ctx, moveIndex, extraData); // Salt narrows to 104 bits to match the engine's storage; ample for an unpredictable @@ -61,6 +62,134 @@ abstract contract CPUMoveManager { _afterTurn(battleKey, p0, winner); } + /// @notice Off-chain-decision CPU flow: p0 submits BOTH their move and the CPU's move (computed + /// client-side) in one tx, and the engine executes them directly — NO on-chain + /// `getCPUContext` load and NO `calculateMove`. This removes the dozen-plus cold SLOADs + /// + the heuristic compute the engine would otherwise pay every CPU turn. + /// @dev Trust model: the CPU move is not verified. Lying only makes the CPU play worse against + /// p0 (a self-inflicted handicap), so there's no on-chain incentive to cheat in PvE; an + /// off-chain server/replay can still validate the CPU move if rewards depend on it. The + /// committer binding is the same as `selectMove`: `msg.sender == p0`. + function selectMoveWithCpuMove( + bytes32 battleKey, + uint8 playerMoveIndex, + uint104 playerSalt, + uint16 playerExtraData, + uint8 cpuMoveIndex, + uint16 cpuExtraData + ) external { + BattleContext memory rctx = ENGINE.getBattleContext(battleKey); + (address p0, uint8 winnerIndex, uint8 playerSwitchForTurnFlag) = + (rctx.p0, rctx.winnerIndex, rctx.playerSwitchForTurnFlag); + + if (msg.sender != p0) { + revert NotP0(); + } + if (winnerIndex != 2) { + return; + } + + address winner; + if (playerSwitchForTurnFlag == 0) { + // p0 forced switch — CPU doesn't act. + winner = ENGINE.executeWithSingleMove(battleKey, playerMoveIndex, playerSalt, playerExtraData); + } else { + // Derive the CPU's salt deterministically (no client input needed; off-chain replay + // reconstructs it from the same inputs). + uint104 cpuSalt = uint104(uint256(keccak256(abi.encode(battleKey, msg.sender, block.timestamp)))); + if (playerSwitchForTurnFlag == 1) { + // CPU forced switch — p0 doesn't act. + winner = ENGINE.executeWithSingleMove(battleKey, cpuMoveIndex, cpuSalt, cpuExtraData); + } else { + winner = ENGINE.executeWithMoves( + battleKey, playerMoveIndex, playerSalt, playerExtraData, cpuMoveIndex, cpuSalt, cpuExtraData + ); + } + } + + _afterTurn(battleKey, p0, winner); + } + + /// @notice One-tx PvE flow: p0 submits the WHOLE game's moves (theirs + the CPU's, computed + /// off-chain) plus their own per-turn salt, and the engine executes every turn in one tx — + /// collapsing the N per-turn submit txs into one. The player supplies a salt each turn (the + /// RNG entropy on their side); the CPU's salt is always 0x0, since a CPU has no move to + /// hide. CPU-ONLY by construction — PvP routes through a different move manager + /// (commit-reveal) and never reaches here. + /// @dev `moves` packs 19 bytes per turn: [p0Move 1 | p0Extra 2 | p0Salt 13 (104-bit) | p1Move 1 | + /// p1Extra 2]; the CPU's salt is omitted (0). Raw move indices (the engine applies + /// MOVE_INDEX_OFFSET). The CPU move is unverified — same trust model as `selectMoveWithCpuMove` + /// (lying only handicaps the CPU). Committer binding: `msg.sender == p0`. + function executeGame(bytes32 battleKey, bytes calldata moves) external returns (address winner) { + BattleContext memory rctx = ENGINE.getBattleContext(battleKey); + if (msg.sender != rctx.p0) { + revert NotP0(); + } + if (rctx.winnerIndex != 2) { + return address(0); + } + + uint256 numTurns = moves.length / 19; + uint256[] memory entries = new uint256[](numTurns); + for (uint256 i; i < numTurns; ++i) { + uint256 off = i * 19; + uint256 p0Move = uint8(moves[off]); + uint256 p0Extra = (uint256(uint8(moves[off + 1])) << 8) | uint256(uint8(moves[off + 2])); + uint256 p0Salt; // 13 bytes = 104 bits, big-endian + for (uint256 b; b < 13; ++b) { + p0Salt = (p0Salt << 8) | uint256(uint8(moves[off + 3 + b])); + } + uint256 p1Move = uint8(moves[off + 16]); + uint256 p1Extra = (uint256(uint8(moves[off + 17])) << 8) | uint256(uint8(moves[off + 18])); + // entry: p0Move 8 | p0Extra 16 | p0Salt 104 | p1Move 8 | p1Extra 16 | p1Salt 104 (= 0). + entries[i] = p0Move | (p0Extra << 8) | (p0Salt << 24) | (p1Move << 128) | (p1Extra << 136); + } + (, winner) = ENGINE.executeBatchedTurns(battleKey, entries); + _afterTurn(battleKey, rctx.p0, winner); + } + + /// @notice Assemble the CPU decision context from granular engine reads. Replaces the removed + /// `Engine.getCPUContext` batch getter (to be revisited in the CPU-flow overhaul). Assumes + /// the CPU is p1. `getBattle()` is the faithful source for the active mon's move-slot array + /// *with its length* (per-slot `getMoveForMonForBattle` reverts past `moves.length`, which + /// breaks <4-move mons); KO bitmaps come from the dedicated getter rather than being + /// reconstructed from monStates. + function _buildCPUContext(bytes32 battleKey) internal view returns (CPUContext memory ctx) { + (BattleConfigView memory cfg, BattleData memory data) = ENGINE.getBattle(battleKey); + + ctx.battleKey = battleKey; + ctx.p0 = data.p0; + ctx.p1 = data.p1; + ctx.validator = address(cfg.validator); + ctx.winnerIndex = data.winnerIndex; + ctx.playerSwitchForTurnFlag = data.playerSwitchForTurnFlag; + ctx.turnId = data.turnId; + + // activeMonIndex packs p0 in the low byte, p1 in the high byte. + uint256 p1MonIndex = uint8(data.activeMonIndex >> 8); + ctx.p0ActiveMonIndex = uint8(data.activeMonIndex); + ctx.p1ActiveMonIndex = uint8(p1MonIndex); + + ctx.p0TeamSize = cfg.teamSizes & 0x0F; + ctx.p1TeamSize = cfg.teamSizes >> 4; + + ctx.p0KOBitmap = uint8(ENGINE.getKOBitmap(battleKey, 0)); + ctx.p1KOBitmap = uint8(ENGINE.getKOBitmap(battleKey, 1)); + + Mon memory p1Active = cfg.teams[1][p1MonIndex]; + MonState memory p1State = cfg.monStates[1][p1MonIndex]; + ctx.cpuActiveMonBaseStamina = p1Active.stats.stamina; + ctx.cpuActiveMonStaminaDelta = + p1State.staminaDelta == CLEARED_MON_STATE_SENTINEL ? int32(0) : p1State.staminaDelta; + ctx.cpuActiveMonKnockedOut = p1State.isKnockedOut; + + uint256 len = p1Active.moves.length; + if (len > 4) len = 4; + for (uint256 i; i < len; ++i) { + ctx.cpuActiveMonMoveSlots[i] = p1Active.moves[i]; + } + } + /// @notice Post-execute hook. `winner == address(0)` means the battle is still ongoing; /// otherwise it's the winning player's address. Subclasses override to react. function _afterTurn(bytes32 battleKey, address p0, address winner) internal virtual {} 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/game-layer/GachaTeamRegistry.sol b/src/game-layer/GachaTeamRegistry.sol index d03e6441..b248d818 100644 --- a/src/game-layer/GachaTeamRegistry.sol +++ b/src/game-layer/GachaTeamRegistry.sol @@ -463,6 +463,7 @@ contract GachaTeamRegistry is uint256 preservedFlags = packed & (BONUS_AWARDED_BIT | IS_CPU_BIT | IS_HARD_CPU_BIT); uint256 points = packed & POINTS_MASK_128; uint32 lastFirstGameTs = uint32(packed >> LAST_FIRST_GAME_TS_SHIFT); + uint32 lastSeenTs = uint32(packed >> LAST_SEEN_TS_SHIFT); uint256 streakDay = (packed >> STREAK_DAY_SHIFT) & STREAK_DAY_MASK; uint32 lastQuestCompletedDay = uint32(packed >> LAST_QUEST_DAY_SHIFT); @@ -478,11 +479,16 @@ contract GachaTeamRegistry is bonusFlags |= BONUS_HARD_CPU; } - // Rolling 24h cooldown gates the streak bonus; 36h grace decides whether to - // ratchet or reset. Pure timestamp delta avoids a UTC-midnight cliff. - uint256 gap = lastFirstGameTs == 0 ? type(uint256).max : currentTime - lastFirstGameTs; - if (gap >= FIRST_GAME_OF_DAY_COOLDOWN) { - if (gap > STREAK_GRACE_WINDOW) { + // The rolling 24h cooldown (measured from the last bonus-earning game) gates the + // streak bonus to once per day. The 36h grace decides ratchet-vs-reset, but is + // measured from the last battle of ANY kind (lastSeenTs) rather than the last + // bonus: a sub-24h "early" play still counts as activity, so it can't strand the + // anchor into a phantom multi-day gap that wrongly resets an active player's + // streak. Pure timestamp delta avoids a UTC-midnight cliff. + uint256 bonusGap = lastFirstGameTs == 0 ? type(uint256).max : currentTime - lastFirstGameTs; + if (bonusGap >= FIRST_GAME_OF_DAY_COOLDOWN) { + uint256 seenGap = lastSeenTs == 0 ? type(uint256).max : currentTime - lastSeenTs; + if (seenGap > STREAK_GRACE_WINDOW) { streakDay = 1; } else if (streakDay < STREAK_FLAT_BONUS_MAX) { streakDay += 1; @@ -491,6 +497,7 @@ contract GachaTeamRegistry is lastFirstGameTs = currentTime; bonusFlags |= BONUS_FIRST_GAME; } + lastSeenTs = currentTime; // every counted battle marks activity for the grace window if ( ctx.winner == player @@ -517,6 +524,7 @@ contract GachaTeamRegistry is playerData[player] = preservedFlags | (streakDay << STREAK_DAY_SHIFT) | (uint256(lastQuestCompletedDay) << LAST_QUEST_DAY_SHIFT) + | (uint256(lastSeenTs) << LAST_SEEN_TS_SHIFT) | (uint256(lastFirstGameTs) << LAST_FIRST_GAME_TS_SHIFT) | points; diff --git a/src/game-layer/PlayerProfile.sol b/src/game-layer/PlayerProfile.sol index bbba927f..fc6a7040 100644 --- a/src/game-layer/PlayerProfile.sol +++ b/src/game-layer/PlayerProfile.sol @@ -11,8 +11,12 @@ import {IGachaPointsAssigner} from "./IGachaPointsAssigner.sol"; /// bit 254 : isWhitelistedAsOpponent (CPU flag) /// bit 253 : isHardCpu (only meaningful when bit 254 is set) /// bits 250-252 : streakDay (1..STREAK_FLAT_BONUS_MAX; 0 = no streak yet) +/// bits 224-249 : (reserved) /// bits 192-223 : lastQuestCompletedDay (uint32 calendar day) -/// bits 128-159 : lastFirstGameTimestamp (uint32 seconds since epoch) +/// bits 160-191 : lastSeenTimestamp (uint32 seconds — last battle of ANY kind; +/// drives the streak grace/reset so sub-24h plays still count as activity) +/// bits 128-159 : lastFirstGameTimestamp (uint32 seconds — last streak-bonus game; +/// gates the rolling 24h cooldown) /// bits 0-127 : pointsBalance (uint128) abstract contract PlayerProfile is IGachaPointsAssigner, Ownable { error NotAssigner(); @@ -24,6 +28,7 @@ abstract contract PlayerProfile is IGachaPointsAssigner, Ownable { uint256 internal constant STREAK_DAY_SHIFT = 250; uint256 internal constant STREAK_DAY_MASK = 0x7; uint256 internal constant LAST_FIRST_GAME_TS_SHIFT = 128; + uint256 internal constant LAST_SEEN_TS_SHIFT = 160; uint256 internal constant LAST_QUEST_DAY_SHIFT = 192; uint256 internal constant FIRST_GAME_OF_DAY_COOLDOWN = 1 days; uint256 internal constant POINTS_MASK_128 = (1 << 128) - 1; diff --git a/src/hooks/SimplePM.sol b/src/hooks/SimplePM.sol index 0818cb38..9e3006a5 100644 --- a/src/hooks/SimplePM.sol +++ b/src/hooks/SimplePM.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.0; import {IEngine} from "../IEngine.sol"; import {Ownable} from "../lib/Ownable.sol"; +import {CommitContext} from "../Structs.sol"; struct PMEntry { uint96 p0Shares; @@ -38,10 +39,13 @@ contract SimplePM is Ownable { } function buyShares(bytes32 battleKey, bool isP0) payable public { - if (ENGINE.getStartTimestamp(battleKey) == 0) { + // One batched read covers both the existence guard and the turnId (getStartTimestamp was + // removed from the engine surface). startTimestamp == 0 means the battle was never started. + CommitContext memory ctx = ENGINE.getCommitContext(battleKey); + if (ctx.startTimestamp == 0) { revert InvalidBattle(battleKey); } - uint256 turnId = ENGINE.getTurnIdForBattleState(battleKey); + uint256 turnId = ctx.turnId; if (turnId > LAST_TURN_TO_JOIN) { revert TooLate(turnId); } diff --git a/src/lib/StaminaRegenLogic.sol b/src/lib/StaminaRegenLogic.sol index 54ce982c..0ad9352c 100644 --- a/src/lib/StaminaRegenLogic.sol +++ b/src/lib/StaminaRegenLogic.sol @@ -54,7 +54,10 @@ library StaminaRegenLogic { uint256 p0ActiveMonIndex, uint256 p1ActiveMonIndex ) internal { - uint256 playerSwitchForTurnFlag = engine.getPlayerSwitchForTurnFlagForBattleState(battleKey); + // Reads the flag off the batched BattleContext (the dedicated getPlayerSwitchForTurnFlagForBattleState + // getter was removed); this external-regen path is not the production hot path, so the extra + // SLOADs are acceptable. + uint256 playerSwitchForTurnFlag = engine.getBattleContext(battleKey).playerSwitchForTurnFlag; if (!_shouldRegenOnRoundEnd(playerSwitchForTurnFlag)) return; _regenStaminaExternal(engine, battleKey, 0, p0ActiveMonIndex); _regenStaminaExternal(engine, battleKey, 1, p1ActiveMonIndex); 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/matchmaker/DefaultMatchmaker.sol b/src/matchmaker/DefaultMatchmaker.sol index 77b4842a..3aa1c82b 100644 --- a/src/matchmaker/DefaultMatchmaker.sol +++ b/src/matchmaker/DefaultMatchmaker.sol @@ -190,7 +190,7 @@ contract DefaultMatchmaker is IMatchmaker, MappingAllocator { delete preP1FillBattleKey[battleKey]; } - function validateMatch(bytes32 battleKey, address player) external view returns (bool) { + function validateMatch(bytes32 battleKey, address p0, address p1) external view returns (bool) { bytes32 battleKeyToUse = battleKey; bytes32 battleKeyOverride = preP1FillBattleKey[battleKey]; if (battleKeyOverride != bytes32(0)) { @@ -199,7 +199,9 @@ contract DefaultMatchmaker is IMatchmaker, MappingAllocator { // This line will fail if we haven't called `proposeBattle()` beforehand (e.g. if someone tries to accept an already accepted battle where p1 = address(0)) // We won't get the right storage key ProposedBattle storage proposal = proposals[_getStorageKey(battleKeyToUse)]; - bool isPlayer = player == proposal.p0 || player == proposal.p1; - return isPlayer; + // Read the proposal pair once and validate both players (batched: was two calls + two reads). + address pp0 = proposal.p0; + address pp1 = proposal.p1; + return (p0 == pp0 || p0 == pp1) && (p1 == pp0 || p1 == pp1); } } diff --git a/src/matchmaker/IMatchmaker.sol b/src/matchmaker/IMatchmaker.sol index 947655d7..bae18413 100644 --- a/src/matchmaker/IMatchmaker.sol +++ b/src/matchmaker/IMatchmaker.sol @@ -2,5 +2,5 @@ pragma solidity ^0.8.0; interface IMatchmaker { - function validateMatch(bytes32 battleKey, address player) external returns (bool); + function validateMatch(bytes32 battleKey, address p0, address p1) external returns (bool); } diff --git a/src/matchmaker/SignedMatchmaker.sol b/src/matchmaker/SignedMatchmaker.sol index 03290de0..9bc5ee92 100644 --- a/src/matchmaker/SignedMatchmaker.sol +++ b/src/matchmaker/SignedMatchmaker.sol @@ -82,7 +82,7 @@ contract SignedMatchmaker is IMatchmaker, EIP712 { ENGINE.startBattle(offer.battle); } - function validateMatch(bytes32, address) external pure returns (bool) { + function validateMatch(bytes32, address, address) external pure returns (bool) { return true; } } diff --git a/src/mons/aurox/GildedRecovery.sol b/src/mons/aurox/GildedRecovery.sol index f8c239a4..1aced326 100644 --- a/src/mons/aurox/GildedRecovery.sol +++ b/src/mons/aurox/GildedRecovery.sol @@ -4,7 +4,7 @@ pragma solidity ^0.8.0; import "../../Constants.sol"; import "../../Enums.sol"; -import {EffectInstance, MoveMeta} from "../../Structs.sol"; +import {MoveMeta} from "../../Structs.sol"; import {IEngine} from "../../IEngine.sol"; import {IMoveSet} from "../../moves/IMoveSet.sol"; @@ -37,14 +37,12 @@ contract GildedRecovery is IMoveSet { // If the mon has a status effect, remove it and heal if (statusFlag != 0) { // Find and remove the status effect - (EffectInstance[] memory effects, uint256[] memory indices) = engine.getEffects(battleKey, attackerPlayerIndex, targetMonIndex); + // Targeted lookup: engine scans for the status effect internally, no full-array build. address statusEffectAddress = address(uint160(statusFlag)); - - for (uint256 i = 0; i < effects.length; i++) { - if (address(effects[i].effect) == statusEffectAddress) { - engine.removeEffect(attackerPlayerIndex, targetMonIndex, indices[i]); - break; - } + (bool exists, uint256 idx,) = + engine.getEffectData(battleKey, attackerPlayerIndex, targetMonIndex, statusEffectAddress); + if (exists) { + engine.removeEffect(attackerPlayerIndex, targetMonIndex, idx); } // Give +1 stamina engine.updateMonState(attackerPlayerIndex, targetMonIndex, MonStateIndexName.Stamina, STAMINA_BONUS); diff --git a/src/mons/aurox/IronWall.sol b/src/mons/aurox/IronWall.sol index 8f540ecc..3f8ec3d5 100644 --- a/src/mons/aurox/IronWall.sol +++ b/src/mons/aurox/IronWall.sol @@ -4,7 +4,7 @@ pragma solidity ^0.8.0; import {DEFAULT_PRIORITY} from "../../Constants.sol"; import {ExtraDataType, MoveClass, Type, MonStateIndexName} from "../../Enums.sol"; -import {EffectInstance, MoveMeta} from "../../Structs.sol"; +import {MoveMeta} from "../../Structs.sol"; import {IEngine} from "../../IEngine.sol"; import {BasicEffect} from "../../effects/BasicEffect.sol"; import {IEffect} from "../../effects/IEffect.sol"; @@ -28,12 +28,10 @@ contract IronWall is IMoveSet, BasicEffect { uint16, uint256 ) external { - // Check to see if the effect is already active - (EffectInstance[] memory effects, ) = engine.getEffects(battleKey, attackerPlayerIndex, attackerMonIndex); - for (uint256 i = 0; i < effects.length; i++) { - if (address(effects[i].effect) == address(this)) { - return; - } + // Check to see if the effect is already active (targeted lookup, no full-array build) + (bool exists,,) = engine.getEffectData(battleKey, attackerPlayerIndex, attackerMonIndex, address(this)); + if (exists) { + return; } // The effect will last until Aurox switches out 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/Baselight.sol b/src/mons/iblivion/Baselight.sol index 5140fa47..ae30dbc8 100644 --- a/src/mons/iblivion/Baselight.sol +++ b/src/mons/iblivion/Baselight.sol @@ -2,7 +2,6 @@ pragma solidity ^0.8.0; -import {EffectInstance} from "../../Structs.sol"; import {IEngine} from "../../IEngine.sol"; import {IAbility} from "../../abilities/IAbility.sol"; import {BasicEffect} from "../../effects/BasicEffect.sol"; @@ -31,13 +30,11 @@ contract Baselight is IAbility, BasicEffect { view returns (bool exists, uint256 effectIndex, uint256 level) { - (EffectInstance[] memory effects, uint256[] memory indices) = engine.getEffects(battleKey, playerIndex, monIndex); - for (uint256 i = 0; i < effects.length; i++) { - if (address(effects[i].effect) == address(this)) { - return (true, indices[i], uint256(effects[i].data)); - } - } - return (false, 0, 0); + // Targeted lookup: the engine scans for this effect internally and returns just the match, + // avoiding the full EffectInstance[] array build + ABI round-trip of getEffects(). + bytes32 data; + (exists, effectIndex, data) = engine.getEffectData(battleKey, playerIndex, monIndex, address(this)); + level = uint256(data); } function getBaselightLevel(IEngine engine, bytes32 battleKey, uint256 playerIndex, uint256 monIndex) public view returns (uint256) { 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/src/mons/pengym/DeepFreeze.sol b/src/mons/pengym/DeepFreeze.sol index 3e111e1e..84f2b417 100644 --- a/src/mons/pengym/DeepFreeze.sol +++ b/src/mons/pengym/DeepFreeze.sol @@ -4,7 +4,7 @@ pragma solidity ^0.8.0; import "../../Constants.sol"; import "../../Enums.sol"; -import { EffectInstance, MoveMeta } from "../../Structs.sol"; +import { MoveMeta } from "../../Structs.sol"; import {IEngine} from "../../IEngine.sol"; import {IEffect} from "../../effects/IEffect.sol"; @@ -28,17 +28,9 @@ contract DeepFreeze is IMoveSet { } function _frostbiteExists(IEngine engine, bytes32 battleKey, uint256 targetIndex, uint256 monIndex) internal view returns (int32) { - (EffectInstance[] memory effects, uint256[] memory indices) = engine.getEffects(battleKey, targetIndex, monIndex); - uint256 numEffects = effects.length; - for (uint256 i; i < numEffects;) { - if (address(effects[i].effect) == address(FROSTBITE)) { - return int32(int256(indices[i])); - } - unchecked { - ++i; - } - } - return -1; + // Targeted lookup: engine scans for FROSTBITE internally, no full-array build. + (bool exists, uint256 idx,) = engine.getEffectData(battleKey, targetIndex, monIndex, address(FROSTBITE)); + return exists ? int32(int256(idx)) : int32(-1); } function move( diff --git a/src/mons/xmon/NightTerrors.sol b/src/mons/xmon/NightTerrors.sol index 06deffa2..715dba77 100644 --- a/src/mons/xmon/NightTerrors.sol +++ b/src/mons/xmon/NightTerrors.sol @@ -4,7 +4,7 @@ pragma solidity ^0.8.0; import {DEFAULT_PRIORITY, DEFAULT_ACCURACY, DEFAULT_VOL, DEFAULT_CRIT_RATE} from "../../Constants.sol"; import {ExtraDataType, MoveClass, Type, MonStateIndexName} from "../../Enums.sol"; -import { EffectInstance, MoveMeta } from "../../Structs.sol"; +import { MoveMeta } from "../../Structs.sol"; import {IEngine} from "../../IEngine.sol"; import {ITypeCalculator} from "../../types/ITypeCalculator.sol"; @@ -41,21 +41,12 @@ contract NightTerrors is IMoveSet, BasicEffect { ) external { uint256 defenderPlayerIndex = (attackerPlayerIndex + 1) % 2; - // Check if the effect is already applied to the attacker - (EffectInstance[] memory effects, uint256[] memory indices) = engine.getEffects(battleKey, attackerPlayerIndex, attackerMonIndex); - bool found = false; - uint256 effectIndex = 0; + // Check if the effect is already applied to the attacker (targeted lookup, no full-array build) + (bool found, uint256 effectIndex, bytes32 effectData) = + engine.getEffectData(battleKey, attackerPlayerIndex, attackerMonIndex, address(this)); uint64 currentTerrorCount = 0; - - for (uint256 i = 0; i < effects.length; i++) { - if (address(effects[i].effect) == address(this)) { - found = true; - effectIndex = indices[i]; - // Decode existing extraData - (, uint64 storedTerrorCount) = _unpackExtraData(effects[i].data); - currentTerrorCount = storedTerrorCount; - break; - } + if (found) { + (, currentTerrorCount) = _unpackExtraData(effectData); } // Increment terror count @@ -129,15 +120,8 @@ contract NightTerrors is IMoveSet, BasicEffect { // Get the defender's active mon index uint256 defenderMonIndex = defenderPlayerIndex == 0 ? p0ActiveMonIndex : p1ActiveMonIndex; - // Check if opponent (defender) is asleep by iterating through their effects - (EffectInstance[] memory defenderEffects, ) = engine.getEffects(battleKey, defenderPlayerIndex, defenderMonIndex); - bool isAsleep = false; - for (uint256 i = 0; i < defenderEffects.length; i++) { - if (address(defenderEffects[i].effect) == address(SLEEP_STATUS)) { - isAsleep = true; - break; - } - } + // Check if opponent (defender) is asleep (targeted lookup, no full-array build) + (bool isAsleep,,) = engine.getEffectData(battleKey, defenderPlayerIndex, defenderMonIndex, address(SLEEP_STATUS)); // Determine damage per stack based on sleep status uint32 damagePerStack = isAsleep ? ASLEEP_DAMAGE_PER_STACK : BASE_DAMAGE_PER_STACK; diff --git a/src/mons/xmon/Somniphobia.sol b/src/mons/xmon/Somniphobia.sol index 1d7d7d59..c7a9257d 100644 --- a/src/mons/xmon/Somniphobia.sol +++ b/src/mons/xmon/Somniphobia.sol @@ -4,7 +4,7 @@ pragma solidity ^0.8.0; import {NO_OP_MOVE_INDEX, DEFAULT_PRIORITY, MOVE_INDEX_MASK} from "../../Constants.sol"; import {ExtraDataType, MoveClass, Type} from "../../Enums.sol"; -import { MoveDecision, MonStateIndexName, EffectInstance, MoveMeta } from "../../Structs.sol"; +import { MoveDecision, MonStateIndexName, MoveMeta } from "../../Structs.sol"; import {IEngine} from "../../IEngine.sol"; import {IMoveSet} from "../../moves/IMoveSet.sol"; @@ -20,11 +20,9 @@ contract Somniphobia is IMoveSet, BasicEffect { function move(IEngine engine, bytes32 battleKey, uint256 attackerPlayerIndex, uint256, uint256, uint16, uint256) external { // Add effect globally for 6 turns (only if it's not already in global effects) - (EffectInstance[] memory effects, ) = engine.getEffects(battleKey, 2, 2); - for (uint256 i = 0; i < effects.length; i++) { - if (address(effects[i].effect) == address(this)) { - return; - } + (bool exists,,) = engine.getEffectData(battleKey, 2, 2, address(this)); + if (exists) { + return; } engine.addEffect(2, attackerPlayerIndex, this, bytes32(DURATION)); } diff --git a/test/BatchInstrumentationTest.sol b/test/BatchInstrumentationTest.sol deleted file mode 100644 index 1d1fb6d2..00000000 --- a/test/BatchInstrumentationTest.sol +++ /dev/null @@ -1,323 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0 -pragma solidity ^0.8.0; - -import "../lib/forge-std/src/Test.sol"; -import "../src/Constants.sol"; -import "../src/Enums.sol"; -import "../src/Structs.sol"; - -import {Engine} from "../src/Engine.sol"; -import {SignedCommitManager} from "../src/commit-manager/SignedCommitManager.sol"; -import {SignedMatchmaker} from "../src/matchmaker/SignedMatchmaker.sol"; -import {BattleOfferLib} from "../src/matchmaker/BattleOfferLib.sol"; -import {StandardAttackFactory} from "../src/moves/StandardAttackFactory.sol"; -import {ATTACK_PARAMS} from "../src/moves/StandardAttackStructs.sol"; - -import {IEngine} from "../src/IEngine.sol"; -import {IEngineHook} from "../src/IEngineHook.sol"; -import {IEffect} from "../src/effects/IEffect.sol"; -import {IMoveSet} from "../src/moves/IMoveSet.sol"; -import {IRandomnessOracle} from "../src/rng/IRandomnessOracle.sol"; -import {IRuleset} from "../src/IRuleset.sol"; -import {IValidator} from "../src/IValidator.sol"; - -import {TypeCalculator} from "../src/types/TypeCalculator.sol"; -import {ITypeCalculator} from "../src/types/ITypeCalculator.sol"; - -import {SignedCommitHelper} from "./abstract/SignedCommitHelper.sol"; -import {TestTeamRegistry} from "./mocks/TestTeamRegistry.sol"; - -/// Counts SLOAD / SSTORE access patterns on a warm steady-state turn, to ground the PLAN_OPT.md -/// gas math in real data instead of estimates. -contract BatchInstrumentationTest is SignedCommitHelper { - - uint256 constant MONS_PER_TEAM = 4; - uint256 constant MOVES_PER_MON = 4; - - uint256 constant P0_PK = 0xA11CE; - uint256 constant P1_PK = 0xB0B; - address p0; - address p1; - - Engine engine; - SignedCommitManager signedCommitManager; - SignedMatchmaker signedMatchmaker; - ITypeCalculator typeCalc; - TestTeamRegistry defaultRegistry; - StandardAttackFactory attackFactory; - - function setUp() public { - p0 = vm.addr(P0_PK); - p1 = vm.addr(P1_PK); - - engine = new Engine(MONS_PER_TEAM, MOVES_PER_MON, 1); - signedCommitManager = new SignedCommitManager(IEngine(address(engine))); - signedMatchmaker = new SignedMatchmaker(engine); - typeCalc = new TypeCalculator(); - defaultRegistry = new TestTeamRegistry(); - attackFactory = new StandardAttackFactory(typeCalc); - } - - function _startBattle(IRuleset ruleset) internal returns (bytes32) { - address[] memory makersToAdd = new address[](1); - makersToAdd[0] = address(signedMatchmaker); - address[] memory makersToRemove = new address[](0); - vm.prank(p0); - engine.updateMatchmakers(makersToAdd, makersToRemove); - vm.prank(p1); - engine.updateMatchmakers(makersToAdd, makersToRemove); - - (bytes32 battleKey, bytes32 pairHash) = engine.computeBattleKey(p0, p1); - uint256 nonce = engine.pairHashNonces(pairHash); - - BattleOffer memory offer = BattleOffer({ - battle: Battle({ - p0: p0, - p0TeamIndex: 0, - p1: p1, - p1TeamIndex: 0, - teamRegistry: defaultRegistry, - validator: IValidator(address(0)), - rngOracle: IRandomnessOracle(address(0)), - ruleset: ruleset, - moveManager: address(signedCommitManager), - matchmaker: signedMatchmaker, - engineHooks: new IEngineHook[](0) - }), - pairHashNonce: nonce - }); - - bytes32 structHash = BattleOfferLib.hashBattleOffer(offer); - bytes32 digest = signedMatchmaker.hashTypedData(structHash); - (uint8 v, bytes32 r, bytes32 s) = vm.sign(P0_PK, digest); - bytes memory signature = abi.encodePacked(r, s, v); - - vm.prank(p1); - signedMatchmaker.startGame(offer, signature); - - return battleKey; - } - - function _fastTurn( - bytes32 battleKey, - uint8 p0MoveIndex, - uint8 p1MoveIndex, - uint16 p0ExtraData, - uint16 p1ExtraData - ) internal { - uint64 turnId = uint64(engine.getTurnIdForBattleState(battleKey)); - uint104 committerSalt = uint104(uint256(keccak256(abi.encode("committer", battleKey, turnId)))); - uint104 revealerSalt = uint104(uint256(keccak256(abi.encode("revealer", battleKey, turnId)))); - - uint8 committerMoveIndex; - uint16 committerExtraData; - uint8 revealerMoveIndex; - uint16 revealerExtraData; - uint256 committerPk; - uint256 revealerPk; - - if (turnId % 2 == 0) { - committerMoveIndex = p0MoveIndex; - committerExtraData = p0ExtraData; - revealerMoveIndex = p1MoveIndex; - revealerExtraData = p1ExtraData; - committerPk = P0_PK; - revealerPk = P1_PK; - } else { - committerMoveIndex = p1MoveIndex; - committerExtraData = p1ExtraData; - revealerMoveIndex = p0MoveIndex; - revealerExtraData = p0ExtraData; - committerPk = P1_PK; - revealerPk = P0_PK; - } - - bytes32 committerMoveHash = - keccak256(abi.encodePacked(committerMoveIndex, committerSalt, committerExtraData)); - bytes memory committerSig = - _signCommit(address(signedCommitManager), committerPk, committerMoveHash, battleKey, turnId); - bytes memory revealerSig = _signDualReveal( - address(signedCommitManager), - revealerPk, - battleKey, - turnId, - committerMoveHash, - revealerMoveIndex, - revealerSalt, - revealerExtraData - ); - - signedCommitManager.executeWithDualSignedMoves( - battleKey, - committerMoveIndex, committerSalt, committerExtraData, - revealerMoveIndex, revealerSalt, revealerExtraData, - committerSig, - revealerSig - ); - engine.resetCallContext(); - } - - function _createMon(Type t1) internal pure returns (Mon memory) { - return Mon({ - stats: MonStats({ - hp: 10000, - stamina: 50, - speed: 10, - attack: 30, - defense: 10, - specialAttack: 30, - specialDefense: 10, - type1: t1, - type2: Type.None - }), - moves: new uint256[](0), - ability: 0 - }); - } - - /// @dev Iterates account accesses returned by stopAndReturnStateDiff and counts SLOAD/SSTORE - /// per (account, slot) — distinguishing first-touch (cold) from subsequent (warm), and - /// for SSTORE distinguishing zero→nonzero / nonzero→nonzero / no-op. - function _summarizeAccesses(Vm.AccountAccess[] memory accesses) - internal - pure - returns ( - uint256 totalSloadCount, - uint256 totalSstoreCount, - uint256 coldSloads, - uint256 warmSloads, - uint256 coldSstores, - uint256 warmSstores, - uint256 zeroToNonzeroSstores, - uint256 nonzeroToNonzeroSstores, - uint256 noopSstores, - uint256 uniqueSlotsTouched, - uint256 multiWriteSlots - ) - { - // Count slot-touch frequencies via a small fixed-capacity table (we don't expect many uniques) - bytes32[] memory keys = new bytes32[](256); - uint8[] memory writes = new uint8[](256); - bool[] memory reads = new bool[](256); - uint256 keyCount; - - for (uint256 i = 0; i < accesses.length; i++) { - Vm.StorageAccess[] memory storageAccesses = accesses[i].storageAccesses; - for (uint256 j = 0; j < storageAccesses.length; j++) { - Vm.StorageAccess memory a = storageAccesses[j]; - bytes32 key = keccak256(abi.encode(a.account, a.slot)); - - // Locate or create entry - uint256 idx = keyCount; - for (uint256 k = 0; k < keyCount; k++) { - if (keys[k] == key) { - idx = k; - break; - } - } - if (idx == keyCount) { - keys[idx] = key; - keyCount++; - } - - if (a.isWrite) { - totalSstoreCount++; - writes[idx]++; - if (a.previousValue == bytes32(0) && a.newValue != bytes32(0)) zeroToNonzeroSstores++; - else if (a.previousValue != bytes32(0) && a.newValue != bytes32(0) && a.previousValue != a.newValue) - nonzeroToNonzeroSstores++; - else if (a.previousValue == a.newValue) noopSstores++; - - if (writes[idx] == 1 && !reads[idx]) { - coldSstores++; - } else { - warmSstores++; - } - } else { - totalSloadCount++; - if (!reads[idx] && writes[idx] == 0) { - coldSloads++; - reads[idx] = true; - } else { - warmSloads++; - } - } - } - } - - uniqueSlotsTouched = keyCount; - for (uint256 i = 0; i < keyCount; i++) { - if (writes[i] >= 2) multiWriteSlots++; - } - } - - /// @notice Per-turn storage-access profile for a clean PvP damage-trade turn (steady state). - function test_storageAccessProfile_cleanDamageTradeTurn() public { - IMoveSet moveA = attackFactory.createAttack( - ATTACK_PARAMS({ - BASE_POWER: 30, STAMINA_COST: 1, ACCURACY: 100, PRIORITY: 1, - MOVE_TYPE: Type.Fire, EFFECT_ACCURACY: 0, MOVE_CLASS: MoveClass.Physical, - CRIT_RATE: 0, VOLATILITY: 0, NAME: "AttackA", EFFECT: IEffect(address(0)) - }) - ); - IMoveSet moveB = attackFactory.createAttack( - ATTACK_PARAMS({ - BASE_POWER: 25, STAMINA_COST: 1, ACCURACY: 100, PRIORITY: 1, - MOVE_TYPE: Type.Fire, EFFECT_ACCURACY: 0, MOVE_CLASS: MoveClass.Special, - CRIT_RATE: 0, VOLATILITY: 0, NAME: "AttackB", EFFECT: IEffect(address(0)) - }) - ); - - Mon memory mon = _createMon(Type.Fire); - mon.moves = new uint256[](MOVES_PER_MON); - mon.moves[0] = uint256(uint160(address(moveA))); - mon.moves[1] = uint256(uint160(address(moveB))); - mon.moves[2] = uint256(uint160(address(moveA))); - mon.moves[3] = uint256(uint160(address(moveB))); - - Mon[] memory team = new Mon[](MONS_PER_TEAM); - for (uint256 i; i < MONS_PER_TEAM; i++) team[i] = mon; - defaultRegistry.setTeam(p0, team); - defaultRegistry.setTeam(p1, team); - - IRuleset ruleset = IRuleset(INLINE_STAMINA_REGEN_RULESET); - bytes32 battleKey = _startBattle(ruleset); - vm.warp(vm.getBlockTimestamp() + 1); - - // Warm-up: lead-in switch + 1 damage trade. - _fastTurn(battleKey, SWITCH_MOVE_INDEX, SWITCH_MOVE_INDEX, uint16(0), uint16(0)); - _fastTurn(battleKey, 0, 0, 0, 0); - - // Now profile a steady-state warm turn. - vm.startStateDiffRecording(); - _fastTurn(battleKey, 1, 1, 0, 0); - Vm.AccountAccess[] memory diffs = vm.stopAndReturnStateDiff(); - - ( - uint256 totalSload, - uint256 totalSstore, - uint256 coldSload, - uint256 warmSload, - uint256 coldSstore, - uint256 warmSstore, - uint256 z2nz, - uint256 nz2nz, - uint256 noop, - uint256 unique, - uint256 multiWrite - ) = _summarizeAccesses(diffs); - - console.log("=== CLEAN DAMAGE-TRADE TURN - STORAGE PROFILE ==="); - console.log("Total SLOADs :", totalSload); - console.log(" Cold (first-touch in tx) :", coldSload); - console.log(" Warm :", warmSload); - console.log("Total SSTOREs :", totalSstore); - console.log(" Cold (first-touch in tx) :", coldSstore); - console.log(" Warm :", warmSstore); - console.log(" zero -> nonzero :", z2nz); - console.log(" nonzero -> nonzero (diff) :", nz2nz); - console.log(" no-op (same value) :", noop); - console.log("Unique slots touched :", unique); - console.log("Slots written 2+ times in turn :", multiWrite); - } -} diff --git a/test/BetterCPUInlineGasTest.sol b/test/BetterCPUInlineGasTest.sol deleted file mode 100644 index e7b4112e..00000000 --- a/test/BetterCPUInlineGasTest.sol +++ /dev/null @@ -1,251 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0 -pragma solidity ^0.8.0; - -import "../lib/forge-std/src/Test.sol"; - -import "../src/Constants.sol"; -import "../src/Enums.sol"; -import "../src/Structs.sol"; - -import {Engine} from "../src/Engine.sol"; - -import {DefaultCommitManager} from "../src/commit-manager/DefaultCommitManager.sol"; -import {BetterCPU} from "../src/cpu/BetterCPU.sol"; - -import {StandardAttackFactory} from "../src/moves/StandardAttackFactory.sol"; -import {DefaultRandomnessOracle} from "../src/rng/DefaultRandomnessOracle.sol"; - -import {MockCPURNG} from "./mocks/MockCPURNG.sol"; -import {TestTeamRegistry} from "./mocks/TestTeamRegistry.sol"; -import {TestTypeCalculator} from "./mocks/TestTypeCalculator.sol"; - -import {IEffect} from "../src/effects/IEffect.sol"; -import {IValidator} from "../src/IValidator.sol"; - -import {DefaultMatchmaker} from "../src/matchmaker/DefaultMatchmaker.sol"; -import {IMoveSet} from "../src/moves/IMoveSet.sol"; -import {ATTACK_PARAMS} from "../src/moves/StandardAttackStructs.sol"; - -/// @title BetterCPU inline-validator gas benchmark -/// @notice Measures BetterCPU.selectMove cost in the production-shape configuration: -/// validator == address(0) (inline validation via the engine's immutable defaults). -/// The existing BetterCPUTest suite uses DefaultValidator and therefore never hits -/// CPU.sol's inline validation fast path, so its numbers understate the production -/// savings from getCPURouteContext + inline-validation-in-CPU. -contract BetterCPUInlineGasTest is Test { - Engine engine; - DefaultCommitManager commitManager; - BetterCPU cpu; - DefaultRandomnessOracle defaultOracle; - TestTypeCalculator typeCalc; - TestTeamRegistry teamRegistry; - MockCPURNG mockCPURNG; - DefaultMatchmaker matchmaker; - StandardAttackFactory attackFactory; - - address constant ALICE = address(1); - - function setUp() public { - defaultOracle = new DefaultRandomnessOracle(); - // Engine constructed with real inline-validation bounds so validator == address(0) works. - engine = new Engine(4, 4, 10); - commitManager = new DefaultCommitManager(engine); - mockCPURNG = new MockCPURNG(); - typeCalc = new TestTypeCalculator(); - teamRegistry = new TestTeamRegistry(); - matchmaker = new DefaultMatchmaker(engine); - attackFactory = new StandardAttackFactory(typeCalc); - } - - function _createMon(Type t, uint32 hp, uint32 attack, uint32 defense) internal pure returns (Mon memory) { - return Mon({ - stats: MonStats({ - hp: hp, - stamina: 10, - speed: 10, - attack: attack, - defense: defense, - specialAttack: attack, - specialDefense: defense, - type1: t, - type2: Type.None - }), - moves: new uint256[](0), - ability: 0 - }); - } - - function _createAttack(uint32 basePower, Type moveType, MoveClass moveClass) internal returns (IMoveSet) { - return attackFactory.createAttack( - ATTACK_PARAMS({ - BASE_POWER: basePower, - STAMINA_COST: 1, - ACCURACY: 100, - PRIORITY: 1, - MOVE_TYPE: moveType, - EFFECT_ACCURACY: 0, - MOVE_CLASS: moveClass, - CRIT_RATE: 0, - VOLATILITY: 0, - NAME: "Attack", - EFFECT: IEffect(address(0)) - }) - ); - } - - function _startBattleInline(Mon[] memory aliceTeam, Mon[] memory cpuTeam) internal returns (bytes32) { - cpu = new BetterCPU(4, engine, mockCPURNG, typeCalc); - - teamRegistry.setTeam(ALICE, aliceTeam); - teamRegistry.setTeam(address(cpu), cpuTeam); - - ProposedBattle memory proposal = ProposedBattle({ - p0: ALICE, - p0TeamIndex: 0, - p0TeamHash: keccak256( - abi.encodePacked(bytes32(""), uint256(0), teamRegistry.getMonRegistryIndicesForTeam(ALICE, 0)) - ), - p1: address(cpu), - p1TeamIndex: 0, - validator: IValidator(address(0)), // inline validation - rngOracle: defaultOracle, - ruleset: IRuleset(address(0)), - teamRegistry: teamRegistry, - engineHooks: new IEngineHook[](0), - moveManager: address(cpu), - matchmaker: cpu - }); - - vm.startPrank(ALICE); - address[] memory makersToAdd = new address[](1); - makersToAdd[0] = address(cpu); - address[] memory makersToRemove = new address[](0); - engine.updateMatchmakers(makersToAdd, makersToRemove); - - return cpu.startBattle(proposal); - } - - function _buildFourMoveSet() internal returns (uint256[] memory moves) { - IMoveSet[] memory moveSet = new IMoveSet[](4); - moveSet[0] = _createAttack(20, Type.Fire, MoveClass.Physical); - moveSet[1] = _createAttack(40, Type.Fire, MoveClass.Physical); - moveSet[2] = _createAttack(10, Type.Fire, MoveClass.Special); - moveSet[3] = _createAttack(30, Type.Fire, MoveClass.Special); - moves = new uint256[](4); - for (uint256 i = 0; i < 4; i++) { - moves[i] = uint256(uint160(address(moveSet[i]))); - } - } - - /// @notice Hot path: both-players-move turns (flag == 2). Mon HP and defense are tuned so - /// no KOs fire during the measured window, keeping all sampled turns on the same - /// path through BetterCPU.calculateMove + CPU._calculateValidMoves. - function test_betterCPUInlineGas_flag2_hotPath() public { - uint256[] memory moves = _buildFourMoveSet(); - - Mon[] memory aliceTeam = new Mon[](4); - Mon[] memory cpuTeam = new Mon[](4); - for (uint256 i = 0; i < 4; i++) { - // High HP + high defense so 4 turns of attacks never KO anyone. - aliceTeam[i] = _createMon(Type.Fire, 10000, 10, 1000); - aliceTeam[i].moves = moves; - cpuTeam[i] = _createMon(Type.Fire, 10000, 10, 1000); - cpuTeam[i].moves = moves; - } - - bytes32 battleKey = _startBattleInline(aliceTeam, cpuTeam); - vm.warp(vm.getBlockTimestamp() + 1); - mockCPURNG.setRNG(0); - - // Turn 0: lead selection. Both players "switch in" a starting mon. - vm.startSnapshotGas("Turn0_Lead"); - cpu.selectMove(battleKey, SWITCH_MOVE_INDEX, 0, uint16(0)); - uint256 turn0Gas = vm.stopSnapshotGas("Turn0_Lead"); - engine.resetCallContext(); - - // Turns 1-4: both attack with move 1. Every one is flag == 2, no KOs. - vm.startSnapshotGas("Turn1_BothAttack"); - cpu.selectMove(battleKey, 1, uint104(0), 0); - uint256 turn1Gas = vm.stopSnapshotGas("Turn1_BothAttack"); - engine.resetCallContext(); - - vm.startSnapshotGas("Turn2_BothAttack"); - cpu.selectMove(battleKey, 1, uint104(0), 0); - uint256 turn2Gas = vm.stopSnapshotGas("Turn2_BothAttack"); - engine.resetCallContext(); - - vm.startSnapshotGas("Turn3_BothAttack"); - cpu.selectMove(battleKey, 1, uint104(0), 0); - uint256 turn3Gas = vm.stopSnapshotGas("Turn3_BothAttack"); - engine.resetCallContext(); - - vm.startSnapshotGas("Turn4_BothAttack"); - cpu.selectMove(battleKey, 1, uint104(0), 0); - uint256 turn4Gas = vm.stopSnapshotGas("Turn4_BothAttack"); - engine.resetCallContext(); - - // Sanity check: no winner yet. - assertEq(engine.getWinner(battleKey), address(0), "battle must still be in progress"); - assertEq(engine.getPlayerSwitchForTurnFlagForBattleState(battleKey), 2, "flag must still be 2"); - - uint256 avgBothAttack = (turn1Gas + turn2Gas + turn3Gas + turn4Gas) / 4; - - console.log("========================================"); - console.log("BetterCPU inline, flag==2 hot path"); - console.log("========================================"); - console.log("Turn 0 (lead select) :", turn0Gas); - console.log("Turn 1 (both attack, flag=2) :", turn1Gas); - console.log("Turn 2 (both attack, flag=2) :", turn2Gas); - console.log("Turn 3 (both attack, flag=2) :", turn3Gas); - console.log("Turn 4 (both attack, flag=2) :", turn4Gas); - console.log("Average flag=2 BetterCPU.selectMove :", avgBothAttack); - console.log("========================================"); - } - - /// @notice Cheap-router path: a turn where Alice is the only player that must act - /// (flag == 0) because CPU's previous move KO'd Alice's active mon. On this turn - /// CPUMoveManager.selectMove should fetch getCPURouteContext (~3.4k) and call - /// executeWithSingleMove directly, without ever touching getCPUContext or - /// BetterCPU.calculateMove. - function test_betterCPUInlineGas_flag0_cheapRouter() public { - uint256[] memory moves = _buildFourMoveSet(); - - // CPU hits hard: one move dispatch KOs Alice's mon. - Mon[] memory aliceTeam = new Mon[](4); - Mon[] memory cpuTeam = new Mon[](4); - for (uint256 i = 0; i < 4; i++) { - aliceTeam[i] = _createMon(Type.Fire, 50, 10, 10); - aliceTeam[i].moves = moves; - cpuTeam[i] = _createMon(Type.Fire, 100, 200, 10); - cpuTeam[i].moves = moves; - } - - bytes32 battleKey = _startBattleInline(aliceTeam, cpuTeam); - vm.warp(vm.getBlockTimestamp() + 1); - mockCPURNG.setRNG(0); - - // Turn 0: lead. - cpu.selectMove(battleKey, SWITCH_MOVE_INDEX, 0, uint16(0)); - engine.resetCallContext(); - - // Turn 1: both attack. CPU's move 1 (BP=40, attack=200, defense=10) should KO Alice. - cpu.selectMove(battleKey, 1, uint104(0), 0); - engine.resetCallContext(); - - // After the KO we should be in flag==0 (Alice forced switch). - uint256 flag = engine.getPlayerSwitchForTurnFlagForBattleState(battleKey); - assertEq(flag, 0, "expected flag==0 forced p0 switch after KO"); - - // Measure the cheap-router path: Alice submits her switch via the CPU manager. - vm.startSnapshotGas("Flag0_P0ForcedSwitch"); - cpu.selectMove(battleKey, SWITCH_MOVE_INDEX, 0, uint16(1)); - uint256 flag0Gas = vm.stopSnapshotGas("Flag0_P0ForcedSwitch"); - engine.resetCallContext(); - - console.log("========================================"); - console.log("BetterCPU inline, flag==0 cheap-router path"); - console.log("========================================"); - console.log("Flag==0 selectMove (p0 forced switch, CPU manager):", flag0Gas); - console.log("========================================"); - } -} diff --git a/test/BetterCPUTest.sol b/test/BetterCPUTest.sol index b30e8f08..16b0f8f3 100644 --- a/test/BetterCPUTest.sol +++ b/test/BetterCPUTest.sol @@ -1745,7 +1745,7 @@ contract BetterCPUTest is Test { } function _cpuActive(bytes32 battleKey) internal view returns (uint256) { - return engine.getCPUContext(battleKey).p1ActiveMonIndex; + return engine.getActiveMonIndexForBattleState(battleKey)[1]; } /// @dev Mirror of `_startBattleWithCPU` but instantiates `TestBetterCPU` (with exposed diff --git a/test/CPUTest.sol b/test/CPUTest.sol index b6180516..166552f5 100644 --- a/test/CPUTest.sol +++ b/test/CPUTest.sol @@ -542,4 +542,82 @@ contract CPUTest is Test { hpDelta = engine.getMonStateForBattle(battleKey, 0, 0, MonStateIndexName.Hp); assertEq(hpDelta, -2); } + + // One-tx PvE: p0 submits the whole game (their moves + CPU's moves) + a per-turn player salt in one + // call; CPU salt is always 0. Batched execution skips the per-turn MonMoves event (the submitter + // already holds every move + salt from the executeGame calldata), so we verify the 19-byte/turn + // decode end-to-end via deterministic final state, and assert no MonMoves logs are emitted. + function test_executeGame_decodesStreamWithoutMonMovesEvents() public { + TestMoveFactory moveFactory = new TestMoveFactory(); + uint256[] memory moves = new uint256[](2); + moves[0] = uint256(uint160(address(moveFactory.createMove(MoveClass.Self, Type.Liquid, 0, 0)))); + moves[1] = uint256(uint160(address(moveFactory.createMove(MoveClass.Physical, Type.Liquid, 0, 1)))); + Mon memory mon = _createMon(Type.Liquid); + mon.stats.hp = 100; + mon.stats.stamina = 10; + mon.moves = moves; + Mon[] memory team = new Mon[](1); + team[0] = mon; + + OkayCPU okayCPU = new OkayCPU(moves.length, engine, mockCPURNG, typeCalc); + teamRegistry.setTeam(address(okayCPU), team); + teamRegistry.setTeam(ALICE, team); + DefaultValidator v = new DefaultValidator( + engine, DefaultValidator.Args({MONS_PER_TEAM: 1, MOVES_PER_MON: 2, TIMEOUT_DURATION: 10}) + ); + ProposedBattle memory proposal = ProposedBattle({ + p0: ALICE, + p0TeamIndex: 0, + p0TeamHash: keccak256( + abi.encodePacked(bytes32(""), uint256(0), teamRegistry.getMonRegistryIndicesForTeam(ALICE, 0)) + ), + p1: address(okayCPU), + p1TeamIndex: 0, + validator: v, + rngOracle: defaultOracle, + ruleset: IRuleset(address(0)), + teamRegistry: teamRegistry, + engineHooks: new IEngineHook[](0), + moveManager: address(okayCPU), + matchmaker: okayCPU + }); + vm.startPrank(ALICE); + address[] memory makersToAdd = new address[](1); + makersToAdd[0] = address(okayCPU); + engine.updateMatchmakers(makersToAdd, new address[](0)); + bytes32 battleKey = okayCPU.startBattle(proposal); + vm.stopPrank(); + + // Turn plan: [switch-to-0, attack, attack] with distinct player salts; CPU salt always 0. + uint8[3] memory p0m = [uint8(SWITCH_MOVE_INDEX), uint8(1), uint8(1)]; + uint8[3] memory p1m = [uint8(SWITCH_MOVE_INDEX), uint8(1), uint8(1)]; + uint104[3] memory p0s = [uint104(0xAAAA1), uint104(0xBBBB2), uint104(0xCCCC3)]; + bytes memory stream; + for (uint256 i; i < 3; i++) { + // [p0Move 1 | p0Extra 2 | p0Salt 13 | p1Move 1 | p1Extra 2] + stream = abi.encodePacked(stream, p0m[i], uint16(0), p0s[i], p1m[i], uint16(0)); + } + assertEq(stream.length, 3 * 19, "19 bytes per turn"); + + vm.recordLogs(); + vm.prank(ALICE); + okayCPU.executeGame(battleKey, stream); + Vm.Log[] memory logs = vm.getRecordedLogs(); + + // Batched execution must NOT emit per-turn MonMoves events (the submitter already holds every + // move + salt from the executeGame calldata, so the log would be pure overhead). + bytes32 monMovesSig = keccak256("MonMoves(bytes32,uint256,uint256)"); + for (uint256 i; i < logs.length; i++) { + if (logs[i].emitter == address(engine)) { + assertTrue(logs[i].topics[0] != monMovesSig, "batched mode must not emit MonMoves"); + } + } + + // The 19-byte/turn stream decoded correctly: after the turn-0 send-in, both mons used move 1 + // (deal-1 damage) on turns 1 and 2, so each active mon took exactly 2 deterministic points of + // damage. A misframed decode would corrupt the move bytes and diverge this final state. + assertEq(engine.getTurnIdForBattleState(battleKey), 3, "all 3 turns executed"); + assertEq(engine.getMonStateForBattle(battleKey, 0, 0, MonStateIndexName.Hp), -2, "p0 mon0 took 2 dmg"); + assertEq(engine.getMonStateForBattle(battleKey, 1, 0, MonStateIndexName.Hp), -2, "cpu mon0 took 2 dmg"); + } } diff --git a/test/EngineGasTest.sol b/test/EngineGasTest.sol deleted file mode 100644 index 218db0b8..00000000 --- a/test/EngineGasTest.sol +++ /dev/null @@ -1,783 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0 -pragma solidity ^0.8.0; - -import "../lib/forge-std/src/Test.sol"; - -import "../src/Constants.sol"; -import "../src/Enums.sol"; -import "../src/Structs.sol"; - -import {DefaultRuleset} from "../src/DefaultRuleset.sol"; - -import {DefaultCommitManager} from "../src/commit-manager/DefaultCommitManager.sol"; -import {Engine} from "../src/Engine.sol"; -import {IEngine} from "../src/IEngine.sol"; -import {DefaultValidator} from "../src/DefaultValidator.sol"; - -import {IEffect} from "../src/effects/IEffect.sol"; -import {StaminaRegen} from "../src/effects/StaminaRegen.sol"; - -import {IMoveSet} from "../src/moves/IMoveSet.sol"; -import {DefaultRandomnessOracle} from "../src/rng/DefaultRandomnessOracle.sol"; -import {IRandomnessOracle} from "../src/rng/IRandomnessOracle.sol"; -import {ITeamRegistry} from "../src/game-layer/ITeamRegistry.sol"; -import {ITypeCalculator} from "../src/types/ITypeCalculator.sol"; -import {CustomAttack} from "./mocks/CustomAttack.sol"; - -import {EffectAttack} from "./mocks/EffectAttack.sol"; -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"; - -import {SingleInstanceEffect} from "./mocks/SingleInstanceEffect.sol"; -import {TestTeamRegistry} from "./mocks/TestTeamRegistry.sol"; - -import {DefaultMatchmaker} from "../src/matchmaker/DefaultMatchmaker.sol"; -import {BattleHelper} from "./abstract/BattleHelper.sol"; -import {TestTypeCalculator} from "./mocks/TestTypeCalculator.sol"; - -contract EngineGasTest is Test, BattleHelper { - - DefaultCommitManager commitManager; - Engine engine; - ITypeCalculator typeCalc; - DefaultRandomnessOracle defaultOracle; - TestTeamRegistry defaultRegistry; - DefaultMatchmaker matchmaker; - - function setUp() public { - defaultOracle = new DefaultRandomnessOracle(); - engine = new Engine(0, 0, 0); - commitManager = new DefaultCommitManager(engine); - typeCalc = new TestTypeCalculator(); - defaultRegistry = new TestTeamRegistry(); - matchmaker = new DefaultMatchmaker(engine); - } - - /** - - Two teams of 4 mons - - Each mon has 4 moves: - - burn move - - frostbite move - - stat boost move - - attacking move - - Set up with default stamina regen - - Battle 2: - - Both players send in mon 0 - - Alice sets up self-stat boost, Bob sets up Burn - - Alice KOs Bob - - Bob swaps in mon index 1 - - Alice swaps in mon index 1, Bob sets up Frostbite - - Alice sets up self-stat boost, Bob rests - - Alice KOs Bob - - Bob sends in mon index 2 - - Alice rests, Bob uses self-stat boost - - Alice rests, Bob KOs - - Alice uses self-stat boost, Bob uses self-stat boost - - Alice KOs, Bob rests - - Bob sends in mon index 3 - - Alice KOs, Bob rests - */ - - function test_consecutiveBattleGas() public { - Mon memory mon = _createMon(); - mon.stats.stamina = 5; - mon.stats.attack = 10; - 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 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))); - mon.moves[2] = uint256(uint160(address(statBoostMove))); - mon.moves[3] = uint256(uint160(address(damageMove))); - - Mon[] memory team = new Mon[](4); - for (uint256 i = 0; i < team.length; i++) { - team[i] = mon; - } - defaultRegistry.setTeam(ALICE, team); - defaultRegistry.setTeam(BOB, team); - DefaultValidator validator = new DefaultValidator( - IEngine(address(engine)), DefaultValidator.Args({MONS_PER_TEAM: team.length, MOVES_PER_MON: mon.moves.length, TIMEOUT_DURATION: 10}) - ); - StaminaRegen staminaRegen = new StaminaRegen(); - IEffect[] memory effects = new IEffect[](1); - effects[0] = staminaRegen; - DefaultRuleset ruleset = new DefaultRuleset(IEngine(address(engine)), effects); - - vm.startSnapshotGas("Setup 1"); - bytes32 battleKey = _startBattle(validator, engine, defaultOracle, defaultRegistry, matchmaker, new IEngineHook[](0), IRuleset(address(ruleset)), address(commitManager)); - uint256 setup1Gas = vm.stopSnapshotGas("Setup 1"); - - // Advance time to avoid GameStartsAndEndsSameBlock error - vm.warp(vm.getBlockTimestamp() + 1); - - // - Battle 1: - // - Both players send in mon 0 [x] - // - Alice sets up Burn, Bob sets up Frostbite [x] - // - Alice swaps to mon 1, Bob sets up self-stat boost [x] - // - Alice sets up self-stat boost, Bob KOs [x] - // - Alice swaps in mon index 0 - // - Alice sets up self-stat boost, Bob rests - // - Alice KOs Bob - // - Bob sends in mon index 1 - // - Alice rests, Bob uses self-stat boost - // - Alice rests, Bob KOs - // - Alice swaps in mon index 2 - // - Alice rests, Bob KOs - // - Alice swaps in mon index 3 - // - Alice rests, Bob KOs - vm.startSnapshotGas("FirstBattle"); - _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey, SWITCH_MOVE_INDEX, SWITCH_MOVE_INDEX, uint16(0), uint16(0)); - // Alice uses burn, Bob uses frostbite - _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey, 0, 1, 0, 0); - // Bob is mon index 0, we boost attack by 90% - _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey, SWITCH_MOVE_INDEX, 2, uint16(1), _packStatBoost(1, 0, uint256(MonStateIndexName.Attack), int32(90))); - // Alice is now mon index 1, Bob is mon index 0 - _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey, 2, 3, _packStatBoost(0, 1, uint256(MonStateIndexName.Attack), int32(90)), 0); - // Alice swaps in mon index 0 - vm.startPrank(ALICE); - commitManager.revealMove(battleKey, SWITCH_MOVE_INDEX, 0, uint16(0), true); - engine.resetCallContext(); - // Alice is now mon index 0, Bob rests - _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey, 2, NO_OP_MOVE_INDEX, _packStatBoost(0, 0, uint256(MonStateIndexName.Attack), int32(90)), 0); - // Alice KOs Bob - _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey, 3, NO_OP_MOVE_INDEX, 0, 0); - // Bob sends in mon index 1 - vm.startPrank(BOB); - commitManager.revealMove(battleKey, SWITCH_MOVE_INDEX, 0, uint16(1), true); - engine.resetCallContext(); - // Alice rests, Bob uses self-stat boost - _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey, NO_OP_MOVE_INDEX, 2, 0, _packStatBoost(1, 1, uint256(MonStateIndexName.Attack), int32(90))); - // Alice rests, Bob KOs - _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey, NO_OP_MOVE_INDEX, 3, 0, 0); - // Alice swaps in mon index 2 - vm.startPrank(ALICE); - commitManager.revealMove(battleKey, SWITCH_MOVE_INDEX, 0, uint16(2), true); - engine.resetCallContext(); - // Alice rests, Bob KOs - _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey, NO_OP_MOVE_INDEX, 3, 0, 0); - // Alice swaps in mon index 3 - vm.startPrank(ALICE); - commitManager.revealMove(battleKey, SWITCH_MOVE_INDEX, 0, uint16(3), true); - engine.resetCallContext(); - // Alice rests, Bob KOs - _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey, NO_OP_MOVE_INDEX, 3, 0, 0); - uint256 firstBattleGas = vm.stopSnapshotGas("FirstBattle"); - - vm.startSnapshotGas("Intermediary stuff"); - // Rearrange order of moves for battle 2 - mon.moves[1] = uint256(uint160(address(burnMove))); - mon.moves[2] = uint256(uint160(address(frostbiteMove))); - mon.moves[3] = uint256(uint160(address(statBoostMove))); - mon.moves[0] = uint256(uint160(address(damageMove))); - for (uint256 i = 0; i < team.length; i++) { - team[i] = mon; - } - defaultRegistry.setTeam(ALICE, team); - defaultRegistry.setTeam(BOB, team); - vm.stopSnapshotGas("Intermediary stuff"); - - // - Battle 2: - // - Both players send in mon 0 - // - Alice sets up self-stat boost, Bob sets up Burn - // - Alice KOs Bob - // - Bob swaps in mon index 1 - // - Alice swaps in mon index 1, Bob sets up Frostbite - // - Alice sets up self-stat boost, Bob rests - // - Alice KOs Bob - // - Bob sends in mon index 2 - // - Alice rests, Bob uses self-stat boost - // - Alice rests, Bob KOs - // - Alice swaps in mon index 2 - // - Alice uses self-stat boost, Bob uses self-stat boost - // - Alice KOs, Bob rests - // - Bob sends in mon index 3 - // - Alice KOs, Bob rests - vm.startSnapshotGas("Setup 2"); - bytes32 battleKey2 = _startBattle(validator, engine, defaultOracle, defaultRegistry, matchmaker, new IEngineHook[](0), IRuleset(address(ruleset)), address(commitManager)); - uint256 setup2Gas = vm.stopSnapshotGas("Setup 2"); - - // Advance time to avoid GameStartsAndEndsSameBlock error - vm.warp(vm.getBlockTimestamp() + 1); - - // Check effects array after setup 2 - (BattleConfigView memory cfgAfterSetup2,) = engine.getBattle(battleKey2); - console.log("After setup 2 - globalEffectsLength:", cfgAfterSetup2.globalEffectsLength); - console.log("After setup 2 - packedP0EffectsCount:", cfgAfterSetup2.packedP0EffectsCount); - console.log("After setup 2 - packedP1EffectsCount:", cfgAfterSetup2.packedP1EffectsCount); - - // - Both players send in mon 0 - vm.startSnapshotGas("SecondBattle"); - _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey2, SWITCH_MOVE_INDEX, SWITCH_MOVE_INDEX, uint16(0), uint16(0)); - // - Alice sets up self-stat boost (move 3), Bob sets up Burn (move 1) - _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey2, 3, 1, _packStatBoost(0, 0, uint256(MonStateIndexName.Attack), int32(90)), 0); - // - Alice KOs Bob (move 0 = damage) - _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey2, 0, NO_OP_MOVE_INDEX, 0, 0); - // - Bob swaps in mon index 1 - vm.startPrank(BOB); - commitManager.revealMove(battleKey2, SWITCH_MOVE_INDEX, 0, uint16(1), true); - engine.resetCallContext(); - // - Alice swaps in mon index 1, Bob sets up Frostbite (move 2) - _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey2, SWITCH_MOVE_INDEX, 2, uint16(1), 0); - // - Alice sets up self-stat boost (move 3, playerIndex=0, monIndex=1), Bob rests - _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey2, 3, NO_OP_MOVE_INDEX, _packStatBoost(0, 1, uint256(MonStateIndexName.Attack), int32(90)), 0); - // - Alice KOs Bob (move 0) - _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey2, 0, NO_OP_MOVE_INDEX, 0, 0); - // - Bob sends in mon index 2 - vm.startPrank(BOB); - commitManager.revealMove(battleKey2, SWITCH_MOVE_INDEX, 0, uint16(2), true); - engine.resetCallContext(); - // - Alice rests, Bob uses self-stat boost (move 3, playerIndex=1, monIndex=2) - _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey2, NO_OP_MOVE_INDEX, 3, 0, _packStatBoost(1, 2, uint256(MonStateIndexName.Attack), int32(90))); - // - Alice rests, Bob KOs (move 0) - _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey2, NO_OP_MOVE_INDEX, 0, 0, 0); - // - Alice swaps in mon index 2 - vm.startPrank(ALICE); - commitManager.revealMove(battleKey2, SWITCH_MOVE_INDEX, 0, uint16(2), true); - engine.resetCallContext(); - // - Alice uses self-stat boost (move 3, p0 mon2), Bob uses self-stat boost (move 3, p1 mon2) - _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey2, 3, 3, _packStatBoost(0, 2, uint256(MonStateIndexName.Attack), int32(90)), _packStatBoost(1, 2, uint256(MonStateIndexName.Attack), int32(90))); - // - Alice KOs Bob (move 0) - _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey2, 0, NO_OP_MOVE_INDEX, 0, 0); - // - Bob sends in mon index 3 - vm.startPrank(BOB); - commitManager.revealMove(battleKey2, SWITCH_MOVE_INDEX, 0, uint16(3), true); - engine.resetCallContext(); - // - Alice KOs Bob (move 0) - _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey2, 0, NO_OP_MOVE_INDEX, 0, 0); - uint256 secondBattleGas = vm.stopSnapshotGas("SecondBattle"); - - // Battle 3: Repeat exact sequence of Battle 1 to test warm storage slots - // Restore original move order (same as battle 1) - mon.moves[0] = uint256(uint160(address(burnMove))); - mon.moves[1] = uint256(uint160(address(frostbiteMove))); - mon.moves[2] = uint256(uint160(address(statBoostMove))); - mon.moves[3] = uint256(uint160(address(damageMove))); - for (uint256 i = 0; i < team.length; i++) { - team[i] = mon; - } - defaultRegistry.setTeam(ALICE, team); - defaultRegistry.setTeam(BOB, team); - - vm.startSnapshotGas("Setup 3"); - bytes32 battleKey3 = _startBattle(validator, engine, defaultOracle, defaultRegistry, matchmaker, new IEngineHook[](0), IRuleset(address(ruleset)), address(commitManager)); - uint256 setup3Gas = vm.stopSnapshotGas("Setup 3"); - - // Advance time to avoid GameStartsAndEndsSameBlock error - vm.warp(vm.getBlockTimestamp() + 1); - - // Battle 3: Exact same sequence as Battle 1 - vm.startSnapshotGas("ThirdBattle"); - _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey3, SWITCH_MOVE_INDEX, SWITCH_MOVE_INDEX, uint16(0), uint16(0)); - // Alice uses burn, Bob uses frostbite - _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey3, 0, 1, 0, 0); - // Bob is mon index 0, we boost attack by 90% - _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey3, SWITCH_MOVE_INDEX, 2, uint16(1), _packStatBoost(1, 0, uint256(MonStateIndexName.Attack), int32(90))); - // Alice is now mon index 1, Bob is mon index 0 - _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey3, 2, 3, _packStatBoost(0, 1, uint256(MonStateIndexName.Attack), int32(90)), 0); - // Alice swaps in mon index 0 - vm.startPrank(ALICE); - commitManager.revealMove(battleKey3, SWITCH_MOVE_INDEX, 0, uint16(0), true); - engine.resetCallContext(); - // Alice is now mon index 0, Bob rests - _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey3, 2, NO_OP_MOVE_INDEX, _packStatBoost(0, 0, uint256(MonStateIndexName.Attack), int32(90)), 0); - // Alice KOs Bob - _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey3, 3, NO_OP_MOVE_INDEX, 0, 0); - // Bob sends in mon index 1 - vm.startPrank(BOB); - commitManager.revealMove(battleKey3, SWITCH_MOVE_INDEX, 0, uint16(1), true); - engine.resetCallContext(); - // Alice rests, Bob uses self-stat boost - _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey3, NO_OP_MOVE_INDEX, 2, 0, _packStatBoost(1, 1, uint256(MonStateIndexName.Attack), int32(90))); - // Alice rests, Bob KOs - _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey3, NO_OP_MOVE_INDEX, 3, 0, 0); - // Alice swaps in mon index 2 - vm.startPrank(ALICE); - commitManager.revealMove(battleKey3, SWITCH_MOVE_INDEX, 0, uint16(2), true); - engine.resetCallContext(); - // Alice rests, Bob KOs - _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey3, NO_OP_MOVE_INDEX, 3, 0, 0); - // Alice swaps in mon index 3 - vm.startPrank(ALICE); - commitManager.revealMove(battleKey3, SWITCH_MOVE_INDEX, 0, uint16(3), true); - engine.resetCallContext(); - // Alice rests, Bob KOs - _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey3, NO_OP_MOVE_INDEX, 3, 0, 0); - uint256 thirdBattleGas = vm.stopSnapshotGas("ThirdBattle"); - - // Log the values - console.log("=== Gas Results ==="); - console.log("Setup 1 Gas:", setup1Gas); - console.log("Setup 2 Gas:", setup2Gas); - console.log("Setup 3 Gas:", setup3Gas); - console.log("Battle 1 Gas:", firstBattleGas); - console.log("Battle 2 Gas:", secondBattleGas); - console.log("Battle 3 Gas:", thirdBattleGas); - - // Setup comparison - this SHOULD pass (reusing storage keys) - assertLt(setup2Gas, setup1Gas, "Setup 2 should be cheaper (storage reuse)"); - assertLt(setup3Gas, setup1Gas, "Setup 3 should be cheaper (storage reuse)"); - - // Battle comparison - console.log("=== Battle Comparisons ==="); - if (secondBattleGas > firstBattleGas) { - console.log("Battle 2 vs 1: MORE expensive by:", secondBattleGas - firstBattleGas); - } else { - console.log("Battle 2 vs 1: LESS expensive by:", firstBattleGas - secondBattleGas); - } - if (thirdBattleGas > firstBattleGas) { - console.log("Battle 3 vs 1: MORE expensive by:", thirdBattleGas - firstBattleGas); - } else { - console.log("Battle 3 vs 1: LESS expensive by:", firstBattleGas - thirdBattleGas); - } - // Battle 3 should be cheaper than Battle 1 since it hits the same storage slots - console.log("Battle 3 savings vs Battle 1:", firstBattleGas > thirdBattleGas ? firstBattleGas - thirdBattleGas : 0); - } - - // Simpler test: run identical battles back-to-back and measure only the execute calls - function test_identicalBattlesGas() public { - // Create identical simple battles where both players just attack until someone wins - // This isolates the effect of storage reuse - - Mon memory mon = Mon({ - stats: MonStats({hp: 100, stamina: 10, speed: 10, attack: 100, defense: 10, specialAttack: 10, specialDefense: 10, type1: Type.Fire, type2: Type.None}), - moves: new uint256[](4), - ability: 0 - }); - - // Simple high-damage move to end battle quickly (200 power, 100% accuracy, 0 stamina cost) - IMoveSet damageMove = IMoveSet(address(new CustomAttack(typeCalc, CustomAttack.Args({TYPE: Type.Fire, BASE_POWER: 200, ACCURACY: 100, STAMINA_COST: 0, PRIORITY: 0})))); - mon.moves[0] = uint256(uint160(address(damageMove))); - mon.moves[1] = uint256(uint160(address(damageMove))); - mon.moves[2] = uint256(uint160(address(damageMove))); - mon.moves[3] = uint256(uint160(address(damageMove))); - - Mon[] memory team = new Mon[](1); - team[0] = mon; - - defaultRegistry.setTeam(ALICE, team); - defaultRegistry.setTeam(BOB, team); - - DefaultValidator simpleValidator = new DefaultValidator( - IEngine(address(engine)), DefaultValidator.Args({MONS_PER_TEAM: 1, MOVES_PER_MON: 4, TIMEOUT_DURATION: 10}) - ); - - // Use empty ruleset (no global effects) - IEffect[] memory noEffects = new IEffect[](0); - IRuleset simpleRuleset = IRuleset(address(new DefaultRuleset(engine, noEffects))); - - // Battle 1: Fresh storage - vm.startSnapshotGas("Battle1_Setup"); - bytes32 battleKey1 = _startBattle(simpleValidator, engine, defaultOracle, defaultRegistry, matchmaker, new IEngineHook[](0), simpleRuleset, address(commitManager)); - uint256 setup1 = vm.stopSnapshotGas("Battle1_Setup"); - - // Advance time to avoid GameStartsAndEndsSameBlock error - vm.warp(vm.getBlockTimestamp() + 1); - - vm.startSnapshotGas("Battle1_Execute"); - _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey1, SWITCH_MOVE_INDEX, SWITCH_MOVE_INDEX, uint16(0), uint16(0)); // Both switch in mon 0 - _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey1, 0, 0, 0, 0); // Both attack - one dies - // After this, battle should end - uint256 execute1 = vm.stopSnapshotGas("Battle1_Execute"); - - // Battle 2: Reusing storage - vm.startSnapshotGas("Battle2_Setup"); - bytes32 battleKey2 = _startBattle(simpleValidator, engine, defaultOracle, defaultRegistry, matchmaker, new IEngineHook[](0), simpleRuleset, address(commitManager)); - uint256 setup2 = vm.stopSnapshotGas("Battle2_Setup"); - - // Advance time to avoid GameStartsAndEndsSameBlock error - vm.warp(vm.getBlockTimestamp() + 1); - - vm.startSnapshotGas("Battle2_Execute"); - _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey2, SWITCH_MOVE_INDEX, SWITCH_MOVE_INDEX, uint16(0), uint16(0)); // Both switch in mon 0 - _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey2, 0, 0, 0, 0); // Both attack - one dies - uint256 execute2 = vm.stopSnapshotGas("Battle2_Execute"); - - console.log("=== Identical Battles Test ==="); - console.log("Setup 1:", setup1); - console.log("Setup 2:", setup2); - console.log("Execute 1:", execute1); - console.log("Execute 2:", execute2); - - if (setup2 < setup1) { - console.log("Setup savings:", setup1 - setup2); - } - if (execute2 < execute1) { - console.log("Execute savings:", execute1 - execute2); - } else { - console.log("Execute OVERHEAD:", execute2 - execute1); - } - } - - // Test with effects being added during battle - function test_identicalBattlesWithEffectsGas() public { - Mon memory mon = Mon({ - stats: MonStats({hp: 100, stamina: 100, speed: 10, attack: 100, defense: 10, specialAttack: 10, specialDefense: 10, type1: Type.Fire, type2: Type.None}), - moves: new uint256[](4), - ability: 0 - }); - - // Move that applies a status effect to opponent (no damage) - SingleInstanceEffect testEffect = new SingleInstanceEffect(); - EffectAttack effectMove = new EffectAttack(IEffect(address(testEffect)), EffectAttack.Args({TYPE: Type.Fire, STAMINA_COST: 0, PRIORITY: 3})); - - // Damage move - high power to guarantee KO - IMoveSet damageMove = IMoveSet(address(new CustomAttack(typeCalc, CustomAttack.Args({TYPE: Type.Fire, BASE_POWER: 500, ACCURACY: 100, STAMINA_COST: 0, PRIORITY: 0})))); - - mon.moves[0] = uint256(uint160(address(effectMove))); - mon.moves[1] = uint256(uint160(address(damageMove))); - mon.moves[2] = uint256(uint160(address(damageMove))); - mon.moves[3] = uint256(uint160(address(damageMove))); - - Mon[] memory team = new Mon[](1); - team[0] = mon; - - defaultRegistry.setTeam(ALICE, team); - defaultRegistry.setTeam(BOB, team); - - DefaultValidator simpleValidator = new DefaultValidator( - IEngine(address(engine)), DefaultValidator.Args({MONS_PER_TEAM: 1, MOVES_PER_MON: 4, TIMEOUT_DURATION: 10}) - ); - - // Use ruleset with StaminaRegen effect - StaminaRegen staminaRegen = new StaminaRegen(); - IEffect[] memory effects = new IEffect[](1); - effects[0] = staminaRegen; - IRuleset rulesetWithEffect = IRuleset(address(new DefaultRuleset(engine, effects))); - - // Battle 1: Fresh storage - vm.startSnapshotGas("B1_Setup"); - bytes32 battleKey1 = _startBattle(simpleValidator, engine, defaultOracle, defaultRegistry, matchmaker, new IEngineHook[](0), rulesetWithEffect, address(commitManager)); - uint256 setup1 = vm.stopSnapshotGas("B1_Setup"); - - // Advance time to avoid GameStartsAndEndsSameBlock error - vm.warp(vm.getBlockTimestamp() + 1); - - vm.startSnapshotGas("B1_Execute"); - _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey1, SWITCH_MOVE_INDEX, SWITCH_MOVE_INDEX, uint16(0), uint16(0)); - - // Check after switch - (BattleConfigView memory cfgAfterSwitch,) = engine.getBattle(battleKey1); - console.log("After B1 switch - globalEffectsLength:", cfgAfterSwitch.globalEffectsLength); - console.log("After B1 switch - packedP0EffectsCount:", cfgAfterSwitch.packedP0EffectsCount); - console.log("After B1 switch - packedP1EffectsCount:", cfgAfterSwitch.packedP1EffectsCount); - - // Both apply effect to each other (adds 2 effects) - _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey1, 0, 0, 0, 0); - - // Check after effects applied - (BattleConfigView memory cfgAfterEffects,) = engine.getBattle(battleKey1); - console.log("After B1 effects - globalEffectsLength:", cfgAfterEffects.globalEffectsLength); - console.log("After B1 effects - packedP0EffectsCount:", cfgAfterEffects.packedP0EffectsCount); - console.log("After B1 effects - packedP1EffectsCount:", cfgAfterEffects.packedP1EffectsCount); - - // Both attack - should KO - _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey1, 1, 1, 0, 0); - uint256 execute1 = vm.stopSnapshotGas("B1_Execute"); - - // Verify battle 1 ended - (, BattleData memory state1) = engine.getBattle(battleKey1); - console.log("Battle 1 winner index:", state1.winnerIndex); - assertTrue(state1.winnerIndex != 2, "Battle 1 should have ended"); - - // Battle 2: Reusing storage - vm.startSnapshotGas("B2_Setup"); - bytes32 battleKey2 = _startBattle(simpleValidator, engine, defaultOracle, defaultRegistry, matchmaker, new IEngineHook[](0), rulesetWithEffect, address(commitManager)); - uint256 setup2 = vm.stopSnapshotGas("B2_Setup"); - - // Advance time to avoid GameStartsAndEndsSameBlock error - vm.warp(vm.getBlockTimestamp() + 1); - - // Check if effects array was reused - (BattleConfigView memory cfg2,) = engine.getBattle(battleKey2); - console.log("After B2 setup - globalEffectsLength:", cfg2.globalEffectsLength); - console.log("After B2 setup - packedP0EffectsCount:", cfg2.packedP0EffectsCount); - console.log("After B2 setup - packedP1EffectsCount:", cfg2.packedP1EffectsCount); - - vm.startSnapshotGas("B2_Execute"); - _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey2, SWITCH_MOVE_INDEX, SWITCH_MOVE_INDEX, uint16(0), uint16(0)); - // Both apply effect to each other (adds 2 effects - should REUSE slots) - _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey2, 0, 0, 0, 0); - // Both attack - KO - _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey2, 1, 1, 0, 0); - uint256 execute2 = vm.stopSnapshotGas("B2_Execute"); - - console.log("=== Battles With Effects ==="); - console.log("Setup 1:", setup1); - console.log("Setup 2:", setup2); - console.log("Execute 1:", execute1); - console.log("Execute 2:", execute2); - - if (setup2 < setup1) { - console.log("Setup savings:", setup1 - setup2); - } - if (execute2 < execute1) { - console.log("Execute savings:", execute1 - execute2); - } else { - console.log("Execute OVERHEAD:", execute2 - execute1); - } - } - - /// @notice Compare gas usage between inline validation (address(0) validator) vs external validator - function test_inlineVsExternalValidationGas() public { - // Create engine with proper inline validation defaults - Engine inlineEngine = new Engine(1, 4, 1); - DefaultCommitManager inlineCommitManager = new DefaultCommitManager(inlineEngine); - DefaultMatchmaker inlineMatchmaker = new DefaultMatchmaker(inlineEngine); - - // Create a simple mon with one high-damage move - Mon memory mon = Mon({ - stats: MonStats({hp: 100, stamina: 10, speed: 10, attack: 100, defense: 10, specialAttack: 10, specialDefense: 10, type1: Type.Fire, type2: Type.None}), - moves: new uint256[](4), - ability: 0 - }); - IMoveSet damageMove = IMoveSet(address(new CustomAttack(typeCalc, CustomAttack.Args({TYPE: Type.Fire, BASE_POWER: 200, ACCURACY: 100, STAMINA_COST: 0, PRIORITY: 0})))); - mon.moves[0] = uint256(uint160(address(damageMove))); - mon.moves[1] = uint256(uint160(address(damageMove))); - mon.moves[2] = uint256(uint160(address(damageMove))); - mon.moves[3] = uint256(uint160(address(damageMove))); - - Mon[] memory team = new Mon[](1); - team[0] = mon; - defaultRegistry.setTeam(ALICE, team); - defaultRegistry.setTeam(BOB, team); - - // Create validator for external validation path - DefaultValidator externalValidator = new DefaultValidator( - IEngine(address(inlineEngine)), DefaultValidator.Args({MONS_PER_TEAM: 1, MOVES_PER_MON: 4, TIMEOUT_DURATION: 10}) - ); - - IEffect[] memory noEffects = new IEffect[](0); - IRuleset simpleRuleset = IRuleset(address(new DefaultRuleset(inlineEngine, noEffects))); - - // === EXTERNAL VALIDATION PATH === - vm.startSnapshotGas("External_Setup"); - bytes32 externalBattleKey = _startBattleForEngine( - externalValidator, - inlineEngine, - defaultOracle, - defaultRegistry, - inlineMatchmaker, - new IEngineHook[](0), - simpleRuleset, - address(inlineCommitManager) - ); - uint256 externalSetup = vm.stopSnapshotGas("External_Setup"); - - vm.warp(vm.getBlockTimestamp() + 1); - - vm.startSnapshotGas("External_Execute"); - _commitRevealExecuteForEngine(inlineEngine, inlineCommitManager, externalBattleKey, SWITCH_MOVE_INDEX, SWITCH_MOVE_INDEX, uint16(0), uint16(0)); - _commitRevealExecuteForEngine(inlineEngine, inlineCommitManager, externalBattleKey, 0, 0, 0, 0); - uint256 externalExecute = vm.stopSnapshotGas("External_Execute"); - - // === INLINE VALIDATION PATH === - vm.startSnapshotGas("Inline_Setup"); - bytes32 inlineBattleKey = _startBattleForEngine( - IValidator(address(0)), // Inline validation! - inlineEngine, - defaultOracle, - defaultRegistry, - inlineMatchmaker, - new IEngineHook[](0), - simpleRuleset, - address(inlineCommitManager) - ); - uint256 inlineSetup = vm.stopSnapshotGas("Inline_Setup"); - - vm.warp(vm.getBlockTimestamp() + 1); - - vm.startSnapshotGas("Inline_Execute"); - _commitRevealExecuteForEngine(inlineEngine, inlineCommitManager, inlineBattleKey, SWITCH_MOVE_INDEX, SWITCH_MOVE_INDEX, uint16(0), uint16(0)); - _commitRevealExecuteForEngine(inlineEngine, inlineCommitManager, inlineBattleKey, 0, 0, 0, 0); - uint256 inlineExecute = vm.stopSnapshotGas("Inline_Execute"); - - console.log("========================================"); - console.log("INLINE vs EXTERNAL VALIDATION BENCHMARK"); - console.log("========================================"); - console.log(""); - console.log("--- SETUP (startBattle) ---"); - console.log("External Validator Setup:", externalSetup); - console.log("Inline Validation Setup:", inlineSetup); - if (inlineSetup < externalSetup) { - console.log("Inline SAVES:", externalSetup - inlineSetup); - } else { - console.log("Inline COSTS MORE:", inlineSetup - externalSetup); - } - console.log(""); - console.log("--- EXECUTE (switch + attack) ---"); - console.log("External Validator Execute:", externalExecute); - console.log("Inline Validation Execute:", inlineExecute); - if (inlineExecute < externalExecute) { - console.log("Inline SAVES:", externalExecute - inlineExecute); - console.log("Percentage saved:", (externalExecute - inlineExecute) * 100 / externalExecute, "%"); - } else { - console.log("Inline COSTS MORE:", inlineExecute - externalExecute); - } - console.log("========================================"); - } - - /// @notice Verify that inline RNG (address(0) oracle) produces identical battle outcomes to DefaultRandomnessOracle - function test_inlineRNGMatchesDefaultOracle() public { - // Create a mon with a damage move (outcome depends on RNG for volatility/crit) - Mon memory mon = Mon({ - stats: MonStats({hp: 100, stamina: 10, speed: 10, attack: 50, defense: 10, specialAttack: 10, specialDefense: 10, type1: Type.Fire, type2: Type.None}), - moves: new uint256[](4), - ability: 0 - }); - - IMoveSet damageMove = IMoveSet(address(new CustomAttack(typeCalc, CustomAttack.Args({TYPE: Type.Fire, BASE_POWER: 30, ACCURACY: 100, STAMINA_COST: 1, PRIORITY: 0})))); - mon.moves[0] = uint256(uint160(address(damageMove))); - mon.moves[1] = uint256(uint160(address(damageMove))); - mon.moves[2] = uint256(uint160(address(damageMove))); - mon.moves[3] = uint256(uint160(address(damageMove))); - - Mon[] memory team = new Mon[](1); - team[0] = mon; - defaultRegistry.setTeam(ALICE, team); - defaultRegistry.setTeam(BOB, team); - - IEffect[] memory noEffects = new IEffect[](0); - IRuleset simpleRuleset = IRuleset(address(new DefaultRuleset(engine, noEffects))); - - // --- Battle with external DefaultRandomnessOracle --- - DefaultValidator validatorExternal = new DefaultValidator( - IEngine(address(engine)), DefaultValidator.Args({MONS_PER_TEAM: 1, MOVES_PER_MON: 4, TIMEOUT_DURATION: 10}) - ); - bytes32 battleKey1 = _startBattleForEngine( - validatorExternal, engine, defaultOracle, defaultRegistry, matchmaker, - new IEngineHook[](0), simpleRuleset, address(commitManager) - ); - vm.warp(vm.getBlockTimestamp() + 1); - _commitRevealExecuteForEngine(engine, commitManager, battleKey1, SWITCH_MOVE_INDEX, SWITCH_MOVE_INDEX, uint16(0), uint16(0)); - _commitRevealExecuteForEngine(engine, commitManager, battleKey1, 0, 0, 0, 0); - - // Get final HP deltas - int32 externalP0Hp = engine.getMonStateForBattle(battleKey1, 0, 0, MonStateIndexName.Hp); - int32 externalP1Hp = engine.getMonStateForBattle(battleKey1, 1, 0, MonStateIndexName.Hp); - - // --- Battle with inline RNG (address(0) oracle) --- - // Need a fresh engine to get a separate battle key pair - Engine inlineEngine = new Engine(1, 4, 10); - DefaultCommitManager inlineCM = new DefaultCommitManager(inlineEngine); - DefaultMatchmaker inlineMM = new DefaultMatchmaker(inlineEngine); - - IRuleset inlineRuleset = IRuleset(address(new DefaultRuleset(inlineEngine, noEffects))); - - bytes32 battleKey2 = _startBattleForEngine( - IValidator(address(0)), inlineEngine, IRandomnessOracle(address(0)), defaultRegistry, inlineMM, - new IEngineHook[](0), inlineRuleset, address(inlineCM) - ); - vm.warp(vm.getBlockTimestamp() + 1); - _commitRevealExecuteForEngine(inlineEngine, inlineCM, battleKey2, SWITCH_MOVE_INDEX, SWITCH_MOVE_INDEX, uint16(0), uint16(0)); - _commitRevealExecuteForEngine(inlineEngine, inlineCM, battleKey2, 0, 0, 0, 0); - - // Get final HP deltas - int32 inlineP0Hp = inlineEngine.getMonStateForBattle(battleKey2, 0, 0, MonStateIndexName.Hp); - int32 inlineP1Hp = inlineEngine.getMonStateForBattle(battleKey2, 1, 0, MonStateIndexName.Hp); - - // Verify identical outcomes - assertEq(externalP0Hp, inlineP0Hp, "P0 HP delta should match between inline and external RNG"); - assertEq(externalP1Hp, inlineP1Hp, "P1 HP delta should match between inline and external RNG"); - } - - // Helper to start battle with a specific engine - function _startBattleForEngine( - IValidator validator, - Engine eng, - IRandomnessOracle rngOracle, - ITeamRegistry registry, - DefaultMatchmaker maker, - IEngineHook[] memory hooks, - IRuleset ruleset, - address moveManager - ) internal returns (bytes32) { - vm.startPrank(ALICE); - address[] memory makersToAdd = new address[](1); - makersToAdd[0] = address(maker); - address[] memory makersToRemove = new address[](0); - eng.updateMatchmakers(makersToAdd, makersToRemove); - - vm.startPrank(BOB); - eng.updateMatchmakers(makersToAdd, makersToRemove); - - bytes32 salt = ""; - uint96 p0TeamIndex = 0; - uint256[] memory p0TeamIndices = registry.getMonRegistryIndicesForTeam(ALICE, p0TeamIndex); - bytes32 p0TeamHash = keccak256(abi.encodePacked(salt, p0TeamIndex, p0TeamIndices)); - - ProposedBattle memory proposal = ProposedBattle({ - p0: ALICE, - p0TeamIndex: 0, - p0TeamHash: p0TeamHash, - p1: BOB, - p1TeamIndex: 0, - teamRegistry: registry, - validator: validator, - rngOracle: rngOracle, - ruleset: ruleset, - engineHooks: hooks, - moveManager: moveManager, - matchmaker: maker - }); - - vm.startPrank(ALICE); - bytes32 battleKey = maker.proposeBattle(proposal); - - bytes32 battleIntegrityHash = maker.getBattleProposalIntegrityHash(proposal); - vm.startPrank(BOB); - maker.acceptBattle(battleKey, 0, battleIntegrityHash); - - vm.startPrank(ALICE); - maker.confirmBattle(battleKey, salt, p0TeamIndex); - - return battleKey; - } - - // Helper to commit/reveal/execute for a specific engine - function _commitRevealExecuteForEngine( - Engine eng, - DefaultCommitManager cm, - bytes32 battleKey, - uint8 aliceMoveIndex, - uint8 bobMoveIndex, - uint16 aliceExtraData, - uint16 bobExtraData - ) internal { - uint104 salt = 0; - bytes32 aliceMoveHash = keccak256(abi.encodePacked(aliceMoveIndex, salt, aliceExtraData)); - bytes32 bobMoveHash = keccak256(abi.encodePacked(bobMoveIndex, salt, bobExtraData)); - uint256 turnId = eng.getTurnIdForBattleState(battleKey); - if (turnId % 2 == 0) { - vm.startPrank(ALICE); - cm.commitMove(battleKey, aliceMoveHash); - vm.startPrank(BOB); - cm.revealMove(battleKey, bobMoveIndex, salt, bobExtraData, true); - engine.resetCallContext(); - vm.startPrank(ALICE); - cm.revealMove(battleKey, aliceMoveIndex, salt, aliceExtraData, true); - engine.resetCallContext(); - } else { - vm.startPrank(BOB); - cm.commitMove(battleKey, bobMoveHash); - vm.startPrank(ALICE); - cm.revealMove(battleKey, aliceMoveIndex, salt, aliceExtraData, true); - engine.resetCallContext(); - vm.startPrank(BOB); - cm.revealMove(battleKey, bobMoveIndex, salt, bobExtraData, true); - engine.resetCallContext(); - } - vm.stopPrank(); - eng.resetCallContext(); - } -} \ No newline at end of file diff --git a/test/EngineOptimizationTest.sol b/test/EngineOptimizationTest.sol index 607f235a..a65c3573 100644 --- a/test/EngineOptimizationTest.sol +++ b/test/EngineOptimizationTest.sol @@ -419,7 +419,7 @@ contract EngineOptimizationTest is Test, BattleHelper { uint256[] memory activeMons = testEngine.getActiveMonIndexForBattleState(battleKey); assertEq(activeMons[1], 1, "P1 should switch to mon 1"); assertEq( - testEngine.getPlayerSwitchForTurnFlagForBattleState(battleKey), + uint256(testEngine.getBattleContext(battleKey).playerSwitchForTurnFlag), 2, "Battle should return to two-player turns" ); @@ -479,7 +479,7 @@ contract EngineOptimizationTest is Test, BattleHelper { uint256[] memory activeMons = testEngine.getActiveMonIndexForBattleState(battleKey); assertEq(activeMons[1], 1, "P1 should switch through normal reveal fallback"); assertEq( - testEngine.getPlayerSwitchForTurnFlagForBattleState(battleKey), + uint256(testEngine.getBattleContext(battleKey).playerSwitchForTurnFlag), 2, "Battle should return to two-player turns" ); @@ -491,11 +491,11 @@ contract EngineOptimizationTest is Test, BattleHelper { _forceP1Switch(testEngine, signedManager, battleKey); _executeSinglePlayerMoveAndReset(testEngine, signedManager, battleKey, BOB, uint16(1)); - assertEq(testEngine.getPlayerSwitchForTurnFlagForBattleState(battleKey), 0, "P0 should be forced to switch"); + assertEq(uint256(testEngine.getBattleContext(battleKey).playerSwitchForTurnFlag), 0, "P0 should be forced to switch"); _executeSinglePlayerMoveAndReset(testEngine, signedManager, battleKey, ALICE, uint16(1)); assertEq( - testEngine.getPlayerSwitchForTurnFlagForBattleState(battleKey), + uint256(testEngine.getBattleContext(battleKey).playerSwitchForTurnFlag), 2, "Battle should return to two-player turns" ); @@ -520,7 +520,7 @@ contract EngineOptimizationTest is Test, BattleHelper { testEngine, signedManager, battleKey, 0, NO_OP_MOVE_INDEX, uint16(0), uint16(0) ); - assertEq(testEngine.getPlayerSwitchForTurnFlagForBattleState(battleKey), 1, "P1 should be forced to switch"); + assertEq(uint256(testEngine.getBattleContext(battleKey).playerSwitchForTurnFlag), 1, "P1 should be forced to switch"); } function _executeSinglePlayerMoveAndReset( diff --git a/test/EngineTest.sol b/test/EngineTest.sol index e9ce8324..880df271 100644 --- a/test/EngineTest.sol +++ b/test/EngineTest.sol @@ -200,7 +200,7 @@ contract EngineTest is Test, BattleHelper { assertEq(engine.getWinner(battleKey), ALICE); // Assert that the staminaDelta was set correctly - assertEq(engine.getMonStateForStorageKey(battleKey, 0, 0, MonStateIndexName.Stamina), -1); + assertEq(engine.getMonStateForBattle(battleKey, 0, 0, MonStateIndexName.Stamina), -1); } // Regression: getBattle must not revert when the battle has ended and its @@ -346,7 +346,7 @@ contract EngineTest is Test, BattleHelper { assertEq(engine.getWinner(battleKey), BOB); // Assert that the staminaDelta was set correctly for Bob's mon - assertEq(engine.getMonStateForStorageKey(battleKey, 1, 0, MonStateIndexName.Stamina), -1); + assertEq(engine.getMonStateForBattle(battleKey, 1, 0, MonStateIndexName.Stamina), -1); } function _setup2v2FasterPriorityBattleAndForceSwitch() internal returns (bytes32) { @@ -456,7 +456,7 @@ contract EngineTest is Test, BattleHelper { // Assert that the staminaDelta was set correctly for Bob's mon // (we used two attacks of 1 stamina, so -2) - assertEq(engine.getMonStateForStorageKey(battleKey, 1, 0, MonStateIndexName.Stamina), -2); + assertEq(engine.getMonStateForBattle(battleKey, 1, 0, MonStateIndexName.Stamina), -2); } function test_fasterPriorityKOsForcesSwitchCorrectlyFailsOnInvalidSwitchReveal() public { @@ -560,7 +560,7 @@ contract EngineTest is Test, BattleHelper { (, BattleData memory state) = engine.getBattle(battleKey); // Assert that the staminaDelta was set correctly (2 moves spent) for the winning mon - assertEq(engine.getMonStateForStorageKey(battleKey, state.winnerIndex, 0, MonStateIndexName.Stamina), -2); + assertEq(engine.getMonStateForBattle(battleKey, state.winnerIndex, 0, MonStateIndexName.Stamina), -2); } function test_switchPriorityIsFasterThanMove() public { @@ -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 new file mode 100644 index 00000000..7dc1816c --- /dev/null +++ b/test/FullyOptimizedInlineGasTest.sol @@ -0,0 +1,420 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity ^0.8.0; + +import "../lib/forge-std/src/Test.sol"; + +import "../src/Constants.sol"; +import "../src/Enums.sol"; +import "../src/Structs.sol"; + + +import {SignedCommitManager} from "../src/commit-manager/SignedCommitManager.sol"; +import {Engine} from "../src/Engine.sol"; +import {IEngine} from "../src/IEngine.sol"; +import {IValidator} from "../src/IValidator.sol"; +import {SignedCommitHelper} from "./abstract/SignedCommitHelper.sol"; + + +import {IMoveSet} from "../src/moves/IMoveSet.sol"; +import {IRandomnessOracle} from "../src/rng/IRandomnessOracle.sol"; +import {ITypeCalculator} from "../src/types/ITypeCalculator.sol"; +import {CustomAttack} from "./mocks/CustomAttack.sol"; + +import {EffectAttack} from "./mocks/EffectAttack.sol"; +import {StatBoostsMove} from "./mocks/StatBoostsMove.sol"; + +import {BurnStatus} from "../src/effects/status/BurnStatus.sol"; +import {FrostbiteStatus} from "../src/effects/status/FrostbiteStatus.sol"; + +import {IEngineHook} from "../src/IEngineHook.sol"; + +import {TestTeamRegistry} from "./mocks/TestTeamRegistry.sol"; + +import {BattleOfferLib} from "../src/matchmaker/BattleOfferLib.sol"; +import {SignedMatchmaker} from "../src/matchmaker/SignedMatchmaker.sol"; +import {BattleHelper} from "./abstract/BattleHelper.sol"; +import {GasMeasure} from "./abstract/GasMeasure.sol"; +import {TestTypeCalculator} from "./mocks/TestTypeCalculator.sol"; + +/// @title Fully Optimized Inline Gas Test +/// @notice The production-representative stack: inline validation (address(0) validator), inline RNG +/// (address(0) oracle), inline stamina regen, SignedMatchmaker (no propose/accept/confirm +/// storage), and SignedCommitManager::executeWithDualSignedMoves (1 TX per two-player turn, +/// single-sig). Forced single-player switches use executeSinglePlayerMove. +/// @dev Battle spans use the production-faithful GasMeasure format: each turn (1 tx) is measured as +/// its own cold-access tx via `vm.cool` with a deterministic storage-access tally — so a +/// regression that adds a cold SLOAD is caught, unlike the old all-warm span. Setup spans stay +/// on vm.startSnapshotGas (one-time battle creation). +contract FullyOptimizedInlineGasTest is BattleHelper, SignedCommitHelper, GasMeasure { + + uint256 constant MONS_PER_TEAM = 4; + uint256 constant MOVES_PER_MON = 4; + + uint256 constant P0_PK = 0xA11CE; + uint256 constant P1_PK = 0xB0B; + address p0; + address p1; + + Engine engine; + SignedCommitManager signedCommitManager; + SignedMatchmaker signedMatchmaker; + ITypeCalculator typeCalc; + TestTeamRegistry defaultRegistry; + + // GasMeasure accumulators: when `_measuring`, _fastTurn / _fastSwitchReveal cool the engine + + // commit-manager before the (1-tx) call and tally its cold-access storage into _acc / _accGas. + bool private _measuring; + Tally private _acc; + uint256 private _accGas; + + function setUp() public { + p0 = vm.addr(P0_PK); + p1 = vm.addr(P1_PK); + + engine = new Engine(MONS_PER_TEAM, MOVES_PER_MON, 1); + signedCommitManager = new SignedCommitManager(IEngine(address(engine))); + signedMatchmaker = new SignedMatchmaker(engine); + typeCalc = new TestTypeCalculator(); + defaultRegistry = new TestTeamRegistry(); + } + + function _coolEngMgr() internal { + vm.cool(address(engine)); + vm.cool(address(signedCommitManager)); + } + + function _beginMeasure() internal { + _measuring = true; + delete _acc; + _accGas = 0; + } + + function _endMeasure(string memory name) internal returns (uint256) { + _measuring = false; + _snapScenario(name, _acc, _accGas); + return _accGas; + } + + /// @dev Starts a battle via SignedMatchmaker::startGame (1 TX instead of 3). + /// Also authorizes the matchmaker each call to mirror _startBattleInline. + function _startBattleFullyOptimized(IRuleset ruleset) internal returns (bytes32) { + address[] memory makersToAdd = new address[](1); + makersToAdd[0] = address(signedMatchmaker); + address[] memory makersToRemove = new address[](0); + vm.prank(p0); + engine.updateMatchmakers(makersToAdd, makersToRemove); + vm.prank(p1); + engine.updateMatchmakers(makersToAdd, makersToRemove); + + (bytes32 battleKey, bytes32 pairHash) = engine.computeBattleKey(p0, p1); + uint256 nonce = engine.pairHashNonces(pairHash); + + BattleOffer memory offer = BattleOffer({ + battle: Battle({ + p0: p0, + p0TeamIndex: 0, + p1: p1, + p1TeamIndex: 0, + teamRegistry: defaultRegistry, + validator: IValidator(address(0)), + rngOracle: IRandomnessOracle(address(0)), + ruleset: ruleset, + moveManager: address(signedCommitManager), + matchmaker: signedMatchmaker, + engineHooks: new IEngineHook[](0) + }), + pairHashNonce: nonce + }); + + bytes32 structHash = BattleOfferLib.hashBattleOffer(offer); + bytes32 digest = signedMatchmaker.hashTypedData(structHash); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(P0_PK, digest); + bytes memory signature = abi.encodePacked(r, s, v); + + vm.prank(p1); + signedMatchmaker.startGame(offer, signature); + + return battleKey; + } + + /// @dev Executes a two-player turn in 1 TX via executeWithDualSignedMoves. + /// p0Move/p1Move semantics match _commitRevealExecuteForAliceAndBob so the + /// battle scripts can be transcribed directly from the non-optimized test. + function _fastTurn( + bytes32 battleKey, + uint8 p0MoveIndex, + uint8 p1MoveIndex, + uint16 p0ExtraData, + uint16 p1ExtraData + ) internal { + uint64 turnId = uint64(engine.getTurnIdForBattleState(battleKey)); + uint104 committerSalt = uint104(uint256(keccak256(abi.encode("committer", battleKey, turnId)))); + uint104 revealerSalt = uint104(uint256(keccak256(abi.encode("revealer", battleKey, turnId)))); + + uint8 committerMoveIndex; + uint16 committerExtraData; + uint8 revealerMoveIndex; + uint16 revealerExtraData; + uint256 committerPk; + uint256 revealerPk; + address committer; + + if (turnId % 2 == 0) { + committerMoveIndex = p0MoveIndex; + committerExtraData = p0ExtraData; + revealerMoveIndex = p1MoveIndex; + revealerExtraData = p1ExtraData; + committerPk = P0_PK; + revealerPk = P1_PK; + committer = p0; + } else { + committerMoveIndex = p1MoveIndex; + committerExtraData = p1ExtraData; + revealerMoveIndex = p0MoveIndex; + revealerExtraData = p0ExtraData; + committerPk = P1_PK; + revealerPk = P0_PK; + committer = p1; + } + + bytes32 committerMoveHash = + keccak256(abi.encodePacked(committerMoveIndex, committerSalt, committerExtraData)); + address mgr = address(signedCommitManager); + committerPk; // single-sig: no committer signature; committer is msg.sender + bytes memory revealerSig = _signDualReveal( + mgr, revealerPk, battleKey, turnId, committerMoveHash, + revealerMoveIndex, revealerSalt, revealerExtraData + ); + + // When measuring, treat this 1-tx turn as a fresh cold-start tx + tally its storage. + if (_measuring) { _coolEngMgr(); vm.startStateDiffRecording(); } + uint256 g0 = gasleft(); + vm.prank(committer); + signedCommitManager.executeWithDualSignedMoves( + battleKey, + committerMoveIndex, committerSalt, committerExtraData, + revealerMoveIndex, revealerSalt, revealerExtraData, + revealerSig + ); + if (_measuring) { _accGas += g0 - gasleft(); _acc = _addTally(_acc, _tally(vm.stopAndReturnStateDiff())); } + engine.resetCallContext(); + } + + /// @dev Single-player forced switch after a KO. This uses the optimized + /// SignedCommitManager path because there is no hidden opponent move to reveal. + function _fastSwitchReveal(bytes32 battleKey, bool isP0, uint16 extraData) internal { + if (_measuring) { _coolEngMgr(); vm.startStateDiffRecording(); } + uint256 g0 = gasleft(); + vm.prank(isP0 ? p0 : p1); + signedCommitManager.executeSinglePlayerMove(battleKey, SWITCH_MOVE_INDEX, uint104(0), extraData); + if (_measuring) { _accGas += g0 - gasleft(); _acc = _addTally(_acc, _tally(vm.stopAndReturnStateDiff())); } + engine.resetCallContext(); + } + + /// @notice Compares the inherited single-player reveal flow against the dedicated + /// SignedCommitManager single-player fast path. + function test_signedCommitManagerOnePlayerActionGasComparison() public { + Mon memory mon = _createMon(); + mon.stats.stamina = 5; + mon.stats.attack = 10; + mon.stats.specialAttack = 10; + mon.moves = new uint256[](4); + + IMoveSet damageMove = new CustomAttack( + ITypeCalculator(address(typeCalc)), + CustomAttack.Args({TYPE: Type.Fire, BASE_POWER: 100, ACCURACY: 100, STAMINA_COST: 0, PRIORITY: 1}) + ); + for (uint256 i; i < mon.moves.length; i++) { + mon.moves[i] = uint256(uint160(address(damageMove))); + } + + Mon[] memory team = new Mon[](4); + for (uint256 i; i < team.length; i++) { + team[i] = mon; + } + defaultRegistry.setTeam(p0, team); + defaultRegistry.setTeam(p1, team); + + IRuleset ruleset = IRuleset(INLINE_STAMINA_REGEN_RULESET); + + bytes32 oldFlowBattleKey = _startBattleFullyOptimized(ruleset); + vm.warp(vm.getBlockTimestamp() + 1); + _fastTurn(oldFlowBattleKey, SWITCH_MOVE_INDEX, SWITCH_MOVE_INDEX, uint16(0), uint16(0)); + _fastTurn(oldFlowBattleKey, 0, NO_OP_MOVE_INDEX, uint16(0), uint16(0)); + assertEq(uint256(engine.getBattleContext(oldFlowBattleKey).playerSwitchForTurnFlag), 1); + + vm.prank(p1); + uint256 gasBefore = gasleft(); + signedCommitManager.revealMove(oldFlowBattleKey, SWITCH_MOVE_INDEX, uint104(0), uint16(1), true); + uint256 oldFlowGas = gasBefore - gasleft(); + engine.resetCallContext(); + + _fastTurn(oldFlowBattleKey, 0, NO_OP_MOVE_INDEX, uint16(0), uint16(0)); + assertEq(uint256(engine.getBattleContext(oldFlowBattleKey).playerSwitchForTurnFlag), 1); + + vm.prank(p1); + gasBefore = gasleft(); + signedCommitManager.revealMove(oldFlowBattleKey, SWITCH_MOVE_INDEX, uint104(0), uint16(2), true); + uint256 oldFlowSecondGas = gasBefore - gasleft(); + engine.resetCallContext(); + + bytes32 fastPathBattleKey = _startBattleFullyOptimized(ruleset); + vm.warp(vm.getBlockTimestamp() + 1); + _fastTurn(fastPathBattleKey, SWITCH_MOVE_INDEX, SWITCH_MOVE_INDEX, uint16(0), uint16(0)); + _fastTurn(fastPathBattleKey, 0, NO_OP_MOVE_INDEX, uint16(0), uint16(0)); + assertEq(uint256(engine.getBattleContext(fastPathBattleKey).playerSwitchForTurnFlag), 1); + + vm.prank(p1); + gasBefore = gasleft(); + signedCommitManager.executeSinglePlayerMove(fastPathBattleKey, SWITCH_MOVE_INDEX, uint104(0), uint16(1)); + uint256 fastPathGas = gasBefore - gasleft(); + engine.resetCallContext(); + + _fastTurn(fastPathBattleKey, 0, NO_OP_MOVE_INDEX, uint16(0), uint16(0)); + assertEq(uint256(engine.getBattleContext(fastPathBattleKey).playerSwitchForTurnFlag), 1); + + vm.prank(p1); + gasBefore = gasleft(); + signedCommitManager.executeSinglePlayerMove(fastPathBattleKey, SWITCH_MOVE_INDEX, uint104(0), uint16(2)); + uint256 fastPathSecondGas = gasBefore - gasleft(); + engine.resetCallContext(); + + console.log("Old SignedCommitManager first revealMove gas:", oldFlowGas); + console.log("New first executeSinglePlayerMove gas:", fastPathGas); + console.log("First forced-switch savings:", oldFlowGas - fastPathGas); + console.log("Old SignedCommitManager second revealMove gas:", oldFlowSecondGas); + console.log("New second executeSinglePlayerMove gas:", fastPathSecondGas); + console.log("Second forced-switch savings:", oldFlowSecondGas - fastPathSecondGas); + + assertLt(fastPathGas, oldFlowGas); + assertLt(fastPathSecondGas, oldFlowSecondGas); + } + + /// @notice Mirrors InlineEngineGasTest::test_consecutiveBattleGas move-for-move, + /// but every TX goes through the dual-signed fast path. + function test_consecutiveBattleGas() public { + Mon memory mon = _createMon(); + mon.stats.stamina = 5; + mon.stats.attack = 10; + mon.stats.specialAttack = 10; + + mon.moves = new uint256[](4); + 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))); + mon.moves[2] = uint256(uint160(address(statBoostMove))); + mon.moves[3] = uint256(uint160(address(damageMove))); + + Mon[] memory team = new Mon[](4); + for (uint256 i = 0; i < team.length; i++) { + team[i] = mon; + } + defaultRegistry.setTeam(p0, team); + defaultRegistry.setTeam(p1, team); + + // Use the INLINE_STAMINA_REGEN_RULESET sentinel so the engine takes its internal stamina-regen + // fast path (no external StaminaRegen contract, no onAfterMove/onRoundEnd callbacks). This is + // the intended production configuration for the fully-optimized stack. + IRuleset ruleset = IRuleset(INLINE_STAMINA_REGEN_RULESET); + + // Setup (one-time battle creation) is not measured here: gasleft would be polluted by the + // cumulative _tally memory from prior battles. Storage-reuse is asserted below via battle gas. + bytes32 battleKey = _startBattleFullyOptimized(ruleset); + + vm.warp(vm.getBlockTimestamp() + 1); + + _beginMeasure(); + _fastTurn(battleKey, SWITCH_MOVE_INDEX, SWITCH_MOVE_INDEX, uint16(0), uint16(0)); + _fastTurn(battleKey, 0, 1, 0, 0); + _fastTurn(battleKey, SWITCH_MOVE_INDEX, 2, uint16(1), _packStatBoost(1, 0, uint256(MonStateIndexName.Attack), int32(90))); + _fastTurn(battleKey, 2, 3, _packStatBoost(0, 1, uint256(MonStateIndexName.Attack), int32(90)), 0); + _fastSwitchReveal(battleKey, true, uint16(0)); + _fastTurn(battleKey, 2, NO_OP_MOVE_INDEX, _packStatBoost(0, 0, uint256(MonStateIndexName.Attack), int32(90)), 0); + _fastTurn(battleKey, 3, NO_OP_MOVE_INDEX, 0, 0); + _fastSwitchReveal(battleKey, false, uint16(1)); + _fastTurn(battleKey, NO_OP_MOVE_INDEX, 2, 0, _packStatBoost(1, 1, uint256(MonStateIndexName.Attack), int32(90))); + _fastTurn(battleKey, NO_OP_MOVE_INDEX, 3, 0, 0); + _fastSwitchReveal(battleKey, true, uint16(2)); + _fastTurn(battleKey, NO_OP_MOVE_INDEX, 3, 0, 0); + _fastSwitchReveal(battleKey, true, uint16(3)); + _fastTurn(battleKey, NO_OP_MOVE_INDEX, 3, 0, 0); + uint256 firstBattleGas = _endMeasure("Fast_Battle1"); + + // Rearrange moves for battle 2 (same as InlineEngineGasTest) + mon.moves[1] = uint256(uint160(address(burnMove))); + mon.moves[2] = uint256(uint160(address(frostbiteMove))); + mon.moves[3] = uint256(uint160(address(statBoostMove))); + mon.moves[0] = uint256(uint160(address(damageMove))); + for (uint256 i = 0; i < team.length; i++) { + team[i] = mon; + } + defaultRegistry.setTeam(p0, team); + defaultRegistry.setTeam(p1, team); + + bytes32 battleKey2 = _startBattleFullyOptimized(IRuleset(address(ruleset))); + + vm.warp(vm.getBlockTimestamp() + 1); + + _beginMeasure(); + _fastTurn(battleKey2, SWITCH_MOVE_INDEX, SWITCH_MOVE_INDEX, uint16(0), uint16(0)); + _fastTurn(battleKey2, 3, 1, _packStatBoost(0, 0, uint256(MonStateIndexName.Attack), int32(90)), 0); + _fastTurn(battleKey2, 0, NO_OP_MOVE_INDEX, 0, 0); + _fastSwitchReveal(battleKey2, false, uint16(1)); + _fastTurn(battleKey2, SWITCH_MOVE_INDEX, 2, uint16(1), 0); + _fastTurn(battleKey2, 3, NO_OP_MOVE_INDEX, _packStatBoost(0, 1, uint256(MonStateIndexName.Attack), int32(90)), 0); + _fastTurn(battleKey2, 0, NO_OP_MOVE_INDEX, 0, 0); + _fastSwitchReveal(battleKey2, false, uint16(2)); + _fastTurn(battleKey2, NO_OP_MOVE_INDEX, 3, 0, _packStatBoost(1, 2, uint256(MonStateIndexName.Attack), int32(90))); + _fastTurn(battleKey2, NO_OP_MOVE_INDEX, 0, 0, 0); + _fastSwitchReveal(battleKey2, true, uint16(2)); + _fastTurn(battleKey2, 3, 3, _packStatBoost(0, 2, uint256(MonStateIndexName.Attack), int32(90)), _packStatBoost(1, 2, uint256(MonStateIndexName.Attack), int32(90))); + _fastTurn(battleKey2, 0, NO_OP_MOVE_INDEX, 0, 0); + _fastSwitchReveal(battleKey2, false, uint16(3)); + _fastTurn(battleKey2, 0, NO_OP_MOVE_INDEX, 0, 0); + uint256 secondBattleGas = _endMeasure("Fast_Battle2"); + + // Battle 3: Repeat exact sequence of Battle 1 + mon.moves[0] = uint256(uint160(address(burnMove))); + mon.moves[1] = uint256(uint160(address(frostbiteMove))); + mon.moves[2] = uint256(uint160(address(statBoostMove))); + mon.moves[3] = uint256(uint160(address(damageMove))); + for (uint256 i = 0; i < team.length; i++) { + team[i] = mon; + } + defaultRegistry.setTeam(p0, team); + defaultRegistry.setTeam(p1, team); + + bytes32 battleKey3 = _startBattleFullyOptimized(IRuleset(address(ruleset))); + + vm.warp(vm.getBlockTimestamp() + 1); + + _beginMeasure(); + _fastTurn(battleKey3, SWITCH_MOVE_INDEX, SWITCH_MOVE_INDEX, uint16(0), uint16(0)); + _fastTurn(battleKey3, 0, 1, 0, 0); + _fastTurn(battleKey3, SWITCH_MOVE_INDEX, 2, uint16(1), _packStatBoost(1, 0, uint256(MonStateIndexName.Attack), int32(90))); + _fastTurn(battleKey3, 2, 3, _packStatBoost(0, 1, uint256(MonStateIndexName.Attack), int32(90)), 0); + _fastSwitchReveal(battleKey3, true, uint16(0)); + _fastTurn(battleKey3, 2, NO_OP_MOVE_INDEX, _packStatBoost(0, 0, uint256(MonStateIndexName.Attack), int32(90)), 0); + _fastTurn(battleKey3, 3, NO_OP_MOVE_INDEX, 0, 0); + _fastSwitchReveal(battleKey3, false, uint16(1)); + _fastTurn(battleKey3, NO_OP_MOVE_INDEX, 2, 0, _packStatBoost(1, 1, uint256(MonStateIndexName.Attack), int32(90))); + _fastTurn(battleKey3, NO_OP_MOVE_INDEX, 3, 0, 0); + _fastSwitchReveal(battleKey3, true, uint16(2)); + _fastTurn(battleKey3, NO_OP_MOVE_INDEX, 3, 0, 0); + _fastSwitchReveal(battleKey3, true, uint16(3)); + _fastTurn(battleKey3, NO_OP_MOVE_INDEX, 3, 0, 0); + uint256 thirdBattleGas = _endMeasure("Fast_Battle3"); + + console.log("=== FULLY OPTIMIZED Gas Results (cold-per-tx, production-faithful) ==="); + console.log("Battle 1 cold gas:", firstBattleGas); + console.log("Battle 2 cold gas:", secondBattleGas); + console.log("Battle 3 cold gas:", thirdBattleGas); + + // Battle 3 replays Battle 1's exact move sequence, but reuses storage freed by earlier + // battles (writes are nz->nz instead of z->nz), so it must be cheaper. Storage-reuse guard. + assertLt(thirdBattleGas, firstBattleGas, "Battle 3 should be cheaper than Battle 1 (storage reuse)"); + } +} diff --git a/test/GachaTeamRegistryTest.sol b/test/GachaTeamRegistryTest.sol index 2824e6fb..09ef1641 100644 --- a/test/GachaTeamRegistryTest.sol +++ b/test/GachaTeamRegistryTest.sol @@ -660,6 +660,64 @@ contract GachaTeamRegistryTest is Test { assertEq(gachaTeamRegistry.getExp(ALICE, 0), 9, "+4 from streak day 2"); } + // Regression: a player who plays slightly more often than once per 24h must still + // build a streak. The bonus anchor (lastFirstGameTs) only advances on a qualifying + // (>=24h) game, so a sub-24h "early" play used to be dropped without recording any + // activity — the next day then measured a phantom ~46h gap from the stale anchor and + // reset the streak to 1 forever. lastSeenTs (advanced on every battle) decouples the + // 36h reset from the bonus anchor, so the ~23h cadence below ratchets 1 -> 2 -> 3. + function test_streak_subDayCadenceDoesNotStrandAnchor() public { + _whitelist(CPU); + uint256 teamIdx = _aliceTeamIndex(); + + // t0: first-ever game. streak = 1. + _runBattleEnd(_ctxAliceVsCpu(ALICE, 0x0, 0x3, uint16(teamIdx))); + assertEq(gachaTeamRegistry.pointsBalance(ALICE), _winPts(1, false, true), "B1: streak 1 (first ever)"); + uint256 prev = gachaTeamRegistry.pointsBalance(ALICE); + + // +23h: under the 24h cooldown → no bonus (streakFlat 0), but activity is recorded. + vm.warp(vm.getBlockTimestamp() + 23 hours); + _runBattleEnd(_ctxAliceVsCpu(ALICE, 0x0, 0x3, uint16(teamIdx))); + assertEq(gachaTeamRegistry.pointsBalance(ALICE) - prev, _winPts(0, false, false), "B2: no bonus within 24h"); + prev = gachaTeamRegistry.pointsBalance(ALICE); + + // +23h (t0+46h): >=24h since last bonus, and only 23h since last activity (<=36h grace) + // → ratchet to 2. Pre-fix this measured 46h from the stale anchor and reset to 1. + vm.warp(vm.getBlockTimestamp() + 23 hours); + _runBattleEnd(_ctxAliceVsCpu(ALICE, 0x0, 0x3, uint16(teamIdx))); + assertEq(gachaTeamRegistry.pointsBalance(ALICE) - prev, _winPts(2, false, false), "B3: ratchet to 2"); + prev = gachaTeamRegistry.pointsBalance(ALICE); + + // +23h (t0+69h): under cooldown again → no bonus, activity recorded. + vm.warp(vm.getBlockTimestamp() + 23 hours); + _runBattleEnd(_ctxAliceVsCpu(ALICE, 0x0, 0x3, uint16(teamIdx))); + assertEq(gachaTeamRegistry.pointsBalance(ALICE) - prev, _winPts(0, false, false), "B4: no bonus within 24h"); + prev = gachaTeamRegistry.pointsBalance(ALICE); + + // +23h (t0+92h): qualifies again, still within grace of last activity → ratchet to 3. + vm.warp(vm.getBlockTimestamp() + 23 hours); + _runBattleEnd(_ctxAliceVsCpu(ALICE, 0x0, 0x3, uint16(teamIdx))); + assertEq(gachaTeamRegistry.pointsBalance(ALICE) - prev, _winPts(3, false, false), "B5: ratchet to 3"); + } + + // A genuine absence (no activity for longer than the 36h grace) must still reset to 1. + function test_streak_genuineAbsenceStillResets() public { + _whitelist(CPU); + uint256 teamIdx = _aliceTeamIndex(); + + _runBattleEnd(_ctxAliceVsCpu(ALICE, 0x0, 0x3, uint16(teamIdx))); // streak 1 + vm.warp(vm.getBlockTimestamp() + 23 hours); + _runBattleEnd(_ctxAliceVsCpu(ALICE, 0x0, 0x3, uint16(teamIdx))); // no bonus, activity recorded + vm.warp(vm.getBlockTimestamp() + 23 hours); + _runBattleEnd(_ctxAliceVsCpu(ALICE, 0x0, 0x3, uint16(teamIdx))); // ratchet to 2 + + // Now disappear for >36h since the last activity → next game resets to 1. + uint256 prev = gachaTeamRegistry.pointsBalance(ALICE); + vm.warp(vm.getBlockTimestamp() + 40 hours); + _runBattleEnd(_ctxAliceVsCpu(ALICE, 0x0, 0x3, uint16(teamIdx))); + assertEq(gachaTeamRegistry.pointsBalance(ALICE) - prev, _winPts(1, false, false), "absence resets to 1"); + } + function test_exp_skipsCPUSide() public { _whitelist(CPU); uint256 teamIdx = _aliceTeamIndex(); diff --git a/test/InlineEngineGasTest.sol b/test/InlineEngineGasTest.sol deleted file mode 100644 index 86c39373..00000000 --- a/test/InlineEngineGasTest.sol +++ /dev/null @@ -1,915 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0 -pragma solidity ^0.8.0; - -import "../lib/forge-std/src/Test.sol"; - -import "../src/Constants.sol"; -import "../src/Enums.sol"; -import "../src/Structs.sol"; - -import {DefaultRuleset} from "../src/DefaultRuleset.sol"; - -import {DefaultCommitManager} from "../src/commit-manager/DefaultCommitManager.sol"; -import {SignedCommitManager} from "../src/commit-manager/SignedCommitManager.sol"; -import {Engine} from "../src/Engine.sol"; -import {IEngine} from "../src/IEngine.sol"; -import {IValidator} from "../src/IValidator.sol"; -import {SignedCommitHelper} from "./abstract/SignedCommitHelper.sol"; - -import {IEffect} from "../src/effects/IEffect.sol"; -import {StaminaRegen} from "../src/effects/StaminaRegen.sol"; - -import {IMoveSet} from "../src/moves/IMoveSet.sol"; -import {DefaultRandomnessOracle} from "../src/rng/DefaultRandomnessOracle.sol"; -import {IRandomnessOracle} from "../src/rng/IRandomnessOracle.sol"; -import {ITeamRegistry} from "../src/game-layer/ITeamRegistry.sol"; -import {ITypeCalculator} from "../src/types/ITypeCalculator.sol"; -import {CustomAttack} from "./mocks/CustomAttack.sol"; - -import {EffectAttack} from "./mocks/EffectAttack.sol"; -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"; - -import {SingleInstanceEffect} from "./mocks/SingleInstanceEffect.sol"; -import {TestTeamRegistry} from "./mocks/TestTeamRegistry.sol"; - -import {BattleOfferLib} from "../src/matchmaker/BattleOfferLib.sol"; -import {DefaultMatchmaker} from "../src/matchmaker/DefaultMatchmaker.sol"; -import {SignedMatchmaker} from "../src/matchmaker/SignedMatchmaker.sol"; -import {BattleHelper} from "./abstract/BattleHelper.sol"; -import {TestTypeCalculator} from "./mocks/TestTypeCalculator.sol"; - -/// @title Inline Engine Gas Test -/// @notice Same as EngineGasTest but uses inline validation (address(0) validator) for comparison -contract InlineEngineGasTest is Test, BattleHelper { - - DefaultCommitManager commitManager; - Engine engine; - ITypeCalculator typeCalc; - DefaultRandomnessOracle defaultOracle; - TestTeamRegistry defaultRegistry; - DefaultMatchmaker matchmaker; - - // Inline validation constants - uint256 constant MONS_PER_TEAM = 4; - uint256 constant MOVES_PER_MON = 4; - - function setUp() public { - defaultOracle = new DefaultRandomnessOracle(); - // Create engine with inline validation defaults - engine = new Engine(MONS_PER_TEAM, MOVES_PER_MON, 1); - commitManager = new DefaultCommitManager(engine); - typeCalc = new TestTypeCalculator(); - defaultRegistry = new TestTeamRegistry(); - matchmaker = new DefaultMatchmaker(engine); - } - - /// @notice Helper to start battle with inline validation (address(0) validator) - function _startBattleInline( - Engine eng, - IRandomnessOracle rngOracle, - ITeamRegistry registry, - DefaultMatchmaker maker, - IEngineHook[] memory hooks, - IRuleset ruleset, - address moveManager - ) internal returns (bytes32) { - vm.startPrank(ALICE); - address[] memory makersToAdd = new address[](1); - makersToAdd[0] = address(maker); - address[] memory makersToRemove = new address[](0); - eng.updateMatchmakers(makersToAdd, makersToRemove); - - vm.startPrank(BOB); - eng.updateMatchmakers(makersToAdd, makersToRemove); - - bytes32 salt = ""; - uint96 p0TeamIndex = 0; - uint256[] memory p0TeamIndices = registry.getMonRegistryIndicesForTeam(ALICE, p0TeamIndex); - bytes32 p0TeamHash = keccak256(abi.encodePacked(salt, p0TeamIndex, p0TeamIndices)); - - ProposedBattle memory proposal = ProposedBattle({ - p0: ALICE, - p0TeamIndex: 0, - p0TeamHash: p0TeamHash, - p1: BOB, - p1TeamIndex: 0, - teamRegistry: registry, - validator: IValidator(address(0)), // INLINE VALIDATION - rngOracle: rngOracle, - ruleset: ruleset, - engineHooks: hooks, - moveManager: moveManager, - matchmaker: maker - }); - - vm.startPrank(ALICE); - bytes32 battleKey = maker.proposeBattle(proposal); - - bytes32 battleIntegrityHash = maker.getBattleProposalIntegrityHash(proposal); - vm.startPrank(BOB); - maker.acceptBattle(battleKey, 0, battleIntegrityHash); - - vm.startPrank(ALICE); - maker.confirmBattle(battleKey, salt, p0TeamIndex); - - return battleKey; - } - - function test_consecutiveBattleGas() public { - Mon memory mon = _createMon(); - mon.stats.stamina = 5; - mon.stats.attack = 10; - 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 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))); - mon.moves[2] = uint256(uint160(address(statBoostMove))); - mon.moves[3] = uint256(uint160(address(damageMove))); - - Mon[] memory team = new Mon[](4); - for (uint256 i = 0; i < team.length; i++) { - team[i] = mon; - } - defaultRegistry.setTeam(ALICE, team); - defaultRegistry.setTeam(BOB, team); - - StaminaRegen staminaRegen = new StaminaRegen(); - IEffect[] memory effects = new IEffect[](1); - effects[0] = staminaRegen; - DefaultRuleset ruleset = new DefaultRuleset(IEngine(address(engine)), effects); - - vm.startSnapshotGas("Setup 1"); - bytes32 battleKey = _startBattleInline(engine, defaultOracle, defaultRegistry, matchmaker, new IEngineHook[](0), IRuleset(address(ruleset)), address(commitManager)); - uint256 setup1Gas = vm.stopSnapshotGas("Setup 1"); - - vm.warp(vm.getBlockTimestamp() + 1); - - vm.startSnapshotGas("FirstBattle"); - _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey, SWITCH_MOVE_INDEX, SWITCH_MOVE_INDEX, uint16(0), uint16(0)); - _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey, 0, 1, 0, 0); - _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey, SWITCH_MOVE_INDEX, 2, uint16(1), _packStatBoost(1, 0, uint256(MonStateIndexName.Attack), int32(90))); - _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey, 2, 3, _packStatBoost(0, 1, uint256(MonStateIndexName.Attack), int32(90)), 0); - vm.startPrank(ALICE); - commitManager.revealMove(battleKey, SWITCH_MOVE_INDEX, 0, uint16(0), true); - engine.resetCallContext(); - _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey, 2, NO_OP_MOVE_INDEX, _packStatBoost(0, 0, uint256(MonStateIndexName.Attack), int32(90)), 0); - _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey, 3, NO_OP_MOVE_INDEX, 0, 0); - vm.startPrank(BOB); - commitManager.revealMove(battleKey, SWITCH_MOVE_INDEX, 0, uint16(1), true); - engine.resetCallContext(); - _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey, NO_OP_MOVE_INDEX, 2, 0, _packStatBoost(1, 1, uint256(MonStateIndexName.Attack), int32(90))); - _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey, NO_OP_MOVE_INDEX, 3, 0, 0); - vm.startPrank(ALICE); - commitManager.revealMove(battleKey, SWITCH_MOVE_INDEX, 0, uint16(2), true); - engine.resetCallContext(); - _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey, NO_OP_MOVE_INDEX, 3, 0, 0); - vm.startPrank(ALICE); - commitManager.revealMove(battleKey, SWITCH_MOVE_INDEX, 0, uint16(3), true); - engine.resetCallContext(); - _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey, NO_OP_MOVE_INDEX, 3, 0, 0); - uint256 firstBattleGas = vm.stopSnapshotGas("FirstBattle"); - - // Rearrange order of moves for battle 2 - mon.moves[1] = uint256(uint160(address(burnMove))); - mon.moves[2] = uint256(uint160(address(frostbiteMove))); - mon.moves[3] = uint256(uint160(address(statBoostMove))); - mon.moves[0] = uint256(uint160(address(damageMove))); - for (uint256 i = 0; i < team.length; i++) { - team[i] = mon; - } - defaultRegistry.setTeam(ALICE, team); - defaultRegistry.setTeam(BOB, team); - - vm.startSnapshotGas("Setup 2"); - bytes32 battleKey2 = _startBattleInline(engine, defaultOracle, defaultRegistry, matchmaker, new IEngineHook[](0), IRuleset(address(ruleset)), address(commitManager)); - uint256 setup2Gas = vm.stopSnapshotGas("Setup 2"); - - vm.warp(vm.getBlockTimestamp() + 1); - - (BattleConfigView memory cfgAfterSetup2,) = engine.getBattle(battleKey2); - console.log("After setup 2 - globalEffectsLength:", cfgAfterSetup2.globalEffectsLength); - console.log("After setup 2 - packedP0EffectsCount:", cfgAfterSetup2.packedP0EffectsCount); - console.log("After setup 2 - packedP1EffectsCount:", cfgAfterSetup2.packedP1EffectsCount); - - vm.startSnapshotGas("SecondBattle"); - _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey2, SWITCH_MOVE_INDEX, SWITCH_MOVE_INDEX, uint16(0), uint16(0)); - _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey2, 3, 1, _packStatBoost(0, 0, uint256(MonStateIndexName.Attack), int32(90)), 0); - _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey2, 0, NO_OP_MOVE_INDEX, 0, 0); - vm.startPrank(BOB); - commitManager.revealMove(battleKey2, SWITCH_MOVE_INDEX, 0, uint16(1), true); - engine.resetCallContext(); - _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey2, SWITCH_MOVE_INDEX, 2, uint16(1), 0); - _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey2, 3, NO_OP_MOVE_INDEX, _packStatBoost(0, 1, uint256(MonStateIndexName.Attack), int32(90)), 0); - _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey2, 0, NO_OP_MOVE_INDEX, 0, 0); - vm.startPrank(BOB); - commitManager.revealMove(battleKey2, SWITCH_MOVE_INDEX, 0, uint16(2), true); - engine.resetCallContext(); - _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey2, NO_OP_MOVE_INDEX, 3, 0, _packStatBoost(1, 2, uint256(MonStateIndexName.Attack), int32(90))); - _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey2, NO_OP_MOVE_INDEX, 0, 0, 0); - vm.startPrank(ALICE); - commitManager.revealMove(battleKey2, SWITCH_MOVE_INDEX, 0, uint16(2), true); - engine.resetCallContext(); - _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey2, 3, 3, _packStatBoost(0, 2, uint256(MonStateIndexName.Attack), int32(90)), _packStatBoost(1, 2, uint256(MonStateIndexName.Attack), int32(90))); - _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey2, 0, NO_OP_MOVE_INDEX, 0, 0); - vm.startPrank(BOB); - commitManager.revealMove(battleKey2, SWITCH_MOVE_INDEX, 0, uint16(3), true); - engine.resetCallContext(); - _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey2, 0, NO_OP_MOVE_INDEX, 0, 0); - uint256 secondBattleGas = vm.stopSnapshotGas("SecondBattle"); - - // Battle 3: Repeat exact sequence of Battle 1 - mon.moves[0] = uint256(uint160(address(burnMove))); - mon.moves[1] = uint256(uint160(address(frostbiteMove))); - mon.moves[2] = uint256(uint160(address(statBoostMove))); - mon.moves[3] = uint256(uint160(address(damageMove))); - for (uint256 i = 0; i < team.length; i++) { - team[i] = mon; - } - defaultRegistry.setTeam(ALICE, team); - defaultRegistry.setTeam(BOB, team); - - vm.startSnapshotGas("Setup 3"); - bytes32 battleKey3 = _startBattleInline(engine, defaultOracle, defaultRegistry, matchmaker, new IEngineHook[](0), IRuleset(address(ruleset)), address(commitManager)); - uint256 setup3Gas = vm.stopSnapshotGas("Setup 3"); - - vm.warp(vm.getBlockTimestamp() + 1); - - vm.startSnapshotGas("ThirdBattle"); - _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey3, SWITCH_MOVE_INDEX, SWITCH_MOVE_INDEX, uint16(0), uint16(0)); - _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey3, 0, 1, 0, 0); - _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey3, SWITCH_MOVE_INDEX, 2, uint16(1), _packStatBoost(1, 0, uint256(MonStateIndexName.Attack), int32(90))); - _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey3, 2, 3, _packStatBoost(0, 1, uint256(MonStateIndexName.Attack), int32(90)), 0); - vm.startPrank(ALICE); - commitManager.revealMove(battleKey3, SWITCH_MOVE_INDEX, 0, uint16(0), true); - engine.resetCallContext(); - _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey3, 2, NO_OP_MOVE_INDEX, _packStatBoost(0, 0, uint256(MonStateIndexName.Attack), int32(90)), 0); - _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey3, 3, NO_OP_MOVE_INDEX, 0, 0); - vm.startPrank(BOB); - commitManager.revealMove(battleKey3, SWITCH_MOVE_INDEX, 0, uint16(1), true); - engine.resetCallContext(); - _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey3, NO_OP_MOVE_INDEX, 2, 0, _packStatBoost(1, 1, uint256(MonStateIndexName.Attack), int32(90))); - _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey3, NO_OP_MOVE_INDEX, 3, 0, 0); - vm.startPrank(ALICE); - commitManager.revealMove(battleKey3, SWITCH_MOVE_INDEX, 0, uint16(2), true); - engine.resetCallContext(); - _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey3, NO_OP_MOVE_INDEX, 3, 0, 0); - vm.startPrank(ALICE); - commitManager.revealMove(battleKey3, SWITCH_MOVE_INDEX, 0, uint16(3), true); - engine.resetCallContext(); - _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey3, NO_OP_MOVE_INDEX, 3, 0, 0); - uint256 thirdBattleGas = vm.stopSnapshotGas("ThirdBattle"); - - console.log("=== INLINE VALIDATION Gas Results ==="); - console.log("Setup 1 Gas:", setup1Gas); - console.log("Setup 2 Gas:", setup2Gas); - console.log("Setup 3 Gas:", setup3Gas); - console.log("Battle 1 Gas:", firstBattleGas); - console.log("Battle 2 Gas:", secondBattleGas); - console.log("Battle 3 Gas:", thirdBattleGas); - - assertLt(setup2Gas, setup1Gas, "Setup 2 should be cheaper (storage reuse)"); - assertLt(setup3Gas, setup1Gas, "Setup 3 should be cheaper (storage reuse)"); - - console.log("=== Battle Comparisons ==="); - if (secondBattleGas > firstBattleGas) { - console.log("Battle 2 vs 1: MORE expensive by:", secondBattleGas - firstBattleGas); - } else { - console.log("Battle 2 vs 1: LESS expensive by:", firstBattleGas - secondBattleGas); - } - if (thirdBattleGas > firstBattleGas) { - console.log("Battle 3 vs 1: MORE expensive by:", thirdBattleGas - firstBattleGas); - } else { - console.log("Battle 3 vs 1: LESS expensive by:", firstBattleGas - thirdBattleGas); - } - console.log("Battle 3 savings vs Battle 1:", firstBattleGas > thirdBattleGas ? firstBattleGas - thirdBattleGas : 0); - } - - function test_identicalBattlesGas() public { - // Note: We need to recreate engine with correct team size for inline validation - // Important: Create engine BEFORE moves so moves reference the correct engine - Engine inlineEngine = new Engine(1, 4, 1); - - Mon memory mon = Mon({ - stats: MonStats({hp: 100, stamina: 10, speed: 10, attack: 100, defense: 10, specialAttack: 10, specialDefense: 10, type1: Type.Fire, type2: Type.None}), - moves: new uint256[](4), - ability: 0 - }); - - // Use inlineEngine for moves so they reference the correct engine - IMoveSet damageMove = IMoveSet(address(new CustomAttack(typeCalc, CustomAttack.Args({TYPE: Type.Fire, BASE_POWER: 200, ACCURACY: 100, STAMINA_COST: 0, PRIORITY: 0})))); - mon.moves[0] = uint256(uint160(address(damageMove))); - mon.moves[1] = uint256(uint160(address(damageMove))); - mon.moves[2] = uint256(uint160(address(damageMove))); - mon.moves[3] = uint256(uint160(address(damageMove))); - - Mon[] memory team = new Mon[](1); - team[0] = mon; - - defaultRegistry.setTeam(ALICE, team); - defaultRegistry.setTeam(BOB, team); - DefaultCommitManager inlineCommitManager = new DefaultCommitManager(inlineEngine); - DefaultMatchmaker inlineMatchmaker = new DefaultMatchmaker(inlineEngine); - - IEffect[] memory noEffects = new IEffect[](0); - IRuleset simpleRuleset = IRuleset(address(new DefaultRuleset(inlineEngine, noEffects))); - - // Battle 1: Fresh storage - vm.startSnapshotGas("Battle1_Setup"); - bytes32 battleKey1 = _startBattleInlineCustomEngine(inlineEngine, defaultOracle, defaultRegistry, inlineMatchmaker, new IEngineHook[](0), simpleRuleset, address(inlineCommitManager)); - uint256 setup1 = vm.stopSnapshotGas("Battle1_Setup"); - - vm.warp(vm.getBlockTimestamp() + 1); - - vm.startSnapshotGas("Battle1_Execute"); - _commitRevealExecuteForEngine(inlineEngine, inlineCommitManager, battleKey1, SWITCH_MOVE_INDEX, SWITCH_MOVE_INDEX, uint16(0), uint16(0)); - _commitRevealExecuteForEngine(inlineEngine, inlineCommitManager, battleKey1, 0, 0, 0, 0); - uint256 execute1 = vm.stopSnapshotGas("Battle1_Execute"); - - // Battle 2: Reusing storage - vm.startSnapshotGas("Battle2_Setup"); - bytes32 battleKey2 = _startBattleInlineCustomEngine(inlineEngine, defaultOracle, defaultRegistry, inlineMatchmaker, new IEngineHook[](0), simpleRuleset, address(inlineCommitManager)); - uint256 setup2 = vm.stopSnapshotGas("Battle2_Setup"); - - vm.warp(vm.getBlockTimestamp() + 1); - - vm.startSnapshotGas("Battle2_Execute"); - _commitRevealExecuteForEngine(inlineEngine, inlineCommitManager, battleKey2, SWITCH_MOVE_INDEX, SWITCH_MOVE_INDEX, uint16(0), uint16(0)); - _commitRevealExecuteForEngine(inlineEngine, inlineCommitManager, battleKey2, 0, 0, 0, 0); - uint256 execute2 = vm.stopSnapshotGas("Battle2_Execute"); - - console.log("=== INLINE Identical Battles Test ==="); - console.log("Setup 1:", setup1); - console.log("Setup 2:", setup2); - console.log("Execute 1:", execute1); - console.log("Execute 2:", execute2); - - if (setup2 < setup1) { - console.log("Setup savings:", setup1 - setup2); - } - if (execute2 < execute1) { - console.log("Execute savings:", execute1 - execute2); - } else { - console.log("Execute OVERHEAD:", execute2 - execute1); - } - } - - function test_identicalBattlesWithEffectsGas() public { - Mon memory mon = Mon({ - stats: MonStats({hp: 100, stamina: 100, speed: 10, attack: 100, defense: 10, specialAttack: 10, specialDefense: 10, type1: Type.Fire, type2: Type.None}), - moves: new uint256[](4), - ability: 0 - }); - - // Recreate engine with correct team size - Engine inlineEngine = new Engine(1, 4, 1); - DefaultCommitManager inlineCommitManager = new DefaultCommitManager(inlineEngine); - DefaultMatchmaker inlineMatchmaker = new DefaultMatchmaker(inlineEngine); - - new StatBoosts(); // deployed for side effect on registry; instance not retained - IMoveSet effectMove = new EffectAttack( - new SingleInstanceEffect(), - EffectAttack.Args({TYPE: Type.Fire, STAMINA_COST: 1, PRIORITY: 1}) - ); - IMoveSet damageMove = IMoveSet(address(new CustomAttack(typeCalc, CustomAttack.Args({TYPE: Type.Fire, BASE_POWER: 200, ACCURACY: 100, STAMINA_COST: 0, PRIORITY: 0})))); - mon.moves[0] = uint256(uint160(address(effectMove))); - mon.moves[1] = uint256(uint160(address(damageMove))); - mon.moves[2] = uint256(uint160(address(damageMove))); - mon.moves[3] = uint256(uint160(address(damageMove))); - - Mon[] memory team = new Mon[](1); - team[0] = mon; - - defaultRegistry.setTeam(ALICE, team); - defaultRegistry.setTeam(BOB, team); - - StaminaRegen staminaRegen = new StaminaRegen(); - IEffect[] memory effects = new IEffect[](1); - effects[0] = staminaRegen; - IRuleset rulesetWithEffect = IRuleset(address(new DefaultRuleset(inlineEngine, effects))); - - // Battle 1: Fresh storage - vm.startSnapshotGas("B1_Setup"); - bytes32 battleKey1 = _startBattleInlineCustomEngine(inlineEngine, defaultOracle, defaultRegistry, inlineMatchmaker, new IEngineHook[](0), rulesetWithEffect, address(inlineCommitManager)); - uint256 setup1 = vm.stopSnapshotGas("B1_Setup"); - - vm.warp(vm.getBlockTimestamp() + 1); - - vm.startSnapshotGas("B1_Execute"); - _commitRevealExecuteForEngine(inlineEngine, inlineCommitManager, battleKey1, SWITCH_MOVE_INDEX, SWITCH_MOVE_INDEX, uint16(0), uint16(0)); - - (BattleConfigView memory cfgAfterSwitch,) = inlineEngine.getBattle(battleKey1); - console.log("After B1 switch - globalEffectsLength:", cfgAfterSwitch.globalEffectsLength); - console.log("After B1 switch - packedP0EffectsCount:", cfgAfterSwitch.packedP0EffectsCount); - console.log("After B1 switch - packedP1EffectsCount:", cfgAfterSwitch.packedP1EffectsCount); - - _commitRevealExecuteForEngine(inlineEngine, inlineCommitManager, battleKey1, 0, 0, 0, 0); - - (BattleConfigView memory cfgAfterEffects,) = inlineEngine.getBattle(battleKey1); - console.log("After B1 effects - globalEffectsLength:", cfgAfterEffects.globalEffectsLength); - console.log("After B1 effects - packedP0EffectsCount:", cfgAfterEffects.packedP0EffectsCount); - console.log("After B1 effects - packedP1EffectsCount:", cfgAfterEffects.packedP1EffectsCount); - - _commitRevealExecuteForEngine(inlineEngine, inlineCommitManager, battleKey1, 1, 1, 0, 0); - uint256 execute1 = vm.stopSnapshotGas("B1_Execute"); - - (, BattleData memory data1) = inlineEngine.getBattle(battleKey1); - console.log("Battle 1 winner index:", data1.winnerIndex); - - // Battle 2: Reusing storage - vm.startSnapshotGas("B2_Setup"); - bytes32 battleKey2 = _startBattleInlineCustomEngine(inlineEngine, defaultOracle, defaultRegistry, inlineMatchmaker, new IEngineHook[](0), rulesetWithEffect, address(inlineCommitManager)); - uint256 setup2 = vm.stopSnapshotGas("B2_Setup"); - - vm.warp(vm.getBlockTimestamp() + 1); - - (BattleConfigView memory cfg2,) = inlineEngine.getBattle(battleKey2); - console.log("After B2 setup - globalEffectsLength:", cfg2.globalEffectsLength); - console.log("After B2 setup - packedP0EffectsCount:", cfg2.packedP0EffectsCount); - console.log("After B2 setup - packedP1EffectsCount:", cfg2.packedP1EffectsCount); - - vm.startSnapshotGas("B2_Execute"); - _commitRevealExecuteForEngine(inlineEngine, inlineCommitManager, battleKey2, SWITCH_MOVE_INDEX, SWITCH_MOVE_INDEX, uint16(0), uint16(0)); - _commitRevealExecuteForEngine(inlineEngine, inlineCommitManager, battleKey2, 0, 0, 0, 0); - _commitRevealExecuteForEngine(inlineEngine, inlineCommitManager, battleKey2, 1, 1, 0, 0); - uint256 execute2 = vm.stopSnapshotGas("B2_Execute"); - - console.log("=== INLINE Battles With Effects ==="); - console.log("Setup 1:", setup1); - console.log("Setup 2:", setup2); - console.log("Execute 1:", execute1); - console.log("Execute 2:", execute2); - - if (setup2 < setup1) { - console.log("Setup savings:", setup1 - setup2); - } - if (execute2 < execute1) { - console.log("Execute savings:", execute1 - execute2); - } else { - console.log("Execute OVERHEAD:", execute2 - execute1); - } - } - - // Helper to start battle with inline validation for a custom engine - function _startBattleInlineCustomEngine( - Engine eng, - IRandomnessOracle rngOracle, - ITeamRegistry registry, - DefaultMatchmaker maker, - IEngineHook[] memory hooks, - IRuleset ruleset, - address moveManager - ) internal returns (bytes32) { - vm.startPrank(ALICE); - address[] memory makersToAdd = new address[](1); - makersToAdd[0] = address(maker); - address[] memory makersToRemove = new address[](0); - eng.updateMatchmakers(makersToAdd, makersToRemove); - - vm.startPrank(BOB); - eng.updateMatchmakers(makersToAdd, makersToRemove); - - bytes32 salt = ""; - uint96 p0TeamIndex = 0; - uint256[] memory p0TeamIndices = registry.getMonRegistryIndicesForTeam(ALICE, p0TeamIndex); - bytes32 p0TeamHash = keccak256(abi.encodePacked(salt, p0TeamIndex, p0TeamIndices)); - - ProposedBattle memory proposal = ProposedBattle({ - p0: ALICE, - p0TeamIndex: 0, - p0TeamHash: p0TeamHash, - p1: BOB, - p1TeamIndex: 0, - teamRegistry: registry, - validator: IValidator(address(0)), // INLINE VALIDATION - rngOracle: rngOracle, - ruleset: ruleset, - engineHooks: hooks, - moveManager: moveManager, - matchmaker: maker - }); - - vm.startPrank(ALICE); - bytes32 battleKey = maker.proposeBattle(proposal); - - bytes32 battleIntegrityHash = maker.getBattleProposalIntegrityHash(proposal); - vm.startPrank(BOB); - maker.acceptBattle(battleKey, 0, battleIntegrityHash); - - vm.startPrank(ALICE); - maker.confirmBattle(battleKey, salt, p0TeamIndex); - - return battleKey; - } - - // Helper to commit/reveal/execute for a specific engine - function _commitRevealExecuteForEngine( - Engine eng, - DefaultCommitManager cm, - bytes32 battleKey, - uint8 aliceMoveIndex, - uint8 bobMoveIndex, - uint16 aliceExtraData, - uint16 bobExtraData - ) internal { - uint104 salt = 0; - bytes32 aliceMoveHash = keccak256(abi.encodePacked(aliceMoveIndex, salt, aliceExtraData)); - bytes32 bobMoveHash = keccak256(abi.encodePacked(bobMoveIndex, salt, bobExtraData)); - uint256 turnId = eng.getTurnIdForBattleState(battleKey); - if (turnId % 2 == 0) { - vm.startPrank(ALICE); - cm.commitMove(battleKey, aliceMoveHash); - vm.startPrank(BOB); - cm.revealMove(battleKey, bobMoveIndex, salt, bobExtraData, true); - engine.resetCallContext(); - vm.startPrank(ALICE); - cm.revealMove(battleKey, aliceMoveIndex, salt, aliceExtraData, true); - engine.resetCallContext(); - } else { - vm.startPrank(BOB); - cm.commitMove(battleKey, bobMoveHash); - vm.startPrank(ALICE); - cm.revealMove(battleKey, aliceMoveIndex, salt, aliceExtraData, true); - engine.resetCallContext(); - vm.startPrank(BOB); - cm.revealMove(battleKey, bobMoveIndex, salt, bobExtraData, true); - engine.resetCallContext(); - } - vm.stopPrank(); - eng.resetCallContext(); - } -} - -/// @title Fully Optimized Inline Gas Test -/// @notice Mirrors the battle sequences from InlineEngineGasTest but stacks every -/// available optimization: inline validation (address(0) validator), -/// inline RNG (address(0) oracle), inline stamina regen, -/// SignedMatchmaker (no propose/accept/confirm storage), and -/// SignedCommitManager::executeWithDualSignedMoves (1 TX per two-player turn). -/// @dev Forced single-player switches after KOs use SignedCommitManager::executeSinglePlayerMove. -contract FullyOptimizedInlineGasTest is BattleHelper, SignedCommitHelper { - - uint256 constant MONS_PER_TEAM = 4; - uint256 constant MOVES_PER_MON = 4; - - uint256 constant P0_PK = 0xA11CE; - uint256 constant P1_PK = 0xB0B; - address p0; - address p1; - - Engine engine; - SignedCommitManager signedCommitManager; - SignedMatchmaker signedMatchmaker; - ITypeCalculator typeCalc; - TestTeamRegistry defaultRegistry; - - // Storage used by _analyzeSteps to track warm/cold SLOAD/SSTORE access - // across one pass. Cleared between passes. - mapping(bytes32 => bool) private _seenSlot; - bytes32[] private _seenKeys; - - function setUp() public { - p0 = vm.addr(P0_PK); - p1 = vm.addr(P1_PK); - - engine = new Engine(MONS_PER_TEAM, MOVES_PER_MON, 1); - signedCommitManager = new SignedCommitManager(IEngine(address(engine))); - signedMatchmaker = new SignedMatchmaker(engine); - typeCalc = new TestTypeCalculator(); - defaultRegistry = new TestTeamRegistry(); - } - - /// @dev Starts a battle via SignedMatchmaker::startGame (1 TX instead of 3). - /// Also authorizes the matchmaker each call to mirror _startBattleInline. - function _startBattleFullyOptimized(IRuleset ruleset) internal returns (bytes32) { - address[] memory makersToAdd = new address[](1); - makersToAdd[0] = address(signedMatchmaker); - address[] memory makersToRemove = new address[](0); - vm.prank(p0); - engine.updateMatchmakers(makersToAdd, makersToRemove); - vm.prank(p1); - engine.updateMatchmakers(makersToAdd, makersToRemove); - - (bytes32 battleKey, bytes32 pairHash) = engine.computeBattleKey(p0, p1); - uint256 nonce = engine.pairHashNonces(pairHash); - - BattleOffer memory offer = BattleOffer({ - battle: Battle({ - p0: p0, - p0TeamIndex: 0, - p1: p1, - p1TeamIndex: 0, - teamRegistry: defaultRegistry, - validator: IValidator(address(0)), - rngOracle: IRandomnessOracle(address(0)), - ruleset: ruleset, - moveManager: address(signedCommitManager), - matchmaker: signedMatchmaker, - engineHooks: new IEngineHook[](0) - }), - pairHashNonce: nonce - }); - - bytes32 structHash = BattleOfferLib.hashBattleOffer(offer); - bytes32 digest = signedMatchmaker.hashTypedData(structHash); - (uint8 v, bytes32 r, bytes32 s) = vm.sign(P0_PK, digest); - bytes memory signature = abi.encodePacked(r, s, v); - - vm.prank(p1); - signedMatchmaker.startGame(offer, signature); - - return battleKey; - } - - /// @dev Executes a two-player turn in 1 TX via executeWithDualSignedMoves. - /// p0Move/p1Move semantics match _commitRevealExecuteForAliceAndBob so the - /// battle scripts can be transcribed directly from the non-optimized test. - function _fastTurn( - bytes32 battleKey, - uint8 p0MoveIndex, - uint8 p1MoveIndex, - uint16 p0ExtraData, - uint16 p1ExtraData - ) internal { - uint64 turnId = uint64(engine.getTurnIdForBattleState(battleKey)); - uint104 committerSalt = uint104(uint256(keccak256(abi.encode("committer", battleKey, turnId)))); - uint104 revealerSalt = uint104(uint256(keccak256(abi.encode("revealer", battleKey, turnId)))); - - uint8 committerMoveIndex; - uint16 committerExtraData; - uint8 revealerMoveIndex; - uint16 revealerExtraData; - uint256 committerPk; - uint256 revealerPk; - address committer; - - if (turnId % 2 == 0) { - committerMoveIndex = p0MoveIndex; - committerExtraData = p0ExtraData; - revealerMoveIndex = p1MoveIndex; - revealerExtraData = p1ExtraData; - committerPk = P0_PK; - revealerPk = P1_PK; - committer = p0; - } else { - committerMoveIndex = p1MoveIndex; - committerExtraData = p1ExtraData; - revealerMoveIndex = p0MoveIndex; - revealerExtraData = p0ExtraData; - committerPk = P1_PK; - revealerPk = P0_PK; - committer = p1; - } - - bytes32 committerMoveHash = - keccak256(abi.encodePacked(committerMoveIndex, committerSalt, committerExtraData)); - address mgr = address(signedCommitManager); - bytes memory committerSig = _signCommit(mgr, committerPk, committerMoveHash, battleKey, turnId); - bytes memory revealerSig = _signDualReveal( - mgr, revealerPk, battleKey, turnId, committerMoveHash, - revealerMoveIndex, revealerSalt, revealerExtraData - ); - - vm.prank(committer); - signedCommitManager.executeWithDualSignedMoves( - battleKey, - committerMoveIndex, committerSalt, committerExtraData, - revealerMoveIndex, revealerSalt, revealerExtraData, - committerSig, revealerSig - ); - engine.resetCallContext(); - } - - /// @dev Single-player forced switch after a KO. This uses the optimized - /// SignedCommitManager path because there is no hidden opponent move to reveal. - function _fastSwitchReveal(bytes32 battleKey, bool isP0, uint16 extraData) internal { - vm.prank(isP0 ? p0 : p1); - signedCommitManager.executeSinglePlayerMove(battleKey, SWITCH_MOVE_INDEX, uint104(0), extraData); - engine.resetCallContext(); - } - - /// @notice Compares the inherited single-player reveal flow against the dedicated - /// SignedCommitManager single-player fast path. - function test_signedCommitManagerOnePlayerActionGasComparison() public { - Mon memory mon = _createMon(); - mon.stats.stamina = 5; - mon.stats.attack = 10; - mon.stats.specialAttack = 10; - mon.moves = new uint256[](4); - - IMoveSet damageMove = new CustomAttack( - ITypeCalculator(address(typeCalc)), - CustomAttack.Args({TYPE: Type.Fire, BASE_POWER: 100, ACCURACY: 100, STAMINA_COST: 0, PRIORITY: 1}) - ); - for (uint256 i; i < mon.moves.length; i++) { - mon.moves[i] = uint256(uint160(address(damageMove))); - } - - Mon[] memory team = new Mon[](4); - for (uint256 i; i < team.length; i++) { - team[i] = mon; - } - defaultRegistry.setTeam(p0, team); - defaultRegistry.setTeam(p1, team); - - IRuleset ruleset = IRuleset(INLINE_STAMINA_REGEN_RULESET); - - bytes32 oldFlowBattleKey = _startBattleFullyOptimized(ruleset); - vm.warp(vm.getBlockTimestamp() + 1); - _fastTurn(oldFlowBattleKey, SWITCH_MOVE_INDEX, SWITCH_MOVE_INDEX, uint16(0), uint16(0)); - _fastTurn(oldFlowBattleKey, 0, NO_OP_MOVE_INDEX, uint16(0), uint16(0)); - assertEq(engine.getPlayerSwitchForTurnFlagForBattleState(oldFlowBattleKey), 1); - - vm.prank(p1); - uint256 gasBefore = gasleft(); - signedCommitManager.revealMove(oldFlowBattleKey, SWITCH_MOVE_INDEX, uint104(0), uint16(1), true); - uint256 oldFlowGas = gasBefore - gasleft(); - engine.resetCallContext(); - - _fastTurn(oldFlowBattleKey, 0, NO_OP_MOVE_INDEX, uint16(0), uint16(0)); - assertEq(engine.getPlayerSwitchForTurnFlagForBattleState(oldFlowBattleKey), 1); - - vm.prank(p1); - gasBefore = gasleft(); - signedCommitManager.revealMove(oldFlowBattleKey, SWITCH_MOVE_INDEX, uint104(0), uint16(2), true); - uint256 oldFlowSecondGas = gasBefore - gasleft(); - engine.resetCallContext(); - - bytes32 fastPathBattleKey = _startBattleFullyOptimized(ruleset); - vm.warp(vm.getBlockTimestamp() + 1); - _fastTurn(fastPathBattleKey, SWITCH_MOVE_INDEX, SWITCH_MOVE_INDEX, uint16(0), uint16(0)); - _fastTurn(fastPathBattleKey, 0, NO_OP_MOVE_INDEX, uint16(0), uint16(0)); - assertEq(engine.getPlayerSwitchForTurnFlagForBattleState(fastPathBattleKey), 1); - - vm.prank(p1); - gasBefore = gasleft(); - signedCommitManager.executeSinglePlayerMove(fastPathBattleKey, SWITCH_MOVE_INDEX, uint104(0), uint16(1)); - uint256 fastPathGas = gasBefore - gasleft(); - engine.resetCallContext(); - - _fastTurn(fastPathBattleKey, 0, NO_OP_MOVE_INDEX, uint16(0), uint16(0)); - assertEq(engine.getPlayerSwitchForTurnFlagForBattleState(fastPathBattleKey), 1); - - vm.prank(p1); - gasBefore = gasleft(); - signedCommitManager.executeSinglePlayerMove(fastPathBattleKey, SWITCH_MOVE_INDEX, uint104(0), uint16(2)); - uint256 fastPathSecondGas = gasBefore - gasleft(); - engine.resetCallContext(); - - console.log("Old SignedCommitManager first revealMove gas:", oldFlowGas); - console.log("New first executeSinglePlayerMove gas:", fastPathGas); - console.log("First forced-switch savings:", oldFlowGas - fastPathGas); - console.log("Old SignedCommitManager second revealMove gas:", oldFlowSecondGas); - console.log("New second executeSinglePlayerMove gas:", fastPathSecondGas); - console.log("Second forced-switch savings:", oldFlowSecondGas - fastPathSecondGas); - - assertLt(fastPathGas, oldFlowGas); - assertLt(fastPathSecondGas, oldFlowSecondGas); - } - - /// @notice Mirrors InlineEngineGasTest::test_consecutiveBattleGas move-for-move, - /// but every TX goes through the dual-signed fast path. - function test_consecutiveBattleGas() public { - Mon memory mon = _createMon(); - mon.stats.stamina = 5; - mon.stats.attack = 10; - 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 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))); - mon.moves[2] = uint256(uint160(address(statBoostMove))); - mon.moves[3] = uint256(uint160(address(damageMove))); - - Mon[] memory team = new Mon[](4); - for (uint256 i = 0; i < team.length; i++) { - team[i] = mon; - } - defaultRegistry.setTeam(p0, team); - defaultRegistry.setTeam(p1, team); - - // Use the INLINE_STAMINA_REGEN_RULESET sentinel so the engine takes its internal stamina-regen - // fast path (no external StaminaRegen contract, no onAfterMove/onRoundEnd callbacks). This is - // the intended production configuration for the fully-optimized stack. - IRuleset ruleset = IRuleset(INLINE_STAMINA_REGEN_RULESET); - - vm.startSnapshotGas("Fast_Setup_1"); - bytes32 battleKey = _startBattleFullyOptimized(ruleset); - uint256 setup1Gas = vm.stopSnapshotGas("Fast_Setup_1"); - - vm.warp(vm.getBlockTimestamp() + 1); - - vm.startSnapshotGas("Fast_Battle1"); - _fastTurn(battleKey, SWITCH_MOVE_INDEX, SWITCH_MOVE_INDEX, uint16(0), uint16(0)); - _fastTurn(battleKey, 0, 1, 0, 0); - _fastTurn(battleKey, SWITCH_MOVE_INDEX, 2, uint16(1), _packStatBoost(1, 0, uint256(MonStateIndexName.Attack), int32(90))); - _fastTurn(battleKey, 2, 3, _packStatBoost(0, 1, uint256(MonStateIndexName.Attack), int32(90)), 0); - _fastSwitchReveal(battleKey, true, uint16(0)); - _fastTurn(battleKey, 2, NO_OP_MOVE_INDEX, _packStatBoost(0, 0, uint256(MonStateIndexName.Attack), int32(90)), 0); - _fastTurn(battleKey, 3, NO_OP_MOVE_INDEX, 0, 0); - _fastSwitchReveal(battleKey, false, uint16(1)); - _fastTurn(battleKey, NO_OP_MOVE_INDEX, 2, 0, _packStatBoost(1, 1, uint256(MonStateIndexName.Attack), int32(90))); - _fastTurn(battleKey, NO_OP_MOVE_INDEX, 3, 0, 0); - _fastSwitchReveal(battleKey, true, uint16(2)); - _fastTurn(battleKey, NO_OP_MOVE_INDEX, 3, 0, 0); - _fastSwitchReveal(battleKey, true, uint16(3)); - _fastTurn(battleKey, NO_OP_MOVE_INDEX, 3, 0, 0); - uint256 firstBattleGas = vm.stopSnapshotGas("Fast_Battle1"); - - // Rearrange moves for battle 2 (same as InlineEngineGasTest) - mon.moves[1] = uint256(uint160(address(burnMove))); - mon.moves[2] = uint256(uint160(address(frostbiteMove))); - mon.moves[3] = uint256(uint160(address(statBoostMove))); - mon.moves[0] = uint256(uint160(address(damageMove))); - for (uint256 i = 0; i < team.length; i++) { - team[i] = mon; - } - defaultRegistry.setTeam(p0, team); - defaultRegistry.setTeam(p1, team); - - vm.startSnapshotGas("Fast_Setup_2"); - bytes32 battleKey2 = _startBattleFullyOptimized(IRuleset(address(ruleset))); - uint256 setup2Gas = vm.stopSnapshotGas("Fast_Setup_2"); - - vm.warp(vm.getBlockTimestamp() + 1); - - vm.startSnapshotGas("Fast_Battle2"); - _fastTurn(battleKey2, SWITCH_MOVE_INDEX, SWITCH_MOVE_INDEX, uint16(0), uint16(0)); - _fastTurn(battleKey2, 3, 1, _packStatBoost(0, 0, uint256(MonStateIndexName.Attack), int32(90)), 0); - _fastTurn(battleKey2, 0, NO_OP_MOVE_INDEX, 0, 0); - _fastSwitchReveal(battleKey2, false, uint16(1)); - _fastTurn(battleKey2, SWITCH_MOVE_INDEX, 2, uint16(1), 0); - _fastTurn(battleKey2, 3, NO_OP_MOVE_INDEX, _packStatBoost(0, 1, uint256(MonStateIndexName.Attack), int32(90)), 0); - _fastTurn(battleKey2, 0, NO_OP_MOVE_INDEX, 0, 0); - _fastSwitchReveal(battleKey2, false, uint16(2)); - _fastTurn(battleKey2, NO_OP_MOVE_INDEX, 3, 0, _packStatBoost(1, 2, uint256(MonStateIndexName.Attack), int32(90))); - _fastTurn(battleKey2, NO_OP_MOVE_INDEX, 0, 0, 0); - _fastSwitchReveal(battleKey2, true, uint16(2)); - _fastTurn(battleKey2, 3, 3, _packStatBoost(0, 2, uint256(MonStateIndexName.Attack), int32(90)), _packStatBoost(1, 2, uint256(MonStateIndexName.Attack), int32(90))); - _fastTurn(battleKey2, 0, NO_OP_MOVE_INDEX, 0, 0); - _fastSwitchReveal(battleKey2, false, uint16(3)); - _fastTurn(battleKey2, 0, NO_OP_MOVE_INDEX, 0, 0); - uint256 secondBattleGas = vm.stopSnapshotGas("Fast_Battle2"); - - // Battle 3: Repeat exact sequence of Battle 1 - mon.moves[0] = uint256(uint160(address(burnMove))); - mon.moves[1] = uint256(uint160(address(frostbiteMove))); - mon.moves[2] = uint256(uint160(address(statBoostMove))); - mon.moves[3] = uint256(uint160(address(damageMove))); - for (uint256 i = 0; i < team.length; i++) { - team[i] = mon; - } - defaultRegistry.setTeam(p0, team); - defaultRegistry.setTeam(p1, team); - - vm.startSnapshotGas("Fast_Setup_3"); - bytes32 battleKey3 = _startBattleFullyOptimized(IRuleset(address(ruleset))); - uint256 setup3Gas = vm.stopSnapshotGas("Fast_Setup_3"); - - vm.warp(vm.getBlockTimestamp() + 1); - - vm.startSnapshotGas("Fast_Battle3"); - _fastTurn(battleKey3, SWITCH_MOVE_INDEX, SWITCH_MOVE_INDEX, uint16(0), uint16(0)); - _fastTurn(battleKey3, 0, 1, 0, 0); - _fastTurn(battleKey3, SWITCH_MOVE_INDEX, 2, uint16(1), _packStatBoost(1, 0, uint256(MonStateIndexName.Attack), int32(90))); - _fastTurn(battleKey3, 2, 3, _packStatBoost(0, 1, uint256(MonStateIndexName.Attack), int32(90)), 0); - _fastSwitchReveal(battleKey3, true, uint16(0)); - _fastTurn(battleKey3, 2, NO_OP_MOVE_INDEX, _packStatBoost(0, 0, uint256(MonStateIndexName.Attack), int32(90)), 0); - _fastTurn(battleKey3, 3, NO_OP_MOVE_INDEX, 0, 0); - _fastSwitchReveal(battleKey3, false, uint16(1)); - _fastTurn(battleKey3, NO_OP_MOVE_INDEX, 2, 0, _packStatBoost(1, 1, uint256(MonStateIndexName.Attack), int32(90))); - _fastTurn(battleKey3, NO_OP_MOVE_INDEX, 3, 0, 0); - _fastSwitchReveal(battleKey3, true, uint16(2)); - _fastTurn(battleKey3, NO_OP_MOVE_INDEX, 3, 0, 0); - _fastSwitchReveal(battleKey3, true, uint16(3)); - _fastTurn(battleKey3, NO_OP_MOVE_INDEX, 3, 0, 0); - uint256 thirdBattleGas = vm.stopSnapshotGas("Fast_Battle3"); - - console.log("=== FULLY OPTIMIZED Gas Results ==="); - console.log("Setup 1 Gas:", setup1Gas); - console.log("Setup 2 Gas:", setup2Gas); - console.log("Setup 3 Gas:", setup3Gas); - console.log("Battle 1 Gas:", firstBattleGas); - console.log("Battle 2 Gas:", secondBattleGas); - console.log("Battle 3 Gas:", thirdBattleGas); - - assertLt(setup2Gas, setup1Gas, "Setup 2 should be cheaper (storage reuse)"); - assertLt(setup3Gas, setup1Gas, "Setup 3 should be cheaper (storage reuse)"); - } -} 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 new file mode 100644 index 00000000..b56e9795 --- /dev/null +++ b/test/RealMonReplayGasTest.t.sol @@ -0,0 +1,345 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity ^0.8.0; + +import "../src/Constants.sol"; +import "../src/Enums.sol"; +import "../src/Structs.sol"; +import {Test} from "forge-std/Test.sol"; +import {console} from "forge-std/console.sol"; + +import {SetupMons} from "../script/SetupMons.s.sol"; +import {Engine} from "../src/Engine.sol"; +import {GachaTeamRegistry} from "../src/game-layer/GachaTeamRegistry.sol"; +import {IGachaRNG} from "../src/rng/IGachaRNG.sol"; +import {IEngine} from "../src/IEngine.sol"; + +import {TypeCalculator} from "../src/types/TypeCalculator.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"; +import {SleepStatus} from "../src/effects/status/SleepStatus.sol"; +import {ZapStatus} from "../src/effects/status/ZapStatus.sol"; +import {Overclock} from "../src/effects/battlefield/Overclock.sol"; + +import {SignedCommitManager} from "../src/commit-manager/SignedCommitManager.sol"; +import {SignedMatchmaker} from "../src/matchmaker/SignedMatchmaker.sol"; +import {BattleOfferLib} from "../src/matchmaker/BattleOfferLib.sol"; +import {IEngineHook} from "../src/IEngineHook.sol"; +import {IRandomnessOracle} from "../src/rng/IRandomnessOracle.sol"; +import {IRuleset} from "../src/IRuleset.sol"; +import {IValidator} from "../src/IValidator.sol"; + +import {BatchHelper} from "./abstract/BatchHelper.sol"; +import {TestTeamRegistry} from "./mocks/TestTeamRegistry.sol"; + +/// @notice Faithful replay of a REAL prod battle (26 turns, switch/no-op heavy): real mon loadouts +/// via SetupMons' canonical deployX() recipes + the log's per-turn moveIndex/salt/extraData, +/// run through LEGACY (per-turn execute) and BATCHED (single-sig submit x N-1 + a final +/// submitTurnMovesAndExecute that buffers the last turn and drains in the same tx). +/// Asserts byte-equal end state (equivalence) and reports production-faithful (vm.cool, +/// steady-state) total gas. Batching uses direct storage (no shadow) + single-sig submit. +contract RealMonReplayGasTest is Test, SetupMons, BatchHelper { + uint256 constant P0_PK = 0xA11CE; + uint256 constant P1_PK = 0xB0B; + uint256 constant TX_BASE = 21000; + address p0; + address p1; + + Engine engine; + SignedCommitManager mgr; + SignedMatchmaker maker; + GachaTeamRegistry gachaReg; + TestTeamRegistry registry; + + uint256[4] P0_IDS = [uint256(6), 3, 9, 8]; + uint256[4] P1_IDS = [uint256(0), 3, 10, 5]; + + struct Turn { + uint8 p0Move; uint16 p0Extra; uint104 p0Salt; bool p0Present; + uint8 p1Move; uint16 p1Extra; uint104 p1Salt; bool p1Present; + } + + function setUp() public { + p0 = vm.addr(P0_PK); + p1 = vm.addr(P1_PK); + _deployStack(); + registry.setTeam(p0, _buildTeam(P0_IDS, _p0Stats())); + registry.setTeam(p1, _buildTeam(P1_IDS, _p1Stats())); + } + + function _deployStack() internal { + engine = new Engine(4, 4, 1); + TypeCalculator tc = new TypeCalculator(); + Overclock oc = new Overclock(); + SleepStatus sleepStatus = new SleepStatus(); + PanicStatus panicStatus = new PanicStatus(); + 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("OVERCLOCK", vm.toString(address(oc))); + vm.setEnv("SLEEP_STATUS", vm.toString(address(sleepStatus))); + vm.setEnv("PANIC_STATUS", vm.toString(address(panicStatus))); + vm.setEnv("FROSTBITE_STATUS", vm.toString(address(frost))); + vm.setEnv("BURN_STATUS", vm.toString(address(burn))); + vm.setEnv("ZAP_STATUS", vm.toString(address(zap))); + vm.setEnv("GACHA_TEAM_REGISTRY", vm.toString(address(gachaReg))); + deployGhouliath(gachaReg); deployInutia(gachaReg); deployMalalien(gachaReg); deployIblivion(gachaReg); + deployGorillax(gachaReg); deploySofabbi(gachaReg); deployPengym(gachaReg); deployEmbursa(gachaReg); + deployVolthare(gachaReg); deployAurox(gachaReg); deployXmon(gachaReg); + mgr = new SignedCommitManager(IEngine(address(engine))); + maker = new SignedMatchmaker(engine); + registry = new TestTeamRegistry(); + // Production uses the inline stamina-regen path (INLINE_STAMINA_REGEN_RULESET sentinel), + // handled internally by the engine — no external StaminaRegen effect / ruleset deployed. + } + + function _buildTeam(uint256[4] memory ids, MonStats[4] memory logStats) internal view returns (Mon[] memory team) { + team = new Mon[](4); + for (uint256 i; i < 4; i++) { + (, uint256[] memory moves, uint256[] memory abilities) = gachaReg.getMonData(ids[i]); + team[i] = Mon({stats: logStats[i], ability: abilities.length > 0 ? abilities[0] : 0, moves: moves}); + } + } + + function _mk(uint32 hp, uint32 stam, uint32 spe, uint32 atk, uint32 def, uint32 spa, uint32 spd, Type t1, Type t2) + internal pure returns (MonStats memory) + { return MonStats({hp: hp, stamina: stam, speed: spe, attack: atk, defense: def, specialAttack: spa, specialDefense: spd, type1: t1, type2: t2}); } + + function _p0Stats() internal pure returns (MonStats[4] memory s) { + s[0] = _mk(371, 5, 149, 202, 200, 222, 180, Type.Ice, Type.None); + s[1] = _mk(277, 5, 256, 197, 156, 252, 160, Type.Yang, Type.Air); + s[2] = _mk(420, 5, 100, 143, 230, 95, 220, Type.Metal, Type.None); + s[3] = _mk(295, 5, 311, 120, 193, 255, 184, Type.Lightning, Type.Cyber); + } + function _p1Stats() internal pure returns (MonStats[4] memory s) { + s[0] = _mk(303, 5, 181, 157, 202, 151, 202, Type.Yin, Type.Fire); + s[1] = _mk(277, 5, 256, 188, 164, 240, 168, Type.Yang, Type.Air); + s[2] = _mk(311, 5, 285, 123, 179, 222, 185, Type.Cosmic, Type.None); + s[3] = _mk(333, 5, 175, 180, 201, 120, 269, Type.Nature, Type.None); + } + + function _plan() internal pure returns (Turn[] memory t) { + t = new Turn[](26); + t[0] = Turn(125,1,15450001689812990757318517192966,true, 125,0,18252122845989030006812243139474,true); + t[1] = Turn(2,0,4834210944993112651816909106126,true, 3,0,15255474349613996056713761071686,true); + t[2] = Turn(2,0,6583714706138183953804767275678,true, 1,0,15461637266987935369279566108124,true); + t[3] = Turn(2,0,7210161534971784956923416751886,true, 1,0,15016064050662495416725412652563,true); + t[4] = Turn(126,0,0,false, 125,1,19240011345095274681466263674330,true); + t[5] = Turn(126,0,3284692555853178397455092928083,true, 126,0,7835549805310255467442088074506,true); + t[6] = Turn(125,3,12334118906782137414472592949424,true, 126,0,19374785281272442474766137271163,true); + t[7] = Turn(2,0,15077791565903026790875989318528,true, 125,0,11421095052443333388573678495326,true); + t[8] = Turn(126,0,0,false, 125,1,6291473213391741470941218170218,true); + t[9] = Turn(0,0,7022931971424196742811121512061,true, 125,3,15438085774022369100235175410030,true); + t[10] = Turn(1,2,4420670065419414850590787481288,true, 2,0,1960761762236369089740333992246,true); + t[11] = Turn(3,0,19801295147355512497167142159749,true, 2,0,6166359188124075649524594725791,true); + t[12] = Turn(3,0,17171843021366040478135578264996,true, 2,0,5383564461617129507072037502214,true); + t[13] = Turn(125,3,1986471879882982807378747309426,true, 126,0,14414938581786935425390960964000,true); + t[14] = Turn(1,2,3458675293930857335960176057085,true, 2,0,7749328072402731440980579744946,true); + t[15] = Turn(125,3,17293194887286872287278788602290,true, 126,0,4383111541380336465729024026150,true); + t[16] = Turn(1,2,10450409746379039708229821790015,true, 125,2,8716693538680640339539097046509,true); + t[17] = Turn(3,0,15015474814001600635537093680446,true, 1,0,2288645315003210275352244731355,true); + t[18] = Turn(125,3,11920649514225307809051229177287,true, 1,0,11401157979167469193859133635460,true); + t[19] = Turn(126,0,17457123310241581297033221314838,true, 1,0,10631747601138287248935576077466,true); + t[20] = Turn(0,0,6835862067306040477454545192907,true, 1,0,9322809856242922630776583049082,true); + t[21] = Turn(0,0,4954019214144165935310368793018,true, 1,0,134338259296852826632816183133,true); + t[22] = Turn(126,0,0,false, 125,1,16613587181676977476579639480048,true); + t[23] = Turn(1,0,17804535964781524133768449087333,true, 125,3,18580496538728489255944038457804,true); + t[24] = Turn(126,0,0,false, 125,1,7470981269216264771411536686385,true); + t[25] = Turn(2,0,12785556958579953943913050575887,true, 126,0,17130052050856558701654168347952,true); + } + + function _startBattle() internal returns (bytes32) { + address[] memory makersToAdd = new address[](1); + makersToAdd[0] = address(maker); + address[] memory makersToRemove = new address[](0); + vm.prank(p0); engine.updateMatchmakers(makersToAdd, makersToRemove); + vm.prank(p1); engine.updateMatchmakers(makersToAdd, makersToRemove); + (bytes32 key, bytes32 pairHash) = engine.computeBattleKey(p0, p1); + uint256 nonce = engine.pairHashNonces(pairHash); + BattleOffer memory offer = BattleOffer({ + battle: Battle({ + p0: p0, p0TeamIndex: 0, p1: p1, p1TeamIndex: 0, + teamRegistry: registry, validator: IValidator(address(0)), + rngOracle: IRandomnessOracle(address(0)), ruleset: IRuleset(INLINE_STAMINA_REGEN_RULESET), + moveManager: address(mgr), matchmaker: maker, engineHooks: new IEngineHook[](0) + }), + pairHashNonce: nonce + }); + bytes32 digest = maker.hashTypedData(BattleOfferLib.hashBattleOffer(offer)); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(P0_PK, digest); + vm.prank(p1); maker.startGame(offer, abi.encodePacked(r, s, v)); + return key; + } + + function _coolEngineAndMgr() internal { vm.cool(address(engine)); vm.cool(address(mgr)); } + + // ---- LEGACY (per-turn, 2-sig executeWithDualSignedMoves as on main) ---- + function _legacyTurn(bytes32 battleKey, Turn memory tn, bool measure) internal returns (uint256 gasUsed) { + uint64 turnId = uint64(engine.getTurnIdForBattleState(battleKey)); + bool twoPlayer = tn.p0Present && tn.p1Present; + if (twoPlayer) { + (uint8 cM, uint16 cE, uint104 cS, uint8 rM, uint16 rE, uint104 rS, uint256 rPk) = + turnId % 2 == 0 + ? (tn.p0Move, tn.p0Extra, tn.p0Salt, tn.p1Move, tn.p1Extra, tn.p1Salt, P1_PK) + : (tn.p1Move, tn.p1Extra, tn.p1Salt, tn.p0Move, tn.p0Extra, tn.p0Salt, P0_PK); + // Single-sig: committer (msg.sender, by parity) submits; only the revealer signs. + address committer = turnId % 2 == 0 ? p0 : p1; + bytes32 cHash = keccak256(abi.encodePacked(cM, cS, cE)); + bytes memory rSig = _signDualReveal(address(mgr), rPk, battleKey, turnId, cHash, rM, rS, rE); + if (measure) { _coolEngineAndMgr(); vm.prank(committer); uint256 g0 = gasleft(); + mgr.executeWithDualSignedMoves(battleKey, cM, cS, cE, rM, rS, rE, rSig); + gasUsed = g0 - gasleft(); + } else { vm.prank(committer); mgr.executeWithDualSignedMoves(battleKey, cM, cS, cE, rM, rS, rE, rSig); } + } else { + (uint8 m, uint16 e, uint104 s, address actor) = tn.p0Present + ? (tn.p0Move, tn.p0Extra, tn.p0Salt, p0) : (tn.p1Move, tn.p1Extra, tn.p1Salt, p1); + if (measure) _coolEngineAndMgr(); + uint256 g0 = gasleft(); + vm.prank(actor); mgr.executeSinglePlayerMove(battleKey, m, s, e); + if (measure) gasUsed = g0 - gasleft(); + } + engine.resetCallContext(); + } + + // ---- BATCHED (single-sig submit: committer = msg.sender) ---- + function _submitTurn(bytes32 battleKey, uint64 turnId, Turn memory tn, bool combined, bool measure) + internal returns (uint256 gasUsed) + { + uint8 p0m = tn.p0Present ? tn.p0Move : NO_OP_MOVE_INDEX; + uint8 p1m = tn.p1Present ? tn.p1Move : NO_OP_MOVE_INDEX; + uint104 p0s = tn.p0Present ? tn.p0Salt : uint104(uint256(keccak256(abi.encode("noop0", battleKey, turnId)))); + uint104 p1s = tn.p1Present ? tn.p1Salt : uint104(uint256(keccak256(abi.encode("noop1", battleKey, turnId)))); + TurnSubmission memory entry = _buildTurnSubmission( + address(mgr), battleKey, turnId, p0m, tn.p0Extra, p0s, p1m, tn.p1Extra, p1s, P0_PK, P1_PK + ); + address committer = _committerFor(turnId, p0, p1); + if (measure) _coolEngineAndMgr(); + vm.prank(committer); + uint256 g0 = gasleft(); + // Final submission drains the whole buffer in the same tx; the rest only buffer. + if (combined) { + mgr.submitTurnMovesAndExecute(battleKey, entry); + } else { + mgr.submitTurnMoves(battleKey, entry); + } + gasUsed = g0 - gasleft(); + } + + function _runLegacy(bytes32 battleKey, Turn[] memory plan, bool measure) internal returns (uint256 totalExec) { + for (uint256 i; i < plan.length; i++) totalExec += _legacyTurn(battleKey, plan[i], measure); + } + + function _runBatched(bytes32 battleKey, Turn[] memory plan, bool measure) internal returns (uint256 submitExec, uint256 execExec) { + uint64 lastIdx = uint64(plan.length - 1); + for (uint64 i; i < lastIdx; i++) submitExec += _submitTurn(battleKey, i, plan[i], false, measure); + // Final turn submits AND drains the buffer in one tx (submit + executeBuffered combined). + execExec = _submitTurn(battleKey, lastIdx, plan[lastIdx], true, measure); + engine.resetCallContext(); + } + + // ---- ONE-TX (single-player / CPU): all moves provided up front and executed in ONE tx via + // engine.executeBatchedTurns (the same loop the batched drain runs). No per-turn + // commit-reveal — single-player has no adversary to hide moves from — so the (plan.length-1) + // submit txs and their sig/getSubmitContext overhead collapse into this single call. ---- + function _oneTxEntries(bytes32 battleKey, Turn[] memory plan) internal pure returns (uint256[] memory entries) { + entries = new uint256[](plan.length); + for (uint256 i; i < plan.length; i++) { + Turn memory tn = plan[i]; + uint8 p0m = tn.p0Present ? tn.p0Move : NO_OP_MOVE_INDEX; + uint8 p1m = tn.p1Present ? tn.p1Move : NO_OP_MOVE_INDEX; + uint104 p0s = tn.p0Present ? tn.p0Salt : uint104(uint256(keccak256(abi.encode("noop0", battleKey, i)))); + uint104 p1s = tn.p1Present ? tn.p1Salt : uint104(uint256(keccak256(abi.encode("noop1", battleKey, i)))); + // executeBatchedTurns entry layout: p0Move 8 | p0Extra 16 | p0Salt 104 | p1Move 8 | p1Extra 16 | p1Salt 104 + entries[i] = uint256(p0m) | (uint256(tn.p0Extra) << 8) | (uint256(p0s) << 24) + | (uint256(p1m) << 128) | (uint256(tn.p1Extra) << 136) | (uint256(p1s) << 152); + } + } + + function _runOneTx(bytes32 battleKey, Turn[] memory plan, bool measure) internal returns (uint256 execGas) { + uint256[] memory entries = _oneTxEntries(battleKey, plan); + if (measure) _coolEngineAndMgr(); + vm.prank(address(mgr)); // executeBatchedTurns is moveManager-gated + uint256 g0 = gasleft(); + engine.executeBatchedTurns(battleKey, entries); + if (measure) execGas = g0 - gasleft(); + engine.resetCallContext(); + } + + function _stateHash(bytes32 battleKey) internal view returns (bytes32) { + (, BattleData memory data) = engine.getBattle(battleKey); + MonState[] memory p0s = engine.getMonStatesForSide(battleKey, 0); + MonState[] memory p1s = engine.getMonStatesForSide(battleKey, 1); + return keccak256(abi.encode(data.turnId, data.winnerIndex, data.activeMonIndex, p0s, p1s)); + } + + function _endViaTimeout(bytes32 battleKey) internal { + vm.warp(vm.getBlockTimestamp() + 2 hours); + engine.end(battleKey); + engine.resetCallContext(); + } + + function test_realGameReplay_legacyVsBatched() public { + Turn[] memory plan = _plan(); + vm.warp(vm.getBlockTimestamp() + 1); + + // LEGACY: battle 1 warms slots + ends via timeout (frees storageKey), battle 2 measured. + bytes32 lKey1 = _startBattle(); + vm.warp(vm.getBlockTimestamp() + 1); + _runLegacy(lKey1, plan, false); + bytes32 legacyState = _stateHash(lKey1); + _endViaTimeout(lKey1); + bytes32 lKey2 = _startBattle(); + require(engine.getStorageKey(lKey1) == engine.getStorageKey(lKey2), "legacy storageKey reuse"); + vm.warp(vm.getBlockTimestamp() + 1); + uint256 legacyExec = _runLegacy(lKey2, plan, true); + uint256 legacyTotal = legacyExec + plan.length * TX_BASE; + + // BATCHED: fresh stack, same pattern. + _deployStack(); + registry.setTeam(p0, _buildTeam(P0_IDS, _p0Stats())); + registry.setTeam(p1, _buildTeam(P1_IDS, _p1Stats())); + bytes32 bKey1 = _startBattle(); + vm.warp(vm.getBlockTimestamp() + 1); + _runBatched(bKey1, plan, false); + bytes32 batchedState = _stateHash(bKey1); + _endViaTimeout(bKey1); + bytes32 bKey2 = _startBattle(); + require(engine.getStorageKey(bKey1) == engine.getStorageKey(bKey2), "batched storageKey reuse"); + vm.warp(vm.getBlockTimestamp() + 1); + (uint256 submitExec, uint256 execExec) = _runBatched(bKey2, plan, true); + // plan.length transactions total: (plan.length - 1) buffer-only submits + 1 combined submit+execute. + uint256 batchedTotal = submitExec + execExec + plan.length * TX_BASE; + + assertEq(legacyState, batchedState, "legacy and batched must reach identical end state"); + + // ONE-TX (single-player/CPU): all moves up front, executed in one tx. Fresh stack, same pattern. + _deployStack(); + registry.setTeam(p0, _buildTeam(P0_IDS, _p0Stats())); + registry.setTeam(p1, _buildTeam(P1_IDS, _p1Stats())); + bytes32 oKey1 = _startBattle(); + vm.warp(vm.getBlockTimestamp() + 1); + _runOneTx(oKey1, plan, false); + bytes32 oneTxState = _stateHash(oKey1); + _endViaTimeout(oKey1); + bytes32 oKey2 = _startBattle(); + require(engine.getStorageKey(oKey1) == engine.getStorageKey(oKey2), "one-tx storageKey reuse"); + vm.warp(vm.getBlockTimestamp() + 1); + uint256 oneTxExec = _runOneTx(oKey2, plan, true); + uint256 oneTxTotal = oneTxExec + TX_BASE; // submit + execute everything in ONE tx + + assertEq(legacyState, oneTxState, "legacy and one-tx must reach identical end state"); + + console.log(""); + console.log("=== CLEAN BRANCH: REAL game (26 turns), PROD config (inline regen), production-faithful ==="); + console.log(" LEGACY (inline regen, repack, 1-sig):", legacyTotal); + console.log(" BATCHED (inline regen, repack, 1-sig):", batchedTotal); + if (batchedTotal < legacyTotal) console.log(" batching saves vs clean-legacy :", legacyTotal - batchedTotal); + console.log(" ONE-TX (CPU: all moves + execute, 1 tx):", oneTxTotal); + if (oneTxTotal < batchedTotal) console.log(" one-tx saves vs batched :", batchedTotal - oneTxTotal); + if (oneTxTotal < legacyTotal) console.log(" one-tx saves vs legacy :", legacyTotal - oneTxTotal); + // NOTE: the old external-StaminaRegen main baseline (5,277,953) is NOT comparable — it + // measured the slow ruleset. A fair main comparison needs main itself re-measured under inline. + } +} diff --git a/test/SignedCommitManager.t.sol b/test/SignedCommitManager.t.sol index 843774e9..fb17efd0 100644 --- a/test/SignedCommitManager.t.sol +++ b/test/SignedCommitManager.t.sol @@ -160,14 +160,12 @@ abstract contract SignedCommitManagerTestBase is BattleHelper, SignedCommitHelpe uint8 moveIndex = turnId == 0 ? SWITCH_MOVE_INDEX : NO_OP_MOVE_INDEX; bytes32 committerMoveHash = keccak256(abi.encodePacked(moveIndex, committerSalt, uint16(0))); - (uint256 committerPk, uint256 revealerPk) = turnId % 2 == 0 ? (P0_PK, P1_PK) : (P1_PK, P0_PK); - bytes memory committerSignature = - _signCommit(address(signedCommitManager), committerPk, committerMoveHash, battleKey, uint64(turnId)); + (, uint256 revealerPk) = turnId % 2 == 0 ? (P0_PK, P1_PK) : (P1_PK, P0_PK); bytes memory revealerSignature = _signDualReveal(address(signedCommitManager), revealerPk, battleKey, uint64(turnId), committerMoveHash, moveIndex, revealerSalt, 0 ); - // Caller can be anyone; pick committer for parity with old test setup. + // Single-sig: the committer must be msg.sender (no committer signature). vm.startPrank(turnId % 2 == 0 ? p0 : p1); signedCommitManager.executeWithDualSignedMoves( battleKey, @@ -177,7 +175,6 @@ abstract contract SignedCommitManagerTestBase is BattleHelper, SignedCommitHelpe moveIndex, revealerSalt, 0, - committerSignature, revealerSignature ); vm.stopPrank(); @@ -201,8 +198,7 @@ contract SignedCommitManagerTest is SignedCommitManagerTestBase { uint104 p0Salt = uint104(1); bytes32 p0MoveHash = keccak256(abi.encodePacked(SWITCH_MOVE_INDEX, p0Salt, uint16(0))); - // p0 signs their commitment, p1 signs their move + p0's hash - bytes memory p0CommitSig = _signCommit(address(signedCommitManager), P0_PK, p0MoveHash, battleKey, turnId); + // Single-sig: committer (p0) is msg.sender; only p1 (revealer) signs. uint104 p1Salt = uint104(2); bytes memory p1Signature = _signDualReveal(address(signedCommitManager), P1_PK, battleKey, turnId, p0MoveHash, SWITCH_MOVE_INDEX, p1Salt, 0 @@ -218,7 +214,6 @@ contract SignedCommitManagerTest is SignedCommitManagerTestBase { SWITCH_MOVE_INDEX, p1Salt, 0, - p0CommitSig, p1Signature ); @@ -241,13 +236,13 @@ contract SignedCommitManagerTest is SignedCommitManagerTestBase { uint104 p1Salt = uint104(2); bytes32 p1MoveHash = keccak256(abi.encodePacked(NO_OP_MOVE_INDEX, p1Salt, uint16(0))); - bytes memory p1CommitSig = _signCommit(address(signedCommitManager), P1_PK, p1MoveHash, battleKey, turnId); uint104 p0Salt = uint104(3); bytes memory p0Signature = _signDualReveal(address(signedCommitManager), P0_PK, battleKey, turnId, p1MoveHash, NO_OP_MOVE_INDEX, p0Salt, 0 ); + // Single-sig: committer (p1) is msg.sender; only p0 (revealer) signs. vm.startPrank(p1); signedCommitManager.executeWithDualSignedMoves( battleKey, @@ -257,7 +252,6 @@ contract SignedCommitManagerTest is SignedCommitManagerTestBase { NO_OP_MOVE_INDEX, p0Salt, 0, - p1CommitSig, p0Signature ); @@ -329,8 +323,7 @@ contract SignedCommitManagerTest is SignedCommitManagerTestBase { uint104 p0Salt = uint104(1); bytes32 p0MoveHash = keccak256(abi.encodePacked(SWITCH_MOVE_INDEX, p0Salt, uint16(0))); - // Valid committer sig, but garbage revealer sig. - bytes memory p0CommitSig = _signCommit(address(signedCommitManager), P0_PK, p0MoveHash, battleKey, 0); + // Committer is msg.sender (p0); a garbage revealer sig must revert. bytes memory invalidSignature = abi.encodePacked(bytes32(uint256(1)), bytes32(uint256(2)), uint8(27)); vm.startPrank(p0); @@ -343,7 +336,6 @@ contract SignedCommitManagerTest is SignedCommitManagerTestBase { SWITCH_MOVE_INDEX, uint104(0), 0, - p0CommitSig, invalidSignature ); } @@ -354,8 +346,7 @@ contract SignedCommitManagerTest is SignedCommitManagerTestBase { uint104 p0Salt = uint104(1); bytes32 p0MoveHash = keccak256(abi.encodePacked(SWITCH_MOVE_INDEX, p0Salt, uint16(0))); - bytes memory p0CommitSig = _signCommit(address(signedCommitManager), P0_PK, p0MoveHash, battleKey, 0); - // p0 signs the revealer slot instead of p1 (wrong signer - should be revealer p1) + // Wrong revealer signer: p0 signs the revealer slot instead of p1. Committer is msg.sender (p0). bytes memory wrongSignature = _signDualReveal(address(signedCommitManager), P0_PK, battleKey, 0, p0MoveHash, SWITCH_MOVE_INDEX, uint104(0), 0 ); @@ -370,7 +361,6 @@ contract SignedCommitManagerTest is SignedCommitManagerTestBase { SWITCH_MOVE_INDEX, uint104(0), 0, - p0CommitSig, wrongSignature ); } @@ -387,11 +377,11 @@ contract SignedCommitManagerTest is SignedCommitManagerTestBase { bytes32 p0MoveHash = keccak256(abi.encodePacked(NO_OP_MOVE_INDEX, p0Salt, uint16(0))); // Both signatures bound to turnId=0, replayed at turnId=2 - bytes memory turn0CommitSig = _signCommit(address(signedCommitManager), P0_PK, p0MoveHash, battleKey, 0); bytes memory turn0Signature = _signDualReveal(address(signedCommitManager), P1_PK, battleKey, 0, p0MoveHash, NO_OP_MOVE_INDEX, uint104(0), 0 ); + // Committer (p0) is msg.sender; replayed turn-0 revealer sig is invalid at turn 2. vm.startPrank(p0); vm.expectRevert(SignedCommitManager.InvalidSignature.selector); signedCommitManager.executeWithDualSignedMoves( @@ -402,7 +392,6 @@ contract SignedCommitManagerTest is SignedCommitManagerTestBase { NO_OP_MOVE_INDEX, uint104(0), 0, - turn0CommitSig, turn0Signature ); } @@ -414,14 +403,14 @@ contract SignedCommitManagerTest is SignedCommitManagerTestBase { bytes32 p0MoveHash = keccak256(abi.encodePacked(SWITCH_MOVE_INDEX, p0Salt, uint16(0))); // Both signatures bound to battle 1 - bytes memory battle1CommitSig = _signCommit(address(signedCommitManager), P0_PK, p0MoveHash, battleKey1, 0); bytes memory battle1Signature = _signDualReveal(address(signedCommitManager), P1_PK, battleKey1, 0, p0MoveHash, SWITCH_MOVE_INDEX, uint104(0), 0 ); - // Start second battle and try to use battle 1's signatures + // Start second battle and try to use battle 1's revealer signature bytes32 battleKey2 = _startBattleWith(address(signedCommitManager)); + // Committer (p0) is msg.sender; battle-1 revealer sig is invalid on battle 2. vm.startPrank(p0); vm.expectRevert(SignedCommitManager.InvalidSignature.selector); signedCommitManager.executeWithDualSignedMoves( @@ -432,72 +421,61 @@ contract SignedCommitManagerTest is SignedCommitManagerTestBase { SWITCH_MOVE_INDEX, uint104(0), 0, - battle1CommitSig, battle1Signature ); } - /// @notice Regression: a revealer alone (without an explicit committer signature) cannot - /// inject a self-chosen committer preimage `P*`. Previously this was blocked only by the - /// `msg.sender == committer` check; now both signatures are mandatory and bind each - /// player independently, so the check holds even under a relayer model. + /// @notice Regression: a revealer alone cannot inject a self-chosen committer preimage `P*`. + /// Under single-sig the committer binding is `msg.sender == committer`, so the revealer (p1) + /// submitting on turn 0 (where p0 is committer) reverts NotCommitter — they cannot play a + /// forged committer move on p0's behalf. function test_revert_executeWithDualSigned_unilateralRevealerAttack() public { bytes32 battleKey = _startBattleWith(address(signedCommitManager)); - // Attacker (p1, the revealer for turn 0) picks a preimage P* of their choosing for p0 + // Attacker (p1, the revealer for turn 0) picks a preimage P* of their choosing for p0. uint104 attackerCommitterSalt = uint104(0xdead); - uint16 attackerCommitterExtraData = 0; - uint8 attackerCommitterMoveIndex = SWITCH_MOVE_INDEX; - bytes32 chosenCommitterMoveHash = keccak256( - abi.encodePacked(attackerCommitterMoveIndex, attackerCommitterSalt, attackerCommitterExtraData) - ); + bytes32 chosenCommitterMoveHash = + keccak256(abi.encodePacked(SWITCH_MOVE_INDEX, attackerCommitterSalt, uint16(0))); - // p1 signs the DualSignedReveal binding themselves to a chosen committer preimage + // p1 signs the DualSignedReveal binding themselves to the chosen committer preimage. bytes memory p1Signature = _signDualReveal(address(signedCommitManager), P1_PK, battleKey, 0, chosenCommitterMoveHash, SWITCH_MOVE_INDEX, uint104(0), 0 ); - // Attacker forges a "committer signature" (signed by themselves, P1, over the same hash). - bytes memory forgedCommitterSig = _signCommit(address(signedCommitManager), P1_PK, chosenCommitterMoveHash, battleKey, 0); - - // _startBattleWith leaves an active prank on p0; clear it. + // _startBattleWith leaves an active prank on p0; the attacker (p1, NOT the committer) submits. vm.stopPrank(); - - // Submit (from any sender) — committer sig recover will return p1, not p0 → revert. - vm.expectRevert(SignedCommitManager.InvalidSignature.selector); + vm.prank(p1); + vm.expectRevert(SignedCommitManager.NotCommitter.selector); signedCommitManager.executeWithDualSignedMoves( battleKey, - attackerCommitterMoveIndex, + SWITCH_MOVE_INDEX, attackerCommitterSalt, - attackerCommitterExtraData, + 0, SWITCH_MOVE_INDEX, uint104(0), 0, - forgedCommitterSig, p1Signature ); } - /// @notice Drops the old `msg.sender == committer` check: anyone can submit when both - /// EIP-712 signatures are present and valid (relayer-friendly). - function test_executeWithDualSigned_thirdPartyRelay_succeeds() public { + /// @notice Single-sig is NOT relayer-friendly: a third party (not the committer) reverts + /// NotCommitter — the committer must submit their own turn's tx. + function test_revert_executeWithDualSigned_thirdPartyRelay_notCommitter() public { bytes32 battleKey = _startBattleWith(address(signedCommitManager)); uint104 p0Salt = uint104(1); uint104 p1Salt = uint104(2); bytes32 p0MoveHash = keccak256(abi.encodePacked(SWITCH_MOVE_INDEX, p0Salt, uint16(0))); - bytes memory p0CommitSig = _signCommit(address(signedCommitManager), P0_PK, p0MoveHash, battleKey, 0); bytes memory p1Signature = _signDualReveal(address(signedCommitManager), P1_PK, battleKey, 0, p0MoveHash, SWITCH_MOVE_INDEX, p1Salt, 0 ); // _startBattleWith leaves an active prank on p0; clear it before pranking the relayer. vm.stopPrank(); - - // A random third party (neither p0 nor p1) can submit the bundle. address relayer = address(0xCAFE); vm.prank(relayer); + vm.expectRevert(SignedCommitManager.NotCommitter.selector); signedCommitManager.executeWithDualSignedMoves( battleKey, SWITCH_MOVE_INDEX, @@ -506,59 +484,27 @@ contract SignedCommitManagerTest is SignedCommitManagerTestBase { SWITCH_MOVE_INDEX, p1Salt, 0, - p0CommitSig, - p1Signature - ); - - assertEq(engine.getTurnIdForBattleState(battleKey), 1, "Turn should advance via relayer"); - } - - /// @notice Wrong committer signer (sig recovers to revealer's address, not committer's) reverts. - function test_revert_executeWithDualSigned_wrongCommitterSigner() public { - bytes32 battleKey = _startBattleWith(address(signedCommitManager)); - - uint104 p0Salt = uint104(1); - bytes32 p0MoveHash = keccak256(abi.encodePacked(SWITCH_MOVE_INDEX, p0Salt, uint16(0))); - - // p1 signs the SignedCommit instead of p0 → recovers to p1, not the committer p0. - bytes memory wrongCommitSig = _signCommit(address(signedCommitManager), P1_PK, p0MoveHash, battleKey, 0); - bytes memory p1Signature = _signDualReveal(address(signedCommitManager), - P1_PK, battleKey, 0, p0MoveHash, SWITCH_MOVE_INDEX, uint104(0), 0 - ); - - vm.startPrank(p0); - vm.expectRevert(SignedCommitManager.InvalidSignature.selector); - signedCommitManager.executeWithDualSignedMoves( - battleKey, - SWITCH_MOVE_INDEX, - p0Salt, - 0, - SWITCH_MOVE_INDEX, - uint104(0), - 0, - wrongCommitSig, p1Signature ); } - /// @notice Committer signature over a different `moveHash` than the submitted preimage - /// reverts with InvalidSignature (the recovered hash differs from what the engine computes). - function test_revert_executeWithDualSigned_committerSigForWrongHash() public { + /// @notice The committer cannot change their move after the revealer signs: the revealer's + /// signature is over `committerMoveHash`, so if the committer (msg.sender) submits a preimage + /// whose hash differs from the one the revealer signed over, the revealer sig fails to recover + /// → InvalidSignature. (This is what keeps the committer honest without a committer signature.) + function test_revert_executeWithDualSigned_committerMoveChanged() public { bytes32 battleKey = _startBattleWith(address(signedCommitManager)); uint104 p0Salt = uint104(1); - bytes32 p0DifferentMoveHash = - keccak256(abi.encodePacked(NO_OP_MOVE_INDEX, p0Salt, uint16(0))); // committer signs over a different move - - bytes memory mismatchedCommitSig = _signCommit(address(signedCommitManager), P0_PK, p0DifferentMoveHash, battleKey, 0); - // Revealer signs the same different hash so the revealer side would have validated + // Revealer (p1) signs over the committer's ORIGINAL move hash (NO_OP). + bytes32 p0OriginalHash = keccak256(abi.encodePacked(NO_OP_MOVE_INDEX, p0Salt, uint16(0))); bytes memory p1Signature = _signDualReveal(address(signedCommitManager), - P1_PK, battleKey, 0, p0DifferentMoveHash, SWITCH_MOVE_INDEX, uint104(0), 0 + P1_PK, battleKey, 0, p0OriginalHash, SWITCH_MOVE_INDEX, uint104(0), 0 ); - // p0 submits with their REAL move data (SWITCH_MOVE_INDEX, p0Salt, 0). Engine recomputes - // committerMoveHash from those fields → does not equal `p0DifferentMoveHash`. Committer sig - // recovery against the recomputed hash returns a non-p0 address → InvalidSignature. + // Committer (p0, msg.sender) submits a DIFFERENT move (SWITCH). The engine recomputes + // committerMoveHash from the submitted fields → != p0OriginalHash → the revealer sig + // recovers a non-p1 address → InvalidSignature. vm.startPrank(p0); vm.expectRevert(SignedCommitManager.InvalidSignature.selector); signedCommitManager.executeWithDualSignedMoves( @@ -569,7 +515,6 @@ contract SignedCommitManagerTest is SignedCommitManagerTestBase { SWITCH_MOVE_INDEX, uint104(0), 0, - mismatchedCommitSig, p1Signature ); } @@ -584,7 +529,6 @@ contract SignedCommitManagerTest is SignedCommitManagerTestBase { uint104 p0Salt = uint104(1); bytes32 p0MoveHash = keccak256(abi.encodePacked(SWITCH_MOVE_INDEX, p0Salt, uint16(0))); - bytes memory p0CommitSig = _signCommit(address(signedCommitManager), P0_PK, p0MoveHash, fakeBattleKey, 0); bytes memory p1Signature = _signDualReveal(address(signedCommitManager), P1_PK, fakeBattleKey, 0, p0MoveHash, SWITCH_MOVE_INDEX, uint104(0), 0 ); @@ -599,7 +543,6 @@ contract SignedCommitManagerTest is SignedCommitManagerTestBase { SWITCH_MOVE_INDEX, uint104(0), 0, - p0CommitSig, p1Signature ); } @@ -616,8 +559,7 @@ contract SignedCommitManagerTest is SignedCommitManagerTestBase { uint104 p1Salt = uint104(99); bytes32 p1MoveHash = keccak256(abi.encodePacked(NO_OP_MOVE_INDEX, p1Salt, uint16(0))); - // Both signatures are bound to turnId=0 (replay attempt) - bytes memory p1CommitSig = _signCommit(address(signedCommitManager), P1_PK, p1MoveHash, battleKey, 0); + // Revealer signature bound to turnId=0 (replay attempt at turn 1). bytes memory p0Signature = _signDualReveal(address(signedCommitManager), P0_PK, battleKey, 0, p1MoveHash, NO_OP_MOVE_INDEX, uint104(0), 0 ); @@ -632,7 +574,6 @@ contract SignedCommitManagerTest is SignedCommitManagerTestBase { NO_OP_MOVE_INDEX, uint104(0), 0, - p1CommitSig, p0Signature ); } @@ -643,7 +584,6 @@ contract SignedCommitManagerTest is SignedCommitManagerTestBase { uint104 p0Salt = uint104(1); bytes32 p0MoveHash = keccak256(abi.encodePacked(SWITCH_MOVE_INDEX, p0Salt, uint16(0))); - bytes memory p0CommitSig = _signCommit(address(signedCommitManager), P0_PK, p0MoveHash, battleKey, 0); bytes memory p1Signature = _signDualReveal(address(signedCommitManager), P1_PK, battleKey, 0, p0MoveHash, SWITCH_MOVE_INDEX, uint104(0), 0 ); @@ -657,13 +597,13 @@ contract SignedCommitManagerTest is SignedCommitManagerTestBase { SWITCH_MOVE_INDEX, uint104(0), 0, - p0CommitSig, p1Signature ); - // After execution, turn advances to 1. Replaying the same signatures (turnId=0) at - // turnId=1 fails on the committer signature recovery — sig was bound to turn 0. - vm.expectRevert(SignedCommitManager.InvalidSignature.selector); + // After execution, turn advances to 1, where p1 (not p0) is the committer. The same caller + // (p0) replaying is no longer the committer → NotCommitter (single-sig replay prevention; + // the revealer-sig-replay path is covered by test_revert_replayPrevented_byTurnAdvancement). + vm.expectRevert(SignedCommitManager.NotCommitter.selector); signedCommitManager.executeWithDualSignedMoves( battleKey, SWITCH_MOVE_INDEX, @@ -672,7 +612,6 @@ contract SignedCommitManagerTest is SignedCommitManagerTestBase { SWITCH_MOVE_INDEX, uint104(0), 0, - p0CommitSig, p1Signature ); } @@ -684,17 +623,14 @@ contract SignedCommitManagerTest is SignedCommitManagerTestBase { uint104 p0Salt = uint104(1); bytes32 p0RealMoveHash = keccak256(abi.encodePacked(SWITCH_MOVE_INDEX, p0Salt, uint16(0))); - // p0 signs the commitment for the REAL move hash (matches what they'll submit) - bytes memory p0CommitSig = _signCommit(address(signedCommitManager), P0_PK, p0RealMoveHash, battleKey, 0); - // p1 signs over a DIFFERENT hash than what p0 will submit bytes32 fakeP0MoveHash = keccak256(abi.encodePacked(SWITCH_MOVE_INDEX, uint104(999), uint16(0))); bytes memory p1Signature = _signDualReveal(address(signedCommitManager), P1_PK, battleKey, 0, fakeP0MoveHash, SWITCH_MOVE_INDEX, uint104(0), 0 ); - // p0 tries to submit with their real move data: committer sig validates (matches - // p0RealMoveHash), but revealer sig was over fakeP0MoveHash → revealer recovery fails. + // p0 (committer, msg.sender) submits their real move data; revealer sig was over + // fakeP0MoveHash → the recomputed committerMoveHash differs → revealer recovery fails. vm.startPrank(p0); vm.expectRevert(SignedCommitManager.InvalidSignature.selector); signedCommitManager.executeWithDualSignedMoves( @@ -705,7 +641,6 @@ contract SignedCommitManagerTest is SignedCommitManagerTestBase { SWITCH_MOVE_INDEX, uint104(0), 0, - p0CommitSig, p1Signature ); } @@ -716,13 +651,12 @@ contract SignedCommitManagerTest is SignedCommitManagerTestBase { uint104 p0Salt = uint104(1); bytes32 p0MoveHash = keccak256(abi.encodePacked(SWITCH_MOVE_INDEX, p0Salt, uint16(0))); - bytes memory p0CommitSig = _signCommit(address(signedCommitManager), P0_PK, p0MoveHash, battleKey, 0); // p1 signs with SWITCH_MOVE_INDEX bytes memory p1Signature = _signDualReveal(address(signedCommitManager), P1_PK, battleKey, 0, p0MoveHash, SWITCH_MOVE_INDEX, uint104(0), 0 ); - // p0 tries to submit with different move for p1 (NO_OP instead of SWITCH) + // p0 (committer, msg.sender) tries to submit a different revealer move (NO_OP not SWITCH). vm.startPrank(p0); vm.expectRevert(SignedCommitManager.InvalidSignature.selector); signedCommitManager.executeWithDualSignedMoves( @@ -733,7 +667,6 @@ contract SignedCommitManagerTest is SignedCommitManagerTestBase { NO_OP_MOVE_INDEX, // Different from what p1 signed! uint104(0), 0, - p0CommitSig, p1Signature ); } @@ -928,11 +861,11 @@ contract SignedCommitManagerEngineSafetyTest is SignedCommitManagerTestBase { bytes32 committerMoveHash = keccak256(abi.encodePacked(committerMoveIndex, committerSalt, committerExtraData)); - // Committer is p0 on even turns, p1 on odd turns. - (uint256 committerPk, uint256 revealerPk, address committerAddr) = + // Committer is p0 on even turns, p1 on odd turns. SINGLE-SIG: committer == msg.sender (no + // committer signature); only the revealer signs. + (, uint256 revealerPk, address committerAddr) = turnId % 2 == 0 ? (P0_PK, P1_PK, p0) : (P1_PK, P0_PK, p1); - bytes memory committerSig = _signCommit(address(signedCommitManager), committerPk, committerMoveHash, battleKey, turnId); bytes memory revealerSig = _signDualReveal(address(signedCommitManager), revealerPk, battleKey, turnId, committerMoveHash, revealerMoveIndex, revealerSalt, revealerExtraData ); @@ -946,7 +879,6 @@ contract SignedCommitManagerEngineSafetyTest is SignedCommitManagerTestBase { revealerMoveIndex, revealerSalt, revealerExtraData, - committerSig, revealerSig ); vm.stopPrank(); diff --git a/test/SignedCommitManagerGasBenchmark.t.sol b/test/SignedCommitManagerGasBenchmark.t.sol index 06a732c3..6e10fda8 100644 --- a/test/SignedCommitManagerGasBenchmark.t.sol +++ b/test/SignedCommitManagerGasBenchmark.t.sol @@ -59,8 +59,7 @@ contract SignedCommitManagerGasBenchmarkTest is SignedCommitManagerTestBase { uint104 p1Salt = uint104(2); bytes32 p0MoveHash = keccak256(abi.encodePacked(SWITCH_MOVE_INDEX, p0Salt, uint16(0))); - // Both players sign off-chain - bytes memory p0CommitSig = _signCommit(address(signedCommitManager), P0_PK, p0MoveHash, battleKey, 0); + // Single-sig: committer (p0) is msg.sender; only the revealer signs. bytes memory p1Signature = _signDualReveal(address(signedCommitManager), P1_PK, battleKey, 0, p0MoveHash, SWITCH_MOVE_INDEX, p1Salt, 0 ); @@ -76,7 +75,6 @@ contract SignedCommitManagerGasBenchmarkTest is SignedCommitManagerTestBase { SWITCH_MOVE_INDEX, p1Salt, 0, - p0CommitSig, p1Signature ); gasUsed_dualSignedFlow_cold = gasBefore - gasleft(); @@ -128,7 +126,6 @@ contract SignedCommitManagerGasBenchmarkTest is SignedCommitManagerTestBase { uint104 p1Salt = uint104(101); bytes32 p0MoveHash = keccak256(abi.encodePacked(NO_OP_MOVE_INDEX, p0Salt, uint16(0))); - bytes memory p0CommitSig = _signCommit(address(signedCommitManager), P0_PK, p0MoveHash, battleKey, 2); bytes memory p1Signature = _signDualReveal(address(signedCommitManager), P1_PK, battleKey, 2, p0MoveHash, NO_OP_MOVE_INDEX, p1Salt, 0 ); @@ -143,7 +140,6 @@ contract SignedCommitManagerGasBenchmarkTest is SignedCommitManagerTestBase { NO_OP_MOVE_INDEX, p1Salt, 0, - p0CommitSig, p1Signature ); gasUsed_dualSignedFlow_warm = gasBefore - gasleft(); @@ -186,7 +182,6 @@ contract SignedCommitManagerGasBenchmarkTest is SignedCommitManagerTestBase { uint104 p1Salt = uint104(2); bytes32 p0MoveHash = keccak256(abi.encodePacked(SWITCH_MOVE_INDEX, p0Salt, uint16(0))); - bytes memory p0CommitSig = _signCommit(address(signedCommitManager), P0_PK, p0MoveHash, battleKey2, 0); bytes memory p1Signature = _signDualReveal(address(signedCommitManager), P1_PK, battleKey2, 0, p0MoveHash, SWITCH_MOVE_INDEX, p1Salt, 0 ); @@ -201,7 +196,6 @@ contract SignedCommitManagerGasBenchmarkTest is SignedCommitManagerTestBase { SWITCH_MOVE_INDEX, p1Salt, 0, - p0CommitSig, p1Signature ); gasUsed_dualSignedFlow_cold = gasBefore - gasleft(); @@ -240,7 +234,6 @@ contract SignedCommitManagerGasBenchmarkTest is SignedCommitManagerTestBase { uint104 p1Salt = uint104(101); bytes32 p0MoveHash = keccak256(abi.encodePacked(NO_OP_MOVE_INDEX, p0Salt, uint16(0))); - bytes memory p0CommitSig = _signCommit(address(signedCommitManager), P0_PK, p0MoveHash, battleKey2, 2); bytes memory p1Signature = _signDualReveal(address(signedCommitManager), P1_PK, battleKey2, 2, p0MoveHash, NO_OP_MOVE_INDEX, p1Salt, 0 ); @@ -255,7 +248,6 @@ contract SignedCommitManagerGasBenchmarkTest is SignedCommitManagerTestBase { NO_OP_MOVE_INDEX, p1Salt, 0, - p0CommitSig, p1Signature ); gasUsed_dualSignedFlow_warm = gasBefore - gasleft(); diff --git a/test/StandardAttackPvPGasTest.sol b/test/StandardAttackPvPGasTest.sol deleted file mode 100644 index cb1e91e9..00000000 --- a/test/StandardAttackPvPGasTest.sol +++ /dev/null @@ -1,289 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0 -pragma solidity ^0.8.0; - -import "../lib/forge-std/src/Test.sol"; - -import "../src/Constants.sol"; -import "../src/Enums.sol"; -import "../src/Structs.sol"; - -import {Engine} from "../src/Engine.sol"; -import {SignedCommitManager} from "../src/commit-manager/SignedCommitManager.sol"; -import {SignedMatchmaker} from "../src/matchmaker/SignedMatchmaker.sol"; -import {BattleOfferLib} from "../src/matchmaker/BattleOfferLib.sol"; -import {StandardAttackFactory} from "../src/moves/StandardAttackFactory.sol"; -import {ATTACK_PARAMS} from "../src/moves/StandardAttackStructs.sol"; -import {SignedCommitHelper} from "./abstract/SignedCommitHelper.sol"; - -import {IEngine} from "../src/IEngine.sol"; -import {IEngineHook} from "../src/IEngineHook.sol"; -import {IEffect} from "../src/effects/IEffect.sol"; -import {IMoveSet} from "../src/moves/IMoveSet.sol"; -import {IRandomnessOracle} from "../src/rng/IRandomnessOracle.sol"; -import {IRuleset} from "../src/IRuleset.sol"; -import {IValidator} from "../src/IValidator.sol"; - -import {TypeCalculator} from "../src/types/TypeCalculator.sol"; -import {ITypeCalculator} from "../src/types/ITypeCalculator.sol"; - -import {TestTeamRegistry} from "./mocks/TestTeamRegistry.sol"; - -/// @title StandardAttack PvP gas benchmark -/// @notice Measures the per-turn cost of a fully-optimized PvP battle whose moves are real -/// StandardAttack-derived contracts (the production shape for ~30 mon move contracts -/// in src/mons/). Uses the production TypeCalculator (which delegates to TypeCalcLib) -/// so pre/post numbers reflect only gas-path changes, not type-chart differences. -/// -/// Existing PvP benchmarks (FullyOptimizedInlineGasTest) use CustomAttack / -/// EffectAttack / StatBoostsMove — none of which extend StandardAttack — so the -/// StandardAttack hot path doesn't show up there. -contract StandardAttackPvPGasTest is SignedCommitHelper { - - uint256 constant MONS_PER_TEAM = 4; - uint256 constant MOVES_PER_MON = 4; - - uint256 constant P0_PK = 0xA11CE; - uint256 constant P1_PK = 0xB0B; - address p0; - address p1; - - Engine engine; - SignedCommitManager signedCommitManager; - SignedMatchmaker signedMatchmaker; - ITypeCalculator typeCalc; - TestTeamRegistry defaultRegistry; - StandardAttackFactory attackFactory; - - function setUp() public { - p0 = vm.addr(P0_PK); - p1 = vm.addr(P1_PK); - - engine = new Engine(MONS_PER_TEAM, MOVES_PER_MON, 1); - signedCommitManager = new SignedCommitManager(IEngine(address(engine))); - signedMatchmaker = new SignedMatchmaker(engine); - - // Production TypeCalculator wraps TypeCalcLib — same chart the engine's internal - // dispatch path uses. With this, moving from StandardAttack._move to - // engine.dispatchStandardAttack is a pure code-path swap, no damage-value drift. - typeCalc = new TypeCalculator(); - - defaultRegistry = new TestTeamRegistry(); - attackFactory = new StandardAttackFactory(typeCalc); - } - - function _startBattle(IRuleset ruleset) internal returns (bytes32) { - address[] memory makersToAdd = new address[](1); - makersToAdd[0] = address(signedMatchmaker); - address[] memory makersToRemove = new address[](0); - vm.prank(p0); - engine.updateMatchmakers(makersToAdd, makersToRemove); - vm.prank(p1); - engine.updateMatchmakers(makersToAdd, makersToRemove); - - (bytes32 battleKey, bytes32 pairHash) = engine.computeBattleKey(p0, p1); - uint256 nonce = engine.pairHashNonces(pairHash); - - BattleOffer memory offer = BattleOffer({ - battle: Battle({ - p0: p0, - p0TeamIndex: 0, - p1: p1, - p1TeamIndex: 0, - teamRegistry: defaultRegistry, - validator: IValidator(address(0)), // inline validator - rngOracle: IRandomnessOracle(address(0)), // inline RNG - ruleset: ruleset, - moveManager: address(signedCommitManager), - matchmaker: signedMatchmaker, - engineHooks: new IEngineHook[](0) - }), - pairHashNonce: nonce - }); - - bytes32 structHash = BattleOfferLib.hashBattleOffer(offer); - bytes32 digest = signedMatchmaker.hashTypedData(structHash); - (uint8 v, bytes32 r, bytes32 s) = vm.sign(P0_PK, digest); - bytes memory signature = abi.encodePacked(r, s, v); - - vm.prank(p1); - signedMatchmaker.startGame(offer, signature); - - return battleKey; - } - - function _fastTurn( - bytes32 battleKey, - uint8 p0MoveIndex, - uint8 p1MoveIndex, - uint16 p0ExtraData, - uint16 p1ExtraData - ) internal { - uint64 turnId = uint64(engine.getTurnIdForBattleState(battleKey)); - uint104 committerSalt = uint104(uint256(keccak256(abi.encode("committer", battleKey, turnId)))); - uint104 revealerSalt = uint104(uint256(keccak256(abi.encode("revealer", battleKey, turnId)))); - - uint8 committerMoveIndex; - uint16 committerExtraData; - uint8 revealerMoveIndex; - uint16 revealerExtraData; - uint256 committerPk; - uint256 revealerPk; - address committer; - - if (turnId % 2 == 0) { - committerMoveIndex = p0MoveIndex; - committerExtraData = p0ExtraData; - revealerMoveIndex = p1MoveIndex; - revealerExtraData = p1ExtraData; - committerPk = P0_PK; - revealerPk = P1_PK; - committer = p0; - } else { - committerMoveIndex = p1MoveIndex; - committerExtraData = p1ExtraData; - revealerMoveIndex = p0MoveIndex; - revealerExtraData = p0ExtraData; - committerPk = P1_PK; - revealerPk = P0_PK; - committer = p1; - } - - bytes32 committerMoveHash = - keccak256(abi.encodePacked(committerMoveIndex, committerSalt, committerExtraData)); - address mgr = address(signedCommitManager); - bytes memory committerSig = _signCommit(mgr, committerPk, committerMoveHash, battleKey, turnId); - bytes memory revealerSig = _signDualReveal( - mgr, revealerPk, battleKey, turnId, committerMoveHash, revealerMoveIndex, revealerSalt, revealerExtraData - ); - - vm.prank(committer); - signedCommitManager.executeWithDualSignedMoves( - battleKey, - committerMoveIndex, - committerSalt, - committerExtraData, - revealerMoveIndex, - revealerSalt, - revealerExtraData, - committerSig, - revealerSig - ); - engine.resetCallContext(); - } - - function _createMon(Type t1) internal pure returns (Mon memory) { - return Mon({ - stats: MonStats({ - hp: 10000, // High HP — no KOs during the measured window - stamina: 50, - speed: 10, - attack: 30, - defense: 10, - specialAttack: 30, - specialDefense: 10, - type1: t1, - type2: Type.None - }), - moves: new uint256[](0), - ability: 0 - }); - } - - /// @notice Hot-path benchmark: PvP battle, 4 turns of damage trades using two real - /// StandardAttack-derived moves. No KOs, no switches, no effects — isolates - /// the StandardAttack._move → AttackCalculator → engine.dealDamage path that - /// 4-B will collapse into engine.dispatchStandardAttack. - function test_standardAttackPvP_damageTrade() public { - // Two damage-only StandardAttack moves. Both Fire → Fire (TypeCalcLib: 1x baseline). - IMoveSet moveA = attackFactory.createAttack( - ATTACK_PARAMS({ - BASE_POWER: 30, - STAMINA_COST: 1, - ACCURACY: 100, - PRIORITY: 1, - MOVE_TYPE: Type.Fire, - EFFECT_ACCURACY: 0, - MOVE_CLASS: MoveClass.Physical, - CRIT_RATE: 0, - VOLATILITY: 0, - NAME: "AttackA", - EFFECT: IEffect(address(0)) - }) - ); - IMoveSet moveB = attackFactory.createAttack( - ATTACK_PARAMS({ - BASE_POWER: 25, - STAMINA_COST: 1, - ACCURACY: 100, - PRIORITY: 1, - MOVE_TYPE: Type.Fire, - EFFECT_ACCURACY: 0, - MOVE_CLASS: MoveClass.Special, - CRIT_RATE: 0, - VOLATILITY: 0, - NAME: "AttackB", - EFFECT: IEffect(address(0)) - }) - ); - - Mon memory mon = _createMon(Type.Fire); - mon.moves = new uint256[](MOVES_PER_MON); - mon.moves[0] = uint256(uint160(address(moveA))); - mon.moves[1] = uint256(uint160(address(moveB))); - mon.moves[2] = uint256(uint160(address(moveA))); - mon.moves[3] = uint256(uint160(address(moveB))); - - Mon[] memory team = new Mon[](MONS_PER_TEAM); - for (uint256 i; i < MONS_PER_TEAM; i++) { - team[i] = mon; - } - defaultRegistry.setTeam(p0, team); - defaultRegistry.setTeam(p1, team); - - // Inline stamina-regen ruleset — production-shape. - IRuleset ruleset = IRuleset(INLINE_STAMINA_REGEN_RULESET); - - bytes32 battleKey = _startBattle(ruleset); - vm.warp(vm.getBlockTimestamp() + 1); - - // Turn 0: lead-in switch. - vm.startSnapshotGas("Turn0_Lead"); - _fastTurn(battleKey, SWITCH_MOVE_INDEX, SWITCH_MOVE_INDEX, uint16(0), uint16(0)); - uint256 turn0 = vm.stopSnapshotGas("Turn0_Lead"); - - // Turns 1-4: pure damage trades. Both players use move 0 / move 1 alternately. - // No effects fire, no KOs (mon HP is 10000), so this is isolated dispatch cost. - vm.startSnapshotGas("Turn1_BothAttack"); - _fastTurn(battleKey, 0, 0, 0, 0); - uint256 turn1 = vm.stopSnapshotGas("Turn1_BothAttack"); - - vm.startSnapshotGas("Turn2_BothAttack"); - _fastTurn(battleKey, 1, 1, 0, 0); - uint256 turn2 = vm.stopSnapshotGas("Turn2_BothAttack"); - - vm.startSnapshotGas("Turn3_BothAttack"); - _fastTurn(battleKey, 0, 1, 0, 0); - uint256 turn3 = vm.stopSnapshotGas("Turn3_BothAttack"); - - vm.startSnapshotGas("Turn4_BothAttack"); - _fastTurn(battleKey, 1, 0, 0, 0); - uint256 turn4 = vm.stopSnapshotGas("Turn4_BothAttack"); - - // Sanity: battle still in progress, both mons still alive. - assertEq(engine.getWinner(battleKey), address(0), "battle must still be in progress"); - assertEq(engine.getPlayerSwitchForTurnFlagForBattleState(battleKey), 2, "flag must still be 2"); - - uint256 avg = (turn1 + turn2 + turn3 + turn4) / 4; - - console.log("========================================"); - console.log("StandardAttack PvP damage-trade benchmark"); - console.log("========================================"); - console.log("Turn 0 (lead select) :", turn0); - console.log("Turn 1 (both attack, move 0) :", turn1); - console.log("Turn 2 (both attack, move 1) :", turn2); - console.log("Turn 3 (mixed) :", turn3); - console.log("Turn 4 (mixed) :", turn4); - console.log("Average flag==2 attack turn :", avg); - console.log("========================================"); - } -} diff --git a/test/StartBattleGasTest.t.sol b/test/StartBattleGasTest.t.sol new file mode 100644 index 00000000..3686cac1 --- /dev/null +++ b/test/StartBattleGasTest.t.sol @@ -0,0 +1,246 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity ^0.8.0; + +import "../lib/forge-std/src/Test.sol"; +import {Vm} from "../lib/forge-std/src/Vm.sol"; + +import "../src/Constants.sol"; +import "../src/Enums.sol"; +import "../src/Structs.sol"; + +import {Engine} from "../src/Engine.sol"; +import {IEngine} from "../src/IEngine.sol"; +import {IValidator} from "../src/IValidator.sol"; +import {IRuleset} from "../src/IRuleset.sol"; +import {IEngineHook} from "../src/IEngineHook.sol"; +import {GachaTeamRegistry} from "../src/game-layer/GachaTeamRegistry.sol"; +import {DefaultCommitManager} from "../src/commit-manager/DefaultCommitManager.sol"; +import {DefaultMatchmaker} from "../src/matchmaker/DefaultMatchmaker.sol"; + +import {MockRandomnessOracle} from "./mocks/MockRandomnessOracle.sol"; +import {MockGachaRNG} from "./mocks/MockGachaRNG.sol"; +import {GasMeasure} from "./abstract/GasMeasure.sol"; + +/// @notice Step-0 measurement for the startBattle optimization (PLAN_STARTBATTLE.md). +/// Measures Engine.startBattle (via matchmaker confirmBattle) against the REAL +/// GachaTeamRegistry with a facet on every mon, so getTeams does the full facet-delta fold. +/// Reports a per-account (engine = team-store/clear, registry = getTeams) SSTORE/SLOAD +/// breakdown for two regimes: +/// - COLD: first-ever storageKey (z->nz everywhere) — the rare first battle. +/// - WARM: recycled storageKey holding a DIFFERENT prior team (the production steady +/// state). Mons have DISTINCT data + the CPU team is swapped between battles, so +/// the recycled config is genuinely overwritten (nz->nz), not no-op'd. +/// 4 mons x 4 moves to match prod team size. +contract StartBattleGasTest is Test, GasMeasure { + address constant ALICE = address(0x1); + address constant CPU = address(0xC9); + uint256 constant MONS_PER_TEAM = 4; + uint256 constant MOVES_PER_MON = 4; + uint256 constant TIMEOUT = 10; + uint256 constant POOL = 12; // mon ids 0..11 (ALICE {0,3,4,5}, CPU-T1 {1,2,6,7}, CPU-T2 {8,9,10,11}) + + Engine engine; + GachaTeamRegistry registry; + MockGachaRNG mockRNG; + MockRandomnessOracle mockOracle; + DefaultCommitManager commitManager; + DefaultMatchmaker matchmaker; + + uint256[] aliceTeam; + uint96 cpuPhantomIndex; + + function setUp() public { + vm.warp(2 days); // gacha day-bucketed logic needs a non-zero day + + engine = new Engine(MONS_PER_TEAM, MOVES_PER_MON, TIMEOUT); + mockOracle = new MockRandomnessOracle(); + mockRNG = new MockGachaRNG(); + registry = new GachaTeamRegistry(MONS_PER_TEAM, MOVES_PER_MON, engine, mockRNG); + commitManager = new DefaultCommitManager(engine); + matchmaker = new DefaultMatchmaker(engine); + + while (registry.getQuestPoolLength() > 0) registry.removeQuest(0); + + address[] memory none = new address[](0); + bytes32[] memory noK = new bytes32[](0); + bytes32[] memory noV = new bytes32[](0); + + // DISTINCT data per mon (stats vary by id; synthetic distinct move/ability refs) so storing a + // DIFFERENT team over a recycled key overwrites config with different values (nz->nz), which is + // what the production steady state actually pays — not the same-team no-op artifact. + for (uint256 i; i < POOL; ++i) { + MonStats memory s = MonStats({ + hp: uint32(200 + i * 7), stamina: uint32(8 + i), speed: uint32(50 + i * 3), + attack: uint32(40 + i * 2), defense: uint32(40 + i), specialAttack: uint32(45 + i), + specialDefense: uint32(42 + i), type1: Type.Fire, type2: Type.None + }); + uint256[] memory mvs = new uint256[](MOVES_PER_MON); + for (uint256 j; j < MOVES_PER_MON; ++j) mvs[j] = uint256(uint160(0x100000 + i * 16 + j)); + uint256[] memory abl = new uint256[](1); + abl[0] = uint256(uint160(0x200000 + i)); + registry.createMon(i, s, mvs, abl, noK, noV); + } + + // Deployer (this) is the exp assigner. + address[] memory me = new address[](1); + me[0] = address(this); + registry.setAssigners(me, none); + + // ALICE: firstRoll(0) -> owns {0,3,4,5}; build that team. + vm.prank(ALICE); + registry.firstRoll(0); + aliceTeam = new uint256[](MONS_PER_TEAM); + aliceTeam[0] = 0; aliceTeam[1] = 3; aliceTeam[2] = 4; aliceTeam[3] = 5; + vm.prank(ALICE); + registry.createTeam(aliceTeam); + + // Max exp on every ALICE mon -> unlocks all 12 facets -> assign facet 1 to each. + uint256[] memory amounts = new uint256[](MONS_PER_TEAM); + uint8[] memory facetOnes = new uint8[](MONS_PER_TEAM); + for (uint256 i; i < MONS_PER_TEAM; ++i) { amounts[i] = 65535; facetOnes[i] = 1; } + registry.assignExp(ALICE, aliceTeam, amounts); + vm.prank(ALICE); + registry.assignFacets(aliceTeam, facetOnes); + + // CPU phantom: whitelist + initial team T1 = {1,2,6,7}, facet 1 on every slot. + address[] memory allow = new address[](1); + allow[0] = CPU; + registry.setWhitelistedOpponents(allow, none); + cpuPhantomIndex = uint96(uint16(uint160(ALICE))); + _setCpuTeam([uint256(1), 2, 6, 7]); + + // Authorize matchmaker for both sides. + address[] memory mk = new address[](1); + mk[0] = address(matchmaker); + vm.prank(ALICE); + engine.updateMatchmakers(mk, none); + vm.prank(CPU); + engine.updateMatchmakers(mk, none); + } + + function _setCpuTeam(uint256[4] memory mons) internal { + uint256[] memory cpuMons = new uint256[](MONS_PER_TEAM); + uint8[] memory cpuFacets = new uint8[](MONS_PER_TEAM); + for (uint256 i; i < MONS_PER_TEAM; ++i) { cpuMons[i] = mons[i]; cpuFacets[i] = 1; } + vm.prank(ALICE); + registry.setOpponentTeam(CPU, cpuMons, cpuFacets); + } + + /// @dev propose + accept (unmeasured); caller measures confirmBattle (-> startBattle) alone. + function _proposeAndAccept(bytes32 salt) internal returns (bytes32 battleKey) { + uint96 ti = 0; + uint256[] memory ids = registry.getMonRegistryIndicesForTeam(ALICE, ti); + bytes32 teamHash = keccak256(abi.encodePacked(salt, ti, ids)); + ProposedBattle memory proposal = ProposedBattle({ + p0: ALICE, p0TeamIndex: ti, p0TeamHash: teamHash, + p1: CPU, p1TeamIndex: cpuPhantomIndex, + teamRegistry: registry, validator: IValidator(address(0)), + rngOracle: mockOracle, ruleset: IRuleset(address(0)), + moveManager: address(commitManager), matchmaker: matchmaker, engineHooks: new IEngineHook[](0) + }); + vm.prank(ALICE); + battleKey = matchmaker.proposeBattle(proposal); + bytes32 integrity = matchmaker.getBattleProposalIntegrityHash(proposal); + vm.prank(CPU); + matchmaker.acceptBattle(battleKey, cpuPhantomIndex, integrity); + } + + function _coolAll4() internal { + vm.cool(address(engine)); + vm.cool(address(registry)); + vm.cool(address(matchmaker)); + vm.cool(address(commitManager)); + } + + function _accountTally(Vm.AccountAccess[] memory acc, address who) + internal pure + returns (uint256 sstores, uint256 zToNz, uint256 nzToNz, uint256 noop, uint256 sloads) + { + for (uint256 i; i < acc.length; i++) { + if (acc[i].account != who) continue; + Vm.StorageAccess[] memory sa = acc[i].storageAccesses; + for (uint256 j; j < sa.length; j++) { + if (sa[j].isWrite) { + sstores++; + if (sa[j].previousValue == bytes32(0) && sa[j].newValue != bytes32(0)) zToNz++; + else if (sa[j].previousValue == sa[j].newValue) noop++; + else nzToNz++; + } else { + sloads++; + } + } + } + } + + function _report(string memory label, string memory snapName, Vm.AccountAccess[] memory acc, uint256 gasUsed) + internal + { + Tally memory t = _tally(acc); + (uint256 eS, uint256 eZ, uint256 eN, uint256 eNo, uint256 eL) = _accountTally(acc, address(engine)); + (,,,, uint256 rL) = _accountTally(acc, address(registry)); + _snapScenario(snapName, t, gasUsed); + console.log(""); + console.log(label); + console.log(" confirmBattle->startBattle gas :", gasUsed); + console.log(" ENGINE SSTORE total :", eS); + console.log(" ENGINE SSTORE z->nz / nz->nz / noop :", eZ, eN, eNo); + console.log(" ENGINE SLOAD total :", eL); + console.log(" REGISTRY SLOAD (getTeams) :", rL); + console.log(" ALL: SLOAD cold / warm :", t.coldSload, t.warmSload); + } + + function test_startBattle_breakdown() public { + // ---- COLD: first battle on a fresh storageKey (z->nz everywhere). CPU team = T1 {1,2,6,7}. ---- + bytes32 k1 = _proposeAndAccept("s1"); + _coolAll4(); + vm.startStateDiffRecording(); + uint256 g1 = gasleft(); + vm.prank(ALICE); + matchmaker.confirmBattle(k1, "s1", 0); + uint256 coldGas = g1 - gasleft(); + Vm.AccountAccess[] memory acc1 = vm.stopAndReturnStateDiff(); + bytes32 sk1 = engine.getStorageKey(k1); + _report("=== COLD (first-ever key, z->nz) ===", "StartBattle_Cold", acc1, coldGas); + + // Free the key; swap the CPU team to DISTINCT mons T2 {8,9,10,11} so the recycled config is + // genuinely overwritten on the p1 (CPU) side. + vm.warp(vm.getBlockTimestamp() + 1 hours); + engine.end(k1); + engine.resetCallContext(); + _setCpuTeam([uint256(8), 9, 10, 11]); + + // ---- WARM STEADY: recycled key; CPU side stores DIFFERENT mons -> nz->nz. ALICE side is the + // same team -> no-op. So the CPU side (28 slots) shows the true per-slot nz->nz team-store + // cost; a fully-different matchup (both sides) is ~2x that delta. ---- + bytes32 k2 = _proposeAndAccept("s2"); + _coolAll4(); + vm.startStateDiffRecording(); + uint256 g2 = gasleft(); + vm.prank(ALICE); + matchmaker.confirmBattle(k2, "s2", 0); + uint256 warmGas = g2 - gasleft(); + Vm.AccountAccess[] memory acc2 = vm.stopAndReturnStateDiff(); + require(engine.getStorageKey(k2) == sk1, "storageKey reuse (warm steady)"); + _report("=== WARM STEADY (recycled key, CPU team differs -> CPU-side nz->nz) ===", "StartBattle_WarmSteady", acc2, warmGas); + + // ---- WARM SAME: recycle again, CPU team UNCHANGED (T2) -> team-store all no-op. Baseline so + // (WARM_DIFF - WARM_SAME) isolates the 28-slot CPU team-store nz->nz cost. ---- + vm.warp(vm.getBlockTimestamp() + 1 hours); + engine.end(k2); + engine.resetCallContext(); + bytes32 k3 = _proposeAndAccept("s3"); + _coolAll4(); + vm.startStateDiffRecording(); + uint256 g3 = gasleft(); + vm.prank(ALICE); + matchmaker.confirmBattle(k3, "s3", 0); + uint256 sameGas = g3 - gasleft(); + Vm.AccountAccess[] memory acc3 = vm.stopAndReturnStateDiff(); + require(engine.getStorageKey(k3) == sk1, "storageKey reuse (warm same)"); + _report("=== WARM SAME (recycled key, CPU team unchanged -> team-store all no-op) ===", "StartBattle_WarmSame", acc3, sameGas); + + console.log(""); + console.log(" 28-slot CPU team-store nz->nz cost (WARM_DIFF - WARM_SAME) :", warmGas - sameGas); + console.log(" => fully-different matchup team-store ~= WARM_SAME + 2x that delta"); + } +} diff --git a/test/abstract/BatchHelper.sol b/test/abstract/BatchHelper.sol new file mode 100644 index 00000000..88ff2502 --- /dev/null +++ b/test/abstract/BatchHelper.sol @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity ^0.8.0; + +import "../../src/Constants.sol"; +import "../../src/Structs.sol"; + +import {SignedCommitHelper} from "./SignedCommitHelper.sol"; + +/// @notice Test helpers for the batched per-turn-submission flow (SINGLE-SIG model). +/// @dev The committer is `msg.sender` (no committer signature), so callers must `vm.prank` the +/// committer before `submitTurnMoves`. `_committerFor` gives the committer for a turnId. +abstract contract BatchHelper is SignedCommitHelper { + /// @notice The committer for a turn (parity: even → p0, odd → p1). + function _committerFor(uint64 turnId, address p0, address p1) internal pure returns (address) { + return turnId % 2 == 0 ? p0 : p1; + } + + /// @notice Build a single-sig `TurnSubmission` (committer preimage + revealer sig only). + function _buildTurnSubmission( + address signedCommitManagerAddr, + bytes32 battleKey, + uint64 turnId, + uint8 p0MoveIndex, + uint16 p0ExtraData, + uint104 p0Salt, + uint8 p1MoveIndex, + uint16 p1ExtraData, + uint104 p1Salt, + uint256 p0Pk, + uint256 p1Pk + ) internal view returns (TurnSubmission memory entry) { + uint8 cM; uint16 cE; uint104 cS; + uint8 rM; uint16 rE; uint104 rS; uint256 rPk; + if (turnId % 2 == 0) { + cM = p0MoveIndex; cE = p0ExtraData; cS = p0Salt; + rM = p1MoveIndex; rE = p1ExtraData; rS = p1Salt; rPk = p1Pk; + } else { + cM = p1MoveIndex; cE = p1ExtraData; cS = p1Salt; + rM = p0MoveIndex; rE = p0ExtraData; rS = p0Salt; rPk = p0Pk; + } + bytes32 committerMoveHash = keccak256(abi.encodePacked(cM, cS, cE)); + entry = TurnSubmission({ + turnId: turnId, + committerMoveIndex: cM, + committerExtraData: cE, + committerSalt: cS, + revealerMoveIndex: rM, + revealerExtraData: rE, + revealerSalt: rS, + revealerSig: _signDualReveal(signedCommitManagerAddr, rPk, battleKey, turnId, committerMoveHash, rM, rS, rE) + }); + } +} diff --git a/test/abstract/GasMeasure.sol b/test/abstract/GasMeasure.sol new file mode 100644 index 00000000..b30430b1 --- /dev/null +++ b/test/abstract/GasMeasure.sol @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity ^0.8.0; + +import {CommonBase} from "../../lib/forge-std/src/Base.sol"; +import {Vm} from "../../lib/forge-std/src/Vm.sol"; + +/// @notice Shared production-faithful gas measurement: per-tx cold-access accounting (via `vm.cool` +/// between measured units) + a deterministic storage-access tally. Replaces the all-warm +/// `vm.startSnapshotGas` span, which (a) doesn't reflect production (each turn is its own +/// cold-start tx) and (b) MASKS storage-access regressions — a new cold SLOAD is invisible +/// once the slot is warm within a single foundry tx. +/// +/// The tally counts are regime-INDEPENDENT (a new SLOAD is +1 totalSload regardless of +/// warmth), so they are the robust regression guard; cold/warm split + cold-gas add the +/// production-cost picture. Snapshot one scenario with `_snapScenario(name, tally, coldGas)`. +abstract contract GasMeasure is CommonBase { + struct Tally { + uint256 totalSload; + uint256 coldSload; + uint256 warmSload; + uint256 totalSstore; + uint256 zToNz; // zero -> nonzero (SSTORE_SET, ~20k) + uint256 nzToNz; // nonzero -> different nonzero (SSTORE_RESET, ~2.9k) + uint256 noop; // value unchanged (~100) + } + + /// @dev Classify a state-diff window as ONE transaction: first touch of a slot is cold, + /// subsequent touches warm. Call once per prod-tx-equivalent unit (e.g. once per turn) + /// so cold/warm reflects a fresh cold-start access list. + function _tally(Vm.AccountAccess[] memory accesses) internal pure returns (Tally memory t) { + // Size the dedup scratch to the actual access count in THIS window (not a fixed large + // array), so calling _tally once per turn doesn't blow up cumulative memory across a battle. + uint256 cap; + for (uint256 i; i < accesses.length; i++) cap += accesses[i].storageAccesses.length; + bytes32[] memory keys = new bytes32[](cap); + uint16[] memory writes = new uint16[](cap); + bool[] memory reads = new bool[](cap); + uint256 keyCount; + for (uint256 i; i < accesses.length; i++) { + Vm.StorageAccess[] memory sa = accesses[i].storageAccesses; + for (uint256 j; j < sa.length; j++) { + Vm.StorageAccess memory a = sa[j]; + bytes32 key = keccak256(abi.encode(a.account, a.slot)); + uint256 idx = keyCount; + for (uint256 k; k < keyCount; k++) { + if (keys[k] == key) { idx = k; break; } + } + if (idx == keyCount) { keys[idx] = key; keyCount++; } + if (a.isWrite) { + t.totalSstore++; + if (a.previousValue == bytes32(0) && a.newValue != bytes32(0)) t.zToNz++; + else if (a.previousValue != bytes32(0) && a.newValue != bytes32(0) && a.previousValue != a.newValue) t.nzToNz++; + else if (a.previousValue == a.newValue) t.noop++; + writes[idx]++; + } else { + t.totalSload++; + if (!reads[idx] && writes[idx] == 0) { t.coldSload++; reads[idx] = true; } + else t.warmSload++; + } + } + } + } + + function _addTally(Tally memory a, Tally memory b) internal pure returns (Tally memory o) { + o.totalSload = a.totalSload + b.totalSload; + o.coldSload = a.coldSload + b.coldSload; + o.warmSload = a.warmSload + b.warmSload; + o.totalSstore = a.totalSstore + b.totalSstore; + o.zToNz = a.zToNz + b.zToNz; + o.nzToNz = a.nzToNz + b.nzToNz; + o.noop = a.noop + b.noop; + } + + /// @dev Cool every listed account's storage (resets the EIP-2929 access list to cold), so the + /// next access pays cold prices — modeling a fresh production transaction. + function _coolAll(address[] memory addrs) internal { + for (uint256 i; i < addrs.length; i++) vm.cool(addrs[i]); + } + + /// @notice Snapshot one scenario: the deterministic access tally + the cold-per-tx total gas. + function _snapScenario(string memory name, Tally memory t, uint256 coldGas) internal { + vm.snapshotValue(string.concat(name, "_coldGas"), coldGas); + vm.snapshotValue(string.concat(name, "_totalSload"), t.totalSload); + vm.snapshotValue(string.concat(name, "_coldSload"), t.coldSload); + vm.snapshotValue(string.concat(name, "_totalSstore"), t.totalSstore); + vm.snapshotValue(string.concat(name, "_zToNz"), t.zToNz); + vm.snapshotValue(string.concat(name, "_nzToNz"), t.nzToNz); + vm.snapshotValue(string.concat(name, "_noop"), t.noop); + } +} 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/fixtures/desync_reports/battle_0x88cb_turn26.md b/test/fixtures/desync_reports/battle_0x88cb_turn26.md new file mode 100644 index 00000000..776d041e --- /dev/null +++ b/test/fixtures/desync_reports/battle_0x88cb_turn26.md @@ -0,0 +1,314 @@ +# Battle desync report + +**Turn:** 26 +**Mode:** PvP +**Battle key:** 0x88cba00cf215675e03a0a11ac2cbcbcf0cf1a312784dba88348585ce49d84da0 +**Timestamp:** 2026-05-28T22:15:46.474Z + +## Observed problem +(none provided) + +## Teams + +### Player 0 (0x1c0f105d03e8dd2a551aea3ed55411f9a03c0d41) +- 0: Pengym {hp:371, atk:202, def:200, spAtk:222, spDef:180, spe:149, type:6} +- 1: Iblivion {hp:277, atk:197, def:156, spAtk:252, spDef:160, spe:256, type:1/10} +- 2: Aurox {hp:420, atk:143, def:230, spAtk:95, spDef:220, spe:100, type:5} +- 3: Volthare {hp:295, atk:120, def:193, spAtk:255, spDef:184, spe:311, type:8/12} + +### Player 1 (0xa95cd70475a182055f7a16bcb314e48643d08e37) +- 0: Ghouliath {hp:303, atk:157, def:202, spAtk:151, spDef:202, spe:181, type:0/4} +- 1: Iblivion {hp:277, atk:188, def:164, spAtk:240, spDef:168, spe:256, type:1/10} +- 2: Xmon {hp:311, atk:123, def:179, spAtk:222, spDef:185, spe:285, type:14} +- 3: Sofabbi {hp:333, atk:180, def:201, spAtk:120, spDef:269, spe:175, type:7} + +## Session +activeAddress: 0x586d6fe5c3fb47e7395dcf31ffd5b8aea7ac5e8e +moveSelection: + phase: IDLE + selectedMove: null + +## Turn 0 +turnId: 0 +p0: + moveIndex: 125n + salt: 15450001689812990757318517192966n + extraData: 1n +p1: + moveIndex: 125n + salt: 18252122845989030006812243139474n + extraData: 0n + +## Turn 1 +turnId: 1 +p0: + moveIndex: 2n + salt: 4834210944993112651816909106126n + extraData: 0n +p1: + moveIndex: 3n + salt: 15255474349613996056713761071686n + extraData: 0n + +## Turn 2 +turnId: 2 +p0: + moveIndex: 2n + salt: 6583714706138183953804767275678n + extraData: 0n +p1: + moveIndex: 1n + salt: 15461637266987935369279566108124n + extraData: 0n + +## Turn 3 +turnId: 3 +p0: + moveIndex: 2n + salt: 7210161534971784956923416751886n + extraData: 0n +p1: + moveIndex: 1n + salt: 15016064050662495416725412652563n + extraData: 0n + +## Turn 4 +turnId: 4 +p0: {} +p1: + moveIndex: 125n + salt: 19240011345095274681466263674330n + extraData: 1n + +## Turn 5 +turnId: 5 +p0: + moveIndex: 126n + salt: 3284692555853178397455092928083n + extraData: 0n +p1: + moveIndex: 126n + salt: 7835549805310255467442088074506n + extraData: 0n + +## Turn 6 +turnId: 6 +p0: + moveIndex: 125n + salt: 12334118906782137414472592949424n + extraData: 3n +p1: + moveIndex: 126n + salt: 19374785281272442474766137271163n + extraData: 0n + +## Turn 7 +turnId: 7 +p0: + moveIndex: 2n + salt: 15077791565903026790875989318528n + extraData: 0n +p1: + moveIndex: 125n + salt: 11421095052443333388573678495326n + extraData: 0n + +## Turn 8 +turnId: 8 +p0: {} +p1: + moveIndex: 125n + salt: 6291473213391741470941218170218n + extraData: 1n + +## Turn 9 +turnId: 9 +p0: + moveIndex: 0n + salt: 7022931971424196742811121512061n + extraData: 0n +p1: + moveIndex: 125n + salt: 15438085774022369100235175410030n + extraData: 3n + +## Turn 10 +turnId: 10 +p0: + moveIndex: 1n + salt: 4420670065419414850590787481288n + extraData: 2n +p1: + moveIndex: 2n + salt: 1960761762236369089740333992246n + extraData: 0n + +## Turn 11 +turnId: 11 +p0: + moveIndex: 3n + salt: 19801295147355512497167142159749n + extraData: 0n +p1: + moveIndex: 2n + salt: 6166359188124075649524594725791n + extraData: 0n + +## Turn 12 +turnId: 12 +p0: + moveIndex: 3n + salt: 17171843021366040478135578264996n + extraData: 0n +p1: + moveIndex: 2n + salt: 5383564461617129507072037502214n + extraData: 0n + +## Turn 13 +turnId: 13 +p0: + moveIndex: 125n + salt: 1986471879882982807378747309426n + extraData: 3n +p1: + moveIndex: 126n + salt: 14414938581786935425390960964000n + extraData: 0n + +## Turn 14 +turnId: 14 +p0: + moveIndex: 1n + salt: 3458675293930857335960176057085n + extraData: 2n +p1: + moveIndex: 2n + salt: 7749328072402731440980579744946n + extraData: 0n + +## Turn 15 +turnId: 15 +p0: + moveIndex: 125n + salt: 17293194887286872287278788602290n + extraData: 3n +p1: + moveIndex: 126n + salt: 4383111541380336465729024026150n + extraData: 0n + +## Turn 16 +turnId: 16 +p0: + moveIndex: 1n + salt: 10450409746379039708229821790015n + extraData: 2n +p1: + moveIndex: 125n + salt: 8716693538680640339539097046509n + extraData: 2n + +## Turn 17 +turnId: 17 +p0: + moveIndex: 3n + salt: 15015474814001600635537093680446n + extraData: 0n +p1: + moveIndex: 1n + salt: 2288645315003210275352244731355n + extraData: 0n + +## Turn 18 +turnId: 18 +p0: + moveIndex: 125n + salt: 11920649514225307809051229177287n + extraData: 3n +p1: + moveIndex: 1n + salt: 11401157979167469193859133635460n + extraData: 0n + +## Turn 19 +turnId: 19 +p0: + moveIndex: 126n + salt: 17457123310241581297033221314838n + extraData: 0n +p1: + moveIndex: 1n + salt: 10631747601138287248935576077466n + extraData: 0n + +## Turn 20 +turnId: 20 +p0: + moveIndex: 0n + salt: 6835862067306040477454545192907n + extraData: 0n +p1: + moveIndex: 1n + salt: 9322809856242922630776583049082n + extraData: 0n + +## Turn 21 +turnId: 21 +p0: + moveIndex: 0n + salt: 4954019214144165935310368793018n + extraData: 0n +p1: + moveIndex: 1n + salt: 134338259296852826632816183133n + extraData: 0n + +## Turn 22 +turnId: 22 +p0: {} +p1: + moveIndex: 125n + salt: 16613587181676977476579639480048n + extraData: 1n + +## Turn 23 +turnId: 23 +p0: + moveIndex: 1n + salt: 17804535964781524133768449087333n + extraData: 0n +p1: + moveIndex: 125n + salt: 18580496538728489255944038457804n + extraData: 3n + +## Turn 24 +turnId: 24 +p0: {} +p1: + moveIndex: 125n + salt: 7470981269216264771411536686385n + extraData: 1n + +## Turn 25 +turnId: 25 +p0: + moveIndex: 2n + salt: 12785556958579953943913050575887n + extraData: 0n +p1: + moveIndex: 126n + salt: 17130052050856558701654168347952n + extraData: 0n + +## Turn 25 +turnId: 25 +p0: + moveIndex: 2n + salt: 11308943186059704035530128945724n + extraData: 0n +p1: + moveIndex: 126n + salt: 15443756612106056892399192506182n + extraData: 0n 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/MockSimplePMEngine.sol b/test/mocks/MockSimplePMEngine.sol index 5805343b..af82e1d5 100644 --- a/test/mocks/MockSimplePMEngine.sol +++ b/test/mocks/MockSimplePMEngine.sol @@ -1,6 +1,8 @@ // SPDX-License-Identifier: AGPL-3.0 pragma solidity ^0.8.0; +import {CommitContext} from "../../src/Structs.sol"; + contract MockSimplePMEngine { mapping(bytes32 => uint256) public turnIds; mapping(bytes32 => address) public winners; @@ -37,7 +39,9 @@ contract MockSimplePMEngine { return playersList[battleKey]; } - function getStartTimestamp(bytes32 battleKey) external view returns (uint256) { - return startTimestamps[battleKey]; + /// @notice SimplePM reads startTimestamp + turnId via the batched CommitContext. + function getCommitContext(bytes32 battleKey) external view returns (CommitContext memory ctx) { + ctx.startTimestamp = uint48(startTimestamps[battleKey]); + ctx.turnId = uint64(turnIds[battleKey]); } } 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 2e3d7aac..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, @@ -518,7 +516,7 @@ contract EmbursaTest is Test, BattleHelper { // After turn ends, only Bob has a forced switch pending. assertEq( - uint256(engine.getPlayerSwitchForTurnFlagForBattleState(battleKey)), + uint256(engine.getBattleContext(battleKey).playerSwitchForTurnFlag), 1, "Next turn should be Bob's single-player forced switch" ); @@ -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);