diff --git a/GAS_OPTIMIZATION.md b/GAS_OPTIMIZATION.md new file mode 100644 index 00000000..8ec9b9a1 --- /dev/null +++ b/GAS_OPTIMIZATION.md @@ -0,0 +1,164 @@ +# Gas Optimization — Record & Remaining Opportunities + +Consolidated record of the batched-execute + gas work on `claude/batched-from-main`. +**Supersedes** the earlier `ANALYSIS_BATCHED_GAS.md` and `OPT_PLAN.md` — the latter described a +transient-shadow / dual-sig / CPU-state-hint design that was tried and **abandoned** (see §5). + +Hard constraints this work respected: fully on-chain; per-turn move submission (no batching multiple +PvP submissions into one tx); no optimistic/dispute-only execution. Deferring all *executions* into +one tx is allowed. Single-sig submit (`msg.sender == committer`) is intentional (saves an ecrecover). + +--- + +## 1. Headline (production-faithful, real 26-turn game) + +Measured via `test/RealMonReplayGasTest.t.sol`: a faithful replay of a real prod game (real mon +loadouts via `SetupMons` `deployX()` recipes + the log's exact moves/salts), each turn/submission a +separate cold-access tx (`vm.cool`), steady-state value-warm storage (storageKey reuse), +**production config (inline stamina regen)**, with byte-identical end-state equivalence asserted +between the legacy and batched paths. + +| Flow (prod config: inline regen + repack + single-sig) | total gas | | +|---|---|---| +| clean-legacy (per-turn execute) | 4,624,316 | | +| **clean-batched** (submit ×26 + 1 execute) | **4,106,467** | batching saves **517,849 (~11.2%)** | + +Two wins compound, in order of impact: + +1. **Inline stamina regen — ~11% (~477–577k/game).** A *config* choice, not new code: prod battles + must use `INLINE_STAMINA_REGEN_RULESET` (sentinel `address(0x57A)`), which sets + `config.hasInlineStaminaRegen` so the engine runs regen internally. The external `StaminaRegen` + effect is the slow path — every round-end/after-move it makes reentrant calls + (`getPlayerSwitchForTurnFlagForBattleState`, `getMoveDecisionForBattleState`, stamina + `getMonStateForBattle`, `updateMonState`) that all vanish under inline. This was the single + biggest lever and was always available — see §4 for the methodology lesson. +2. **Batching — ~11.2% (517,849/game).** One execute tx amortizes execute-side cold reads across all + sub-turns (EIP-2929 warm-slot discount), for free; plus single-sig submit and the BattleData + slot-1 repack. + +> The old external-regen `main` baseline (5,277,953) is **no longer comparable** — it measured the +> slow ruleset. A fair main comparison needs `main` itself re-measured under inline; estimate +> main-inline-legacy ≈ 4.70M (clean-legacy-inline + the measured ~76k repack+single-sig delta). + +--- + +## 2. What shipped on this branch + +| Change | Effect | +|---|---| +| **BattleData slot-1 repack** | All per-turn-mutable fields in one slot → 1 SSTORE/turn (both paths) | +| **Batching**: `submitTurnMoves` + `executeBuffered` + `Engine.executeBatchedTurns` (direct storage, **no shadow**) | ~11% execute-side cold-read amortization | +| **Single-sig submit** | Revealer sig pins the committer move hash; `msg.sender == committer`. Drops one ecrecover + one 65-byte sig vs dual-sig (~2% on submit). Applied to the legacy `executeWithDualSignedMoves` too. | +| **Offchain-CPU** `selectMoveWithCpuMove` | Player submits both their move and the CPU's move in one tx; skips `getCPUContext` (dozen+ cold SLOADs) + on-chain `calculateMove` every CPU turn | +| **`RealMonReplayGasTest` + `parse_desync_report.py`** | Desync log → replay test (equivalence + prod-faithful gas). Any real game becomes a gas + equivalence regression. | +| **`FullyOptimizedInlineGasTest` + `GasMeasure`** | Per-tx cold-access + deterministic storage-access tally; the canonical inline gas tracker | + +Removed: `InlineEngineGasTest`, `EngineGasTest` (subsets / external-validator baseline — not the prod stack). + +--- + +## 3. Security model (single-sig) + +Replay protection lives in the **revealer signature** over +`DualSignedReveal{battleKey, turnId, committerMoveHash, revealerMove…}`: + +- **Cross-turn / cross-battle replay:** the sig binds `(battleKey, turnId)` → a turn-N sig can't be + replayed on turn N+1 or another battle (digest differs → recovery fails). +- **Committer can't change their move:** the revealer sig binds `committerMoveHash`; a different + committer preimage recomputes a different hash → revealer sig recovers the wrong address → revert. +- **No impersonation / unilateral-revealer attack:** `msg.sender == committer` (load-bearing). A + third party / revealer submitting a forged committer move reverts `NotCommitter` before execution. + Trade-off: no relaying — the committer sends their own turn's tx. The CPU path uses the same + `msg.sender == alice` binding. + +--- + +## 4. Measurement methodology (and the lesson that re-baselined everything) + +The authoritative instrument is `RealMonReplayGasTest` (above). Per-tx cold via `vm.cool`, +steady-state via storageKey reuse, equivalence asserted. The `--gas-report` over it attributes the +reentrant cost to each Engine call. + +**Lesson (cost us a whole analysis pass):** the replay was originally built against +`DefaultRuleset(new StaminaRegen())` — the *external* regen path, which prod does not use. That +inflated the baseline and made the reentrant-read breakdown look dominated by `getMonStateForBattle` +(260 calls) / `getMoveDecisionForBattleState` (180) / `getPlayerSwitchForTurnFlag` (88). After +switching to the prod inline config those collapsed to 64 / 4 / 0. **Always baseline against the +actual production config before ranking optimizations.** + +--- + +## 5. Tried and rejected (measured) + +- **Transient shadow** (the original `OPT_PLAN.md` premise: mirror MonState + BattleData in transient, + flush once at batch end). **Net-negative (~−94k/game).** The EVM already amortizes cold→warm SLOADs + within one tx for free, and repeated same-slot SSTOREs are already ~100 gas; the shadow added a + TLOAD-check + dirty/loaded bookkeeping on *every* access (reads dominate). Removed entirely — the + clean engine (direct storage pointers, like `main`) shows batching's ~11% with no regression. +- **#4 no-op globalKV SSTORE guard** (skip the write when the value is unchanged). **Regressed every + measured scenario** (Battle1 +191, Battle3 +191, real-game +335) with **zero** no-op writes avoided + — statuses set distinct per-mon flags and abilities set-once-then-read, so the guard only ever pays + the compare, never skips. Reverted. +- **#6 transient-reset trimming** (the "8→2 TSTOREs" idea in `executeBatchedTurns`). **Premise was + wrong.** Audit: the 4 move/salt resets + `tempRNG` are *load-bearing for legacy↔batched + equivalence* (`_emitMonMoves` reads both players' moves unconditionally every turn incl. one-sided; + one-sided switch-in effects read `tempRNG`, which is only set on two-player turns). The other 3 + (`tempPreDamage`/`koOccurredFlag`/`effectsDirtyBitmap`) are only *probably* redundant under fragile + within-turn self-clearing invariants, worth ~2.6k/game (batched-only) to chase. Skipped. +- **Delegatecall moves** (run move logic in the engine's context to "save SLOADs"). Rejected on both + counts: (a) move params are **mutable** (`StandardAttack.changeVar`, owner balance-tuning) so they + can't be bytecode/immutable, and they're **already packed into one slot** — there's no scattered + SLOAD to collapse; delegatecall would only save the warm cross-contract CALL (~100–700), not the + SLOAD. (b) Running curated/user move code in the engine's storage context is arbitrary state + corruption (`winnerIndex`, balances) — a hard no for a game built on user-created moves. + +--- + +## 6. Remaining opportunities (ranked, honest) + +The two big wins (inline, batching) are banked. What's left is marginal, conditional, or +architectural — listed for completeness: + +1. **CPU / single-player one-tx batch-submit — the biggest remaining lever, but a CONSTRAINT + QUESTION.** The "no batching submissions" rule exists for **commit-reveal fairness** (PvP). In + single-player vs CPU there is no adversary to hide moves from — the CPU is deterministic/RNG-seeded + and (with the ported offchain-CPU) already computed off-chain. So a single-player game has *no + fairness reason* for per-turn submission: the player could submit the entire move list and execute + the whole game in **one tx**, collapsing ~27 txs → 1 (~546k tx-base + ~1.25M submit execution) — + roughly **halving single-player cost**, more than every micro-lever combined. **Gated on: does the + no-batch-submission constraint apply to single-player, or is it purely a PvP-fairness rule?** If + PvP-only, this is the win to pursue. +2. **`getSubmitContext` (~182k/batched-game, ~7k/submit)** — the biggest submit-side bucket, but + ~irreducible: it's already a single bundled engine call (one cold account access + the p0/p1/ + storageKey/winnerIndex reads, all genuinely needed — p0/p1 are load-bearing for the anti-grief + `msg.sender == committer` check). The only structural cut is **moving the move-buffer into the + engine** (eliminates the cross-contract call, ~68k/batched-game) — a real refactor that merges the + swappable move-manager boundary into the engine. +3. **Ability-idempotency (`getEffects` scan, ~103k bucket)** — abilities scan the effect list on every + switch-in to avoid double-registration. A `globalKV`/bitmap flag or a caller-provided slot **hint** + is O(1), but **conditional**: it adds an SSTORE that *regresses* single-switch-in mons and only + wins for mons that switch in ≥2× / carry many effects. Same shape as #4/#6 — validate empirically + before committing. +4. **Per-tx base floor: 27 × 21,000 = 567k.** Structural under the per-turn-submission constraint — + only the single-player batch-submit (#1) touches it. + +### Verified already-optimal (no win available) + +- Reentrant views resolve storage via the transient `storageKeyForWrite` during execute (1 TLOAD, + not the `battleKeyToStorageKey` SLOAD). +- Move params (`StandardAttack`) are packed into one slot; reads are cold-once-per-tx then warm. +- The prod (inline-validation) path calls `stamina()` once and `priority()` once per move (1784/1798 + are mutually exclusive branches, not a double call). +- Storage is bit-packed throughout (BattleData → 1 slot/turn, MonState → 1 slot/mon, KO bitmaps, + effect counts), and the damage path already batches reads via `getDamageCalcContext` / `getMeta`. + +--- + +## 7. Files + +- `test/RealMonReplayGasTest.t.sol` — authoritative prod-faithful real-game replay (equivalence + gas). +- `test/FullyOptimizedInlineGasTest.sol` + `test/abstract/GasMeasure.sol` — inline gas tracker + tally. +- `processing/parse_desync_report.py` — desync log → replay test data. +- `src/Engine.sol` — `executeBatchedTurns`, `getSubmitContext`, BattleData repack. +- `src/commit-manager/SignedCommitManager.sol` — `submitTurnMoves` / `executeBuffered` (single-sig). +- `src/cpu/CPUMoveManager.sol` — `selectMoveWithCpuMove` (offchain-CPU, submit-both-moves). 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/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/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..7aec1bc7 100644 --- a/snapshots/EngineOptimizationTest.json +++ b/snapshots/EngineOptimizationTest.json @@ -1,4 +1,4 @@ { - "ExternalStaminaRegen": "389950", - "InlineStaminaRegen": "1035668" + "ExternalStaminaRegen": "412562", + "InlineStaminaRegen": "1057867" } \ No newline at end of file diff --git a/snapshots/FullyOptimizedInlineGasTest.json b/snapshots/FullyOptimizedInlineGasTest.json index 180cde78..747dcd39 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": "1707812", + "Fast_Battle1_coldSload": "252", + "Fast_Battle1_noop": "29", + "Fast_Battle1_nzToNz": "87", + "Fast_Battle1_totalSload": "1612", + "Fast_Battle1_totalSstore": "146", + "Fast_Battle1_zToNz": "30", + "Fast_Battle2_coldGas": "1653570", + "Fast_Battle2_coldSload": "258", + "Fast_Battle2_noop": "37", + "Fast_Battle2_nzToNz": "108", + "Fast_Battle2_totalSload": "1684", + "Fast_Battle2_totalSstore": "166", + "Fast_Battle2_zToNz": "20", + "Fast_Battle3_coldGas": "1238277", + "Fast_Battle3_coldSload": "252", + "Fast_Battle3_noop": "42", + "Fast_Battle3_nzToNz": "98", + "Fast_Battle3_totalSload": "1612", + "Fast_Battle3_totalSstore": "146", + "Fast_Battle3_zToNz": "4" } \ 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..20ffb0fc 100644 --- a/snapshots/MatchmakerTest.json +++ b/snapshots/MatchmakerTest.json @@ -1,5 +1,5 @@ { - "Accept1": "343446", + "Accept1": "343670", "Accept2": "34250", "Propose1": "197406" } \ 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/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..ab296fd9 100644 --- a/src/Engine.sol +++ b/src/Engine.sol @@ -374,6 +374,91 @@ contract Engine is IEngine, MappingAllocator { return _executeInternal(battleKey, storageKey); } + /// @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; + _turnP0MoveEncoded = (uint256(p0Stored) | uint256(IS_REAL_TURN_BIT)) | (uint256(p0Extra) << 8); + _turnP1MoveEncoded = (uint256(p1Stored) | uint256(IS_REAL_TURN_BIT)) | (uint256(p1Extra) << 8); + _turnP0Salt = p0Salt; + _turnP1Salt = p1Salt; + } else if (flag == 0) { + uint8 p0Stored = p0Move < SWITCH_MOVE_INDEX ? p0Move + MOVE_INDEX_OFFSET : p0Move; + _turnP0MoveEncoded = (uint256(p0Stored) | uint256(IS_REAL_TURN_BIT)) | (uint256(p0Extra) << 8); + _turnP0Salt = p0Salt; + } else { + uint8 p1Stored = p1Move < SWITCH_MOVE_INDEX ? p1Move + MOVE_INDEX_OFFSET : p1Move; + _turnP1MoveEncoded = (uint256(p1Stored) | uint256(IS_REAL_TURN_BIT)) | (uint256(p1Extra) << 8); + _turnP1Salt = p1Salt; + } + + winner = _executeInternal(battleKey, storageKey); + executed++; + if (winner != address(0)) { + break; + } + + // Reset per-turn transients so the next sub-turn starts like a fresh legacy tx. + _turnP0MoveEncoded = 0; + _turnP1MoveEncoded = 0; + _turnP0Salt = 0; + _turnP1Salt = 0; + tempRNG = 0; + koOccurredFlag = 0; + tempPreDamage = 0; + effectsDirtyBitmap = 0; + } + } + + /// @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 /// MoveDecision. Encoded == 0 means "no current turn move" since packedMoveIndex always has /// IS_REAL_TURN_BIT set for a real move. @@ -728,7 +813,7 @@ 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); } @@ -750,6 +835,12 @@ contract Engine is IEngine, MappingAllocator { _turnP1Salt = 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 { @@ -2527,10 +2618,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 +2765,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,10 +2812,6 @@ 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) { bytes32 storageKey = _resolveStorageKey(battleKey); bytes32 packed = globalKV[storageKey][key]; @@ -2760,6 +2834,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 +2877,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 +2885,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]; @@ -2896,97 +2992,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..c1e6849c 100644 --- a/src/IEngine.sol +++ b/src/IEngine.sol @@ -55,13 +55,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 +86,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 +98,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 +106,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 +123,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..46527604 100644 --- a/src/Structs.sol +++ b/src/Structs.sol @@ -73,11 +73,15 @@ 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) + prevPlayerSwitchForTurnFlag (8) + playerSwitchForTurnFlag (8) + +// activeMonIndex (16) + lastExecuteTimestamp (40) + turnId (16) = 256 bits exactly. +// 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; @@ -85,7 +89,8 @@ struct BattleData { 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 @@ -235,6 +240,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; @@ -293,22 +313,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 +327,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..d43a3a6a 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)); } /** diff --git a/src/cpu/CPUMoveManager.sol b/src/cpu/CPUMoveManager.sol index 52b0c109..83518419 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,96 @@ 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 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/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/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/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/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/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..29f5d95c 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 { diff --git a/test/FullyOptimizedInlineGasTest.sol b/test/FullyOptimizedInlineGasTest.sol new file mode 100644 index 00000000..59ccf62e --- /dev/null +++ b/test/FullyOptimizedInlineGasTest.sol @@ -0,0 +1,422 @@ +// 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 {StatBoosts} from "../src/effects/StatBoosts.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); + 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); + + // 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/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/RealMonReplayGasTest.t.sol b/test/RealMonReplayGasTest.t.sol new file mode 100644 index 00000000..6e0b0e7f --- /dev/null +++ b/test/RealMonReplayGasTest.t.sol @@ -0,0 +1,300 @@ +// 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 {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"; +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(); + StatBoosts sb = new StatBoosts(); + Overclock oc = new Overclock(sb); + SleepStatus sleepStatus = new SleepStatus(); + PanicStatus panicStatus = new PanicStatus(); + FrostbiteStatus frost = new FrostbiteStatus(sb); + BurnStatus burn = new BurnStatus(sb); + ZapStatus zap = new ZapStatus(); + gachaReg = new GachaTeamRegistry(4, 4, IEngine(address(engine)), IGachaRNG(address(0))); + vm.setEnv("TYPE_CALCULATOR", vm.toString(address(tc))); + vm.setEnv("STAT_BOOSTS", vm.toString(address(sb))); + vm.setEnv("OVERCLOCK", vm.toString(address(oc))); + vm.setEnv("SLEEP_STATUS", vm.toString(address(sleepStatus))); + vm.setEnv("PANIC_STATUS", vm.toString(address(panicStatus))); + 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(); + } + + 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"); + + 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); + // 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/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/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/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/mons/EmbursaTest.sol b/test/mons/EmbursaTest.sol index 2e3d7aac..06632865 100644 --- a/test/mons/EmbursaTest.sol +++ b/test/mons/EmbursaTest.sol @@ -518,7 +518,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" );