diff --git a/.gitignore b/.gitignore index e45a0f88..e616b62e 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,9 @@ cache/ out/ +# Default forge gas snapshot (we use snapshots/ directory instead) +.gas-snapshot + # Ignores development broadcast logs !/broadcast /broadcast/*/31337/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..8ddc6679 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,180 @@ +# Changelog + +## Double Battles Implementation + +This document summarizes all changes made to implement double battles support. + +--- + +### Core Data Structure Changes + +#### `src/Enums.sol` +- Added `GameMode` enum: `Singles`, `Doubles` + +#### `src/Structs.sol` +- **`BattleArgs`** and **`Battle`**: Added `GameMode gameMode` field +- **`BattleData`**: Added `slotSwitchFlagsAndGameMode` (packed field: lower 4 bits = per-slot switch flags, bit 4 = game mode) +- **`BattleContext`** / **`BattleConfigView`**: Added `p0ActiveMonIndex0`, `p0ActiveMonIndex1`, `p1ActiveMonIndex0`, `p1ActiveMonIndex1`, `slotSwitchFlags`, `gameMode` + +#### `src/Constants.sol` +- Added `GAME_MODE_BIT`, `SWITCH_FLAGS_MASK`, `ACTIVE_MON_INDEX_MASK` for packed storage + +--- + +### New Files + +#### `src/BaseCommitManager.sol` +Extracted shared commit/reveal logic from singles and doubles managers: +- Common errors, events, and storage +- Shared validation functions: `_validateCommit`, `_validateRevealPreconditions`, `_validateRevealTiming`, `_updateAfterReveal`, `_shouldAutoExecute` + +#### `src/DoublesCommitManager.sol` +Commit/reveal manager for doubles handling 2 moves per player per turn: +- `commitMoves(battleKey, moveHash)` - Single hash for both moves +- `revealMoves(...)` - Reveal both slot moves with cross-slot switch validation + +--- + +### Interface Changes + +#### `src/IEngine.sol` +```solidity +function getActiveMonIndexForSlot(bytes32 battleKey, uint256 playerIndex, uint256 slotIndex) external view returns (uint256); +function getGameMode(bytes32 battleKey) external view returns (GameMode); +function switchActiveMonForSlot(uint256 playerIndex, uint256 slotIndex, uint256 monToSwitchIndex) external; +function setMoveForSlot(bytes32 battleKey, uint256 playerIndex, uint256 slotIndex, uint256 moveIndex, bytes32 salt, uint240 extraData) external; +``` + +#### `src/IValidator.sol` +```solidity +function validatePlayerMoveForSlot(bytes32 battleKey, uint256 moveIndex, uint256 playerIndex, uint256 slotIndex, uint240 extraData) external returns (bool); +function validatePlayerMoveForSlotWithClaimed(bytes32 battleKey, uint256 moveIndex, uint256 playerIndex, uint256 slotIndex, uint240 extraData, uint256 claimedByOtherSlot) external returns (bool); +function validateSpecificMoveSelection(bytes32 battleKey, uint256 moveIndex, uint256 playerIndex, uint256 slotIndex, uint240 extraData) external returns (bool); +``` + +--- + +### Engine Changes + +#### Unified Active Mon Index Packing +- Singles and doubles now use the same 4-bit-per-slot packing format +- Singles uses slot 0 only; doubles uses slots 0 and 1 +- Removed deprecated `_packActiveMonIndices`, `_unpackActiveMonIndex`, `_setActiveMonIndex` +- All code now uses `_unpackActiveMonIndexForSlot` and `_setActiveMonIndexForSlot` + +#### Slot-Aware Effect Execution +- Added overloaded `_runEffects` accepting explicit `monIndex` parameter +- Switch effects (`OnMonSwitchIn`, `OnMonSwitchOut`) pass the switching mon's index +- `dealDamage` passes target mon index to `AfterDamage` effects +- `updateMonState` passes affected mon index to `OnUpdateMonState` effects + +#### Slot-Aware Damage Calculations +- `getDamageCalcContext` now accepts `attackerSlotIndex` and `defenderSlotIndex` parameters +- `AttackCalculator._calculateDamage` and `_calculateDamageView` updated with slot parameters +- Ensures doubles damage calculations use correct attacker/defender stats based on slot +- All mon-specific attacks updated to pass explicit slot indices (singles use 0, 0) + +#### Doubles Execution Flow +- `_executeDoubles` handles 4 moves per turn with priority/speed ordering +- `_checkForGameOverOrKO_Doubles` checks both slots for each player +- Per-slot switch flags track which slots need to switch after KOs + +--- + +### Validator Changes + +#### `src/DefaultValidator.sol` +- `validateSwitch` checks both slots in doubles mode +- `validateSpecificMoveSelection` accepts `slotIndex` for correct mon lookup +- `_getActiveMonIndexFromContext` helper for slot-aware active mon retrieval +- Unified `_hasValidSwitchTargetForSlot` with optional `claimedByOtherSlot` parameter + +--- + +### Test Coverage + +#### `test/DoublesValidationTest.sol` (36 tests) +- Turn 0 switch requirements +- KO'd slot handling (with/without valid switch targets) +- Both slots KO'd scenarios (0, 1, or 2 reserves) +- Single-player switch turns (one player switches, other attacks) +- Force-switch moves targeting specific slots +- Storage reuse between singles↔doubles transitions +- Effects running on correct mon for both slots +- Move validation using correct slot's mon stamina +- AfterDamage effects healing correct mon +- Slot 1 damage calculations (defender stats, attacker stats) + +#### `test/DoublesCommitManagerTest.sol` (11 tests) +- Commit/reveal flow for doubles +- Move execution ordering by priority and speed +- Position tiebreaker for equal speed +- Game over detection when all mons KO'd + +#### Test Mocks Added +- `DoublesTargetedAttack` - Attack targeting specific opponent slot +- `DoublesForceSwitchMove` - Force-switch specific opponent slot +- `DoublesEffectAttack` - Apply effect to specific slot +- `EffectApplyingAttack` - Generic effect applicator for testing +- `MonIndexTrackingEffect` - Tracks which mon effects run on +- `DoublesSlotAttack` - Attack using AttackCalculator with explicit slot parameters + +--- + +### Client Usage + +#### Starting a Doubles Battle +```solidity +Battle memory battle = Battle({ + // ... other fields ... + moveManager: address(doublesCommitManager), + gameMode: GameMode.Doubles +}); +``` + +#### Turn Flow +```solidity +// Commit hash of both moves +bytes32 moveHash = keccak256(abi.encodePacked( + moveIndex0, extraData0, + moveIndex1, extraData1, + salt +)); +doublesCommitManager.commitMoves(battleKey, moveHash); + +// Reveal both moves +doublesCommitManager.revealMoves(battleKey, moveIndex0, extraData0, moveIndex1, extraData1, salt, true); +``` + +#### KO'd Slot Handling +- KO'd slot with valid switch targets → must SWITCH +- KO'd slot with no valid switch targets → must NO_OP +- Both slots KO'd with one reserve → slot 0 switches, slot 1 NO_OPs + +--- + +### Future Work + +#### Target Redirection +When a target slot is KO'd mid-turn, moves targeting that slot should redirect or fail. Currently handled by individual move implementations. + +#### Move Targeting System +- Standardize targeting semantics (self, ally, opponent slot 0/1, both opponents, all) +- Consider `TargetType` enum and standardized `extraData` encoding + +#### Speed Tie Handling +Currently uses basic speed comparison with position tiebreaker. May need explicit rules (random, player advantage). + +#### Ability/Effect Integration +- Abilities affecting both slots (e.g., Intimidate) +- Weather/terrain affecting 4 mons +- Spread moves hitting multiple targets + +#### Execution Pattern Unification +- Singles: `revealMove` → `execute` directly +- Doubles: `revealMoves` → `setMoveForSlot` × 2 → `execute` +- Consider unifying if performance permits + +#### Slot Information in Move Interface +- `IMoveSet.move()` doesn't receive attacker's slot index +- Limits slot-aware move logic in doubles diff --git a/snapshots/EngineGasTest.json b/snapshots/EngineGasTest.json index 290dcd50..987af881 100644 --- a/snapshots/EngineGasTest.json +++ b/snapshots/EngineGasTest.json @@ -1,17 +1,17 @@ { - "B1_Execute": "973562", - "B1_Setup": "812723", - "B2_Execute": "753779", - "B2_Setup": "278155", - "Battle1_Execute": "494044", - "Battle1_Setup": "789140", - "Battle2_Execute": "408734", - "Battle2_Setup": "234804", - "FirstBattle": "3493668", - "Intermediary stuff": "44162", - "SecondBattle": "3586835", - "Setup 1": "1668829", - "Setup 2": "295644", - "Setup 3": "335644", - "ThirdBattle": "2904295" + "B1_Execute": "1020627", + "B1_Setup": "817509", + "B2_Execute": "795025", + "B2_Setup": "282847", + "Battle1_Execute": "512654", + "Battle1_Setup": "793925", + "Battle2_Execute": "427344", + "Battle2_Setup": "237589", + "FirstBattle": "3647997", + "Intermediary stuff": "43924", + "SecondBattle": "3765806", + "Setup 1": "1673640", + "Setup 2": "298473", + "Setup 3": "338195", + "ThirdBattle": "3058698" } \ No newline at end of file diff --git a/snapshots/MatchmakerTest.json b/snapshots/MatchmakerTest.json index f9b54b9b..4e04e97f 100644 --- a/snapshots/MatchmakerTest.json +++ b/snapshots/MatchmakerTest.json @@ -1,5 +1,5 @@ { - "Accept1": "305380", - "Accept2": "33991", - "Propose1": "197148" + "Accept1": "307493", + "Accept2": "34420", + "Propose1": "199579" } \ No newline at end of file diff --git a/src/BaseCommitManager.sol b/src/BaseCommitManager.sol new file mode 100644 index 00000000..117f4ee7 --- /dev/null +++ b/src/BaseCommitManager.sol @@ -0,0 +1,251 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity ^0.8.0; + +import "./Constants.sol"; +import "./Enums.sol"; +import "./Structs.sol"; + +import {IEngine} from "./IEngine.sol"; + +/** + * @title BaseCommitManager + * @notice Abstract base contract with shared commit/reveal logic for singles and doubles + * @dev Subclasses implement mode-specific validation and move storage + */ +abstract contract BaseCommitManager { + IEngine internal immutable ENGINE; + + mapping(bytes32 battleKey => mapping(uint256 playerIndex => PlayerDecisionData)) internal playerData; + + error NotP0OrP1(); + error AlreadyCommited(); + error AlreadyRevealed(); + error NotYetRevealed(); + error RevealBeforeOtherCommit(); + error RevealBeforeSelfCommit(); + error WrongPreimage(); + error PlayerNotAllowed(); + error BattleNotYetStarted(); + error BattleAlreadyComplete(); + + event MoveCommit(bytes32 indexed battleKey, address player); + + constructor(IEngine engine) { + ENGINE = engine; + } + + /** + * @dev Validates common commit preconditions + * @return ctx The commit context + * @return playerIndex The caller's player index + * @return pd Storage reference to player's decision data + */ + function _validateCommit(bytes32 battleKey, bytes32 moveHash) + internal + returns (CommitContext memory ctx, uint256 playerIndex, PlayerDecisionData storage pd) + { + ctx = ENGINE.getCommitContext(battleKey); + + if (ctx.startTimestamp == 0) { + revert BattleNotYetStarted(); + } + + address caller = msg.sender; + playerIndex = (caller == ctx.p0) ? 0 : 1; + + if (caller != ctx.p0 && caller != ctx.p1) { + revert NotP0OrP1(); + } + + if (ctx.winnerIndex != 2) { + revert BattleAlreadyComplete(); + } + + pd = playerData[battleKey][playerIndex]; + uint64 turnId = ctx.turnId; + + // Check no commitment exists for this turn + if (turnId == 0) { + if (pd.moveHash != bytes32(0)) { + revert AlreadyCommited(); + } + } else if (pd.lastCommitmentTurnId == turnId) { + revert AlreadyCommited(); + } + + // Cannot commit if it's a single-player switch turn + if (ctx.playerSwitchForTurnFlag != 2) { + revert PlayerNotAllowed(); + } + + // Alternating commit: p0 on even turns, p1 on odd turns + if (caller == ctx.p0 && turnId % 2 == 1) { + revert PlayerNotAllowed(); + } else if (caller == ctx.p1 && turnId % 2 == 0) { + revert PlayerNotAllowed(); + } + + // Store commitment + pd.lastCommitmentTurnId = uint16(turnId); + pd.moveHash = moveHash; + pd.lastMoveTimestamp = uint96(block.timestamp); + + emit MoveCommit(battleKey, caller); + } + + /** + * @dev Validates common reveal preconditions + * @return ctx The commit context + * @return currentPlayerIndex The caller's player index + * @return otherPlayerIndex The other player's index + * @return currentPd Storage reference to caller's decision data + * @return otherPd Storage reference to other player's decision data + * @return playerSkipsPreimageCheck Whether the caller skips preimage verification + */ + function _validateRevealPreconditions(bytes32 battleKey) + internal + view + returns ( + CommitContext memory ctx, + uint256 currentPlayerIndex, + uint256 otherPlayerIndex, + PlayerDecisionData storage currentPd, + PlayerDecisionData storage otherPd, + bool playerSkipsPreimageCheck + ) + { + ctx = ENGINE.getCommitContext(battleKey); + + if (ctx.startTimestamp == 0) { + revert BattleNotYetStarted(); + } + if (msg.sender != ctx.p0 && msg.sender != ctx.p1) { + revert NotP0OrP1(); + } + if (ctx.winnerIndex != 2) { + revert BattleAlreadyComplete(); + } + + currentPlayerIndex = msg.sender == ctx.p0 ? 0 : 1; + otherPlayerIndex = 1 - currentPlayerIndex; + + currentPd = playerData[battleKey][currentPlayerIndex]; + otherPd = playerData[battleKey][otherPlayerIndex]; + + uint64 turnId = ctx.turnId; + uint8 playerSwitchForTurnFlag = ctx.playerSwitchForTurnFlag; + + // Determine if player skips preimage check + if (playerSwitchForTurnFlag == 2) { + playerSkipsPreimageCheck = + (((turnId % 2 == 1) && (currentPlayerIndex == 0)) || ((turnId % 2 == 0) && (currentPlayerIndex == 1))); + } else { + playerSkipsPreimageCheck = (playerSwitchForTurnFlag == currentPlayerIndex); + if (!playerSkipsPreimageCheck) { + revert PlayerNotAllowed(); + } + } + } + + /** + * @dev Validates reveal timing (commitment order, preimage if needed) + */ + function _validateRevealTiming( + CommitContext memory ctx, + PlayerDecisionData storage currentPd, + PlayerDecisionData storage otherPd, + bool playerSkipsPreimageCheck, + bytes32 expectedHash + ) internal view { + uint64 turnId = ctx.turnId; + uint8 playerSwitchForTurnFlag = ctx.playerSwitchForTurnFlag; + + if (playerSkipsPreimageCheck) { + // Must wait for other player's commitment (if 2-player turn) + if (playerSwitchForTurnFlag == 2) { + if (turnId != 0) { + if (otherPd.lastCommitmentTurnId != turnId) { + revert RevealBeforeOtherCommit(); + } + } else { + if (otherPd.moveHash == bytes32(0)) { + revert RevealBeforeOtherCommit(); + } + } + } + } else { + // Validate preimage + if (expectedHash != currentPd.moveHash) { + revert WrongPreimage(); + } + + // Ensure reveal happens after caller commits + if (currentPd.lastCommitmentTurnId != turnId) { + revert RevealBeforeSelfCommit(); + } + + // Check that other player has already revealed + if (otherPd.numMovesRevealed < turnId || otherPd.lastMoveTimestamp == 0) { + revert NotYetRevealed(); + } + } + + // Prevent double revealing + if (currentPd.numMovesRevealed > turnId) { + revert AlreadyRevealed(); + } + } + + /** + * @dev Updates player data after successful reveal + */ + function _updateAfterReveal( + bytes32 battleKey, + uint256 currentPlayerIndex, + uint8 playerSwitchForTurnFlag + ) internal { + PlayerDecisionData storage currentPd = playerData[battleKey][currentPlayerIndex]; + PlayerDecisionData storage otherPd = playerData[battleKey][1 - currentPlayerIndex]; + + currentPd.lastMoveTimestamp = uint96(block.timestamp); + currentPd.numMovesRevealed += 1; + + // Handle single-player turns + if (playerSwitchForTurnFlag == 0 || playerSwitchForTurnFlag == 1) { + otherPd.lastMoveTimestamp = uint96(block.timestamp); + otherPd.numMovesRevealed += 1; + } + } + + /** + * @dev Determines if auto-execute should run + */ + function _shouldAutoExecute( + uint256 currentPlayerIndex, + uint8 playerSwitchForTurnFlag, + bool playerSkipsPreimageCheck + ) internal pure returns (bool) { + return (playerSwitchForTurnFlag == currentPlayerIndex) || (!playerSkipsPreimageCheck); + } + + // View functions + + function getCommitment(bytes32 battleKey, address player) public view virtual returns (bytes32 moveHash, uint256 turnId) { + address[] memory players = ENGINE.getPlayersForBattle(battleKey); + uint256 playerIndex = (player == players[0]) ? 0 : 1; + PlayerDecisionData storage pd = playerData[battleKey][playerIndex]; + return (pd.moveHash, pd.lastCommitmentTurnId); + } + + function getMoveCountForBattleState(bytes32 battleKey, address player) public view virtual returns (uint256) { + address[] memory players = ENGINE.getPlayersForBattle(battleKey); + uint256 playerIndex = (player == players[0]) ? 0 : 1; + return playerData[battleKey][playerIndex].numMovesRevealed; + } + + function getLastMoveTimestampForPlayer(bytes32 battleKey, address player) public view virtual returns (uint256) { + address[] memory players = ENGINE.getPlayersForBattle(battleKey); + uint256 playerIndex = (player == players[0]) ? 0 : 1; + return playerData[battleKey][playerIndex].lastMoveTimestamp; + } +} diff --git a/src/Constants.sol b/src/Constants.sol index 6a887d6c..35a89771 100644 --- a/src/Constants.sol +++ b/src/Constants.sol @@ -44,6 +44,29 @@ address constant TOMBSTONE_ADDRESS = address(0xdead); uint256 constant MAX_BATTLE_DURATION = 1 hours; +// Active mon index packing (uint16): +// Singles: lower 8 bits = p0 active, upper 8 bits = p1 active (backwards compatible) +// Doubles: 4 bits per slot (supports up to 16 mons per team) +// Bits 0-3: p0 slot 0 active mon index +// Bits 4-7: p0 slot 1 active mon index +// Bits 8-11: p1 slot 0 active mon index +// Bits 12-15: p1 slot 1 active mon index +uint8 constant ACTIVE_MON_INDEX_BITS = 4; +uint8 constant ACTIVE_MON_INDEX_MASK = 0x0F; // 4 bits + +// Slot switch flags + game mode packing (uint8): +// Bit 0: p0 slot 0 needs switch +// Bit 1: p0 slot 1 needs switch +// Bit 2: p1 slot 0 needs switch +// Bit 3: p1 slot 1 needs switch +// Bit 4: game mode (0 = singles, 1 = doubles) +uint8 constant SWITCH_FLAG_P0_SLOT0 = 0x01; +uint8 constant SWITCH_FLAG_P0_SLOT1 = 0x02; +uint8 constant SWITCH_FLAG_P1_SLOT0 = 0x04; +uint8 constant SWITCH_FLAG_P1_SLOT1 = 0x08; +uint8 constant SWITCH_FLAGS_MASK = 0x0F; +uint8 constant GAME_MODE_BIT = 0x10; // Bit 4: 0 = singles, 1 = doubles + bytes32 constant MOVE_MISS_EVENT_TYPE = sha256(abi.encode("MoveMiss")); bytes32 constant MOVE_CRIT_EVENT_TYPE = sha256(abi.encode("MoveCrit")); bytes32 constant MOVE_TYPE_IMMUNITY_EVENT_TYPE = sha256(abi.encode("MoveTypeImmunity")); diff --git a/src/DefaultCommitManager.sol b/src/DefaultCommitManager.sol index 98ad6bae..c6c89181 100644 --- a/src/DefaultCommitManager.sol +++ b/src/DefaultCommitManager.sol @@ -2,248 +2,93 @@ pragma solidity ^0.8.0; import "./Constants.sol"; -import "./Enums.sol"; import "./Structs.sol"; +import {BaseCommitManager} from "./BaseCommitManager.sol"; import {ICommitManager} from "./ICommitManager.sol"; import {IEngine} from "./IEngine.sol"; +import {IValidator} from "./IValidator.sol"; -contract DefaultCommitManager is ICommitManager { - IEngine private immutable ENGINE; - - mapping(bytes32 battleKey => mapping(uint256 playerIndex => PlayerDecisionData)) private playerData; - - error NotP0OrP1(); - error AlreadyCommited(); - error AlreadyRevealed(); - error NotYetRevealed(); - error RevealBeforeOtherCommit(); - error RevealBeforeSelfCommit(); - error WrongPreimage(); - error PlayerNotAllowed(); +/** + * @title DefaultCommitManager + * @notice Commit/reveal manager for singles battles (one move per player per turn) + */ +contract DefaultCommitManager is BaseCommitManager, ICommitManager { error InvalidMove(address player); - error BattleNotYetStarted(); - error BattleAlreadyComplete(); - event MoveCommit(bytes32 indexed battleKey, address player); event MoveReveal(bytes32 indexed battleKey, address player, uint256 moveIndex); - constructor(IEngine engine) { - ENGINE = engine; + constructor(IEngine engine) BaseCommitManager(engine) {} + + // Override view functions to satisfy both base class and interface + function getCommitment(bytes32 battleKey, address player) + public view override(BaseCommitManager, ICommitManager) returns (bytes32 moveHash, uint256 turnId) + { + return BaseCommitManager.getCommitment(battleKey, player); + } + + function getMoveCountForBattleState(bytes32 battleKey, address player) + public view override(BaseCommitManager, ICommitManager) returns (uint256) + { + return BaseCommitManager.getMoveCountForBattleState(battleKey, player); + } + + function getLastMoveTimestampForPlayer(bytes32 battleKey, address player) + public view override(BaseCommitManager, ICommitManager) returns (uint256) + { + return BaseCommitManager.getLastMoveTimestampForPlayer(battleKey, player); } /** - * Committing is for: - * - p0 if the turn index % 2 == 0 - * - p1 if the turn index % 2 == 1 - * - UNLESS there is a player switch for turn flag, in which case, no commits at all + * @notice Commit a move hash for a singles battle + * @param battleKey The battle identifier + * @param moveHash Hash of (moveIndex, salt, extraData) */ function commitMove(bytes32 battleKey, bytes32 moveHash) external { - // Get all battle context in one call - CommitContext memory ctx = ENGINE.getCommitContext(battleKey); - - // Can only commit moves to battles with nonzero timestamp and no winner - if (ctx.startTimestamp == 0) { - revert BattleNotYetStarted(); - } - - address caller = msg.sender; - uint256 playerIndex = (caller == ctx.p0) ? 0 : 1; - - // Only battle participants can commit - if (caller != ctx.p0 && caller != ctx.p1) { - revert NotP0OrP1(); - } - - if (ctx.winnerIndex != 2) { - revert BattleAlreadyComplete(); - } - - // Cache storage reference for player data - PlayerDecisionData storage pd = playerData[battleKey][playerIndex]; - - // 3) Validate no commitment already exists for this turn: - uint64 turnId = ctx.turnId; - - // If it's the zeroth turn, require that no hash is set for the player - // otherwise, just check if the turn id (which we overwrite each turn) is in sync - // (if we already committed this turn, then the turn id should match) - if (turnId == 0) { - if (pd.moveHash != bytes32(0)) { - revert AlreadyCommited(); - } - } else if (pd.lastCommitmentTurnId == turnId) { - revert AlreadyCommited(); - } - - // 5) Cannot commit if the battle state says it's only for one player - if (ctx.playerSwitchForTurnFlag != 2) { - revert PlayerNotAllowed(); - } - - // 6) Can only commit if the turn index % lines up with the player index - // (Otherwise, just go straight to revealing) - if (caller == ctx.p0 && turnId % 2 == 1) { - revert PlayerNotAllowed(); - } else if (caller == ctx.p1 && turnId % 2 == 0) { - revert PlayerNotAllowed(); - } - - // 7) Store the commitment - pd.lastCommitmentTurnId = uint16(turnId); - pd.moveHash = moveHash; - pd.lastMoveTimestamp = uint96(block.timestamp); - - emit MoveCommit(battleKey, caller); + _validateCommit(battleKey, moveHash); } + /** + * @notice Reveal a move for a singles battle + * @param battleKey The battle identifier + * @param moveIndex The move index + * @param salt Salt used in the commitment hash + * @param extraData Extra data for the move + * @param autoExecute Whether to auto-execute after both players reveal + */ function revealMove(bytes32 battleKey, uint8 moveIndex, bytes32 salt, uint240 extraData, bool autoExecute) external { - // Get all battle context in one call - CommitContext memory ctx = ENGINE.getCommitContext(battleKey); - - // Can only reveal moves to battles with nonzero timestamp and no winner - if (ctx.startTimestamp == 0) { - revert BattleNotYetStarted(); - } - - // Only battle participants can reveal - if (msg.sender != ctx.p0 && msg.sender != ctx.p1) { - revert NotP0OrP1(); - } - - // Set current and other player based on the caller - uint256 currentPlayerIndex = msg.sender == ctx.p0 ? 0 : 1; - uint256 otherPlayerIndex = 1 - currentPlayerIndex; - - if (ctx.winnerIndex != 2) { - revert BattleAlreadyComplete(); - } - - // Cache storage references for both players' data - PlayerDecisionData storage currentPd = playerData[battleKey][currentPlayerIndex]; - PlayerDecisionData storage otherPd = playerData[battleKey][otherPlayerIndex]; - - // Use turn id and switch for turn flag from context - uint64 turnId = ctx.turnId; - uint8 playerSwitchForTurnFlag = ctx.playerSwitchForTurnFlag; - - // 2) If the turn index does NOT line up with the player index - // OR it's a turn with only one player, and that player is us: - // Then we don't need to check the preimage - bool playerSkipsPreimageCheck; - if (playerSwitchForTurnFlag == 2) { - playerSkipsPreimageCheck = - (((turnId % 2 == 1) && (currentPlayerIndex == 0)) || ((turnId % 2 == 0) && (currentPlayerIndex == 1))); - } else { - playerSkipsPreimageCheck = (playerSwitchForTurnFlag == currentPlayerIndex); - - // We cannot reveal if the player index is different than the switch for turn flag - // (if it's a one player turn, but it's not our turn to reveal) - if (!playerSkipsPreimageCheck) { - revert PlayerNotAllowed(); - } - } - if (playerSkipsPreimageCheck) { - // If it's a 2 player turn (and we can skip the preimage verification), - // then we check to see if an existing commitment from the other player exists - // (we can only reveal after other player commit) - if (playerSwitchForTurnFlag == 2) { - // If it's not the zeroth turn, make sure that player cannot reveal until other player has committed - if (turnId != 0) { - if (otherPd.lastCommitmentTurnId != turnId) { - revert RevealBeforeOtherCommit(); - } - } - // If it is the zeroth turn, do the same check, but check moveHash instead of turnId (which would be zero) - else { - if (otherPd.moveHash == bytes32(0)) { - revert RevealBeforeOtherCommit(); - } - } - } - // (Otherwise, it's a single player turn, so we don't need to check for an existing commitment) - } - // 3) Otherwise (we need to both commit + reveal), so we need to check: - // - the preimage checks out - // - reveal happens after a commit - // - the other player has already revealed - else { - // - validate preimage - if (keccak256(abi.encodePacked(moveIndex, salt, extraData)) != currentPd.moveHash) { - revert WrongPreimage(); - } - - // - ensure reveal happens after caller commits - if (currentPd.lastCommitmentTurnId != turnId) { - revert RevealBeforeSelfCommit(); - } - - // - check that other player has already revealed (i.e. a nonzero last move timestamp) - if (otherPd.numMovesRevealed < turnId || otherPd.lastMoveTimestamp == 0) { - revert NotYetRevealed(); - } - } - - // 4) Regardless, we still need to check there was no prior reveal (prevents double revealing) - if (currentPd.numMovesRevealed > turnId) { - revert AlreadyRevealed(); - } - - // 5) Validate that the commited moves are legal - // (e.g. there is enough stamina, move is not disabled, etc.) - // Use validator from context instead of calling getBattleValidator + // Validate preconditions + ( + CommitContext memory ctx, + uint256 currentPlayerIndex, + , + PlayerDecisionData storage currentPd, + PlayerDecisionData storage otherPd, + bool playerSkipsPreimageCheck + ) = _validateRevealPreconditions(battleKey); + + // Validate timing and preimage + bytes32 expectedHash = keccak256(abi.encodePacked(moveIndex, salt, extraData)); + _validateRevealTiming(ctx, currentPd, otherPd, playerSkipsPreimageCheck, expectedHash); + + // Validate move is legal if (!IValidator(ctx.validator).validatePlayerMove(battleKey, moveIndex, currentPlayerIndex, extraData)) { revert InvalidMove(msg.sender); } - // 6) Store revealed move and extra data for the current player - // Update their last move timestamp and num moves revealed + // Store revealed move ENGINE.setMove(battleKey, currentPlayerIndex, moveIndex, salt, extraData); - currentPd.lastMoveTimestamp = uint96(block.timestamp); - currentPd.numMovesRevealed += 1; - - // 7) Store empty move for other player if it's a turn where only a single player has to make a move - if (playerSwitchForTurnFlag == 0 || playerSwitchForTurnFlag == 1) { - // TODO: add this later to mutate the engine directly - otherPd.lastMoveTimestamp = uint96(block.timestamp); - otherPd.numMovesRevealed += 1; - } - // 8) Emit move reveal event before game engine execution + // Update player data + _updateAfterReveal(battleKey, currentPlayerIndex, ctx.playerSwitchForTurnFlag); + emit MoveReveal(battleKey, msg.sender, moveIndex); - // 9) Auto execute if desired/available - if (autoExecute) { - // We can execute if: - // - it's a single player turn (no other commitments to wait on) - // - we're the player who previously committed (the other party already revealed) - if ((playerSwitchForTurnFlag == currentPlayerIndex) || (!playerSkipsPreimageCheck)) { - ENGINE.execute(battleKey); - } + // Auto execute if desired + if (autoExecute && _shouldAutoExecute(currentPlayerIndex, ctx.playerSwitchForTurnFlag, playerSkipsPreimageCheck)) { + ENGINE.execute(battleKey); } } - - function getCommitment(bytes32 battleKey, address player) external view returns (bytes32 moveHash, uint256 turnId) { - // Use lighter-weight getPlayersForBattle instead of getBattleContext (fewer SLOADs) - address[] memory players = ENGINE.getPlayersForBattle(battleKey); - uint256 playerIndex = (player == players[0]) ? 0 : 1; - PlayerDecisionData storage pd = playerData[battleKey][playerIndex]; - return (pd.moveHash, pd.lastCommitmentTurnId); - } - - function getMoveCountForBattleState(bytes32 battleKey, address player) external view returns (uint256) { - // Use lighter-weight getPlayersForBattle instead of getBattleContext (fewer SLOADs) - address[] memory players = ENGINE.getPlayersForBattle(battleKey); - uint256 playerIndex = (player == players[0]) ? 0 : 1; - return playerData[battleKey][playerIndex].numMovesRevealed; - } - - function getLastMoveTimestampForPlayer(bytes32 battleKey, address player) external view returns (uint256) { - // Use lighter-weight getPlayersForBattle instead of getBattleContext (fewer SLOADs) - address[] memory players = ENGINE.getPlayersForBattle(battleKey); - uint256 playerIndex = (player == players[0]) ? 0 : 1; - return playerData[battleKey][playerIndex].lastMoveTimestamp; - } } diff --git a/src/DefaultValidator.sol b/src/DefaultValidator.sol index a52b398b..7be20d88 100644 --- a/src/DefaultValidator.sol +++ b/src/DefaultValidator.sol @@ -78,13 +78,14 @@ contract DefaultValidator is IValidator { } // A switch is valid if the new mon isn't knocked out and the index is valid (not out of range or the same one) + // For doubles, also checks that the mon isn't already active in either slot function validateSwitch(bytes32 battleKey, uint256 playerIndex, uint256 monToSwitchIndex) public view returns (bool) { BattleContext memory ctx = ENGINE.getBattleContext(battleKey); - uint256 activeMonIndex = (playerIndex == 0) ? ctx.p0ActiveMonIndex : ctx.p1ActiveMonIndex; + uint256 activeMonIndex = (playerIndex == 0) ? ctx.p0ActiveMonIndex0 : ctx.p1ActiveMonIndex0; if (monToSwitchIndex >= MONS_PER_TEAM) { return false; @@ -100,6 +101,13 @@ contract DefaultValidator is IValidator { if (monToSwitchIndex == activeMonIndex) { return false; } + // For doubles, also check the second slot + if (ctx.gameMode == GameMode.Doubles) { + uint256 activeMonIndex2 = (playerIndex == 0) ? ctx.p0ActiveMonIndex1 : ctx.p1ActiveMonIndex1; + if (monToSwitchIndex == activeMonIndex2) { + return false; + } + } } return true; } @@ -108,10 +116,12 @@ contract DefaultValidator is IValidator { bytes32 battleKey, uint256 moveIndex, uint256 playerIndex, + uint256 slotIndex, uint240 extraData ) public view returns (bool) { BattleContext memory ctx = ENGINE.getBattleContext(battleKey); - uint256 activeMonIndex = (playerIndex == 0) ? ctx.p0ActiveMonIndex : ctx.p1ActiveMonIndex; + // Use slot-aware active mon index lookup for doubles support + uint256 activeMonIndex = _getActiveMonIndexFromContext(ctx, playerIndex, slotIndex); // A move cannot be selected if its stamina costs more than the mon's current stamina IMoveSet moveSet = ENGINE.getMoveForMonForBattle(battleKey, playerIndex, activeMonIndex, moveIndex); @@ -138,7 +148,7 @@ contract DefaultValidator is IValidator { returns (bool) { BattleContext memory ctx = ENGINE.getBattleContext(battleKey); - uint256 activeMonIndex = (playerIndex == 0) ? ctx.p0ActiveMonIndex : ctx.p1ActiveMonIndex; + uint256 activeMonIndex = (playerIndex == 0) ? ctx.p0ActiveMonIndex0 : ctx.p1ActiveMonIndex0; // Enforce a switch IF: // - if it is the zeroth turn @@ -189,7 +199,7 @@ contract DefaultValidator is IValidator { uint256 monToSwitchIndex, BattleContext memory ctx ) internal view returns (bool) { - uint256 activeMonIndex = (playerIndex == 0) ? ctx.p0ActiveMonIndex : ctx.p1ActiveMonIndex; + uint256 activeMonIndex = (playerIndex == 0) ? ctx.p0ActiveMonIndex0 : ctx.p1ActiveMonIndex0; if (monToSwitchIndex >= MONS_PER_TEAM) { return false; @@ -235,6 +245,186 @@ contract DefaultValidator is IValidator { return true; } + /** + * @notice Validates a move for a specific slot in doubles mode + * @dev Enforces: + * - If slot's mon is KO'd, must switch (unless no valid targets → NO_OP allowed) + * - Switch target can't be KO'd or already active in another slot + * - Standard move validation for non-switch moves + */ + function validatePlayerMoveForSlot( + bytes32 battleKey, + uint256 moveIndex, + uint256 playerIndex, + uint256 slotIndex, + uint240 extraData + ) external view returns (bool) { + return _validatePlayerMoveForSlotImpl(battleKey, moveIndex, playerIndex, slotIndex, extraData, type(uint256).max); + } + + /** + * @dev Internal implementation for slot move validation + * @param claimedByOtherSlot Mon index claimed by other slot's switch (use type(uint256).max if none) + */ + function _validatePlayerMoveForSlotImpl( + bytes32 battleKey, + uint256 moveIndex, + uint256 playerIndex, + uint256 slotIndex, + uint240 extraData, + uint256 claimedByOtherSlot + ) internal view returns (bool) { + BattleContext memory ctx = ENGINE.getBattleContext(battleKey); + + // Extract active mon indices from context (avoids extra ENGINE calls) + uint256 activeMonIndex = _getActiveMonIndexFromContext(ctx, playerIndex, slotIndex); + uint256 otherSlotActiveMonIndex = _getActiveMonIndexFromContext(ctx, playerIndex, 1 - slotIndex); + + // Check if this slot's mon is KO'd + bool isActiveMonKnockedOut = ENGINE.getMonStateForBattle( + battleKey, playerIndex, activeMonIndex, MonStateIndexName.IsKnockedOut + ) == 1; + + // Turn 0: must switch to set initial mon + // KO'd mon: must switch (unless no valid targets) + if (ctx.turnId == 0 || isActiveMonKnockedOut) { + if (moveIndex != SWITCH_MOVE_INDEX) { + // Check if NO_OP is allowed (no valid switch targets) + if (moveIndex == NO_OP_MOVE_INDEX && !_hasValidSwitchTargetForSlot(battleKey, playerIndex, otherSlotActiveMonIndex, claimedByOtherSlot)) { + return true; + } + return false; + } + } + + // Validate move index range + if (moveIndex != NO_OP_MOVE_INDEX && moveIndex != SWITCH_MOVE_INDEX) { + if (moveIndex >= MOVES_PER_MON) { + return false; + } + } + // NO_OP is always valid (if we got past the KO check) + else if (moveIndex == NO_OP_MOVE_INDEX) { + return true; + } + // Switch validation + else if (moveIndex == SWITCH_MOVE_INDEX) { + uint256 monToSwitchIndex = uint256(extraData); + return _validateSwitchForSlot(battleKey, playerIndex, monToSwitchIndex, activeMonIndex, otherSlotActiveMonIndex, claimedByOtherSlot, ctx); + } + + // Validate specific move selection + return _validateSpecificMoveSelectionInternal(battleKey, moveIndex, playerIndex, extraData, activeMonIndex); + } + + /** + * @dev Extracts active mon index from BattleContext for a given player/slot + */ + function _getActiveMonIndexFromContext(BattleContext memory ctx, uint256 playerIndex, uint256 slotIndex) + internal + pure + returns (uint256) + { + if (playerIndex == 0) { + return slotIndex == 0 ? ctx.p0ActiveMonIndex0 : ctx.p0ActiveMonIndex1; + } else { + return slotIndex == 0 ? ctx.p1ActiveMonIndex0 : ctx.p1ActiveMonIndex1; + } + } + + /** + * @dev Checks if there's any valid switch target for a slot + * @param otherSlotActiveMonIndex The mon index active in the other slot (excluded from valid targets) + * @param claimedByOtherSlot Optional: mon index the other slot is switching to (use type(uint256).max if none) + */ + function _hasValidSwitchTargetForSlot( + bytes32 battleKey, + uint256 playerIndex, + uint256 otherSlotActiveMonIndex, + uint256 claimedByOtherSlot + ) internal view returns (bool) { + for (uint256 i = 0; i < MONS_PER_TEAM; i++) { + // Skip if it's the other slot's active mon + if (i == otherSlotActiveMonIndex) { + continue; + } + // Skip if it's being claimed by the other slot + if (i == claimedByOtherSlot) { + continue; + } + // Check if mon is not KO'd + bool isKnockedOut = ENGINE.getMonStateForBattle( + battleKey, playerIndex, i, MonStateIndexName.IsKnockedOut + ) == 1; + if (!isKnockedOut) { + return true; + } + } + return false; + } + + /** + * @dev Validates switch for a specific slot in doubles (can't switch to other slot's active mon) + * @param claimedByOtherSlot Mon index claimed by other slot's switch (use type(uint256).max if none) + */ + function _validateSwitchForSlot( + bytes32 battleKey, + uint256 playerIndex, + uint256 monToSwitchIndex, + uint256 currentSlotActiveMonIndex, + uint256 otherSlotActiveMonIndex, + uint256 claimedByOtherSlot, + BattleContext memory ctx + ) internal view returns (bool) { + if (monToSwitchIndex >= MONS_PER_TEAM) { + return false; + } + + // Can't switch to a KO'd mon + bool isNewMonKnockedOut = ENGINE.getMonStateForBattle( + battleKey, playerIndex, monToSwitchIndex, MonStateIndexName.IsKnockedOut + ) == 1; + if (isNewMonKnockedOut) { + return false; + } + + // Can't switch to mon already active in the other slot + if (monToSwitchIndex == otherSlotActiveMonIndex) { + return false; + } + + // Can't switch to mon being claimed by the other slot + if (monToSwitchIndex == claimedByOtherSlot) { + return false; + } + + // Can't switch to same mon (except turn 0) + if (ctx.turnId != 0) { + if (monToSwitchIndex == currentSlotActiveMonIndex) { + return false; + } + } + + return true; + } + + /** + * @notice Validates a move for a specific slot, accounting for what the other slot is switching to + * @dev Use this when slot 0 is switching and you need to validate slot 1's move while + * accounting for the mon that slot 0 is claiming + * @param claimedByOtherSlot The mon index that the other slot is switching to (type(uint256).max if not applicable) + */ + function validatePlayerMoveForSlotWithClaimed( + bytes32 battleKey, + uint256 moveIndex, + uint256 playerIndex, + uint256 slotIndex, + uint240 extraData, + uint256 claimedByOtherSlot + ) external view returns (bool) { + return _validatePlayerMoveForSlotImpl(battleKey, moveIndex, playerIndex, slotIndex, extraData, claimedByOtherSlot); + } + /* Check switch for turn flag: diff --git a/src/DoublesCommitManager.sol b/src/DoublesCommitManager.sol new file mode 100644 index 00000000..5fd2dc36 --- /dev/null +++ b/src/DoublesCommitManager.sol @@ -0,0 +1,160 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity ^0.8.0; + +import "./Constants.sol"; +import "./Enums.sol"; +import "./Structs.sol"; + +import {BaseCommitManager} from "./BaseCommitManager.sol"; +import {ICommitManager} from "./ICommitManager.sol"; +import {IEngine} from "./IEngine.sol"; +import {IValidator} from "./IValidator.sol"; + +/** + * @title DoublesCommitManager + * @notice Commit/reveal manager for double battles where each player commits 2 moves per turn + * @dev Follows same alternating commit scheme as DefaultCommitManager: + * - p0 commits on even turns, p1 commits on odd turns + * - Non-committing player reveals first, then committing player reveals + * - Each commit/reveal handles both slot 0 and slot 1 moves together + */ +contract DoublesCommitManager is BaseCommitManager, ICommitManager { + error InvalidMove(address player, uint256 slotIndex); + error BothSlotsSwitchToSameMon(); + error NotDoublesMode(); + + event MoveReveal(bytes32 indexed battleKey, address player, uint256 moveIndex0, uint256 moveIndex1); + + constructor(IEngine engine) BaseCommitManager(engine) {} + + // Override view functions to satisfy both base class and interface + function getCommitment(bytes32 battleKey, address player) + public view override(BaseCommitManager, ICommitManager) returns (bytes32 moveHash, uint256 turnId) + { + return BaseCommitManager.getCommitment(battleKey, player); + } + + function getMoveCountForBattleState(bytes32 battleKey, address player) + public view override(BaseCommitManager, ICommitManager) returns (uint256) + { + return BaseCommitManager.getMoveCountForBattleState(battleKey, player); + } + + function getLastMoveTimestampForPlayer(bytes32 battleKey, address player) + public view override(BaseCommitManager, ICommitManager) returns (uint256) + { + return BaseCommitManager.getLastMoveTimestampForPlayer(battleKey, player); + } + + /** + * @notice Commit a hash of both moves for a doubles battle + * @param battleKey The battle identifier + * @param moveHash Hash of (moveIndex0, extraData0, moveIndex1, extraData1, salt) + */ + function commitMove(bytes32 battleKey, bytes32 moveHash) external { + (CommitContext memory ctx,,) = _validateCommit(battleKey, moveHash); + + // Doubles-specific validation + if (ctx.gameMode != GameMode.Doubles) { + revert NotDoublesMode(); + } + } + + /** + * @notice Commit moves - alias for commitMove to match expected pattern + */ + function commitMoves(bytes32 battleKey, bytes32 moveHash) external { + (CommitContext memory ctx,,) = _validateCommit(battleKey, moveHash); + + if (ctx.gameMode != GameMode.Doubles) { + revert NotDoublesMode(); + } + } + + /** + * @notice Reveal both moves for a doubles battle + * @param battleKey The battle identifier + * @param moveIndex0 Move index for slot 0 mon + * @param extraData0 Extra data for slot 0 move (includes target) + * @param moveIndex1 Move index for slot 1 mon + * @param extraData1 Extra data for slot 1 move (includes target) + * @param salt Salt used in the commitment hash + * @param autoExecute Whether to auto-execute after both players reveal + */ + function revealMoves( + bytes32 battleKey, + uint8 moveIndex0, + uint240 extraData0, + uint8 moveIndex1, + uint240 extraData1, + bytes32 salt, + bool autoExecute + ) external { + // Validate preconditions + ( + CommitContext memory ctx, + uint256 currentPlayerIndex, + , + PlayerDecisionData storage currentPd, + PlayerDecisionData storage otherPd, + bool playerSkipsPreimageCheck + ) = _validateRevealPreconditions(battleKey); + + // Doubles-specific validation + if (ctx.gameMode != GameMode.Doubles) { + revert NotDoublesMode(); + } + + // Validate timing and preimage (hash covers both moves) + bytes32 expectedHash = keccak256(abi.encodePacked(moveIndex0, extraData0, moveIndex1, extraData1, salt)); + _validateRevealTiming(ctx, currentPd, otherPd, playerSkipsPreimageCheck, expectedHash); + + // Validate both moves are legal for their respective slots + IValidator validator = IValidator(ctx.validator); + if (!validator.validatePlayerMoveForSlot(battleKey, moveIndex0, currentPlayerIndex, 0, extraData0)) { + revert InvalidMove(msg.sender, 0); + } + // For slot 1, if slot 0 is switching, we need to account for the mon being claimed + // This allows slot 1 to NO_OP if slot 0 is taking the last available reserve + if (moveIndex0 == SWITCH_MOVE_INDEX) { + if (!validator.validatePlayerMoveForSlotWithClaimed( + battleKey, moveIndex1, currentPlayerIndex, 1, extraData1, uint256(extraData0) + )) { + revert InvalidMove(msg.sender, 1); + } + } else { + if (!validator.validatePlayerMoveForSlot(battleKey, moveIndex1, currentPlayerIndex, 1, extraData1)) { + revert InvalidMove(msg.sender, 1); + } + } + + // Prevent both slots from switching to the same mon + if (moveIndex0 == SWITCH_MOVE_INDEX && moveIndex1 == SWITCH_MOVE_INDEX) { + if (extraData0 == extraData1) { + revert BothSlotsSwitchToSameMon(); + } + } + + // Store both revealed moves using slot-aware setters + ENGINE.setMoveForSlot(battleKey, currentPlayerIndex, 0, moveIndex0, salt, extraData0); + ENGINE.setMoveForSlot(battleKey, currentPlayerIndex, 1, moveIndex1, salt, extraData1); + + // Update player data + _updateAfterReveal(battleKey, currentPlayerIndex, ctx.playerSwitchForTurnFlag); + + emit MoveReveal(battleKey, msg.sender, moveIndex0, moveIndex1); + + // Auto execute if desired + if (autoExecute && _shouldAutoExecute(currentPlayerIndex, ctx.playerSwitchForTurnFlag, playerSkipsPreimageCheck)) { + ENGINE.execute(battleKey); + } + } + + /** + * @notice Reveal a single move - required by ICommitManager but not used for doubles + * @dev Reverts as doubles requires revealMoves with both slot moves + */ + function revealMove(bytes32, uint8, bytes32, uint240, bool) external pure { + revert NotDoublesMode(); + } +} diff --git a/src/Engine.sol b/src/Engine.sol index 2ebdd689..9c9d38cb 100644 --- a/src/Engine.sol +++ b/src/Engine.sol @@ -177,14 +177,26 @@ contract Engine is IEngine, MappingAllocator { config.koBitmaps = 0; // Store the battle data with initial state + // activeMonIndex always uses 4-bit-per-slot packing (unified for singles and doubles): + // Bits 0-3: p0 slot 0, Bits 4-7: p0 slot 1, Bits 8-11: p1 slot 0, Bits 12-15: p1 slot 1 + // For doubles: all 4 slots are active (p0s0=0, p0s1=1, p1s0=0, p1s1=1) + // For singles: only slot 0 is used for each player, slot 1 stays 0 + uint16 initialActiveMonIndex = battle.gameMode == GameMode.Doubles + ? uint16(0) | (uint16(1) << 4) | (uint16(0) << 8) | (uint16(1) << 12) // p0s0=0, p0s1=1, p1s0=0, p1s1=1 + : uint16(0); // Singles: p0s0=0, p1s0=0 (slot 1 unused) + + // Pack game mode into slotSwitchFlagsAndGameMode (bit 4 = game mode) + uint8 slotSwitchFlagsAndGameMode = battle.gameMode == GameMode.Doubles ? GAME_MODE_BIT : 0; + battleData[battleKey] = BattleData({ p0: battle.p0, p1: battle.p1, winnerIndex: 2, // Initialize to 2 (uninitialized/no winner) prevPlayerSwitchForTurnFlag: 0, playerSwitchForTurnFlag: 2, // Set flag to be 2 which means both players act - activeMonIndex: 0, // Defaults to 0 (both players start with mon index 0) - turnId: 0 + activeMonIndex: initialActiveMonIndex, + turnId: 0, + slotSwitchFlagsAndGameMode: slotSwitchFlagsAndGameMode }); // Set the team for p0 and p1 in the reusable config storage @@ -292,6 +304,12 @@ contract Engine is IEngine, MappingAllocator { config.engineHooks[i].onRoundStart(battleKey); } + // Branch for doubles mode + if (_isDoublesMode(battle)) { + _executeDoubles(battleKey, config, battle, turnId, numHooks); + return; + } + // If only a single player has a move to submit, then we don't trigger any effects // (Basically this only handles switching mons for now) if (battle.playerSwitchForTurnFlag == 0 || battle.playerSwitchForTurnFlag == 1) { @@ -403,12 +421,12 @@ contract Engine is IEngine, MappingAllocator { // For turn 0 only: wait for both mons to be sent in, then handle the ability activateOnSwitch // Happens immediately after both mons are sent in, before any other effects if (turnId == 0) { - uint256 priorityMonIndex = _unpackActiveMonIndex(battle.activeMonIndex, priorityPlayerIndex); + uint256 priorityMonIndex = _unpackActiveMonIndexForSlot(battle.activeMonIndex, priorityPlayerIndex, 0); Mon memory priorityMon = _getTeamMon(config, priorityPlayerIndex, priorityMonIndex); if (address(priorityMon.ability) != address(0)) { priorityMon.ability.activateOnSwitch(battleKey, priorityPlayerIndex, priorityMonIndex); } - uint256 otherMonIndex = _unpackActiveMonIndex(battle.activeMonIndex, otherPlayerIndex); + uint256 otherMonIndex = _unpackActiveMonIndexForSlot(battle.activeMonIndex, otherPlayerIndex, 0); Mon memory otherMon = _getTeamMon(config, otherPlayerIndex, otherMonIndex); if (address(otherMon.ability) != address(0)) { otherMon.ability.activateOnSwitch(battleKey, otherPlayerIndex, otherMonIndex); @@ -594,13 +612,15 @@ contract Engine is IEngine, MappingAllocator { ); // Trigger OnUpdateMonState lifecycle hook + // Pass explicit monIndex so effects run on the correct mon (not just slot 0) _runEffects( battleKey, tempRNG, playerIndex, playerIndex, EffectStep.OnUpdateMonState, - abi.encode(playerIndex, monIndex, stateVarIndex, valueToAdd) + abi.encode(playerIndex, monIndex, stateVarIndex, valueToAdd), + monIndex ); } @@ -792,7 +812,8 @@ contract Engine is IEngine, MappingAllocator { _setMonKO(config, playerIndex, monIndex); } emit DamageDeal(battleKey, playerIndex, monIndex, damage, _getUpstreamCallerAndResetValue(), currentStep); - _runEffects(battleKey, tempRNG, playerIndex, playerIndex, EffectStep.AfterDamage, abi.encode(damage)); + // Pass explicit monIndex so effects run on the correct mon (not just slot 0) + _runEffects(battleKey, tempRNG, playerIndex, playerIndex, EffectStep.AfterDamage, abi.encode(damage), monIndex); } function switchActiveMon(uint256 playerIndex, uint256 monToSwitchIndex) external { @@ -808,7 +829,7 @@ contract Engine is IEngine, MappingAllocator { if (config.validator.validateSwitch(battleKey, playerIndex, monToSwitchIndex)) { // Only call the internal switch function if the switch is valid - _handleSwitch(battleKey, playerIndex, monToSwitchIndex, msg.sender); + _handleSwitchForSlot(battleKey, playerIndex, 0, monToSwitchIndex, msg.sender); // Check for game over and/or KOs (uint256 playerSwitchForTurnFlag, bool isGameOver) = _checkForGameOverOrKO(config, battle, playerIndex); @@ -823,6 +844,47 @@ contract Engine is IEngine, MappingAllocator { // If the switch is invalid, we simply do nothing and continue execution } + /// @notice Force switch a mon in a specific slot (for doubles mode) + /// @dev Used by moves that force switches (e.g., Roar, Whirlwind) in doubles battles + /// @param playerIndex The player whose mon will be switched (0 or 1) + /// @param slotIndex The slot to switch (0 or 1) + /// @param monToSwitchIndex The index of the mon to switch to + function switchActiveMonForSlot(uint256 playerIndex, uint256 slotIndex, uint256 monToSwitchIndex) external { + bytes32 battleKey = battleKeyForWrite; + if (battleKey == bytes32(0)) { + revert NoWriteAllowed(); + } + + BattleConfig storage config = battleConfig[storageKeyForWrite]; + BattleData storage battle = battleData[battleKey]; + + // Use the validator to check if the switch is valid + if (config.validator.validateSwitch(battleKey, playerIndex, monToSwitchIndex)) + { + // Use the slot-aware switch handler for doubles + _handleSwitchForSlot(battleKey, playerIndex, slotIndex, monToSwitchIndex, msg.sender); + + // Check for game over using doubles logic + bool isGameOver = _checkForGameOverOrKO_Doubles(config, battle); + if (isGameOver) return; + + // Determine player switch flag based on slot switch flags + uint8 slotFlags = battle.slotSwitchFlagsAndGameMode & SWITCH_FLAGS_MASK; + bool p0NeedsSwitch = (slotFlags & 0x03) != 0; // bits 0-1 for P0 + bool p1NeedsSwitch = (slotFlags & 0x0C) != 0; // bits 2-3 for P1 + if (p0NeedsSwitch && p1NeedsSwitch) { + battle.playerSwitchForTurnFlag = 2; + } else if (p0NeedsSwitch) { + battle.playerSwitchForTurnFlag = 0; + } else if (p1NeedsSwitch) { + battle.playerSwitchForTurnFlag = 1; + } else { + battle.playerSwitchForTurnFlag = 2; + } + } + // If the switch is invalid, we simply do nothing and continue execution + } + function setMove(bytes32 battleKey, uint256 playerIndex, uint8 moveIndex, bytes32 salt, uint240 extraData) external { @@ -845,12 +907,70 @@ contract Engine is IEngine, MappingAllocator { MoveDecision memory newMove = MoveDecision({packedMoveIndex: packedMoveIndex, extraData: extraData}); + // playerIndex 0-1: slot 0 moves, playerIndex 2-3: slot 1 moves (for doubles) if (playerIndex == 0) { config.p0Move = newMove; config.p0Salt = salt; - } else { + } else if (playerIndex == 1) { config.p1Move = newMove; config.p1Salt = salt; + } else if (playerIndex == 2) { + // p0 slot 1 move (doubles) + config.p0Move2 = newMove; + } else if (playerIndex == 3) { + // p1 slot 1 move (doubles) + config.p1Move2 = newMove; + } + } + + /** + * @notice Set a move for a specific slot in doubles battles + * @param battleKey The battle identifier + * @param playerIndex 0 or 1 + * @param slotIndex 0 or 1 + * @param moveIndex The move index + * @param salt Salt for RNG + * @param extraData Extra data for the move (e.g., target) + */ + function setMoveForSlot( + bytes32 battleKey, + uint256 playerIndex, + uint256 slotIndex, + uint8 moveIndex, + bytes32 salt, + uint240 extraData + ) external { + // Use cached key if called during execute(), otherwise lookup + bool isForCurrentBattle = battleKeyForWrite == battleKey; + bytes32 storageKey = isForCurrentBattle ? storageKeyForWrite : _getStorageKey(battleKey); + + BattleConfig storage config = battleConfig[storageKey]; + + bool isMoveManager = msg.sender == address(config.moveManager); + if (!isMoveManager && !isForCurrentBattle) { + revert NoWriteAllowed(); + } + + // Pack moveIndex with isRealTurn bit and apply +1 offset for regular moves + uint8 storedMoveIndex = moveIndex < SWITCH_MOVE_INDEX ? moveIndex + MOVE_INDEX_OFFSET : moveIndex; + uint8 packedMoveIndex = storedMoveIndex | IS_REAL_TURN_BIT; + + MoveDecision memory newMove = MoveDecision({packedMoveIndex: packedMoveIndex, extraData: extraData}); + + if (playerIndex == 0) { + if (slotIndex == 0) { + config.p0Move = newMove; + config.p0Salt = salt; + } else { + config.p0Move2 = newMove; + } + } else { + if (slotIndex == 0) { + config.p1Move = newMove; + config.p1Salt = salt; + } else { + config.p1Move2 = newMove; + } } } @@ -872,99 +992,114 @@ contract Engine is IEngine, MappingAllocator { battleKey = keccak256(abi.encode(pairHash, pairHashNonce)); } - function _checkForGameOverOrKO( - BattleConfig storage config, - BattleData storage battle, - uint256 priorityPlayerIndex - ) internal returns (uint256 playerSwitchForTurnFlag, bool isGameOver) { - uint256 otherPlayerIndex = (priorityPlayerIndex + 1) % 2; - uint8 existingWinnerIndex = battle.winnerIndex; - + // Shared game over check - returns winner index (0, 1, or 2 if no winner) + function _checkForGameOver(BattleConfig storage config, BattleData storage battle) + internal + view + returns (uint256 winnerIndex, uint256 p0KOBitmap, uint256 p1KOBitmap) + { // First check if we already calculated a winner - if (existingWinnerIndex != 2) { - return (playerSwitchForTurnFlag, true); + if (battle.winnerIndex != 2) { + return (battle.winnerIndex, 0, 0); } - // Check for game over using KO bitmaps (O(1) instead of O(n) loop) - // A game is over if all of a player's mons are KOed (all bits set up to teamSize) - uint256 newWinnerIndex = 2; + // Load KO bitmaps and team sizes uint256 p0TeamSize = config.teamSizes & 0x0F; uint256 p1TeamSize = config.teamSizes >> 4; - uint256 p0KOBitmap = _getKOBitmap(config, 0); - uint256 p1KOBitmap = _getKOBitmap(config, 1); + p0KOBitmap = _getKOBitmap(config, 0); + p1KOBitmap = _getKOBitmap(config, 1); + // Full team mask: (1 << teamSize) - 1, e.g. teamSize=3 -> 0b111 uint256 p0FullMask = (1 << p0TeamSize) - 1; uint256 p1FullMask = (1 << p1TeamSize) - 1; + // Check if all mons are KO'd for either player if (p0KOBitmap == p0FullMask) { - newWinnerIndex = 1; // p1 wins + winnerIndex = 1; // p1 wins } else if (p1KOBitmap == p1FullMask) { - newWinnerIndex = 0; // p0 wins + winnerIndex = 0; // p0 wins + } else { + winnerIndex = 2; // No winner yet } - // If we found a winner, set it on the battle data and return - if (newWinnerIndex != 2) { - battle.winnerIndex = uint8(newWinnerIndex); + } + + function _checkForGameOverOrKO( + BattleConfig storage config, + BattleData storage battle, + uint256 priorityPlayerIndex + ) internal returns (uint256 playerSwitchForTurnFlag, bool isGameOver) { + uint256 otherPlayerIndex = (priorityPlayerIndex + 1) % 2; + + // Use shared game over check + (uint256 winnerIndex, uint256 p0KOBitmap, uint256 p1KOBitmap) = _checkForGameOver(config, battle); + + if (winnerIndex != 2) { + battle.winnerIndex = uint8(winnerIndex); return (playerSwitchForTurnFlag, true); } - // Otherwise if it isn't a game over, we check for KOs and set the player switch for turn flag - else { - // Always set default switch to be 2 (allow both players to make a move) - playerSwitchForTurnFlag = 2; - - // Use already-loaded KO bitmaps to check active mon KO status (avoids SLOAD) - uint256 priorityActiveMonIndex = _unpackActiveMonIndex(battle.activeMonIndex, priorityPlayerIndex); - uint256 otherActiveMonIndex = _unpackActiveMonIndex(battle.activeMonIndex, otherPlayerIndex); - uint256 priorityKOBitmap = priorityPlayerIndex == 0 ? p0KOBitmap : p1KOBitmap; - uint256 otherKOBitmap = priorityPlayerIndex == 0 ? p1KOBitmap : p0KOBitmap; - bool isPriorityPlayerActiveMonKnockedOut = (priorityKOBitmap & (1 << priorityActiveMonIndex)) != 0; - bool isNonPriorityPlayerActiveMonKnockedOut = (otherKOBitmap & (1 << otherActiveMonIndex)) != 0; - - // If the priority player mon is KO'ed (and the other player isn't), then next turn we tenatively set it to be just the other player - if (isPriorityPlayerActiveMonKnockedOut && !isNonPriorityPlayerActiveMonKnockedOut) { - playerSwitchForTurnFlag = priorityPlayerIndex; - } - // If the non priority player mon is KO'ed (and the other player isn't), then next turn we tenatively set it to be just the priority player - if (!isPriorityPlayerActiveMonKnockedOut && isNonPriorityPlayerActiveMonKnockedOut) { - playerSwitchForTurnFlag = otherPlayerIndex; - } + // No game over - check for KOs and set player switch for turn flag + playerSwitchForTurnFlag = 2; + + // Use already-loaded KO bitmaps to check active mon KO status (slot 0 for singles) + uint256 priorityActiveMonIndex = _unpackActiveMonIndexForSlot(battle.activeMonIndex, priorityPlayerIndex, 0); + uint256 otherActiveMonIndex = _unpackActiveMonIndexForSlot(battle.activeMonIndex, otherPlayerIndex, 0); + uint256 priorityKOBitmap = priorityPlayerIndex == 0 ? p0KOBitmap : p1KOBitmap; + uint256 otherKOBitmap = priorityPlayerIndex == 0 ? p1KOBitmap : p0KOBitmap; + bool isPriorityPlayerActiveMonKnockedOut = (priorityKOBitmap & (1 << priorityActiveMonIndex)) != 0; + bool isNonPriorityPlayerActiveMonKnockedOut = (otherKOBitmap & (1 << otherActiveMonIndex)) != 0; + + // If the priority player mon is KO'ed (and the other player isn't), next turn only other player acts + if (isPriorityPlayerActiveMonKnockedOut && !isNonPriorityPlayerActiveMonKnockedOut) { + playerSwitchForTurnFlag = priorityPlayerIndex; } - } - function _handleSwitch(bytes32 battleKey, uint256 playerIndex, uint256 monToSwitchIndex, address source) internal { - // NOTE: We will check for game over after the switch in the engine for two player turns, so we don't do it here - // But this also means that the current flow of OnMonSwitchOut effects -> OnMonSwitchIn effects -> ability activateOnSwitch - // will all resolve before checking for KOs or winners - // (could break this up even more, but that's for a later version / PR) + // If the non priority player mon is KO'ed (and the other player isn't), next turn only priority player acts + if (!isPriorityPlayerActiveMonKnockedOut && isNonPriorityPlayerActiveMonKnockedOut) { + playerSwitchForTurnFlag = otherPlayerIndex; + } + } + // Core switch logic shared between singles and doubles + function _handleSwitchCore( + bytes32 battleKey, + uint256 playerIndex, + uint256 currentActiveMonIndex, + uint256 monToSwitchIndex, + address source + ) internal { BattleData storage battle = battleData[battleKey]; BattleConfig storage config = battleConfig[storageKeyForWrite]; - uint256 currentActiveMonIndex = _unpackActiveMonIndex(battle.activeMonIndex, playerIndex); MonState storage currentMonState = _getMonState(config, playerIndex, currentActiveMonIndex); // Emit event first, then run effects emit MonSwitch(battleKey, playerIndex, monToSwitchIndex, source); - // If the current mon is not KO'ed - // Go through each effect to see if it should be cleared after a switch, - // If so, remove the effect and the extra data + // If the current mon is not KO'ed, run switch-out effects + // Pass explicit monIndex so effects run on the correct mon (not just slot 0) if (!currentMonState.isKnockedOut) { - _runEffects(battleKey, tempRNG, playerIndex, playerIndex, EffectStep.OnMonSwitchOut, ""); - - // Then run the global on mon switch out hook as well - _runEffects(battleKey, tempRNG, 2, playerIndex, EffectStep.OnMonSwitchOut, ""); + _runEffects(battleKey, tempRNG, playerIndex, playerIndex, EffectStep.OnMonSwitchOut, "", currentActiveMonIndex); + _runEffects(battleKey, tempRNG, 2, playerIndex, EffectStep.OnMonSwitchOut, "", currentActiveMonIndex); } - // Update to new active mon (we assume validateSwitch already resolved and gives us a valid target) - battle.activeMonIndex = _setActiveMonIndex(battle.activeMonIndex, playerIndex, monToSwitchIndex); + // Note: Caller is responsible for updating activeMonIndex with appropriate packing + + // Run onMonSwitchIn hooks (these run after the index is updated by the caller) + } + + // Complete switch-in effects (called after activeMonIndex is updated) + function _completeSwitchIn(bytes32 battleKey, uint256 playerIndex, uint256 monToSwitchIndex) internal { + BattleData storage battle = battleData[battleKey]; + BattleConfig storage config = battleConfig[storageKeyForWrite]; // Run onMonSwitchIn hook for local effects - _runEffects(battleKey, tempRNG, playerIndex, playerIndex, EffectStep.OnMonSwitchIn, ""); + // Pass explicit monIndex so effects run on the correct mon (not just slot 0) + _runEffects(battleKey, tempRNG, playerIndex, playerIndex, EffectStep.OnMonSwitchIn, "", monToSwitchIndex); // Run onMonSwitchIn hook for global effects - _runEffects(battleKey, tempRNG, 2, playerIndex, EffectStep.OnMonSwitchIn, ""); + _runEffects(battleKey, tempRNG, 2, playerIndex, EffectStep.OnMonSwitchIn, "", monToSwitchIndex); - // Run ability for the newly switched in mon as long as it's not KO'ed and as long as it's not turn 0, (execute() has a special case to run activateOnSwitch after both moves are handled) + // Run ability for the newly switched in mon Mon memory mon = _getTeamMon(config, playerIndex, monToSwitchIndex); if ( address(mon.ability) != address(0) && battle.turnId != 0 @@ -989,8 +1124,8 @@ contract Engine is IEngine, MappingAllocator { uint8 storedMoveIndex = move.packedMoveIndex & MOVE_INDEX_MASK; uint8 moveIndex = storedMoveIndex >= SWITCH_MOVE_INDEX ? storedMoveIndex : storedMoveIndex - MOVE_INDEX_OFFSET; - // Handle shouldSkipTurn flag first and toggle it off if set - uint256 activeMonIndex = _unpackActiveMonIndex(battle.activeMonIndex, playerIndex); + // Handle shouldSkipTurn flag first and toggle it off if set (slot 0 for singles) + uint256 activeMonIndex = _unpackActiveMonIndexForSlot(battle.activeMonIndex, playerIndex, 0); MonState storage currentMonState = _getMonState(config, playerIndex, activeMonIndex); if (currentMonState.shouldSkipTurn) { currentMonState.shouldSkipTurn = false; @@ -1007,7 +1142,7 @@ contract Engine is IEngine, MappingAllocator { // otherwise, execute the moveset if (moveIndex == SWITCH_MOVE_INDEX) { // Handle the switch (extraData contains the mon index to switch to as raw uint240) - _handleSwitch(battleKey, playerIndex, uint256(move.extraData), address(0)); + _handleSwitchForSlot(battleKey, playerIndex, 0, uint256(move.extraData), address(0)); } else if (moveIndex == NO_OP_MOVE_INDEX) { // Emit event and do nothing (e.g. just recover stamina) emit MonMove(battleKey, playerIndex, activeMonIndex, moveIndex, move.extraData, staminaCost); @@ -1017,7 +1152,8 @@ contract Engine is IEngine, MappingAllocator { // Call validateSpecificMoveSelection again from the validator to ensure that it is still valid to execute // If not, then we just return early // Handles cases where e.g. some condition outside of the player's control leads to an invalid move - if (!config.validator.validateSpecificMoveSelection(battleKey, moveIndex, playerIndex, move.extraData)) + // Singles always uses slot 0 + if (!config.validator.validateSpecificMoveSelection(battleKey, moveIndex, playerIndex, 0, move.extraData)) { return playerSwitchForTurnFlag; } @@ -1053,32 +1189,54 @@ contract Engine is IEngine, MappingAllocator { uint256 playerIndex, EffectStep round, bytes memory extraEffectsData + ) internal { + // Default: calculate monIndex from active mon (singles behavior) + _runEffectsForMon(battleKey, rng, effectIndex, playerIndex, round, extraEffectsData, type(uint256).max); + } + + // Overload with explicit monIndex for doubles-aware effect execution + function _runEffects( + bytes32 battleKey, + uint256 rng, + uint256 effectIndex, + uint256 playerIndex, + EffectStep round, + bytes memory extraEffectsData, + uint256 monIndex + ) internal { + _runEffectsForMon(battleKey, rng, effectIndex, playerIndex, round, extraEffectsData, monIndex); + } + + function _runEffectsForMon( + bytes32 battleKey, + uint256 rng, + uint256 effectIndex, + uint256 playerIndex, + EffectStep round, + bytes memory extraEffectsData, + uint256 explicitMonIndex ) internal { BattleData storage battle = battleData[battleKey]; BattleConfig storage config = battleConfig[storageKeyForWrite]; uint256 monIndex; - // Determine the mon index for the target - if (effectIndex == 2) { - // Global effects - monIndex doesn't matter for filtering + // Use explicit monIndex if provided, otherwise calculate from active mon (slot 0 for singles) + if (explicitMonIndex != type(uint256).max) { + monIndex = explicitMonIndex; + } else if (playerIndex != 2) { + // Specific player - get their active mon (this takes priority over effectIndex) + monIndex = _unpackActiveMonIndexForSlot(battle.activeMonIndex, playerIndex, 0); + } else if (effectIndex == 2) { + // Global effects with global playerIndex - monIndex doesn't matter for filtering monIndex = 0; } else { - monIndex = _unpackActiveMonIndex(battle.activeMonIndex, effectIndex); - } - - // Grab the active mon (global effect won't know which player index to get, so we set it here) - if (playerIndex != 2) { - monIndex = _unpackActiveMonIndex(battle.activeMonIndex, playerIndex); + // effectIndex is player-specific but playerIndex is global - use effectIndex + monIndex = _unpackActiveMonIndexForSlot(battle.activeMonIndex, effectIndex, 0); } // Iterate directly over storage, skipping tombstones // With tombstones, indices are stable so no snapshot needed - uint256 baseSlot; - if (effectIndex == 0) { - baseSlot = _getEffectSlotIndex(monIndex, 0); - } else if (effectIndex == 1) { - baseSlot = _getEffectSlotIndex(monIndex, 0); - } + uint256 baseSlot = (effectIndex != 2) ? _getEffectSlotIndex(monIndex, 0) : 0; // Use a loop index that reads current length each iteration (allows processing newly added effects) uint256 i = 0; @@ -1224,10 +1382,10 @@ contract Engine is IEngine, MappingAllocator { if (battle.winnerIndex != 2) { return playerSwitchForTurnFlag; } - // If non-global effect, check if we should still run if mon is KOed + // If non-global effect, check if we should still run if mon is KOed (slot 0 for singles) if (effectIndex != 2) { bool isMonKOed = - _getMonState(config, playerIndex, _unpackActiveMonIndex(battle.activeMonIndex, playerIndex)).isKnockedOut; + _getMonState(config, playerIndex, _unpackActiveMonIndexForSlot(battle.activeMonIndex, playerIndex, 0)).isKnockedOut; if (isMonKOed && condition == EffectRunCondition.SkipIfGameOverOrMonKO) { return playerSwitchForTurnFlag; } @@ -1251,8 +1409,8 @@ contract Engine is IEngine, MappingAllocator { uint8 p0MoveIndex = p0StoredIndex >= SWITCH_MOVE_INDEX ? p0StoredIndex : p0StoredIndex - MOVE_INDEX_OFFSET; uint8 p1MoveIndex = p1StoredIndex >= SWITCH_MOVE_INDEX ? p1StoredIndex : p1StoredIndex - MOVE_INDEX_OFFSET; - uint256 p0ActiveMonIndex = _unpackActiveMonIndex(battle.activeMonIndex, 0); - uint256 p1ActiveMonIndex = _unpackActiveMonIndex(battle.activeMonIndex, 1); + uint256 p0ActiveMonIndex = _unpackActiveMonIndexForSlot(battle.activeMonIndex, 0, 0); + uint256 p1ActiveMonIndex = _unpackActiveMonIndexForSlot(battle.activeMonIndex, 1, 0); uint256 p0Priority; uint256 p1Priority; @@ -1310,29 +1468,6 @@ contract Engine is IEngine, MappingAllocator { return source; } - /** - * - Helper functions for packing/unpacking activeMonIndex - */ - function _packActiveMonIndices(uint8 player0Index, uint8 player1Index) internal pure returns (uint16) { - return uint16(player0Index) | (uint16(player1Index) << 8); - } - - function _unpackActiveMonIndex(uint16 packed, uint256 playerIndex) internal pure returns (uint256) { - if (playerIndex == 0) { - return uint256(uint8(packed)); - } else { - return uint256(uint8(packed >> 8)); - } - } - - function _setActiveMonIndex(uint16 packed, uint256 playerIndex, uint256 monIndex) internal pure returns (uint16) { - if (playerIndex == 0) { - return (packed & 0xFF00) | uint16(uint8(monIndex)); - } else { - return (packed & 0x00FF) | (uint16(uint8(monIndex)) << 8); - } - } - // Helper functions for per-mon effect count packing function _getMonEffectCount(uint96 packedCounts, uint256 monIndex) private pure returns (uint256) { return (uint256(packedCounts) >> (monIndex * PLAYER_EFFECT_BITS)) & EFFECT_COUNT_MASK; @@ -1502,6 +1637,8 @@ contract Engine is IEngine, MappingAllocator { p1Salt: config.p1Salt, p0Move: config.p0Move, p1Move: config.p1Move, + p0Move2: config.p0Move2, + p1Move2: config.p1Move2, globalEffects: globalEffects, p0Effects: p0Effects, p1Effects: p1Effects, @@ -1696,13 +1833,32 @@ contract Engine is IEngine, MappingAllocator { } function getActiveMonIndexForBattleState(bytes32 battleKey) external view returns (uint256[] memory) { - uint16 packed = battleData[battleKey].activeMonIndex; + BattleData storage data = battleData[battleKey]; + uint16 packed = data.activeMonIndex; + + // Unified packing: always use slot 0 for each player (works for both singles and doubles) uint256[] memory result = new uint256[](2); - result[0] = _unpackActiveMonIndex(packed, 0); - result[1] = _unpackActiveMonIndex(packed, 1); + result[0] = _unpackActiveMonIndexForSlot(packed, 0, 0); + result[1] = _unpackActiveMonIndexForSlot(packed, 1, 0); return result; } + function getGameMode(bytes32 battleKey) external view returns (GameMode) { + uint8 slotSwitchFlagsAndGameMode = battleData[battleKey].slotSwitchFlagsAndGameMode; + return (slotSwitchFlagsAndGameMode & GAME_MODE_BIT) != 0 ? GameMode.Doubles : GameMode.Singles; + } + + function getActiveMonIndexForSlot(bytes32 battleKey, uint256 playerIndex, uint256 slotIndex) + external + view + returns (uint256) + { + BattleData storage data = battleData[battleKey]; + // Unified packing: 4 bits per slot for both singles and doubles + // For singles, slot 1 returns 0 (unused) + return _unpackActiveMonIndexForSlot(data.activeMonIndex, playerIndex, slotIndex); + } + function getPlayerSwitchForTurnFlagForBattleState(bytes32 battleKey) external view returns (uint256) { return battleData[battleKey].playerSwitchForTurnFlag; } @@ -1761,8 +1917,18 @@ contract Engine is IEngine, MappingAllocator { ctx.turnId = data.turnId; ctx.playerSwitchForTurnFlag = data.playerSwitchForTurnFlag; ctx.prevPlayerSwitchForTurnFlag = data.prevPlayerSwitchForTurnFlag; - ctx.p0ActiveMonIndex = uint8(data.activeMonIndex & 0xFF); - ctx.p1ActiveMonIndex = uint8(data.activeMonIndex >> 8); + + // Extract game mode and active mon indices (unified 4-bit packing for both modes) + uint8 slotSwitchFlagsAndGameMode = data.slotSwitchFlagsAndGameMode; + ctx.gameMode = (slotSwitchFlagsAndGameMode & GAME_MODE_BIT) != 0 ? GameMode.Doubles : GameMode.Singles; + ctx.slotSwitchFlags = slotSwitchFlagsAndGameMode & SWITCH_FLAGS_MASK; + + // Unified packing: 4 bits per slot (for singles, slot 1 values are 0/unused) + ctx.p0ActiveMonIndex0 = uint8(data.activeMonIndex & ACTIVE_MON_INDEX_MASK); + ctx.p0ActiveMonIndex1 = uint8((data.activeMonIndex >> 4) & ACTIVE_MON_INDEX_MASK); + ctx.p1ActiveMonIndex0 = uint8((data.activeMonIndex >> 8) & ACTIVE_MON_INDEX_MASK); + ctx.p1ActiveMonIndex1 = uint8((data.activeMonIndex >> 12) & ACTIVE_MON_INDEX_MASK); + ctx.validator = address(config.validator); ctx.moveManager = config.moveManager; } @@ -1778,21 +1944,29 @@ contract Engine is IEngine, MappingAllocator { ctx.winnerIndex = data.winnerIndex; ctx.turnId = data.turnId; ctx.playerSwitchForTurnFlag = data.playerSwitchForTurnFlag; + + // Extract game mode and slot switch flags + uint8 slotSwitchFlagsAndGameMode = data.slotSwitchFlagsAndGameMode; + ctx.gameMode = (slotSwitchFlagsAndGameMode & GAME_MODE_BIT) != 0 ? GameMode.Doubles : GameMode.Singles; + ctx.slotSwitchFlags = slotSwitchFlagsAndGameMode & SWITCH_FLAGS_MASK; + ctx.validator = address(config.validator); } - function getDamageCalcContext(bytes32 battleKey, uint256 attackerPlayerIndex, uint256 defenderPlayerIndex) - external - view - returns (DamageCalcContext memory ctx) - { + function getDamageCalcContext( + bytes32 battleKey, + uint256 attackerPlayerIndex, + uint256 attackerSlotIndex, + uint256 defenderPlayerIndex, + uint256 defenderSlotIndex + ) external view returns (DamageCalcContext memory ctx) { bytes32 storageKey = _getStorageKey(battleKey); BattleData storage data = battleData[battleKey]; BattleConfig storage config = battleConfig[storageKey]; - // Get active mon indices - uint256 attackerMonIndex = _unpackActiveMonIndex(data.activeMonIndex, attackerPlayerIndex); - uint256 defenderMonIndex = _unpackActiveMonIndex(data.activeMonIndex, defenderPlayerIndex); + // Get active mon indices using slot parameters for doubles support + uint256 attackerMonIndex = _unpackActiveMonIndexForSlot(data.activeMonIndex, attackerPlayerIndex, attackerSlotIndex); + uint256 defenderMonIndex = _unpackActiveMonIndexForSlot(data.activeMonIndex, defenderPlayerIndex, defenderSlotIndex); ctx.attackerMonIndex = uint8(attackerMonIndex); ctx.defenderMonIndex = uint8(defenderMonIndex); @@ -1815,4 +1989,444 @@ contract Engine is IEngine, MappingAllocator { ctx.defenderType1 = defenderMon.stats.type1; ctx.defenderType2 = defenderMon.stats.type2; } + + /** + * - Doubles helper functions + */ + + // Unpack active mon index for a specific slot in doubles mode + // Doubles packing: bits 0-3 = p0s0, 4-7 = p0s1, 8-11 = p1s0, 12-15 = p1s1 + function _unpackActiveMonIndexForSlot(uint16 packed, uint256 playerIndex, uint256 slotIndex) internal pure returns (uint256) { + uint256 shift = (playerIndex * 2 + slotIndex) * ACTIVE_MON_INDEX_BITS; + return (packed >> shift) & ACTIVE_MON_INDEX_MASK; + } + + // Set active mon index for a specific slot in doubles mode + function _setActiveMonIndexForSlot(uint16 packed, uint256 playerIndex, uint256 slotIndex, uint256 monIndex) internal pure returns (uint16) { + uint256 shift = (playerIndex * 2 + slotIndex) * ACTIVE_MON_INDEX_BITS; + uint16 mask = uint16(uint256(ACTIVE_MON_INDEX_MASK) << shift); + return (packed & ~mask) | uint16((monIndex & ACTIVE_MON_INDEX_MASK) << shift); + } + + // Get the move decision for a specific player and slot + function _getMoveDecisionForSlot(BattleConfig storage config, uint256 playerIndex, uint256 slotIndex) internal view returns (MoveDecision memory) { + if (playerIndex == 0) { + return slotIndex == 0 ? config.p0Move : config.p0Move2; + } else { + return slotIndex == 0 ? config.p1Move : config.p1Move2; + } + } + + // Check if game mode is doubles + function _isDoublesMode(BattleData storage battle) internal view returns (bool) { + return (battle.slotSwitchFlagsAndGameMode & GAME_MODE_BIT) != 0; + } + + // Get slot switch flags (lower 4 bits of slotSwitchFlagsAndGameMode) + function _getSlotSwitchFlags(BattleData storage battle) internal view returns (uint8) { + return battle.slotSwitchFlagsAndGameMode & SWITCH_FLAGS_MASK; + } + + // Set slot switch flag for a specific slot + function _setSlotSwitchFlag(BattleData storage battle, uint256 playerIndex, uint256 slotIndex) internal { + uint8 flagBit; + if (playerIndex == 0) { + flagBit = slotIndex == 0 ? SWITCH_FLAG_P0_SLOT0 : SWITCH_FLAG_P0_SLOT1; + } else { + flagBit = slotIndex == 0 ? SWITCH_FLAG_P1_SLOT0 : SWITCH_FLAG_P1_SLOT1; + } + battle.slotSwitchFlagsAndGameMode |= flagBit; + } + + // Clear all slot switch flags (keep game mode bit) + function _clearSlotSwitchFlags(BattleData storage battle) internal { + battle.slotSwitchFlagsAndGameMode &= ~SWITCH_FLAGS_MASK; + } + + /** + * @dev Check if a player has any KO'd slot that has a valid switch target + * @param config Battle config + * @param battle Battle data + * @param playerIndex Which player to check (0 or 1) + * @param koBitmap Bitmap of KO'd mons for this player + * @return needsSwitch True if player has a KO'd slot with valid switch target + */ + function _playerNeedsSwitchTurn( + BattleConfig storage config, + BattleData storage battle, + uint256 playerIndex, + uint256 koBitmap + ) internal view returns (bool needsSwitch) { + uint256 teamSize = playerIndex == 0 ? (config.teamSizes & 0x0F) : (config.teamSizes >> 4); + + // Check each slot + for (uint256 s = 0; s < 2; s++) { + uint256 activeMonIndex = _unpackActiveMonIndexForSlot(battle.activeMonIndex, playerIndex, s); + bool isSlotKOed = (koBitmap & (1 << activeMonIndex)) != 0; + + if (isSlotKOed) { + // This slot is KO'd - check if there's a valid switch target + uint256 otherSlotMonIndex = _unpackActiveMonIndexForSlot(battle.activeMonIndex, playerIndex, 1 - s); + + for (uint256 m = 0; m < teamSize; m++) { + // Skip if mon is KO'd + if ((koBitmap & (1 << m)) != 0) continue; + // Skip if mon is active in other slot + if (m == otherSlotMonIndex) continue; + // Found a valid switch target + return true; + } + } + } + return false; + } + + // Struct for tracking move order in doubles + struct MoveOrder { + uint256 playerIndex; + uint256 slotIndex; + uint256 priority; + uint256 speed; + } + + // Compute move order for all 4 slots in doubles (sorted by priority desc, then speed desc, then position) + // Position tiebreaker: p0s0 > p0s1 > p1s0 > p1s1 (lower position index = higher priority) + function _computeMoveOrderForDoubles( + bytes32 battleKey, + BattleConfig storage config, + BattleData storage battle + ) internal view returns (MoveOrder[4] memory moveOrder) { + // Collect move info for all 4 slots + for (uint256 p = 0; p < 2; p++) { + for (uint256 s = 0; s < 2; s++) { + uint256 idx = p * 2 + s; + moveOrder[idx].playerIndex = p; + moveOrder[idx].slotIndex = s; + + MoveDecision memory move = _getMoveDecisionForSlot(config, p, s); + + // If move wasn't set (single-player turn), treat as NO_OP for ordering + if ((move.packedMoveIndex & IS_REAL_TURN_BIT) == 0) { + moveOrder[idx].priority = 0; // Lowest priority - will be skipped anyway + moveOrder[idx].speed = 0; + continue; + } + + uint8 storedMoveIndex = move.packedMoveIndex & MOVE_INDEX_MASK; + uint8 moveIndex = storedMoveIndex >= SWITCH_MOVE_INDEX ? storedMoveIndex : storedMoveIndex - MOVE_INDEX_OFFSET; + + uint256 monIndex = _unpackActiveMonIndexForSlot(battle.activeMonIndex, p, s); + + // Get priority + if (moveIndex == SWITCH_MOVE_INDEX || moveIndex == NO_OP_MOVE_INDEX) { + moveOrder[idx].priority = SWITCH_PRIORITY; + } else { + IMoveSet moveSet = _getTeamMon(config, p, monIndex).moves[moveIndex]; + moveOrder[idx].priority = moveSet.priority(battleKey, p); + } + + // Get speed + int32 speedDelta = _getMonState(config, p, monIndex).speedDelta; + uint32 monSpeed = uint32( + int32(_getTeamMon(config, p, monIndex).stats.speed) + + (speedDelta == CLEARED_MON_STATE_SENTINEL ? int32(0) : speedDelta) + ); + moveOrder[idx].speed = monSpeed; + } + } + + // Sort by priority (desc), then speed (desc), then position (asc, implicit from initial order) + // Simple bubble sort (only 4 elements) + for (uint256 i = 0; i < 3; i++) { + for (uint256 j = 0; j < 3 - i; j++) { + bool shouldSwap = false; + if (moveOrder[j].priority < moveOrder[j + 1].priority) { + shouldSwap = true; + } else if (moveOrder[j].priority == moveOrder[j + 1].priority) { + if (moveOrder[j].speed < moveOrder[j + 1].speed) { + shouldSwap = true; + } + // If both priority and speed are equal, keep original order (position tiebreaker) + } + + if (shouldSwap) { + MoveOrder memory temp = moveOrder[j]; + moveOrder[j] = moveOrder[j + 1]; + moveOrder[j + 1] = temp; + } + } + } + } + + // Handle a move for a specific slot in doubles + function _handleMoveForSlot( + bytes32 battleKey, + BattleConfig storage config, + BattleData storage battle, + uint256 playerIndex, + uint256 slotIndex + ) internal returns (bool monKOed) { + MoveDecision memory move = _getMoveDecisionForSlot(config, playerIndex, slotIndex); + int32 staminaCost; + + // Check if move was set (isRealTurn bit) + if ((move.packedMoveIndex & IS_REAL_TURN_BIT) == 0) { + return false; + } + + // Unpack moveIndex from packedMoveIndex + uint8 storedMoveIndex = move.packedMoveIndex & MOVE_INDEX_MASK; + uint8 moveIndex = storedMoveIndex >= SWITCH_MOVE_INDEX ? storedMoveIndex : storedMoveIndex - MOVE_INDEX_OFFSET; + + // Get active mon for this slot + uint256 activeMonIndex = _unpackActiveMonIndexForSlot(battle.activeMonIndex, playerIndex, slotIndex); + MonState storage currentMonState = _getMonState(config, playerIndex, activeMonIndex); + + // Handle shouldSkipTurn flag + if (currentMonState.shouldSkipTurn) { + currentMonState.shouldSkipTurn = false; + return false; + } + + // Skip if mon is already KO'd (unless it's a switch - switching away from KO'd mon is allowed) + if (currentMonState.isKnockedOut && moveIndex != SWITCH_MOVE_INDEX) { + return false; + } + + // Handle switch, no-op, or regular move + if (moveIndex == SWITCH_MOVE_INDEX) { + uint256 targetMonIndex = uint256(move.extraData); + // Check if target mon is already active in other slot (handles case where both slots try to switch to same mon) + uint256 otherSlotIndex = 1 - slotIndex; + uint256 otherSlotActiveMonIndex = _unpackActiveMonIndexForSlot(battle.activeMonIndex, playerIndex, otherSlotIndex); + if (targetMonIndex == otherSlotActiveMonIndex) { + // Target mon is already active in other slot - treat as NO_OP + emit MonMove(battleKey, playerIndex, activeMonIndex, NO_OP_MOVE_INDEX, move.extraData, staminaCost); + } else { + _handleSwitchForSlot(battleKey, playerIndex, slotIndex, targetMonIndex, address(0)); + } + } else if (moveIndex == NO_OP_MOVE_INDEX) { + emit MonMove(battleKey, playerIndex, activeMonIndex, moveIndex, move.extraData, staminaCost); + } else { + // Validate move is still valid (pass slotIndex for correct mon lookup in doubles) + if (!config.validator.validateSpecificMoveSelection(battleKey, moveIndex, playerIndex, slotIndex, move.extraData)) { + return false; + } + + IMoveSet moveSet = _getTeamMon(config, playerIndex, activeMonIndex).moves[moveIndex]; + + // Deduct stamina + staminaCost = int32(moveSet.stamina(battleKey, playerIndex, activeMonIndex)); + MonState storage monState = _getMonState(config, playerIndex, activeMonIndex); + monState.staminaDelta = (monState.staminaDelta == CLEARED_MON_STATE_SENTINEL) ? -staminaCost : monState.staminaDelta - staminaCost; + + emit MonMove(battleKey, playerIndex, activeMonIndex, moveIndex, move.extraData, staminaCost); + + // Execute the move + moveSet.move(battleKey, playerIndex, move.extraData, tempRNG); + } + + // Check if mon got KO'd as a result of this move + return currentMonState.isKnockedOut; + } + + // Handle switch for a specific slot in doubles (uses shared core functions) + function _handleSwitchForSlot(bytes32 battleKey, uint256 playerIndex, uint256 slotIndex, uint256 monToSwitchIndex, address source) internal { + BattleData storage battle = battleData[battleKey]; + uint256 currentActiveMonIndex = _unpackActiveMonIndexForSlot(battle.activeMonIndex, playerIndex, slotIndex); + + // Run switch-out effects (shared) + _handleSwitchCore(battleKey, playerIndex, currentActiveMonIndex, monToSwitchIndex, source); + + // Update active mon for this slot (doubles packing) + battle.activeMonIndex = _setActiveMonIndexForSlot(battle.activeMonIndex, playerIndex, slotIndex, monToSwitchIndex); + + // Run switch-in effects (shared) + _completeSwitchIn(battleKey, playerIndex, monToSwitchIndex); + } + + // Check for game over or KO in doubles mode (uses shared game over check) + function _checkForGameOverOrKO_Doubles( + BattleConfig storage config, + BattleData storage battle + ) internal returns (bool isGameOver) { + // Use shared game over check + (uint256 winnerIndex, uint256 p0KOBitmap, uint256 p1KOBitmap) = _checkForGameOver(config, battle); + + if (winnerIndex != 2) { + battle.winnerIndex = uint8(winnerIndex); + return true; + } + + // No game over - check each slot for KO and set switch flags + _clearSlotSwitchFlags(battle); + for (uint256 p = 0; p < 2; p++) { + uint256 koBitmap = p == 0 ? p0KOBitmap : p1KOBitmap; + for (uint256 s = 0; s < 2; s++) { + uint256 activeMonIndex = _unpackActiveMonIndexForSlot(battle.activeMonIndex, p, s); + bool isKOed = (koBitmap & (1 << activeMonIndex)) != 0; + if (isKOed) { + _setSlotSwitchFlag(battle, p, s); + } + } + } + + // Determine if either player needs a switch turn (has KO'd slot with valid target) + bool p0NeedsSwitch = _playerNeedsSwitchTurn(config, battle, 0, p0KOBitmap); + bool p1NeedsSwitch = _playerNeedsSwitchTurn(config, battle, 1, p1KOBitmap); + + // Set playerSwitchForTurnFlag based on who needs to switch + if (p0NeedsSwitch && p1NeedsSwitch) { + // Both players have KO'd mons with valid targets - both act (switch-only turn) + battle.playerSwitchForTurnFlag = 2; + } else if (p0NeedsSwitch) { + // Only p0 needs to switch + battle.playerSwitchForTurnFlag = 0; + } else if (p1NeedsSwitch) { + // Only p1 needs to switch + battle.playerSwitchForTurnFlag = 1; + } else { + // Neither needs switch - normal turn (both act) + battle.playerSwitchForTurnFlag = 2; + } + + return false; + } + + // Main execution function for doubles mode + function _executeDoubles( + bytes32 battleKey, + BattleConfig storage config, + BattleData storage battle, + uint256 turnId, + uint256 numHooks + ) internal { + // Update the temporary RNG + uint256 rng = config.rngOracle.getRNG(config.p0Salt, config.p1Salt); + tempRNG = rng; + + // Compute move order for all 4 slots + MoveOrder[4] memory moveOrder = _computeMoveOrderForDoubles(battleKey, config, battle); + + // Run beginning of round effects (global) + _runEffects(battleKey, rng, 2, 2, EffectStep.RoundStart, ""); + + // Run beginning of round effects for each slot's mon (if not KO'd) + for (uint256 i = 0; i < 4; i++) { + uint256 p = moveOrder[i].playerIndex; + uint256 s = moveOrder[i].slotIndex; + uint256 monIndex = _unpackActiveMonIndexForSlot(battle.activeMonIndex, p, s); + if (!_getMonState(config, p, monIndex).isKnockedOut) { + _runEffectsForMon(battleKey, rng, p, p, EffectStep.RoundStart, "", monIndex); + } + } + + // Execute moves in priority order + for (uint256 i = 0; i < 4; i++) { + uint256 p = moveOrder[i].playerIndex; + uint256 s = moveOrder[i].slotIndex; + + // Execute the move for this slot + _handleMoveForSlot(battleKey, config, battle, p, s); + + // Check for game over after each move + if (_checkForGameOverOrKO_Doubles(config, battle)) { + // Game is over, handle cleanup and return + address winner = (battle.winnerIndex == 0) ? battle.p0 : battle.p1; + _handleGameOver(battleKey, winner); + + // Run round end hooks + for (uint256 j = 0; j < numHooks; ++j) { + config.engineHooks[j].onRoundEnd(battleKey); + } + + emit EngineExecute(battleKey, turnId, 2, moveOrder[0].playerIndex); + return; + } + } + + // For turn 0 only: handle ability activateOnSwitch for all 4 mons + if (turnId == 0) { + for (uint256 p = 0; p < 2; p++) { + for (uint256 s = 0; s < 2; s++) { + uint256 monIndex = _unpackActiveMonIndexForSlot(battle.activeMonIndex, p, s); + Mon memory mon = _getTeamMon(config, p, monIndex); + if (address(mon.ability) != address(0)) { + mon.ability.activateOnSwitch(battleKey, p, monIndex); + } + } + } + } + + // Run afterMove effects for each slot (in move order) + for (uint256 i = 0; i < 4; i++) { + uint256 p = moveOrder[i].playerIndex; + uint256 s = moveOrder[i].slotIndex; + uint256 monIndex = _unpackActiveMonIndexForSlot(battle.activeMonIndex, p, s); + if (!_getMonState(config, p, monIndex).isKnockedOut) { + _runEffectsForMon(battleKey, rng, p, p, EffectStep.AfterMove, "", monIndex); + } + } + + // Run global afterMove effects + _runEffects(battleKey, rng, 2, 2, EffectStep.AfterMove, ""); + + // Check for game over after effects + if (_checkForGameOverOrKO_Doubles(config, battle)) { + address winner = (battle.winnerIndex == 0) ? battle.p0 : battle.p1; + _handleGameOver(battleKey, winner); + + for (uint256 j = 0; j < numHooks; ++j) { + config.engineHooks[j].onRoundEnd(battleKey); + } + + emit EngineExecute(battleKey, turnId, 2, moveOrder[0].playerIndex); + return; + } + + // Run global roundEnd effects + _runEffects(battleKey, rng, 2, 2, EffectStep.RoundEnd, ""); + + // Run roundEnd effects for each slot (in move order) + for (uint256 i = 0; i < 4; i++) { + uint256 p = moveOrder[i].playerIndex; + uint256 s = moveOrder[i].slotIndex; + uint256 monIndex = _unpackActiveMonIndexForSlot(battle.activeMonIndex, p, s); + if (!_getMonState(config, p, monIndex).isKnockedOut) { + _runEffectsForMon(battleKey, rng, p, p, EffectStep.RoundEnd, "", monIndex); + } + } + + // Final game over check after round end effects + if (_checkForGameOverOrKO_Doubles(config, battle)) { + address winner = (battle.winnerIndex == 0) ? battle.p0 : battle.p1; + _handleGameOver(battleKey, winner); + + for (uint256 j = 0; j < numHooks; ++j) { + config.engineHooks[j].onRoundEnd(battleKey); + } + + emit EngineExecute(battleKey, turnId, 2, moveOrder[0].playerIndex); + return; + } + + // Run round end hooks + for (uint256 i = 0; i < numHooks; ++i) { + config.engineHooks[i].onRoundEnd(battleKey); + } + + // End of turn cleanup + battle.turnId += 1; + + // playerSwitchForTurnFlag was already set by _checkForGameOverOrKO_Doubles + // based on whether players need to switch (have KO'd slots with valid targets) + + // Clear move flags for next turn + config.p0Move.packedMoveIndex = 0; + config.p1Move.packedMoveIndex = 0; + config.p0Move2.packedMoveIndex = 0; + config.p1Move2.packedMoveIndex = 0; + + emit EngineExecute(battleKey, turnId, 2, moveOrder[0].playerIndex); + } } diff --git a/src/Enums.sol b/src/Enums.sol index 4dbc1843..00365e12 100644 --- a/src/Enums.sol +++ b/src/Enums.sol @@ -25,6 +25,11 @@ enum GameStatus { Ended } +enum GameMode { + Singles, + Doubles +} + enum EffectStep { OnApply, RoundStart, diff --git a/src/IEngine.sol b/src/IEngine.sol index cd1c0c67..65147e85 100644 --- a/src/IEngine.sol +++ b/src/IEngine.sol @@ -23,7 +23,9 @@ interface IEngine { function setGlobalKV(bytes32 key, uint192 value) external; function dealDamage(uint256 playerIndex, uint256 monIndex, int32 damage) external; function switchActiveMon(uint256 playerIndex, uint256 monToSwitchIndex) external; + function switchActiveMonForSlot(uint256 playerIndex, uint256 slotIndex, uint256 monToSwitchIndex) external; function setMove(bytes32 battleKey, uint256 playerIndex, uint8 moveIndex, bytes32 salt, uint240 extraData) external; + function setMoveForSlot(bytes32 battleKey, uint256 playerIndex, uint256 slotIndex, uint8 moveIndex, bytes32 salt, uint240 extraData) external; function execute(bytes32 battleKey) external; function emitEngineEvent(bytes32 eventType, bytes memory extraData) external; function setUpstreamCaller(address caller) external; @@ -79,8 +81,18 @@ interface IEngine { 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 getDamageCalcContext(bytes32 battleKey, uint256 attackerPlayerIndex, uint256 defenderPlayerIndex) + function getDamageCalcContext( + bytes32 battleKey, + uint256 attackerPlayerIndex, + uint256 attackerSlotIndex, + uint256 defenderPlayerIndex, + uint256 defenderSlotIndex + ) external view returns (DamageCalcContext memory); + + // Doubles-specific getters + function getGameMode(bytes32 battleKey) external view returns (GameMode); + function getActiveMonIndexForSlot(bytes32 battleKey, uint256 playerIndex, uint256 slotIndex) external view - returns (DamageCalcContext memory); + returns (uint256); } diff --git a/src/IValidator.sol b/src/IValidator.sol index 8dea5f1d..5e664284 100644 --- a/src/IValidator.sol +++ b/src/IValidator.sol @@ -15,11 +15,33 @@ interface IValidator { external returns (bool); + // Validates a move for a specific slot in doubles mode + function validatePlayerMoveForSlot( + bytes32 battleKey, + uint256 moveIndex, + uint256 playerIndex, + uint256 slotIndex, + uint240 extraData + ) external returns (bool); + + // Validates a move for a specific slot, accounting for what the other slot is switching to + // claimedByOtherSlot is the mon index the other slot is switching to (type(uint256).max if not applicable) + function validatePlayerMoveForSlotWithClaimed( + bytes32 battleKey, + uint256 moveIndex, + uint256 playerIndex, + uint256 slotIndex, + uint240 extraData, + uint256 claimedByOtherSlot + ) external returns (bool); + // Validates that a move selection is valid (specifically wrt stamina) + // slotIndex is the slot (0 or 1) of the mon making the move in doubles function validateSpecificMoveSelection( bytes32 battleKey, uint256 moveIndex, uint256 playerIndex, + uint256 slotIndex, uint240 extraData ) external returns (bool); diff --git a/src/Structs.sol b/src/Structs.sol index 4146dfb0..b96fb28e 100644 --- a/src/Structs.sol +++ b/src/Structs.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0 pragma solidity ^0.8.0; -import {Type, MonStateIndexName, StatBoostType} from "./Enums.sol"; +import {Type, MonStateIndexName, StatBoostType, GameMode} from "./Enums.sol"; import {IEngineHook} from "./IEngineHook.sol"; import {IRuleset} from "./IRuleset.sol"; import {IValidator} from "./IValidator.sol"; @@ -26,6 +26,7 @@ struct ProposedBattle { address moveManager; IMatchmaker matchmaker; IEngineHook[] engineHooks; + GameMode gameMode; // Singles or Doubles } // Used by Engine to initialize a battle's parameters @@ -41,6 +42,7 @@ struct Battle { address moveManager; IMatchmaker matchmaker; IEngineHook[] engineHooks; + GameMode gameMode; // Singles or Doubles } // Packed into 1 storage slot (8 + 240 = 248 bits) @@ -58,7 +60,12 @@ struct BattleData { uint8 winnerIndex; // 2 = uninitialized (no winner), 0 = p0 winner, 1 = p1 winner uint8 prevPlayerSwitchForTurnFlag; uint8 playerSwitchForTurnFlag; - uint16 activeMonIndex; // Packed: lower 8 bits = player0, upper 8 bits = player1 + // Packed active mon indices: + // Singles: lower 8 bits = p0 active, upper 8 bits = p1 active + // Doubles: 4 bits per slot (p0s0, p0s1, p1s0, p1s1) + uint16 activeMonIndex; + // Packed: lower 4 bits = per-slot switch flags, bit 4 = game mode (0=singles, 1=doubles) + uint8 slotSwitchFlagsAndGameMode; } // Stored by the Engine for a battle, is overwritten after a battle is over @@ -75,8 +82,10 @@ struct BattleConfig { uint48 startTimestamp; bytes32 p0Salt; bytes32 p1Salt; - MoveDecision p0Move; - MoveDecision p1Move; + MoveDecision p0Move; // Slot 0 move for p0 (singles: only move, doubles: first mon's move) + MoveDecision p1Move; // Slot 0 move for p1 + MoveDecision p0Move2; // Slot 1 move for p0 (doubles only) + MoveDecision p1Move2; // Slot 1 move for p1 (doubles only) mapping(uint256 index => Mon) p0Team; mapping(uint256 index => Mon) p1Team; mapping(uint256 index => MonState) p0States; @@ -105,6 +114,8 @@ struct BattleConfigView { bytes32 p1Salt; MoveDecision p0Move; MoveDecision p1Move; + MoveDecision p0Move2; // Doubles only + MoveDecision p1Move2; // Doubles only EffectInstance[] globalEffects; EffectInstance[][] p0Effects; // Returns effects per mon in team EffectInstance[][] p1Effects; @@ -117,7 +128,10 @@ struct BattleState { uint8 winnerIndex; // 2 = uninitialized (no winner), 0 = p0 winner, 1 = p1 winner uint8 prevPlayerSwitchForTurnFlag; uint8 playerSwitchForTurnFlag; - uint16 activeMonIndex; // Packed: lower 8 bits = player0, upper 8 bits = player1 + // Packed active mon indices (see BattleData for layout) + uint16 activeMonIndex; + // Packed: lower 4 bits = per-slot switch flags, bit 4 = game mode (0=singles, 1=doubles) + uint8 slotSwitchFlagsAndGameMode; uint64 turnId; } @@ -165,6 +179,15 @@ struct RevealedMove { bytes32 salt; } +// Used for Doubles commit manager - reveals both slot moves at once +struct RevealedMovesPair { + uint8 moveIndex0; // Slot 0 move index + uint240 extraData0; // Slot 0 extra data (includes target) + uint8 moveIndex1; // Slot 1 move index + uint240 extraData1; // Slot 1 extra data (includes target) + bytes32 salt; // Single salt for both moves +} + // Used for StatBoosts struct StatBoostToApply { MonStateIndexName stat; @@ -187,8 +210,12 @@ struct BattleContext { uint64 turnId; uint8 playerSwitchForTurnFlag; uint8 prevPlayerSwitchForTurnFlag; - uint8 p0ActiveMonIndex; - uint8 p1ActiveMonIndex; + uint8 p0ActiveMonIndex0; // Slot 0 active mon for p0 + uint8 p1ActiveMonIndex0; // Slot 0 active mon for p1 + uint8 p0ActiveMonIndex1; // Slot 1 active mon for p0 (doubles only) + uint8 p1ActiveMonIndex1; // Slot 1 active mon for p1 (doubles only) + uint8 slotSwitchFlags; // Per-slot switch flags (doubles) + GameMode gameMode; address validator; address moveManager; } @@ -201,6 +228,8 @@ struct CommitContext { uint8 winnerIndex; uint64 turnId; uint8 playerSwitchForTurnFlag; + uint8 slotSwitchFlags; // Per-slot switch flags (doubles) + GameMode gameMode; address validator; } diff --git a/src/cpu/CPU.sol b/src/cpu/CPU.sol index 42197edc..285023f1 100644 --- a/src/cpu/CPU.sol +++ b/src/cpu/CPU.sol @@ -11,7 +11,7 @@ import {CPUMoveManager} from "./CPUMoveManager.sol"; import {IValidator} from "../IValidator.sol"; import {NO_OP_MOVE_INDEX, SWITCH_MOVE_INDEX} from "../Constants.sol"; -import {ExtraDataType} from "../Enums.sol"; +import {ExtraDataType, GameMode} from "../Enums.sol"; import {Battle, ProposedBattle, RevealedMove} from "../Structs.sol"; abstract contract CPU is CPUMoveManager, ICPU, ICPURNG, IMatchmaker { @@ -153,7 +153,8 @@ abstract contract CPU is CPUMoveManager, ICPU, ICPURNG, IMatchmaker { ruleset: proposal.ruleset, engineHooks: proposal.engineHooks, moveManager: proposal.moveManager, - matchmaker: proposal.matchmaker + matchmaker: proposal.matchmaker, + gameMode: proposal.gameMode }) ); } diff --git a/src/effects/StaminaRegen.sol b/src/effects/StaminaRegen.sol index 42d583e3..7afb3f71 100644 --- a/src/effects/StaminaRegen.sol +++ b/src/effects/StaminaRegen.sol @@ -33,15 +33,21 @@ contract StaminaRegen is BasicEffect { } } - // Regen stamina on round end for both active mons + // Regen stamina on round end for all active mons (both slots in doubles) function onRoundEnd(uint256, bytes32, uint256, uint256) external override returns (bytes32, bool) { bytes32 battleKey = ENGINE.battleKeyForWrite(); uint256 playerSwitchForTurnFlag = ENGINE.getPlayerSwitchForTurnFlagForBattleState(battleKey); - uint256[] memory activeMonIndex = ENGINE.getActiveMonIndexForBattleState(battleKey); - // Update stamina for both active mons only if it's a 2 player turn + + // Only regen stamina if it's a 2 player turn (not a switch-only turn) if (playerSwitchForTurnFlag == 2) { + bool isDoubles = ENGINE.getGameMode(battleKey) == GameMode.Doubles; + uint256 slotsPerPlayer = isDoubles ? 2 : 1; + for (uint256 playerIndex; playerIndex < 2; ++playerIndex) { - _regenStamina(playerIndex, activeMonIndex[playerIndex]); + for (uint256 slotIndex; slotIndex < slotsPerPlayer; ++slotIndex) { + uint256 monIndex = ENGINE.getActiveMonIndexForSlot(battleKey, playerIndex, slotIndex); + _regenStamina(playerIndex, monIndex); + } } } return (bytes32(0), false); diff --git a/src/effects/battlefield/Overclock.sol b/src/effects/battlefield/Overclock.sol index 5618a6a9..bee0dffd 100644 --- a/src/effects/battlefield/Overclock.sol +++ b/src/effects/battlefield/Overclock.sol @@ -85,13 +85,19 @@ contract Overclock is BasicEffect { returns (bytes32 updatedExtraData, bool removeAfterRun) { uint256 playerIndex = uint256(extraData); + bytes32 battleKey = ENGINE.battleKeyForWrite(); // Set default duration setDuration(DEFAULT_DURATION, playerIndex); - // Apply stat change to the team of the player who summoned Overclock - uint256 activeMonIndex = ENGINE.getActiveMonIndexForBattleState(ENGINE.battleKeyForWrite())[playerIndex]; - _applyStatChange(playerIndex, activeMonIndex); + // Apply stat change to all active mons of the player who summoned Overclock + bool isDoubles = ENGINE.getGameMode(battleKey) == GameMode.Doubles; + uint256 slotsPerPlayer = isDoubles ? 2 : 1; + + for (uint256 slotIndex; slotIndex < slotsPerPlayer; ++slotIndex) { + uint256 monIndex = ENGINE.getActiveMonIndexForSlot(battleKey, playerIndex, slotIndex); + _applyStatChange(playerIndex, monIndex); + } return (extraData, false); } @@ -126,9 +132,17 @@ contract Overclock is BasicEffect { function onRemove(bytes32 extraData, uint256, uint256) external override { uint256 playerIndex = uint256(extraData); - uint256 activeMonIndex = ENGINE.getActiveMonIndexForBattleState(ENGINE.battleKeyForWrite())[playerIndex]; - // Reset stat changes from the mon on the team of the player who summoned Overclock - _removeStatChange(playerIndex, activeMonIndex); + bytes32 battleKey = ENGINE.battleKeyForWrite(); + + // Remove stat changes from all active mons of the player who summoned Overclock + bool isDoubles = ENGINE.getGameMode(battleKey) == GameMode.Doubles; + uint256 slotsPerPlayer = isDoubles ? 2 : 1; + + for (uint256 slotIndex; slotIndex < slotsPerPlayer; ++slotIndex) { + uint256 monIndex = ENGINE.getActiveMonIndexForSlot(battleKey, playerIndex, slotIndex); + _removeStatChange(playerIndex, monIndex); + } + // Clear the duration when we clear the effect setDuration(0, playerIndex); } diff --git a/src/matchmaker/DefaultMatchmaker.sol b/src/matchmaker/DefaultMatchmaker.sol index 3fbf5491..ce86e097 100644 --- a/src/matchmaker/DefaultMatchmaker.sol +++ b/src/matchmaker/DefaultMatchmaker.sol @@ -2,6 +2,7 @@ pragma solidity ^0.8.0; import {IEngine} from "../IEngine.sol"; +import {GameMode} from "../Enums.sol"; import {ProposedBattle, Battle} from "../Structs.sol"; import {IMatchmaker} from "./IMatchmaker.sol"; import {MappingAllocator} from "../lib/MappingAllocator.sol"; @@ -95,6 +96,9 @@ contract DefaultMatchmaker is IMatchmaker, MappingAllocator { if (existingBattle.engineHooks.length != proposal.engineHooks.length && proposal.engineHooks.length != 0) { existingBattle.engineHooks = proposal.engineHooks; } + if (existingBattle.gameMode != proposal.gameMode) { + existingBattle.gameMode = proposal.gameMode; + } proposals[storageKey].p1TeamIndex = UNSET_P1_TEAM_INDEX; emit BattleProposal(battleKey, proposal.p0, proposal.p1, proposal.p0TeamHash == FAST_BATTLE_SENTINAL_HASH, proposal.p0TeamHash); return battleKey; @@ -134,7 +138,8 @@ contract DefaultMatchmaker is IMatchmaker, MappingAllocator { ruleset: proposal.ruleset, engineHooks: proposal.engineHooks, moveManager: proposal.moveManager, - matchmaker: proposal.matchmaker + matchmaker: proposal.matchmaker, + gameMode: proposal.gameMode }) ); _cleanUpBattleProposal(battleKey); @@ -174,7 +179,8 @@ contract DefaultMatchmaker is IMatchmaker, MappingAllocator { ruleset: proposal.ruleset, engineHooks: proposal.engineHooks, moveManager: proposal.moveManager, - matchmaker: proposal.matchmaker + matchmaker: proposal.matchmaker, + gameMode: proposal.gameMode }) ); _cleanUpBattleProposal(battleKey); diff --git a/src/mons/embursa/Q5.sol b/src/mons/embursa/Q5.sol index bce6d491..f9e1e51d 100644 --- a/src/mons/embursa/Q5.sol +++ b/src/mons/embursa/Q5.sol @@ -85,12 +85,14 @@ contract Q5 is IMoveSet, BasicEffect { { (uint256 turnCount, uint256 attackerPlayerIndex) = _unpackExtraData(extraData); if (turnCount == DELAY) { - // Deal damage + // Deal damage (singles = slot 0) AttackCalculator._calculateDamage( ENGINE, TYPE_CALCULATOR, ENGINE.battleKeyForWrite(), attackerPlayerIndex, + 0, // attackerSlotIndex + 0, // defenderSlotIndex BASE_POWER, DEFAULT_ACCURACY, DEFAULT_VOL, diff --git a/src/mons/gorillax/RockPull.sol b/src/mons/gorillax/RockPull.sol index a864edf4..1cb44755 100644 --- a/src/mons/gorillax/RockPull.sol +++ b/src/mons/gorillax/RockPull.sol @@ -38,12 +38,14 @@ contract RockPull is IMoveSet { function move(bytes32 battleKey, uint256 attackerPlayerIndex, uint240, uint256 rng) external { if (_didOtherPlayerChooseSwitch(battleKey, attackerPlayerIndex)) { - // Deal damage to the opposing mon + // Deal damage to the opposing mon (singles = slot 0) AttackCalculator._calculateDamage( ENGINE, TYPE_CALCULATOR, battleKey, attackerPlayerIndex, + 0, // attackerSlotIndex + 0, // defenderSlotIndex OPPONENT_BASE_POWER, DEFAULT_ACCURACY, DEFAULT_VOL, @@ -53,13 +55,15 @@ contract RockPull is IMoveSet { DEFAULT_CRIT_RATE ); } else { - // Deal damage to ourselves + // Deal damage to ourselves (singles = slot 0) (int32 selfDamage,) = AttackCalculator._calculateDamageView( ENGINE, TYPE_CALCULATOR, battleKey, attackerPlayerIndex, + 0, // attackerSlotIndex attackerPlayerIndex, + 0, // defenderSlotIndex SELF_DAMAGE_BASE_POWER, DEFAULT_ACCURACY, DEFAULT_VOL, diff --git a/src/mons/iblivion/Brightback.sol b/src/mons/iblivion/Brightback.sol index 5cf9ee58..0934f3ba 100644 --- a/src/mons/iblivion/Brightback.sol +++ b/src/mons/iblivion/Brightback.sol @@ -37,11 +37,14 @@ contract Brightback is IMoveSet { } function move(bytes32 battleKey, uint256 attackerPlayerIndex, uint240, uint256 rng) external { + // Singles uses slot 0 (int32 damageDealt,) = AttackCalculator._calculateDamage( ENGINE, TYPE_CALCULATOR, battleKey, attackerPlayerIndex, + 0, // attackerSlotIndex + 0, // defenderSlotIndex BASE_POWER, DEFAULT_ACCURACY, DEFAULT_VOL, diff --git a/src/mons/iblivion/UnboundedStrike.sol b/src/mons/iblivion/UnboundedStrike.sol index 02b3099b..29f57d1b 100644 --- a/src/mons/iblivion/UnboundedStrike.sol +++ b/src/mons/iblivion/UnboundedStrike.sol @@ -54,11 +54,14 @@ contract UnboundedStrike is IMoveSet { power = BASE_POWER; } + // Singles uses slot 0 AttackCalculator._calculateDamage( ENGINE, TYPE_CALCULATOR, battleKey, attackerPlayerIndex, + 0, // attackerSlotIndex + 0, // defenderSlotIndex power, DEFAULT_ACCURACY, DEFAULT_VOL, diff --git a/src/mons/pengym/DeepFreeze.sol b/src/mons/pengym/DeepFreeze.sol index 67b05f70..031ddf91 100644 --- a/src/mons/pengym/DeepFreeze.sol +++ b/src/mons/pengym/DeepFreeze.sol @@ -54,12 +54,14 @@ contract DeepFreeze is IMoveSet { ENGINE.removeEffect(otherPlayerIndex, otherPlayerActiveMonIndex, uint256(uint32(frostbiteIndex))); damageToDeal = damageToDeal * 2; } - // Deal damage + // Deal damage (singles = slot 0) AttackCalculator._calculateDamage( ENGINE, TYPE_CALCULATOR, battleKey, attackerPlayerIndex, + 0, // attackerSlotIndex + 0, // defenderSlotIndex damageToDeal, DEFAULT_ACCURACY, DEFAULT_VOL, diff --git a/src/mons/sofabbi/Gachachacha.sol b/src/mons/sofabbi/Gachachacha.sol index f9aa250d..672e2a9e 100644 --- a/src/mons/sofabbi/Gachachacha.sol +++ b/src/mons/sofabbi/Gachachacha.sol @@ -54,11 +54,14 @@ contract Gachachacha is IMoveSet { battleKey, defenderPlayerIndex, activeMon[defenderPlayerIndex], MonStateIndexName.Hp ); } + // Singles uses slot 0 AttackCalculator._calculateDamage( ENGINE, TYPE_CALCULATOR, battleKey, playerForCalculator, + 0, // attackerSlotIndex + 0, // defenderSlotIndex basePower, DEFAULT_ACCURACY, DEFAULT_VOL, diff --git a/src/mons/sofabbi/GuestFeature.sol b/src/mons/sofabbi/GuestFeature.sol index 470064b2..77056254 100644 --- a/src/mons/sofabbi/GuestFeature.sol +++ b/src/mons/sofabbi/GuestFeature.sol @@ -29,11 +29,14 @@ contract GuestFeature is IMoveSet { uint256 monIndex = uint256(extraData); Type guestType = Type(ENGINE.getMonValueForBattle(battleKey, attackerPlayerIndex, monIndex, MonStateIndexName.Type1)); + // Singles uses slot 0 AttackCalculator._calculateDamage( ENGINE, TYPE_CALCULATOR, battleKey, attackerPlayerIndex, + 0, // attackerSlotIndex + 0, // defenderSlotIndex BASE_POWER, DEFAULT_ACCURACY, DEFAULT_VOL, diff --git a/src/mons/volthare/MegaStarBlast.sol b/src/mons/volthare/MegaStarBlast.sol index d307cf48..cf53bd02 100644 --- a/src/mons/volthare/MegaStarBlast.sol +++ b/src/mons/volthare/MegaStarBlast.sol @@ -55,12 +55,14 @@ contract MegaStarBlast is IMoveSet { // Upgrade accuracy acc = 100; } - // Deal damage + // Deal damage (singles = slot 0) (int32 damage,) = AttackCalculator._calculateDamage( ENGINE, TYPE_CALCULATOR, battleKey, attackerPlayerIndex, + 0, // attackerSlotIndex + 0, // defenderSlotIndex BASE_POWER, acc, DEFAULT_VOL, diff --git a/src/mons/volthare/PreemptiveShock.sol b/src/mons/volthare/PreemptiveShock.sol index bb577c08..ad9bb95d 100644 --- a/src/mons/volthare/PreemptiveShock.sol +++ b/src/mons/volthare/PreemptiveShock.sol @@ -26,11 +26,14 @@ contract PreemptiveShock is IAbility { } function activateOnSwitch(bytes32 battleKey, uint256 playerIndex, uint256) external override { + // Singles uses slot 0 AttackCalculator._calculateDamage( ENGINE, TYPE_CALCULATOR, battleKey, playerIndex, + 0, // attackerSlotIndex + 0, // defenderSlotIndex BASE_POWER, 100, DEFAULT_VOL, diff --git a/src/mons/xmon/NightTerrors.sol b/src/mons/xmon/NightTerrors.sol index 31c6c528..f0afc364 100644 --- a/src/mons/xmon/NightTerrors.sol +++ b/src/mons/xmon/NightTerrors.sol @@ -147,11 +147,14 @@ contract NightTerrors is IMoveSet, BasicEffect { uint32 totalBasePower = damagePerStack * uint32(terrorCount); // Deal damage using AttackCalculator (attacker damages defender) + // Singles uses slot 0 AttackCalculator._calculateDamage( ENGINE, TYPE_CALCULATOR, battleKey, targetIndex, // attacker player index + 0, // attackerSlotIndex + 0, // defenderSlotIndex totalBasePower, DEFAULT_ACCURACY, DEFAULT_VOL, diff --git a/src/moves/AttackCalculator.sol b/src/moves/AttackCalculator.sol index 600172b5..e5c2f56c 100644 --- a/src/moves/AttackCalculator.sol +++ b/src/moves/AttackCalculator.sol @@ -16,6 +16,8 @@ library AttackCalculator { ITypeCalculator TYPE_CALCULATOR, bytes32 battleKey, uint256 attackerPlayerIndex, + uint256 attackerSlotIndex, + uint256 defenderSlotIndex, uint32 basePower, uint32 accuracy, // out of 100 uint256 volatility, @@ -25,8 +27,10 @@ library AttackCalculator { uint256 critRate // out of 100 ) internal returns (int32, bytes32) { uint256 defenderPlayerIndex = (attackerPlayerIndex + 1) % 2; - // Use batch getter to reduce external calls (7 -> 1) - DamageCalcContext memory ctx = ENGINE.getDamageCalcContext(battleKey, attackerPlayerIndex, defenderPlayerIndex); + // Use batch getter with slot indices for doubles support + DamageCalcContext memory ctx = ENGINE.getDamageCalcContext( + battleKey, attackerPlayerIndex, attackerSlotIndex, defenderPlayerIndex, defenderSlotIndex + ); (int32 damage, bytes32 eventType) = _calculateDamageFromContext( TYPE_CALCULATOR, ctx, @@ -52,7 +56,9 @@ library AttackCalculator { ITypeCalculator TYPE_CALCULATOR, bytes32 battleKey, uint256 attackerPlayerIndex, + uint256 attackerSlotIndex, uint256 defenderPlayerIndex, + uint256 defenderSlotIndex, uint32 basePower, uint32 accuracy, // out of 100 uint256 volatility, @@ -61,8 +67,10 @@ library AttackCalculator { uint256 rng, uint256 critRate // out of 100 ) internal view returns (int32, bytes32) { - // Use batch getter to reduce external calls (7 -> 1) - DamageCalcContext memory ctx = ENGINE.getDamageCalcContext(battleKey, attackerPlayerIndex, defenderPlayerIndex); + // Use batch getter with slot indices for doubles support + DamageCalcContext memory ctx = ENGINE.getDamageCalcContext( + battleKey, attackerPlayerIndex, attackerSlotIndex, defenderPlayerIndex, defenderSlotIndex + ); return _calculateDamageFromContext( TYPE_CALCULATOR, ctx, diff --git a/src/moves/StandardAttack.sol b/src/moves/StandardAttack.sol index 1e57eeed..3a257cf0 100644 --- a/src/moves/StandardAttack.sol +++ b/src/moves/StandardAttack.sol @@ -59,11 +59,14 @@ contract StandardAttack is IMoveSet, Ownable { int32 damage = 0; bytes32 eventType = bytes32(0); if (basePower(battleKey) > 0) { + // Singles uses slot 0 for both attacker and defender (damage, eventType) = AttackCalculator._calculateDamage( ENGINE, TYPE_CALCULATOR, battleKey, attackerPlayerIndex, + 0, // attackerSlotIndex (singles = slot 0) + 0, // defenderSlotIndex (singles = slot 0) basePower(battleKey), accuracy(battleKey), volatility(battleKey), diff --git a/test/BattleHistoryTest.sol b/test/BattleHistoryTest.sol index 6e015bce..2bd57c39 100644 --- a/test/BattleHistoryTest.sol +++ b/test/BattleHistoryTest.sol @@ -138,7 +138,8 @@ contract BattleHistoryTest is Test, BattleHelper { ruleset: IRuleset(address(0)), engineHooks: hooks, moveManager: address(commitManager), - matchmaker: matchmaker + matchmaker: matchmaker, + gameMode: GameMode.Singles }); // Propose battle diff --git a/test/CPUTest.sol b/test/CPUTest.sol index 11896178..c7703f0b 100644 --- a/test/CPUTest.sol +++ b/test/CPUTest.sol @@ -195,7 +195,8 @@ contract CPUTest is Test { teamRegistry: teamRegistry, engineHooks: new IEngineHook[](0), moveManager: address(cpu), - matchmaker: cpu + matchmaker: cpu, + gameMode: GameMode.Singles }); vm.startPrank(ALICE); @@ -313,7 +314,8 @@ contract CPUTest is Test { teamRegistry: teamRegistry, engineHooks: new IEngineHook[](0), moveManager: address(playerCPU), - matchmaker: playerCPU + matchmaker: playerCPU, + gameMode: GameMode.Singles }); vm.startPrank(ALICE); @@ -352,7 +354,8 @@ contract CPUTest is Test { teamRegistry: teamRegistry, engineHooks: new IEngineHook[](0), moveManager: address(playerCPU), - matchmaker: playerCPU + matchmaker: playerCPU, + gameMode: GameMode.Singles }); vm.startPrank(ALICE); @@ -442,7 +445,8 @@ contract CPUTest is Test { teamRegistry: teamRegistry, engineHooks: new IEngineHook[](0), moveManager: address(okayCPU), - matchmaker: okayCPU + matchmaker: okayCPU, + gameMode: GameMode.Singles }); vm.startPrank(ALICE); @@ -493,7 +497,8 @@ contract CPUTest is Test { teamRegistry: teamRegistry, engineHooks: new IEngineHook[](0), moveManager: address(okayCPU), - matchmaker: okayCPU + matchmaker: okayCPU, + gameMode: GameMode.Singles }); vm.startPrank(ALICE); @@ -545,7 +550,8 @@ contract CPUTest is Test { teamRegistry: teamRegistry, engineHooks: new IEngineHook[](0), moveManager: address(okayCPU), - matchmaker: okayCPU + matchmaker: okayCPU, + gameMode: GameMode.Singles }); vm.startPrank(ALICE); @@ -607,7 +613,8 @@ contract CPUTest is Test { teamRegistry: teamRegistry, engineHooks: new IEngineHook[](0), moveManager: address(okayCPU), - matchmaker: okayCPU + matchmaker: okayCPU, + gameMode: GameMode.Singles }); vm.startPrank(ALICE); @@ -664,7 +671,8 @@ contract CPUTest is Test { teamRegistry: teamRegistry, engineHooks: new IEngineHook[](0), moveManager: address(okayCPU), - matchmaker: okayCPU + matchmaker: okayCPU, + gameMode: GameMode.Singles }); vm.startPrank(ALICE); @@ -721,7 +729,8 @@ contract CPUTest is Test { teamRegistry: teamRegistry, engineHooks: new IEngineHook[](0), moveManager: address(okayCPU), - matchmaker: okayCPU + matchmaker: okayCPU, + gameMode: GameMode.Singles }); vm.startPrank(ALICE); diff --git a/test/DefaultCommitManagerTest.sol b/test/DefaultCommitManagerTest.sol index 1d31e473..0e908e46 100644 --- a/test/DefaultCommitManagerTest.sol +++ b/test/DefaultCommitManagerTest.sol @@ -7,6 +7,7 @@ import "../src/Constants.sol"; import "../src/Enums.sol"; import "../src/Structs.sol"; +import {BaseCommitManager} from "../src/BaseCommitManager.sol"; import {DefaultCommitManager} from "../src/DefaultCommitManager.sol"; import {Engine} from "../src/Engine.sol"; import {DefaultValidator} from "../src/DefaultValidator.sol"; @@ -76,7 +77,7 @@ contract DefaultCommitManagerTest is Test, BattleHelper { function test_cannotCommitForArbitraryBattleKey() public { bytes32 battleKey = _startBattle(validator, engine, defaultOracle, defaultRegistry, matchmaker, address(commitManager)); vm.startPrank(CARL); - vm.expectRevert(DefaultCommitManager.NotP0OrP1.selector); + vm.expectRevert(BaseCommitManager.NotP0OrP1.selector); commitManager.commitMove(battleKey, ""); } @@ -90,7 +91,7 @@ contract DefaultCommitManagerTest is Test, BattleHelper { commitManager.commitMove(battleKey, moveHash); // Alice tries to reveal - vm.expectRevert(DefaultCommitManager.NotYetRevealed.selector); + vm.expectRevert(BaseCommitManager.NotYetRevealed.selector); commitManager.revealMove(battleKey, moveIndex, bytes32(""), uint240(0), false); } @@ -108,16 +109,16 @@ contract DefaultCommitManagerTest is Test, BattleHelper { _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey, NO_OP_MOVE_INDEX, NO_OP_MOVE_INDEX, 0, 0); // Alice's turn again to move vm.startPrank(ALICE); - vm.expectRevert(DefaultCommitManager.RevealBeforeSelfCommit.selector); + vm.expectRevert(BaseCommitManager.RevealBeforeSelfCommit.selector); commitManager.revealMove(battleKey, NO_OP_MOVE_INDEX, bytes32(""), 0, false); } function test_BattleNotYetStarted() public { vm.startPrank(ALICE); - vm.expectRevert(DefaultCommitManager.BattleNotYetStarted.selector); + vm.expectRevert(BaseCommitManager.BattleNotYetStarted.selector); commitManager.revealMove(bytes32(0), NO_OP_MOVE_INDEX, bytes32(""), 0, false); vm.startPrank(BOB); - vm.expectRevert(DefaultCommitManager.BattleNotYetStarted.selector); + vm.expectRevert(BaseCommitManager.BattleNotYetStarted.selector); commitManager.commitMove(bytes32(0), bytes32(0)); } @@ -127,10 +128,10 @@ contract DefaultCommitManagerTest is Test, BattleHelper { vm.warp(TIMEOUT * TIMEOUT); engine.end(battleKey); vm.startPrank(ALICE); - vm.expectRevert(DefaultCommitManager.BattleAlreadyComplete.selector); + vm.expectRevert(BaseCommitManager.BattleAlreadyComplete.selector); commitManager.revealMove(battleKey, NO_OP_MOVE_INDEX, bytes32(""), 0, false); vm.startPrank(BOB); - vm.expectRevert(DefaultCommitManager.BattleAlreadyComplete.selector); + vm.expectRevert(BaseCommitManager.BattleAlreadyComplete.selector); commitManager.commitMove(battleKey, bytes32(0)); } diff --git a/test/DoublesCommitManagerTest.sol b/test/DoublesCommitManagerTest.sol new file mode 100644 index 00000000..14b5bf26 --- /dev/null +++ b/test/DoublesCommitManagerTest.sol @@ -0,0 +1,884 @@ +// 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 {BaseCommitManager} from "../src/BaseCommitManager.sol"; +import {DoublesCommitManager} from "../src/DoublesCommitManager.sol"; +import {Engine} from "../src/Engine.sol"; +import {DefaultValidator} from "../src/DefaultValidator.sol"; +import {IEngineHook} from "../src/IEngineHook.sol"; +import {DefaultMatchmaker} from "../src/matchmaker/DefaultMatchmaker.sol"; +import {IMoveSet} from "../src/moves/IMoveSet.sol"; +import {DefaultRandomnessOracle} from "../src/rng/DefaultRandomnessOracle.sol"; +import {ITypeCalculator} from "../src/types/ITypeCalculator.sol"; +import {TestTeamRegistry} from "./mocks/TestTeamRegistry.sol"; +import {TestTypeCalculator} from "./mocks/TestTypeCalculator.sol"; +import {CustomAttack} from "./mocks/CustomAttack.sol"; +import {DoublesTargetedAttack} from "./mocks/DoublesTargetedAttack.sol"; + +contract DoublesCommitManagerTest is Test { + address constant ALICE = address(0x1); + address constant BOB = address(0x2); + + DoublesCommitManager commitManager; + Engine engine; + DefaultValidator validator; + ITypeCalculator typeCalc; + DefaultRandomnessOracle defaultOracle; + DefaultMatchmaker matchmaker; + TestTeamRegistry defaultRegistry; + CustomAttack customAttack; + + uint256 constant TIMEOUT_DURATION = 100; + + function setUp() public { + // Deploy core contracts + engine = new Engine(); + typeCalc = new TestTypeCalculator(); + defaultOracle = new DefaultRandomnessOracle(); + validator = new DefaultValidator( + engine, DefaultValidator.Args({MONS_PER_TEAM: 2, MOVES_PER_MON: 4, TIMEOUT_DURATION: TIMEOUT_DURATION}) + ); + matchmaker = new DefaultMatchmaker(engine); + commitManager = new DoublesCommitManager(engine); + defaultRegistry = new TestTeamRegistry(); + + // Create a simple attack for testing + customAttack = new CustomAttack( + engine, typeCalc, CustomAttack.Args({TYPE: Type.Fire, BASE_POWER: 10, ACCURACY: 100, STAMINA_COST: 1, PRIORITY: 0}) + ); + + // Register teams for Alice and Bob (need at least 2 mons for doubles) + IMoveSet[] memory moves = new IMoveSet[](4); + moves[0] = customAttack; + moves[1] = customAttack; + moves[2] = customAttack; + moves[3] = customAttack; + + Mon[] memory team = new Mon[](2); + team[0] = Mon({ + stats: MonStats({ + hp: 100, + stamina: 50, + speed: 10, + attack: 10, + defense: 10, + specialAttack: 10, + specialDefense: 10, + type1: Type.Fire, + type2: Type.None + }), + ability: IAbility(address(0)), + moves: moves + }); + team[1] = Mon({ + stats: MonStats({ + hp: 100, + stamina: 50, + speed: 8, + attack: 10, + defense: 10, + specialAttack: 10, + specialDefense: 10, + type1: Type.Liquid, + type2: Type.None + }), + ability: IAbility(address(0)), + moves: moves + }); + + defaultRegistry.setTeam(ALICE, team); + defaultRegistry.setTeam(BOB, team); + + // Authorize matchmaker for both players + vm.startPrank(ALICE); + address[] memory makersToAdd = new address[](1); + makersToAdd[0] = address(matchmaker); + address[] memory makersToRemove = new address[](0); + engine.updateMatchmakers(makersToAdd, makersToRemove); + vm.stopPrank(); + + vm.startPrank(BOB); + engine.updateMatchmakers(makersToAdd, makersToRemove); + vm.stopPrank(); + } + + function _startDoublesBattle() internal returns (bytes32 battleKey) { + // Compute p0 team hash + bytes32 salt = ""; + uint96 p0TeamIndex = 0; + uint256[] memory p0TeamIndices = defaultRegistry.getMonRegistryIndicesForTeam(ALICE, p0TeamIndex); + bytes32 p0TeamHash = keccak256(abi.encodePacked(salt, p0TeamIndex, p0TeamIndices)); + + // Create proposal for DOUBLES + ProposedBattle memory proposal = ProposedBattle({ + p0: ALICE, + p0TeamIndex: 0, + p0TeamHash: p0TeamHash, + p1: BOB, + p1TeamIndex: 0, + teamRegistry: defaultRegistry, + validator: validator, + rngOracle: defaultOracle, + ruleset: IRuleset(address(0)), + engineHooks: new IEngineHook[](0), + moveManager: address(commitManager), + matchmaker: matchmaker, + gameMode: GameMode.Doubles // KEY: This is a doubles battle + }); + + // Propose battle + vm.startPrank(ALICE); + battleKey = matchmaker.proposeBattle(proposal); + + // Accept battle + bytes32 battleIntegrityHash = matchmaker.getBattleProposalIntegrityHash(proposal); + vm.startPrank(BOB); + matchmaker.acceptBattle(battleKey, 0, battleIntegrityHash); + + // Confirm and start battle + vm.startPrank(ALICE); + matchmaker.confirmBattle(battleKey, salt, p0TeamIndex); + + vm.stopPrank(); + } + + function test_doublesCommitAndReveal() public { + bytes32 battleKey = _startDoublesBattle(); + + // Verify it's a doubles battle + assertEq(uint256(engine.getGameMode(battleKey)), uint256(GameMode.Doubles)); + + // Turn 0: Both players must switch to select initial active mons + // Alice commits (even turn = p0 commits) + bytes32 salt = bytes32("secret"); + uint8 aliceMove0 = SWITCH_MOVE_INDEX; // Switch to mon index 0 for slot 0 + uint240 aliceExtra0 = 0; // Mon index 0 + uint8 aliceMove1 = SWITCH_MOVE_INDEX; // Switch to mon index 1 for slot 1 + uint240 aliceExtra1 = 1; // Mon index 1 + + bytes32 aliceHash = keccak256(abi.encodePacked(aliceMove0, aliceExtra0, aliceMove1, aliceExtra1, salt)); + + vm.startPrank(ALICE); + commitManager.commitMoves(battleKey, aliceHash); + vm.stopPrank(); + + // Bob reveals first (non-committing player reveals first) + uint8 bobMove0 = SWITCH_MOVE_INDEX; + uint240 bobExtra0 = 0; // Mon index 0 + uint8 bobMove1 = SWITCH_MOVE_INDEX; + uint240 bobExtra1 = 1; // Mon index 1 + bytes32 bobSalt = bytes32("bobsalt"); + + vm.startPrank(BOB); + commitManager.revealMoves(battleKey, bobMove0, bobExtra0, bobMove1, bobExtra1, bobSalt, false); + vm.stopPrank(); + + // Alice reveals (committing player reveals second) + vm.startPrank(ALICE); + commitManager.revealMoves(battleKey, aliceMove0, aliceExtra0, aliceMove1, aliceExtra1, salt, false); + vm.stopPrank(); + + // Verify moves were set correctly + MoveDecision memory p0Move = engine.getMoveDecisionForBattleState(battleKey, 0); + MoveDecision memory p1Move = engine.getMoveDecisionForBattleState(battleKey, 1); + + // Check that moves were set (packedMoveIndex should have IS_REAL_TURN_BIT set) + assertTrue(p0Move.packedMoveIndex & IS_REAL_TURN_BIT != 0, "Alice slot 0 move should be set"); + assertTrue(p1Move.packedMoveIndex & IS_REAL_TURN_BIT != 0, "Bob slot 0 move should be set"); + } + + function test_doublesCannotCommitToSinglesBattle() public { + // Start a SINGLES battle instead + bytes32 salt = ""; + uint96 p0TeamIndex = 0; + uint256[] memory p0TeamIndices = defaultRegistry.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: defaultRegistry, + validator: validator, + rngOracle: defaultOracle, + ruleset: IRuleset(address(0)), + engineHooks: new IEngineHook[](0), + moveManager: address(commitManager), + matchmaker: matchmaker, + gameMode: GameMode.Singles // Singles battle + }); + + vm.startPrank(ALICE); + bytes32 battleKey = matchmaker.proposeBattle(proposal); + + bytes32 battleIntegrityHash = matchmaker.getBattleProposalIntegrityHash(proposal); + vm.startPrank(BOB); + matchmaker.acceptBattle(battleKey, 0, battleIntegrityHash); + + vm.startPrank(ALICE); + matchmaker.confirmBattle(battleKey, salt, p0TeamIndex); + + // Try to commit with DoublesCommitManager - should fail + bytes32 moveHash = keccak256(abi.encodePacked(uint8(0), uint240(0), uint8(0), uint240(0), bytes32("salt"))); + vm.expectRevert(DoublesCommitManager.NotDoublesMode.selector); + commitManager.commitMoves(battleKey, moveHash); + vm.stopPrank(); + } + + function test_doublesExecutionWithAllFourMoves() public { + bytes32 battleKey = _startDoublesBattle(); + + // Turn 0: Both players must switch to select initial active mons + bytes32 salt = bytes32("secret"); + uint8 aliceMove0 = SWITCH_MOVE_INDEX; + uint240 aliceExtra0 = 0; // Mon index 0 for slot 0 + uint8 aliceMove1 = SWITCH_MOVE_INDEX; + uint240 aliceExtra1 = 1; // Mon index 1 for slot 1 + + bytes32 aliceHash = keccak256(abi.encodePacked(aliceMove0, aliceExtra0, aliceMove1, aliceExtra1, salt)); + + vm.startPrank(ALICE); + commitManager.commitMoves(battleKey, aliceHash); + vm.stopPrank(); + + // Bob reveals first + uint8 bobMove0 = SWITCH_MOVE_INDEX; + uint240 bobExtra0 = 0; + uint8 bobMove1 = SWITCH_MOVE_INDEX; + uint240 bobExtra1 = 1; + bytes32 bobSalt = bytes32("bobsalt"); + + vm.startPrank(BOB); + commitManager.revealMoves(battleKey, bobMove0, bobExtra0, bobMove1, bobExtra1, bobSalt, false); + vm.stopPrank(); + + // Alice reveals + vm.startPrank(ALICE); + commitManager.revealMoves(battleKey, aliceMove0, aliceExtra0, aliceMove1, aliceExtra1, salt, false); + vm.stopPrank(); + + // Execute turn 0 (initial mon selection) + engine.execute(battleKey); + + // Verify the game advanced to turn 1 + assertEq(engine.getTurnIdForBattleState(battleKey), 1); + + // Verify active mon indices are set correctly for doubles + assertEq(engine.getActiveMonIndexForSlot(battleKey, 0, 0), 0); // p0 slot 0 = mon 0 + assertEq(engine.getActiveMonIndexForSlot(battleKey, 0, 1), 1); // p0 slot 1 = mon 1 + assertEq(engine.getActiveMonIndexForSlot(battleKey, 1, 0), 0); // p1 slot 0 = mon 0 + assertEq(engine.getActiveMonIndexForSlot(battleKey, 1, 1), 1); // p1 slot 1 = mon 1 + + // Turn 1: Both players use attack moves + bytes32 salt2 = bytes32("secret2"); + uint8 aliceAttack0 = 0; // Move index 0 (attack) + uint240 aliceTarget0 = 0; // Target opponent slot 0 + uint8 aliceAttack1 = 0; + uint240 aliceTarget1 = 0; + + bytes32 aliceHash2 = keccak256(abi.encodePacked(aliceAttack0, aliceTarget0, aliceAttack1, aliceTarget1, salt2)); + + vm.startPrank(BOB); + // Bob commits this turn (odd turn = p1 commits) + bytes32 bobSalt2 = bytes32("bobsalt2"); + uint8 bobAttack0 = 0; + uint240 bobTarget0 = 0; + uint8 bobAttack1 = 0; + uint240 bobTarget1 = 0; + bytes32 bobHash2 = keccak256(abi.encodePacked(bobAttack0, bobTarget0, bobAttack1, bobTarget1, bobSalt2)); + commitManager.commitMoves(battleKey, bobHash2); + vm.stopPrank(); + + // Alice reveals first (non-committing player) + vm.startPrank(ALICE); + commitManager.revealMoves(battleKey, aliceAttack0, aliceTarget0, aliceAttack1, aliceTarget1, salt2, false); + vm.stopPrank(); + + // Bob reveals + vm.startPrank(BOB); + commitManager.revealMoves(battleKey, bobAttack0, bobTarget0, bobAttack1, bobTarget1, bobSalt2, false); + vm.stopPrank(); + + // Execute turn 1 (attacks) + engine.execute(battleKey); + + // Verify the game advanced to turn 2 + assertEq(engine.getTurnIdForBattleState(battleKey), 2); + + // Battle should still be ongoing (no winner yet) + assertEq(engine.getWinner(battleKey), address(0)); + } + + function test_doublesWrongPreimageReverts() public { + bytes32 battleKey = _startDoublesBattle(); + + // Alice commits (turn 0 - must use SWITCH_MOVE_INDEX) + bytes32 salt = bytes32("secret"); + uint8 aliceMove0 = SWITCH_MOVE_INDEX; + uint240 aliceExtra0 = 0; + uint8 aliceMove1 = SWITCH_MOVE_INDEX; + uint240 aliceExtra1 = 1; + + bytes32 aliceHash = keccak256(abi.encodePacked(aliceMove0, aliceExtra0, aliceMove1, aliceExtra1, salt)); + + vm.startPrank(ALICE); + commitManager.commitMoves(battleKey, aliceHash); + vm.stopPrank(); + + // Bob reveals first (also must use SWITCH_MOVE_INDEX on turn 0) + vm.startPrank(BOB); + commitManager.revealMoves(battleKey, SWITCH_MOVE_INDEX, 0, SWITCH_MOVE_INDEX, 1, bytes32("bobsalt"), false); + vm.stopPrank(); + + // Alice tries to reveal with wrong moves - should fail + vm.startPrank(ALICE); + vm.expectRevert(BaseCommitManager.WrongPreimage.selector); + commitManager.revealMoves(battleKey, SWITCH_MOVE_INDEX, 1, SWITCH_MOVE_INDEX, 0, salt, false); // Wrong extraData values + vm.stopPrank(); + } + + // ========================================= + // Helper functions for doubles tests + // ========================================= + + // Helper to commit and reveal moves for both players in doubles, then execute + function _doublesCommitRevealExecute( + bytes32 battleKey, + uint8 aliceMove0, + uint240 aliceExtra0, + uint8 aliceMove1, + uint240 aliceExtra1, + uint8 bobMove0, + uint240 bobExtra0, + uint8 bobMove1, + uint240 bobExtra1 + ) internal { + uint256 turnId = engine.getTurnIdForBattleState(battleKey); + bytes32 aliceSalt = bytes32("alicesalt"); + bytes32 bobSalt = bytes32("bobsalt"); + + if (turnId % 2 == 0) { + // Alice commits first on even turns + bytes32 aliceHash = keccak256(abi.encodePacked(aliceMove0, aliceExtra0, aliceMove1, aliceExtra1, aliceSalt)); + vm.startPrank(ALICE); + commitManager.commitMoves(battleKey, aliceHash); + vm.stopPrank(); + + // Bob reveals first + vm.startPrank(BOB); + commitManager.revealMoves(battleKey, bobMove0, bobExtra0, bobMove1, bobExtra1, bobSalt, false); + vm.stopPrank(); + + // Alice reveals + vm.startPrank(ALICE); + commitManager.revealMoves(battleKey, aliceMove0, aliceExtra0, aliceMove1, aliceExtra1, aliceSalt, false); + vm.stopPrank(); + } else { + // Bob commits first on odd turns + bytes32 bobHash = keccak256(abi.encodePacked(bobMove0, bobExtra0, bobMove1, bobExtra1, bobSalt)); + vm.startPrank(BOB); + commitManager.commitMoves(battleKey, bobHash); + vm.stopPrank(); + + // Alice reveals first + vm.startPrank(ALICE); + commitManager.revealMoves(battleKey, aliceMove0, aliceExtra0, aliceMove1, aliceExtra1, aliceSalt, false); + vm.stopPrank(); + + // Bob reveals + vm.startPrank(BOB); + commitManager.revealMoves(battleKey, bobMove0, bobExtra0, bobMove1, bobExtra1, bobSalt, false); + vm.stopPrank(); + } + + // Execute the turn + engine.execute(battleKey); + } + + // Helper to do initial switch on turn 0 + function _doInitialSwitch(bytes32 battleKey) internal { + _doublesCommitRevealExecute( + battleKey, + SWITCH_MOVE_INDEX, 0, SWITCH_MOVE_INDEX, 1, // Alice: slot 0 -> mon 0, slot 1 -> mon 1 + SWITCH_MOVE_INDEX, 0, SWITCH_MOVE_INDEX, 1 // Bob: slot 0 -> mon 0, slot 1 -> mon 1 + ); + } + + // ========================================= + // Doubles Boundary Condition Tests + // ========================================= + + function test_doublesFasterSpeedExecutesFirst() public { + // Test that faster mons execute first in doubles + // NOTE: Current StandardAttack always targets opponent slot 0, so we test + // that faster mon KOs opponent's slot 0 before slower opponent can attack + + IMoveSet[] memory moves = new IMoveSet[](4); + CustomAttack strongAttack = new CustomAttack( + engine, typeCalc, CustomAttack.Args({TYPE: Type.Fire, BASE_POWER: 200, ACCURACY: 100, STAMINA_COST: 1, PRIORITY: 0}) + ); + moves[0] = strongAttack; + moves[1] = strongAttack; + moves[2] = strongAttack; + moves[3] = strongAttack; + + // Alice has faster mons (speed 20 and 18) + Mon[] memory aliceTeam = new Mon[](2); + aliceTeam[0] = Mon({ + stats: MonStats({ + hp: 100, stamina: 50, speed: 20, attack: 100, defense: 10, + specialAttack: 10, specialDefense: 10, type1: Type.Fire, type2: Type.None + }), + ability: IAbility(address(0)), + moves: moves + }); + aliceTeam[1] = Mon({ + stats: MonStats({ + hp: 100, stamina: 50, speed: 18, attack: 100, defense: 10, + specialAttack: 10, specialDefense: 10, type1: Type.Fire, type2: Type.None + }), + ability: IAbility(address(0)), + moves: moves + }); + + // Bob has slower mons (speed 10 and 8) with low HP + Mon[] memory bobTeam = new Mon[](2); + bobTeam[0] = Mon({ + stats: MonStats({ + hp: 10, stamina: 50, speed: 10, attack: 100, defense: 10, + specialAttack: 10, specialDefense: 10, type1: Type.Fire, type2: Type.None + }), + ability: IAbility(address(0)), + moves: moves + }); + bobTeam[1] = Mon({ + stats: MonStats({ + hp: 10, stamina: 50, speed: 8, attack: 100, defense: 10, + specialAttack: 10, specialDefense: 10, type1: Type.Fire, type2: Type.None + }), + ability: IAbility(address(0)), + moves: moves + }); + + defaultRegistry.setTeam(ALICE, aliceTeam); + defaultRegistry.setTeam(BOB, bobTeam); + + bytes32 battleKey = _startDoublesBattle(); + vm.warp(block.timestamp + 1); + + // Turn 0: Initial switch + _doInitialSwitch(battleKey); + + // Turn 1: All attack - Alice's faster slot 0 mon attacks before Bob's slot 0 can act + // Both Alice mons attack Bob slot 0 (default targeting), KO'ing it + // Bob's slot 0 mon is KO'd before it can attack + _doublesCommitRevealExecute( + battleKey, + 0, 0, 0, 0, // Alice: both slots use move 0 + 0, 0, 0, 0 // Bob: both slots use move 0 + ); + + // Bob's slot 0 should be KO'd, game continues + assertEq(engine.getWinner(battleKey), address(0)); // Game not over yet + + // Turn 2: Alice attacks again, Bob's slot 1 now in slot 0 position after forced switch + // Since Bob has no more mons to switch, game should end + // Actually, Bob still has slot 1 alive, so he needs to switch slot 0 to a new mon + // But with only 2 mons and slot 1 still having mon index 1, Bob can't switch + // The game continues with Bob's surviving slot 1 mon + + // Verify turn advanced + assertEq(engine.getTurnIdForBattleState(battleKey), 2); + } + + function test_doublesFasterPriorityExecutesFirst() public { + // Test that higher priority moves execute before lower priority, regardless of speed + // NOTE: All attacks target opponent slot 0 by default + + CustomAttack lowPriorityAttack = new CustomAttack( + engine, typeCalc, CustomAttack.Args({TYPE: Type.Fire, BASE_POWER: 200, ACCURACY: 100, STAMINA_COST: 1, PRIORITY: 0}) + ); + CustomAttack highPriorityAttack = new CustomAttack( + engine, typeCalc, CustomAttack.Args({TYPE: Type.Fire, BASE_POWER: 200, ACCURACY: 100, STAMINA_COST: 1, PRIORITY: 1}) + ); + + IMoveSet[] memory aliceMoves = new IMoveSet[](4); + aliceMoves[0] = highPriorityAttack; // Alice has high priority + aliceMoves[1] = highPriorityAttack; + aliceMoves[2] = highPriorityAttack; + aliceMoves[3] = highPriorityAttack; + + IMoveSet[] memory bobMoves = new IMoveSet[](4); + bobMoves[0] = lowPriorityAttack; // Bob has low priority + bobMoves[1] = lowPriorityAttack; + bobMoves[2] = lowPriorityAttack; + bobMoves[3] = lowPriorityAttack; + + // Alice has SLOWER mons but higher priority moves, high HP to survive + Mon[] memory aliceTeam = new Mon[](2); + aliceTeam[0] = Mon({ + stats: MonStats({ + hp: 100, stamina: 50, speed: 1, attack: 100, defense: 10, + specialAttack: 10, specialDefense: 10, type1: Type.Fire, type2: Type.None + }), + ability: IAbility(address(0)), + moves: aliceMoves + }); + aliceTeam[1] = Mon({ + stats: MonStats({ + hp: 100, stamina: 50, speed: 1, attack: 100, defense: 10, + specialAttack: 10, specialDefense: 10, type1: Type.Fire, type2: Type.None + }), + ability: IAbility(address(0)), + moves: aliceMoves + }); + + // Bob has FASTER mons but lower priority moves, low HP to get KO'd + Mon[] memory bobTeam = new Mon[](2); + bobTeam[0] = Mon({ + stats: MonStats({ + hp: 10, stamina: 50, speed: 100, attack: 100, defense: 10, + specialAttack: 10, specialDefense: 10, type1: Type.Fire, type2: Type.None + }), + ability: IAbility(address(0)), + moves: bobMoves + }); + bobTeam[1] = Mon({ + stats: MonStats({ + hp: 10, stamina: 50, speed: 100, attack: 100, defense: 10, + specialAttack: 10, specialDefense: 10, type1: Type.Fire, type2: Type.None + }), + ability: IAbility(address(0)), + moves: bobMoves + }); + + defaultRegistry.setTeam(ALICE, aliceTeam); + defaultRegistry.setTeam(BOB, bobTeam); + + bytes32 battleKey = _startDoublesBattle(); + vm.warp(block.timestamp + 1); + + _doInitialSwitch(battleKey); + + // Turn 1: Alice's high priority moves execute first, KO'ing Bob's slot 0 + _doublesCommitRevealExecute( + battleKey, + 0, 0, 0, 0, + 0, 0, 0, 0 + ); + + // Bob's slot 0 should be KO'd before it could attack (due to priority) + // Game continues with Bob's slot 1 still alive + assertEq(engine.getWinner(battleKey), address(0)); + assertEq(engine.getTurnIdForBattleState(battleKey), 2); + } + + function test_doublesPositionTiebreaker() public { + // All mons have same speed and priority, test position tiebreaker + // Expected order: p0s0 (Alice slot 0) > p0s1 (Alice slot 1) > p1s0 (Bob slot 0) > p1s1 (Bob slot 1) + + // Create a weak attack that won't KO (to see all 4 moves execute) + CustomAttack weakAttack = new CustomAttack( + engine, typeCalc, CustomAttack.Args({TYPE: Type.Fire, BASE_POWER: 1, ACCURACY: 100, STAMINA_COST: 1, PRIORITY: 0}) + ); + + IMoveSet[] memory moves = new IMoveSet[](4); + moves[0] = weakAttack; + moves[1] = weakAttack; + moves[2] = weakAttack; + moves[3] = weakAttack; + + // All mons have same speed (10) + Mon[] memory team = new Mon[](2); + team[0] = Mon({ + stats: MonStats({ + hp: 100, stamina: 50, speed: 10, attack: 10, defense: 10, + specialAttack: 10, specialDefense: 10, type1: Type.Fire, type2: Type.None + }), + ability: IAbility(address(0)), + moves: moves + }); + team[1] = Mon({ + stats: MonStats({ + hp: 100, stamina: 50, speed: 10, attack: 10, defense: 10, + specialAttack: 10, specialDefense: 10, type1: Type.Fire, type2: Type.None + }), + ability: IAbility(address(0)), + moves: moves + }); + + defaultRegistry.setTeam(ALICE, team); + defaultRegistry.setTeam(BOB, team); + + bytes32 battleKey = _startDoublesBattle(); + vm.warp(block.timestamp + 1); + + _doInitialSwitch(battleKey); + + // Turn 1: All attack with weak attacks (no KOs expected) + _doublesCommitRevealExecute( + battleKey, + 0, 0, 0, 0, + 0, 0, 0, 0 + ); + + // Battle should still be ongoing (all 4 moves executed, no KOs) + assertEq(engine.getWinner(battleKey), address(0)); + assertEq(engine.getTurnIdForBattleState(battleKey), 2); + } + + function test_doublesPartialKOContinuesBattle() public { + // Test that if only 1 mon per player is KO'd, battle continues + + CustomAttack strongAttack = new CustomAttack( + engine, typeCalc, CustomAttack.Args({TYPE: Type.Fire, BASE_POWER: 200, ACCURACY: 100, STAMINA_COST: 1, PRIORITY: 0}) + ); + CustomAttack weakAttack = new CustomAttack( + engine, typeCalc, CustomAttack.Args({TYPE: Type.Fire, BASE_POWER: 1, ACCURACY: 100, STAMINA_COST: 1, PRIORITY: 0}) + ); + + // Slot 0 has strong attack, slot 1 has weak attack + IMoveSet[] memory strongMoves = new IMoveSet[](4); + strongMoves[0] = strongAttack; + strongMoves[1] = strongAttack; + strongMoves[2] = strongAttack; + strongMoves[3] = strongAttack; + + IMoveSet[] memory weakMoves = new IMoveSet[](4); + weakMoves[0] = weakAttack; + weakMoves[1] = weakAttack; + weakMoves[2] = weakAttack; + weakMoves[3] = weakAttack; + + Mon[] memory team = new Mon[](2); + // Slot 0: High HP, strong attack (will KO opponent's slot 0) + team[0] = Mon({ + stats: MonStats({ + hp: 100, stamina: 50, speed: 10, attack: 100, defense: 10, + specialAttack: 10, specialDefense: 10, type1: Type.Fire, type2: Type.None + }), + ability: IAbility(address(0)), + moves: strongMoves + }); + // Slot 1: Low HP, weak attack (won't KO anything, but could get KO'd) + team[1] = Mon({ + stats: MonStats({ + hp: 10, stamina: 50, speed: 5, attack: 10, defense: 10, + specialAttack: 10, specialDefense: 10, type1: Type.Fire, type2: Type.None + }), + ability: IAbility(address(0)), + moves: weakMoves + }); + + defaultRegistry.setTeam(ALICE, team); + defaultRegistry.setTeam(BOB, team); + + bytes32 battleKey = _startDoublesBattle(); + vm.warp(block.timestamp + 1); + + _doInitialSwitch(battleKey); + + // Turn 1: Both slot 0s attack each other (mutual KO), slot 1s use weak attack + // After this, both players should have their slot 0 mons KO'd but slot 1 alive + _doublesCommitRevealExecute( + battleKey, + 0, 0, 0, 0, // Alice: both attack + 0, 0, 0, 0 // Bob: both attack + ); + + // Battle should continue (both still have slot 1 alive) + assertEq(engine.getWinner(battleKey), address(0)); + } + + function test_doublesGameOverWhenAllMonsKOed() public { + // Test that game ends when ALL of one player's mons are KO'd + // Using DoublesTargetedAttack to target specific slots via extraData + + DoublesTargetedAttack targetedAttack = new DoublesTargetedAttack( + engine, typeCalc, DoublesTargetedAttack.Args({TYPE: Type.Fire, BASE_POWER: 500, ACCURACY: 100, STAMINA_COST: 1, PRIORITY: 0}) + ); + + IMoveSet[] memory moves = new IMoveSet[](4); + moves[0] = targetedAttack; + moves[1] = targetedAttack; + moves[2] = targetedAttack; + moves[3] = targetedAttack; + + // Alice has fast mons with high HP + Mon[] memory aliceTeam = new Mon[](2); + aliceTeam[0] = Mon({ + stats: MonStats({ + hp: 1000, stamina: 50, speed: 100, attack: 100, defense: 10, + specialAttack: 10, specialDefense: 10, type1: Type.Fire, type2: Type.None + }), + ability: IAbility(address(0)), + moves: moves + }); + aliceTeam[1] = Mon({ + stats: MonStats({ + hp: 1000, stamina: 50, speed: 99, attack: 100, defense: 10, + specialAttack: 10, specialDefense: 10, type1: Type.Fire, type2: Type.None + }), + ability: IAbility(address(0)), + moves: moves + }); + + // Bob has slow mons with low HP that will be KO'd + Mon[] memory bobTeam = new Mon[](2); + bobTeam[0] = Mon({ + stats: MonStats({ + hp: 10, stamina: 50, speed: 1, attack: 10, defense: 10, + specialAttack: 10, specialDefense: 10, type1: Type.Fire, type2: Type.None + }), + ability: IAbility(address(0)), + moves: moves + }); + bobTeam[1] = Mon({ + stats: MonStats({ + hp: 10, stamina: 50, speed: 1, attack: 10, defense: 10, + specialAttack: 10, specialDefense: 10, type1: Type.Fire, type2: Type.None + }), + ability: IAbility(address(0)), + moves: moves + }); + + defaultRegistry.setTeam(ALICE, aliceTeam); + defaultRegistry.setTeam(BOB, bobTeam); + + bytes32 battleKey = _startDoublesBattle(); + vm.warp(block.timestamp + 1); + + _doInitialSwitch(battleKey); + + // Turn 1: Alice's slot 0 targets Bob slot 0, Alice's slot 1 targets Bob slot 1 + // extraData = 0 means target opponent slot 0, extraData = 1 means target opponent slot 1 + _doublesCommitRevealExecute( + battleKey, + 0, 0, 0, 1, // Alice: slot 0 targets Bob slot 0, slot 1 targets Bob slot 1 + 0, 0, 0, 0 // Bob: both attack (but won't execute - KO'd first) + ); + + // Alice should win because both of Bob's mons are KO'd + assertEq(engine.getWinner(battleKey), ALICE); + } + + function test_doublesSwitchPriorityBeforeAttacks() public { + // Test that switches happen before regular attacks in doubles + + CustomAttack strongAttack = new CustomAttack( + engine, typeCalc, CustomAttack.Args({TYPE: Type.Fire, BASE_POWER: 200, ACCURACY: 100, STAMINA_COST: 1, PRIORITY: 0}) + ); + + IMoveSet[] memory moves = new IMoveSet[](4); + moves[0] = strongAttack; + moves[1] = strongAttack; + moves[2] = strongAttack; + moves[3] = strongAttack; + + // Both players have same stats + Mon[] memory team = new Mon[](2); + team[0] = Mon({ + stats: MonStats({ + hp: 100, stamina: 50, speed: 10, attack: 100, defense: 10, + specialAttack: 10, specialDefense: 10, type1: Type.Fire, type2: Type.None + }), + ability: IAbility(address(0)), + moves: moves + }); + team[1] = Mon({ + stats: MonStats({ + hp: 100, stamina: 50, speed: 10, attack: 100, defense: 10, + specialAttack: 10, specialDefense: 10, type1: Type.Fire, type2: Type.None + }), + ability: IAbility(address(0)), + moves: moves + }); + + defaultRegistry.setTeam(ALICE, team); + defaultRegistry.setTeam(BOB, team); + + bytes32 battleKey = _startDoublesBattle(); + vm.warp(block.timestamp + 1); + + _doInitialSwitch(battleKey); + + // Verify initial state + assertEq(engine.getActiveMonIndexForSlot(battleKey, 0, 0), 0); // Alice slot 0 = mon 0 + assertEq(engine.getActiveMonIndexForSlot(battleKey, 0, 1), 1); // Alice slot 1 = mon 1 + + // Turn 1: Alice switches slot 0 (switching to self is allowed on turn > 0? Let's switch slot indices) + // Actually, for a valid switch, need to switch to a different mon. Since we only have 2 mons + // and both are active, this test needs adjustment. Let me use NO_OP for one slot and attack for others + _doublesCommitRevealExecute( + battleKey, + NO_OP_MOVE_INDEX, 0, 0, 0, // Alice: slot 0 no-op, slot 1 attacks + 0, 0, 0, 0 // Bob: both attack + ); + + // Battle continues (no KOs with these HP values) + assertEq(engine.getWinner(battleKey), address(0)); + assertEq(engine.getTurnIdForBattleState(battleKey), 2); + } + + function test_doublesNonKOSubsequentMoves() public { + // Test that non-KO moves properly advance the game state + + CustomAttack weakAttack = new CustomAttack( + engine, typeCalc, CustomAttack.Args({TYPE: Type.Fire, BASE_POWER: 5, ACCURACY: 100, STAMINA_COST: 1, PRIORITY: 0}) + ); + + IMoveSet[] memory moves = new IMoveSet[](4); + moves[0] = weakAttack; + moves[1] = weakAttack; + moves[2] = weakAttack; + moves[3] = weakAttack; + + Mon[] memory team = new Mon[](2); + team[0] = Mon({ + stats: MonStats({ + hp: 100, stamina: 50, speed: 10, attack: 10, defense: 10, + specialAttack: 10, specialDefense: 10, type1: Type.Fire, type2: Type.None + }), + ability: IAbility(address(0)), + moves: moves + }); + team[1] = Mon({ + stats: MonStats({ + hp: 100, stamina: 50, speed: 8, attack: 10, defense: 10, + specialAttack: 10, specialDefense: 10, type1: Type.Fire, type2: Type.None + }), + ability: IAbility(address(0)), + moves: moves + }); + + defaultRegistry.setTeam(ALICE, team); + defaultRegistry.setTeam(BOB, team); + + bytes32 battleKey = _startDoublesBattle(); + vm.warp(block.timestamp + 1); + + _doInitialSwitch(battleKey); + assertEq(engine.getTurnIdForBattleState(battleKey), 1); + + // Multiple turns of weak attacks + for (uint256 i = 0; i < 3; i++) { + _doublesCommitRevealExecute( + battleKey, + 0, 0, 0, 0, + 0, 0, 0, 0 + ); + } + + // Should have advanced 3 turns + assertEq(engine.getTurnIdForBattleState(battleKey), 4); + assertEq(engine.getWinner(battleKey), address(0)); // No winner yet + } +} diff --git a/test/DoublesValidationTest.sol b/test/DoublesValidationTest.sol new file mode 100644 index 00000000..79b1dc0f --- /dev/null +++ b/test/DoublesValidationTest.sol @@ -0,0 +1,3063 @@ +// 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 {BaseCommitManager} from "../src/BaseCommitManager.sol"; +import {DoublesCommitManager} from "../src/DoublesCommitManager.sol"; +import {DefaultCommitManager} from "../src/DefaultCommitManager.sol"; +import {Engine} from "../src/Engine.sol"; +import {DefaultValidator} from "../src/DefaultValidator.sol"; +import {IEngineHook} from "../src/IEngineHook.sol"; +import {DefaultMatchmaker} from "../src/matchmaker/DefaultMatchmaker.sol"; +import {IMoveSet} from "../src/moves/IMoveSet.sol"; +import {DefaultRandomnessOracle} from "../src/rng/DefaultRandomnessOracle.sol"; +import {ITypeCalculator} from "../src/types/ITypeCalculator.sol"; +import {TestTeamRegistry} from "./mocks/TestTeamRegistry.sol"; +import {TestTypeCalculator} from "./mocks/TestTypeCalculator.sol"; +import {CustomAttack} from "./mocks/CustomAttack.sol"; +import {DoublesTargetedAttack} from "./mocks/DoublesTargetedAttack.sol"; +import {ForceSwitchMove} from "./mocks/ForceSwitchMove.sol"; +import {DoublesForceSwitchMove} from "./mocks/DoublesForceSwitchMove.sol"; +import {DoublesEffectAttack} from "./mocks/DoublesEffectAttack.sol"; +import {InstantDeathEffect} from "./mocks/InstantDeathEffect.sol"; +import {MonIndexTrackingEffect} from "./mocks/MonIndexTrackingEffect.sol"; +import {AfterDamageReboundEffect} from "./mocks/AfterDamageReboundEffect.sol"; +import {EffectApplyingAttack} from "./mocks/EffectApplyingAttack.sol"; +import {IEffect} from "../src/effects/IEffect.sol"; +import {StaminaRegen} from "../src/effects/StaminaRegen.sol"; +import {DefaultRuleset} from "../src/DefaultRuleset.sol"; +import {DoublesSlotAttack} from "./mocks/DoublesSlotAttack.sol"; + +/** + * @title DoublesValidationTest + * @notice Tests for doubles battle validation boundary conditions + * @dev Tests scenarios: + * - One player has 1 KO'd mon (with/without valid switch targets) + * - Both players have 1 KO'd mon each (various combinations) + * - Switch target validation (can't switch to other slot's active mon) + * - NO_OP allowed only when no valid switch targets + */ +contract DoublesValidationTest is Test { + address constant ALICE = address(0x1); + address constant BOB = address(0x2); + + DoublesCommitManager commitManager; + Engine engine; + DefaultValidator validator; + ITypeCalculator typeCalc; + DefaultRandomnessOracle defaultOracle; + DefaultMatchmaker matchmaker; + TestTeamRegistry defaultRegistry; + CustomAttack customAttack; + CustomAttack strongAttack; + CustomAttack highStaminaCostAttack; + DoublesTargetedAttack targetedStrongAttack; + + uint256 constant TIMEOUT_DURATION = 100; + + function setUp() public { + engine = new Engine(); + typeCalc = new TestTypeCalculator(); + defaultOracle = new DefaultRandomnessOracle(); + // Use 3 mons per team to test switch target scenarios + validator = new DefaultValidator( + engine, DefaultValidator.Args({MONS_PER_TEAM: 3, MOVES_PER_MON: 4, TIMEOUT_DURATION: TIMEOUT_DURATION}) + ); + matchmaker = new DefaultMatchmaker(engine); + commitManager = new DoublesCommitManager(engine); + defaultRegistry = new TestTeamRegistry(); + + customAttack = new CustomAttack( + engine, typeCalc, CustomAttack.Args({TYPE: Type.Fire, BASE_POWER: 10, ACCURACY: 100, STAMINA_COST: 1, PRIORITY: 0}) + ); + strongAttack = new CustomAttack( + engine, typeCalc, CustomAttack.Args({TYPE: Type.Fire, BASE_POWER: 200, ACCURACY: 100, STAMINA_COST: 1, PRIORITY: 0}) + ); + targetedStrongAttack = new DoublesTargetedAttack( + engine, typeCalc, DoublesTargetedAttack.Args({TYPE: Type.Fire, BASE_POWER: 200, ACCURACY: 100, STAMINA_COST: 1, PRIORITY: 0}) + ); + highStaminaCostAttack = new CustomAttack( + engine, typeCalc, CustomAttack.Args({TYPE: Type.Fire, BASE_POWER: 10, ACCURACY: 100, STAMINA_COST: 5, PRIORITY: 0}) + ); + + // Register teams for Alice and Bob (3 mons for doubles with switch options) + IMoveSet[] memory moves = new IMoveSet[](4); + moves[0] = customAttack; + moves[1] = customAttack; + moves[2] = customAttack; + moves[3] = customAttack; + + Mon[] memory team = new Mon[](3); + team[0] = _createMon(100, 10, moves); // Mon 0: 100 HP, speed 10 + team[1] = _createMon(100, 8, moves); // Mon 1: 100 HP, speed 8 + team[2] = _createMon(100, 6, moves); // Mon 2: 100 HP, speed 6 (reserve) + + defaultRegistry.setTeam(ALICE, team); + defaultRegistry.setTeam(BOB, team); + + // Authorize matchmaker + vm.startPrank(ALICE); + address[] memory makersToAdd = new address[](1); + makersToAdd[0] = address(matchmaker); + address[] memory makersToRemove = new address[](0); + engine.updateMatchmakers(makersToAdd, makersToRemove); + vm.stopPrank(); + + vm.startPrank(BOB); + engine.updateMatchmakers(makersToAdd, makersToRemove); + vm.stopPrank(); + } + + function _createMon(uint32 hp, uint32 speed, IMoveSet[] memory moves) internal pure returns (Mon memory) { + return Mon({ + stats: MonStats({ + hp: hp, + stamina: 50, + speed: speed, + attack: 10, + defense: 10, + specialAttack: 10, + specialDefense: 10, + type1: Type.Fire, + type2: Type.None + }), + ability: IAbility(address(0)), + moves: moves + }); + } + + function _startDoublesBattle() internal returns (bytes32 battleKey) { + bytes32 salt = ""; + uint96 p0TeamIndex = 0; + uint256[] memory p0TeamIndices = defaultRegistry.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: defaultRegistry, + validator: validator, + rngOracle: defaultOracle, + ruleset: IRuleset(address(0)), + engineHooks: new IEngineHook[](0), + moveManager: address(commitManager), + matchmaker: matchmaker, + gameMode: GameMode.Doubles + }); + + vm.startPrank(ALICE); + battleKey = matchmaker.proposeBattle(proposal); + + bytes32 battleIntegrityHash = matchmaker.getBattleProposalIntegrityHash(proposal); + vm.startPrank(BOB); + matchmaker.acceptBattle(battleKey, 0, battleIntegrityHash); + + vm.startPrank(ALICE); + matchmaker.confirmBattle(battleKey, salt, p0TeamIndex); + vm.stopPrank(); + } + + function _doublesCommitRevealExecute( + bytes32 battleKey, + uint8 aliceMove0, uint240 aliceExtra0, + uint8 aliceMove1, uint240 aliceExtra1, + uint8 bobMove0, uint240 bobExtra0, + uint8 bobMove1, uint240 bobExtra1 + ) internal { + uint256 turnId = engine.getTurnIdForBattleState(battleKey); + bytes32 aliceSalt = bytes32("alicesalt"); + bytes32 bobSalt = bytes32("bobsalt"); + + if (turnId % 2 == 0) { + bytes32 aliceHash = keccak256(abi.encodePacked(aliceMove0, aliceExtra0, aliceMove1, aliceExtra1, aliceSalt)); + vm.startPrank(ALICE); + commitManager.commitMoves(battleKey, aliceHash); + vm.stopPrank(); + + vm.startPrank(BOB); + commitManager.revealMoves(battleKey, bobMove0, bobExtra0, bobMove1, bobExtra1, bobSalt, false); + vm.stopPrank(); + + vm.startPrank(ALICE); + commitManager.revealMoves(battleKey, aliceMove0, aliceExtra0, aliceMove1, aliceExtra1, aliceSalt, false); + vm.stopPrank(); + } else { + bytes32 bobHash = keccak256(abi.encodePacked(bobMove0, bobExtra0, bobMove1, bobExtra1, bobSalt)); + vm.startPrank(BOB); + commitManager.commitMoves(battleKey, bobHash); + vm.stopPrank(); + + vm.startPrank(ALICE); + commitManager.revealMoves(battleKey, aliceMove0, aliceExtra0, aliceMove1, aliceExtra1, aliceSalt, false); + vm.stopPrank(); + + vm.startPrank(BOB); + commitManager.revealMoves(battleKey, bobMove0, bobExtra0, bobMove1, bobExtra1, bobSalt, false); + vm.stopPrank(); + } + + engine.execute(battleKey); + } + + function _doInitialSwitch(bytes32 battleKey) internal { + _doublesCommitRevealExecute( + battleKey, + SWITCH_MOVE_INDEX, 0, SWITCH_MOVE_INDEX, 1, + SWITCH_MOVE_INDEX, 0, SWITCH_MOVE_INDEX, 1 + ); + } + + function _startDoublesBattleWithRuleset(IRuleset ruleset) internal returns (bytes32 battleKey) { + bytes32 salt = ""; + uint96 p0TeamIndex = 0; + uint256[] memory p0TeamIndices = defaultRegistry.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: defaultRegistry, + validator: validator, + rngOracle: defaultOracle, + ruleset: ruleset, + engineHooks: new IEngineHook[](0), + moveManager: address(commitManager), + matchmaker: matchmaker, + gameMode: GameMode.Doubles + }); + + vm.startPrank(ALICE); + battleKey = matchmaker.proposeBattle(proposal); + + bytes32 battleIntegrityHash = matchmaker.getBattleProposalIntegrityHash(proposal); + vm.startPrank(BOB); + matchmaker.acceptBattle(battleKey, 0, battleIntegrityHash); + + vm.startPrank(ALICE); + matchmaker.confirmBattle(battleKey, salt, p0TeamIndex); + vm.stopPrank(); + } + + // ========================================= + // StaminaRegen Doubles Test + // ========================================= + + /** + * @notice Test that StaminaRegen regenerates stamina for BOTH slots in doubles + * @dev Validates the fix for the bug where StaminaRegen.onRoundEnd() only handled slot 0 + */ + function test_staminaRegenAffectsBothSlotsInDoubles() public { + // Create StaminaRegen effect and ruleset + StaminaRegen staminaRegen = new StaminaRegen(engine); + IEffect[] memory effects = new IEffect[](1); + effects[0] = staminaRegen; + DefaultRuleset ruleset = new DefaultRuleset(engine, effects); + + // Create teams with high stamina cost moves + IMoveSet[] memory moves = new IMoveSet[](4); + moves[0] = highStaminaCostAttack; // 5 stamina cost + moves[1] = highStaminaCostAttack; + moves[2] = highStaminaCostAttack; + moves[3] = highStaminaCostAttack; + + Mon[] memory team = new Mon[](3); + team[0] = _createMon(100, 10, moves); // Mon 0: slot 0 + team[1] = _createMon(100, 8, moves); // Mon 1: slot 1 + team[2] = _createMon(100, 6, moves); // Mon 2: reserve + + defaultRegistry.setTeam(ALICE, team); + defaultRegistry.setTeam(BOB, team); + + bytes32 battleKey = _startDoublesBattleWithRuleset(ruleset); + vm.warp(block.timestamp + 1); + _doInitialSwitch(battleKey); + + // Turn 1: Both Alice's slots attack (each costs 5 stamina) + _doublesCommitRevealExecute( + battleKey, + 0, 0, // Alice slot 0: attack (costs 5 stamina) + 0, 0, // Alice slot 1: attack (costs 5 stamina) + NO_OP_MOVE_INDEX, 0, // Bob slot 0: no-op + NO_OP_MOVE_INDEX, 0 // Bob slot 1: no-op + ); + + // After attack: both mons should have -5 stamina delta + // After StaminaRegen: both mons should have -4 stamina delta (regen +1) + + int32 aliceSlot0Stamina = engine.getMonStateForBattle(battleKey, 0, 0, MonStateIndexName.Stamina); + int32 aliceSlot1Stamina = engine.getMonStateForBattle(battleKey, 0, 1, MonStateIndexName.Stamina); + + // Both slots should have received stamina regen + // Expected: -5 (attack cost) + 1 (regen) = -4 + assertEq(aliceSlot0Stamina, -4, "Slot 0 should have -4 stamina (attack -5, regen +1)"); + assertEq(aliceSlot1Stamina, -4, "Slot 1 should have -4 stamina (attack -5, regen +1)"); + } + + // ========================================= + // Direct Validator Tests + // ========================================= + + /** + * @notice Test that on turn 0, only SWITCH_MOVE_INDEX is valid for all slots + */ + function test_turn0_onlySwitchAllowed() public { + bytes32 battleKey = _startDoublesBattle(); + + // Turn 0: validatePlayerMoveForSlot should only accept SWITCH_MOVE_INDEX + // Test slot 0 + assertTrue(validator.validatePlayerMoveForSlot(battleKey, SWITCH_MOVE_INDEX, 0, 0, 0), "SWITCH should be valid on turn 0"); + assertFalse(validator.validatePlayerMoveForSlot(battleKey, 0, 0, 0, 0), "Attack should be invalid on turn 0"); + assertFalse(validator.validatePlayerMoveForSlot(battleKey, NO_OP_MOVE_INDEX, 0, 0, 0), "NO_OP should be invalid on turn 0 (valid targets exist)"); + + // Test slot 1 + assertTrue(validator.validatePlayerMoveForSlot(battleKey, SWITCH_MOVE_INDEX, 0, 1, 1), "SWITCH should be valid on turn 0 slot 1"); + assertFalse(validator.validatePlayerMoveForSlot(battleKey, 0, 0, 1, 0), "Attack should be invalid on turn 0 slot 1"); + } + + /** + * @notice Test that after initial switch, attacks are valid for non-KO'd mons + */ + function test_afterTurn0_attacksAllowed() public { + bytes32 battleKey = _startDoublesBattle(); + vm.warp(block.timestamp + 1); + _doInitialSwitch(battleKey); + + // Turn 1: Attacks should be valid + assertTrue(validator.validatePlayerMoveForSlot(battleKey, 0, 0, 0, 0), "Attack should be valid after turn 0"); + assertTrue(validator.validatePlayerMoveForSlot(battleKey, 0, 0, 1, 0), "Attack should be valid for slot 1"); + assertTrue(validator.validatePlayerMoveForSlot(battleKey, NO_OP_MOVE_INDEX, 0, 0, 0), "NO_OP should be valid"); + + // Switch should also still be valid (to mon index 2, the reserve) + assertTrue(validator.validatePlayerMoveForSlot(battleKey, SWITCH_MOVE_INDEX, 0, 0, 2), "Switch to reserve should be valid"); + } + + /** + * @notice Test that switch to same mon is invalid (except turn 0) + */ + function test_switchToSameMonInvalid() public { + bytes32 battleKey = _startDoublesBattle(); + vm.warp(block.timestamp + 1); + _doInitialSwitch(battleKey); + + // Turn 1: Trying to switch slot 0 (which has mon 0) to mon 0 should fail + assertFalse(validator.validatePlayerMoveForSlot(battleKey, SWITCH_MOVE_INDEX, 0, 0, 0), "Switch to same mon should be invalid"); + + // Trying to switch slot 1 (which has mon 1) to mon 1 should fail + assertFalse(validator.validatePlayerMoveForSlot(battleKey, SWITCH_MOVE_INDEX, 0, 1, 1), "Switch to same mon should be invalid for slot 1"); + } + + /** + * @notice Test that switch to mon active in other slot is invalid + */ + function test_switchToOtherSlotActiveMonInvalid() public { + bytes32 battleKey = _startDoublesBattle(); + vm.warp(block.timestamp + 1); + _doInitialSwitch(battleKey); + + // After initial switch: slot 0 has mon 0, slot 1 has mon 1 + // Trying to switch slot 0 to mon 1 (active in slot 1) should fail + assertFalse(validator.validatePlayerMoveForSlot(battleKey, SWITCH_MOVE_INDEX, 0, 0, 1), "Switch to other slot's active mon should be invalid"); + + // Trying to switch slot 1 to mon 0 (active in slot 0) should fail + assertFalse(validator.validatePlayerMoveForSlot(battleKey, SWITCH_MOVE_INDEX, 0, 1, 0), "Switch to other slot's active mon should be invalid"); + + // But switch to reserve mon (index 2) should be valid + assertTrue(validator.validatePlayerMoveForSlot(battleKey, SWITCH_MOVE_INDEX, 0, 0, 2), "Switch to reserve should be valid"); + assertTrue(validator.validatePlayerMoveForSlot(battleKey, SWITCH_MOVE_INDEX, 0, 1, 2), "Switch to reserve from slot 1 should be valid"); + } + + // ========================================= + // One Player Has 1 KO'd Mon Tests + // ========================================= + + /** + * @notice Setup: Alice's slot 0 mon is KO'd, but she has a reserve mon to switch to + * Expected: Alice must switch slot 0, can use any move for slot 1 + */ + function test_onePlayerOneKO_withValidTarget() public { + // Create teams where Alice's mon 0 has very low HP + IMoveSet[] memory moves = new IMoveSet[](4); + moves[0] = strongAttack; + moves[1] = strongAttack; + moves[2] = strongAttack; + moves[3] = strongAttack; + + Mon[] memory aliceTeam = new Mon[](3); + aliceTeam[0] = _createMon(1, 10, moves); // Will be KO'd easily + aliceTeam[1] = _createMon(100, 8, moves); + aliceTeam[2] = _createMon(100, 6, moves); // Reserve + + Mon[] memory bobTeam = new Mon[](3); + bobTeam[0] = _createMon(100, 20, moves); // Faster to attack first + bobTeam[1] = _createMon(100, 18, moves); + bobTeam[2] = _createMon(100, 16, moves); + + defaultRegistry.setTeam(ALICE, aliceTeam); + defaultRegistry.setTeam(BOB, bobTeam); + + bytes32 battleKey = _startDoublesBattle(); + vm.warp(block.timestamp + 1); + _doInitialSwitch(battleKey); + + // Turn 1: Bob attacks Alice's slot 0, KO'ing it + _doublesCommitRevealExecute( + battleKey, + 0, 0, NO_OP_MOVE_INDEX, 0, // Alice: slot 0 attacks, slot 1 no-op + 0, 0, NO_OP_MOVE_INDEX, 0 // Bob: slot 0 attacks (will KO Alice slot 0), slot 1 no-op + ); + + // Verify Alice's slot 0 mon is KO'd + int32 isKO = engine.getMonStateForBattle(battleKey, 0, 0, MonStateIndexName.IsKnockedOut); + assertEq(isKO, 1, "Alice's mon 0 should be KO'd"); + + // Now validate: Alice slot 0 must switch (to reserve mon 2) + assertFalse(validator.validatePlayerMoveForSlot(battleKey, 0, 0, 0, 0), "Attack should be invalid for KO'd slot"); + assertFalse(validator.validatePlayerMoveForSlot(battleKey, NO_OP_MOVE_INDEX, 0, 0, 0), "NO_OP should be invalid when valid switch exists"); + assertTrue(validator.validatePlayerMoveForSlot(battleKey, SWITCH_MOVE_INDEX, 0, 0, 2), "Switch to reserve should be valid"); + + // Alice slot 1 can use any move (not KO'd) + assertTrue(validator.validatePlayerMoveForSlot(battleKey, 0, 0, 1, 0), "Attack should be valid for non-KO'd slot"); + assertTrue(validator.validatePlayerMoveForSlot(battleKey, NO_OP_MOVE_INDEX, 0, 1, 0), "NO_OP should be valid for non-KO'd slot"); + + // Bob's slots should be able to use any move + assertTrue(validator.validatePlayerMoveForSlot(battleKey, 0, 1, 0, 0), "Bob slot 0 attack should be valid"); + assertTrue(validator.validatePlayerMoveForSlot(battleKey, 0, 1, 1, 0), "Bob slot 1 attack should be valid"); + } + + /** + * @notice Setup: Alice's slot 0 mon is KO'd, and her only other mon is in slot 1 (no reserve) + * Expected: Alice can use NO_OP for slot 0 since no valid switch target + */ + function test_onePlayerOneKO_noValidTarget() public { + // Use only 2 mons per team for this test + DefaultValidator validator2Mon = new DefaultValidator( + engine, DefaultValidator.Args({MONS_PER_TEAM: 2, MOVES_PER_MON: 4, TIMEOUT_DURATION: TIMEOUT_DURATION}) + ); + DoublesCommitManager commitManager2 = new DoublesCommitManager(engine); + TestTeamRegistry registry2 = new TestTeamRegistry(); + + IMoveSet[] memory moves = new IMoveSet[](4); + moves[0] = strongAttack; + moves[1] = strongAttack; + moves[2] = strongAttack; + moves[3] = strongAttack; + + Mon[] memory aliceTeam = new Mon[](2); + aliceTeam[0] = _createMon(1, 10, moves); // Will be KO'd + aliceTeam[1] = _createMon(100, 8, moves); // Active in slot 1 + + Mon[] memory bobTeam = new Mon[](2); + bobTeam[0] = _createMon(100, 20, moves); + bobTeam[1] = _createMon(100, 18, moves); + + registry2.setTeam(ALICE, aliceTeam); + registry2.setTeam(BOB, bobTeam); + + // Start battle with 2-mon validator + bytes32 salt = ""; + uint96 p0TeamIndex = 0; + uint256[] memory p0TeamIndices = registry2.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: registry2, + validator: validator2Mon, + rngOracle: defaultOracle, + ruleset: IRuleset(address(0)), + engineHooks: new IEngineHook[](0), + moveManager: address(commitManager2), + matchmaker: matchmaker, + gameMode: GameMode.Doubles + }); + + vm.startPrank(ALICE); + bytes32 battleKey = matchmaker.proposeBattle(proposal); + bytes32 battleIntegrityHash = matchmaker.getBattleProposalIntegrityHash(proposal); + vm.startPrank(BOB); + matchmaker.acceptBattle(battleKey, 0, battleIntegrityHash); + vm.startPrank(ALICE); + matchmaker.confirmBattle(battleKey, salt, p0TeamIndex); + vm.stopPrank(); + + vm.warp(block.timestamp + 1); + + // Turn 0: Initial switch + { + uint256 turnId = engine.getTurnIdForBattleState(battleKey); + bytes32 aliceSalt = bytes32("alicesalt"); + bytes32 bobSalt = bytes32("bobsalt"); + bytes32 aliceHash = keccak256(abi.encodePacked(SWITCH_MOVE_INDEX, uint240(0), SWITCH_MOVE_INDEX, uint240(1), aliceSalt)); + vm.startPrank(ALICE); + commitManager2.commitMoves(battleKey, aliceHash); + vm.stopPrank(); + vm.startPrank(BOB); + commitManager2.revealMoves(battleKey, SWITCH_MOVE_INDEX, 0, SWITCH_MOVE_INDEX, 1, bobSalt, false); + vm.stopPrank(); + vm.startPrank(ALICE); + commitManager2.revealMoves(battleKey, SWITCH_MOVE_INDEX, 0, SWITCH_MOVE_INDEX, 1, aliceSalt, false); + vm.stopPrank(); + engine.execute(battleKey); + } + + // Turn 1: Bob KOs Alice's slot 0 + { + bytes32 aliceSalt = bytes32("alicesalt2"); + bytes32 bobSalt = bytes32("bobsalt2"); + bytes32 bobHash = keccak256(abi.encodePacked(uint8(0), uint240(0), uint8(NO_OP_MOVE_INDEX), uint240(0), bobSalt)); + vm.startPrank(BOB); + commitManager2.commitMoves(battleKey, bobHash); + vm.stopPrank(); + vm.startPrank(ALICE); + commitManager2.revealMoves(battleKey, uint8(0), 0, uint8(NO_OP_MOVE_INDEX), 0, aliceSalt, false); + vm.stopPrank(); + vm.startPrank(BOB); + commitManager2.revealMoves(battleKey, uint8(0), 0, uint8(NO_OP_MOVE_INDEX), 0, bobSalt, false); + vm.stopPrank(); + engine.execute(battleKey); + } + + // Verify Alice's mon 0 is KO'd + int32 isKO = engine.getMonStateForBattle(battleKey, 0, 0, MonStateIndexName.IsKnockedOut); + assertEq(isKO, 1, "Alice's mon 0 should be KO'd"); + + // Now Alice's slot 0 is KO'd, and slot 1 has mon 1 + // There's no valid switch target (mon 0 is KO'd, mon 1 is in other slot) + // Therefore NO_OP should be valid + assertTrue(validator2Mon.validatePlayerMoveForSlot(battleKey, NO_OP_MOVE_INDEX, 0, 0, 0), "NO_OP should be valid when no switch targets"); + assertFalse(validator2Mon.validatePlayerMoveForSlot(battleKey, 0, 0, 0, 0), "Attack should be invalid for KO'd slot"); + assertFalse(validator2Mon.validatePlayerMoveForSlot(battleKey, SWITCH_MOVE_INDEX, 0, 0, 1), "Can't switch to other slot's mon"); + } + + // ========================================= + // Both Players Have 1 KO'd Mon Tests + // ========================================= + + /** + * @notice Setup: Both Alice and Bob have their slot 0 mons KO'd, both have reserves + * Expected: Both must switch their slot 0 + */ + function test_bothPlayersOneKO_bothHaveValidTargets() public { + IMoveSet[] memory moves = new IMoveSet[](4); + moves[0] = strongAttack; + moves[1] = strongAttack; + moves[2] = strongAttack; + moves[3] = strongAttack; + + // Both teams have weak slot 0 mons, and fast slot 1 mons that will KO opponent's slot 0 + Mon[] memory aliceTeam = new Mon[](3); + aliceTeam[0] = _createMon(1, 5, moves); // Weak, slow - will be KO'd + aliceTeam[1] = _createMon(100, 20, moves); // Fast - attacks first + aliceTeam[2] = _createMon(100, 6, moves); // Reserve + + Mon[] memory bobTeam = new Mon[](3); + bobTeam[0] = _createMon(1, 5, moves); // Weak, slow - will be KO'd + bobTeam[1] = _createMon(100, 18, moves); // Fast - attacks second + bobTeam[2] = _createMon(100, 6, moves); // Reserve + + defaultRegistry.setTeam(ALICE, aliceTeam); + defaultRegistry.setTeam(BOB, bobTeam); + + bytes32 battleKey = _startDoublesBattle(); + vm.warp(block.timestamp + 1); + _doInitialSwitch(battleKey); + + // Turn 1: Slot 1 mons attack opponent's slot 0 (default targeting), KO'ing both slot 0s + // Order: Alice slot 1 (speed 20) → Bob slot 1 (speed 18) → both slot 0s too slow to matter + _doublesCommitRevealExecute( + battleKey, + NO_OP_MOVE_INDEX, 0, 0, 0, // Alice: slot 0 no-op, slot 1 attacks + NO_OP_MOVE_INDEX, 0, 0, 0 // Bob: slot 0 no-op, slot 1 attacks + ); + + // Verify both slot 0 mons are KO'd + assertEq(engine.getMonStateForBattle(battleKey, 0, 0, MonStateIndexName.IsKnockedOut), 1, "Alice mon 0 KO'd"); + assertEq(engine.getMonStateForBattle(battleKey, 1, 0, MonStateIndexName.IsKnockedOut), 1, "Bob mon 0 KO'd"); + + // Both must switch slot 0 to reserve (mon 2) + assertTrue(validator.validatePlayerMoveForSlot(battleKey, SWITCH_MOVE_INDEX, 0, 0, 2), "Alice must switch to reserve"); + assertTrue(validator.validatePlayerMoveForSlot(battleKey, SWITCH_MOVE_INDEX, 1, 0, 2), "Bob must switch to reserve"); + assertFalse(validator.validatePlayerMoveForSlot(battleKey, 0, 0, 0, 0), "Alice attack invalid"); + assertFalse(validator.validatePlayerMoveForSlot(battleKey, 0, 1, 0, 0), "Bob attack invalid"); + assertFalse(validator.validatePlayerMoveForSlot(battleKey, NO_OP_MOVE_INDEX, 0, 0, 0), "Alice NO_OP invalid (has target)"); + assertFalse(validator.validatePlayerMoveForSlot(battleKey, NO_OP_MOVE_INDEX, 1, 0, 0), "Bob NO_OP invalid (has target)"); + + // Slot 1 for both can use any move + assertTrue(validator.validatePlayerMoveForSlot(battleKey, 0, 0, 1, 0), "Alice slot 1 attack valid"); + assertTrue(validator.validatePlayerMoveForSlot(battleKey, 0, 1, 1, 0), "Bob slot 1 attack valid"); + } + + /** + * @notice Setup: Both players have slot 0 KO'd, only 2 mons per team (no reserve) + * Expected: Both can use NO_OP for slot 0 + */ + function test_bothPlayersOneKO_neitherHasValidTarget() public { + // Use 2-mon teams + DefaultValidator validator2Mon = new DefaultValidator( + engine, DefaultValidator.Args({MONS_PER_TEAM: 2, MOVES_PER_MON: 4, TIMEOUT_DURATION: TIMEOUT_DURATION}) + ); + DoublesCommitManager commitManager2 = new DoublesCommitManager(engine); + TestTeamRegistry registry2 = new TestTeamRegistry(); + + IMoveSet[] memory moves = new IMoveSet[](4); + moves[0] = strongAttack; + moves[1] = strongAttack; + moves[2] = strongAttack; + moves[3] = strongAttack; + + // Both teams: weak slot 0, fast slot 1 that will KO opponent's slot 0 + Mon[] memory aliceTeam = new Mon[](2); + aliceTeam[0] = _createMon(1, 5, moves); // Will be KO'd + aliceTeam[1] = _createMon(100, 20, moves); // Fast, attacks first + + Mon[] memory bobTeam = new Mon[](2); + bobTeam[0] = _createMon(1, 5, moves); // Will be KO'd + bobTeam[1] = _createMon(100, 18, moves); // Fast, attacks second + + registry2.setTeam(ALICE, aliceTeam); + registry2.setTeam(BOB, bobTeam); + + // Start battle + bytes32 salt = ""; + uint96 p0TeamIndex = 0; + uint256[] memory p0TeamIndices = registry2.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: registry2, + validator: validator2Mon, + rngOracle: defaultOracle, + ruleset: IRuleset(address(0)), + engineHooks: new IEngineHook[](0), + moveManager: address(commitManager2), + matchmaker: matchmaker, + gameMode: GameMode.Doubles + }); + + vm.startPrank(ALICE); + bytes32 battleKey = matchmaker.proposeBattle(proposal); + bytes32 battleIntegrityHash = matchmaker.getBattleProposalIntegrityHash(proposal); + vm.startPrank(BOB); + matchmaker.acceptBattle(battleKey, 0, battleIntegrityHash); + vm.startPrank(ALICE); + matchmaker.confirmBattle(battleKey, salt, p0TeamIndex); + vm.stopPrank(); + + vm.warp(block.timestamp + 1); + + // Turn 0: Initial switch + { + bytes32 aliceSalt = bytes32("as"); + bytes32 bobSalt = bytes32("bs"); + bytes32 aliceHash = keccak256(abi.encodePacked(SWITCH_MOVE_INDEX, uint240(0), SWITCH_MOVE_INDEX, uint240(1), aliceSalt)); + vm.startPrank(ALICE); + commitManager2.commitMoves(battleKey, aliceHash); + vm.stopPrank(); + vm.startPrank(BOB); + commitManager2.revealMoves(battleKey, SWITCH_MOVE_INDEX, 0, SWITCH_MOVE_INDEX, 1, bobSalt, false); + vm.stopPrank(); + vm.startPrank(ALICE); + commitManager2.revealMoves(battleKey, SWITCH_MOVE_INDEX, 0, SWITCH_MOVE_INDEX, 1, aliceSalt, false); + vm.stopPrank(); + engine.execute(battleKey); + } + + // Turn 1: Both slot 1 mons attack opponent's slot 0, KO'ing both + { + bytes32 aliceSalt = bytes32("as2"); + bytes32 bobSalt = bytes32("bs2"); + bytes32 bobHash = keccak256(abi.encodePacked(uint8(NO_OP_MOVE_INDEX), uint240(0), uint8(0), uint240(0), bobSalt)); + vm.startPrank(BOB); + commitManager2.commitMoves(battleKey, bobHash); + vm.stopPrank(); + vm.startPrank(ALICE); + commitManager2.revealMoves(battleKey, uint8(NO_OP_MOVE_INDEX), 0, uint8(0), 0, aliceSalt, false); + vm.stopPrank(); + vm.startPrank(BOB); + commitManager2.revealMoves(battleKey, uint8(NO_OP_MOVE_INDEX), 0, uint8(0), 0, bobSalt, false); + vm.stopPrank(); + engine.execute(battleKey); + } + + // Verify both slot 0 mons KO'd + assertEq(engine.getMonStateForBattle(battleKey, 0, 0, MonStateIndexName.IsKnockedOut), 1, "Alice mon 0 KO'd"); + assertEq(engine.getMonStateForBattle(battleKey, 1, 0, MonStateIndexName.IsKnockedOut), 1, "Bob mon 0 KO'd"); + + // Both should be able to NO_OP slot 0 (no valid switch targets) + assertTrue(validator2Mon.validatePlayerMoveForSlot(battleKey, NO_OP_MOVE_INDEX, 0, 0, 0), "Alice NO_OP valid"); + assertTrue(validator2Mon.validatePlayerMoveForSlot(battleKey, NO_OP_MOVE_INDEX, 1, 0, 0), "Bob NO_OP valid"); + + // Attacks still invalid for KO'd slot + assertFalse(validator2Mon.validatePlayerMoveForSlot(battleKey, 0, 0, 0, 0), "Alice attack invalid"); + assertFalse(validator2Mon.validatePlayerMoveForSlot(battleKey, 0, 1, 0, 0), "Bob attack invalid"); + + // Can't switch to other slot's mon + assertFalse(validator2Mon.validatePlayerMoveForSlot(battleKey, SWITCH_MOVE_INDEX, 0, 0, 1), "Alice can't switch to slot 1 mon"); + assertFalse(validator2Mon.validatePlayerMoveForSlot(battleKey, SWITCH_MOVE_INDEX, 1, 0, 1), "Bob can't switch to slot 1 mon"); + + // Slot 1 can still attack + assertTrue(validator2Mon.validatePlayerMoveForSlot(battleKey, 0, 0, 1, 0), "Alice slot 1 attack valid"); + assertTrue(validator2Mon.validatePlayerMoveForSlot(battleKey, 0, 1, 1, 0), "Bob slot 1 attack valid"); + } + + // ========================================= + // Integration Test: Full Flow with KO and Forced Switch + // ========================================= + + /** + * @notice Full integration test: Verify validation rejects attack for KO'd slot with valid targets + * And accepts switch to reserve + */ + function test_fullFlow_KOAndForcedSwitch() public { + IMoveSet[] memory moves = new IMoveSet[](4); + moves[0] = strongAttack; + moves[1] = strongAttack; + moves[2] = strongAttack; + moves[3] = strongAttack; + + Mon[] memory aliceTeam = new Mon[](3); + aliceTeam[0] = _createMon(1, 5, moves); // Will be KO'd (slow) + aliceTeam[1] = _createMon(100, 8, moves); + aliceTeam[2] = _createMon(100, 6, moves); // Reserve + + Mon[] memory bobTeam = new Mon[](3); + bobTeam[0] = _createMon(100, 20, moves); // Fast - attacks first + bobTeam[1] = _createMon(100, 18, moves); + bobTeam[2] = _createMon(100, 16, moves); + + defaultRegistry.setTeam(ALICE, aliceTeam); + defaultRegistry.setTeam(BOB, bobTeam); + + bytes32 battleKey = _startDoublesBattle(); + vm.warp(block.timestamp + 1); + + // Turn 0: Initial switch + _doInitialSwitch(battleKey); + assertEq(engine.getTurnIdForBattleState(battleKey), 1); + + // Turn 1: Bob KOs Alice's slot 0 + _doublesCommitRevealExecute( + battleKey, + NO_OP_MOVE_INDEX, 0, NO_OP_MOVE_INDEX, 0, // Alice: both no-op + 0, 0, NO_OP_MOVE_INDEX, 0 // Bob: slot 0 attacks Alice's slot 0 + ); + + // Verify turn advanced and mon is KO'd + assertEq(engine.getTurnIdForBattleState(battleKey), 2); + assertEq(engine.getMonStateForBattle(battleKey, 0, 0, MonStateIndexName.IsKnockedOut), 1, "Alice mon 0 KO'd"); + + // Verify validation state after KO: + // - Alice slot 0: must switch (attack invalid, NO_OP invalid since reserve exists) + assertFalse(validator.validatePlayerMoveForSlot(battleKey, 0, 0, 0, 0), "Attack invalid for KO'd slot"); + assertFalse(validator.validatePlayerMoveForSlot(battleKey, NO_OP_MOVE_INDEX, 0, 0, 0), "NO_OP invalid (reserve exists)"); + assertTrue(validator.validatePlayerMoveForSlot(battleKey, SWITCH_MOVE_INDEX, 0, 0, 2), "Switch to reserve valid"); + + // - Alice slot 1: can use any move + assertTrue(validator.validatePlayerMoveForSlot(battleKey, 0, 0, 1, 0), "Alice slot 1 attack valid"); + + // - Bob: both slots can use any move + assertTrue(validator.validatePlayerMoveForSlot(battleKey, 0, 1, 0, 0), "Bob slot 0 attack valid"); + assertTrue(validator.validatePlayerMoveForSlot(battleKey, 0, 1, 1, 0), "Bob slot 1 attack valid"); + + // Game should still be ongoing + assertEq(engine.getWinner(battleKey), address(0)); + } + + /** + * @notice Test that reveal fails when trying to use attack for KO'd slot with valid targets + * @dev After KO with valid switch target, it's a single-player switch turn (Alice only) + */ + function test_revealFailsForInvalidMoveOnKOdSlot() public { + IMoveSet[] memory moves = new IMoveSet[](4); + moves[0] = strongAttack; + moves[1] = strongAttack; + moves[2] = strongAttack; + moves[3] = strongAttack; + + Mon[] memory aliceTeam = new Mon[](3); + aliceTeam[0] = _createMon(1, 5, moves); // Slow, will be KO'd + aliceTeam[1] = _createMon(100, 8, moves); + aliceTeam[2] = _createMon(100, 6, moves); // Reserve + + Mon[] memory bobTeam = new Mon[](3); + bobTeam[0] = _createMon(100, 20, moves); // Fast, attacks first + bobTeam[1] = _createMon(100, 18, moves); + bobTeam[2] = _createMon(100, 16, moves); + + defaultRegistry.setTeam(ALICE, aliceTeam); + defaultRegistry.setTeam(BOB, bobTeam); + + bytes32 battleKey = _startDoublesBattle(); + vm.warp(block.timestamp + 1); + _doInitialSwitch(battleKey); + + // Turn 1: Bob KOs Alice's slot 0 + _doublesCommitRevealExecute( + battleKey, + NO_OP_MOVE_INDEX, 0, NO_OP_MOVE_INDEX, 0, // Alice: both no-op + 0, 0, NO_OP_MOVE_INDEX, 0 // Bob: slot 0 attacks + ); + + assertEq(engine.getMonStateForBattle(battleKey, 0, 0, MonStateIndexName.IsKnockedOut), 1, "Alice mon 0 KO'd"); + + // Verify it's a single-player switch turn (playerSwitchForTurnFlag = 0 for Alice only) + BattleContext memory ctx = engine.getBattleContext(battleKey); + assertEq(ctx.playerSwitchForTurnFlag, 0, "Should be Alice-only switch turn"); + + // Turn 2: Single-player switch turn - only Alice acts (no commits needed) + // Alice tries to reveal with attack for KO'd slot 0 - should fail with InvalidMove + bytes32 aliceSalt = bytes32("alicesalt"); + + vm.startPrank(ALICE); + vm.expectRevert(abi.encodeWithSelector(DoublesCommitManager.InvalidMove.selector, ALICE, 0)); + commitManager.revealMoves(battleKey, uint8(0), 0, uint8(NO_OP_MOVE_INDEX), 0, aliceSalt, false); + vm.stopPrank(); + } + + /** + * @notice Test single-player switch turn: only the player with KO'd mon acts + */ + function test_singlePlayerSwitchTurn() public { + IMoveSet[] memory moves = new IMoveSet[](4); + moves[0] = strongAttack; + moves[1] = strongAttack; + moves[2] = strongAttack; + moves[3] = strongAttack; + + Mon[] memory aliceTeam = new Mon[](3); + aliceTeam[0] = _createMon(1, 5, moves); // Slow, will be KO'd + aliceTeam[1] = _createMon(100, 8, moves); + aliceTeam[2] = _createMon(100, 6, moves); // Reserve + + Mon[] memory bobTeam = new Mon[](3); + bobTeam[0] = _createMon(100, 20, moves); // Fast + bobTeam[1] = _createMon(100, 18, moves); + bobTeam[2] = _createMon(100, 16, moves); + + defaultRegistry.setTeam(ALICE, aliceTeam); + defaultRegistry.setTeam(BOB, bobTeam); + + bytes32 battleKey = _startDoublesBattle(); + vm.warp(block.timestamp + 1); + _doInitialSwitch(battleKey); + + // Turn 1: Bob KOs Alice's slot 0 + _doublesCommitRevealExecute( + battleKey, + NO_OP_MOVE_INDEX, 0, NO_OP_MOVE_INDEX, 0, + 0, 0, NO_OP_MOVE_INDEX, 0 + ); + + assertEq(engine.getMonStateForBattle(battleKey, 0, 0, MonStateIndexName.IsKnockedOut), 1, "Alice mon 0 KO'd"); + + // Verify it's a single-player switch turn + BattleContext memory ctx = engine.getBattleContext(battleKey); + assertEq(ctx.playerSwitchForTurnFlag, 0, "Should be Alice-only switch turn"); + + // Bob should NOT be able to commit (it's not his turn) + vm.startPrank(BOB); + bytes32 bobHash = keccak256(abi.encodePacked(uint8(0), uint240(0), uint8(0), uint240(0), bytes32("bobsalt"))); + vm.expectRevert(BaseCommitManager.PlayerNotAllowed.selector); + commitManager.commitMoves(battleKey, bobHash); + vm.stopPrank(); + + // Alice reveals her switch (no commit needed for single-player turns) + bytes32 aliceSalt = bytes32("alicesalt"); + vm.startPrank(ALICE); + commitManager.revealMoves(battleKey, SWITCH_MOVE_INDEX, 2, NO_OP_MOVE_INDEX, 0, aliceSalt, true); + vm.stopPrank(); + + // Verify switch happened and turn advanced + assertEq(engine.getActiveMonIndexForSlot(battleKey, 0, 0), 2, "Alice slot 0 should now have mon 2"); + assertEq(engine.getTurnIdForBattleState(battleKey), 3); + + // Next turn should be normal (both players act) + ctx = engine.getBattleContext(battleKey); + assertEq(ctx.playerSwitchForTurnFlag, 2, "Should be normal turn now"); + } + + /** + * @notice Test mixed switch + attack during single-player switch turn + * @dev When slot 0 is KO'd, slot 1 can attack while slot 0 switches + */ + function test_singlePlayerSwitchTurn_withAttack() public { + // Use targeted attack for slot 1 so we can target specific opponent slot + IMoveSet[] memory aliceMoves = new IMoveSet[](4); + aliceMoves[0] = targetedStrongAttack; + aliceMoves[1] = targetedStrongAttack; + aliceMoves[2] = targetedStrongAttack; + aliceMoves[3] = targetedStrongAttack; + + IMoveSet[] memory bobMoves = new IMoveSet[](4); + bobMoves[0] = strongAttack; + bobMoves[1] = strongAttack; + bobMoves[2] = strongAttack; + bobMoves[3] = strongAttack; + + Mon[] memory aliceTeam = new Mon[](3); + aliceTeam[0] = _createMon(1, 5, aliceMoves); // Slow, will be KO'd + aliceTeam[1] = _createMon(100, 15, aliceMoves); // Alive, can attack with targeted move + aliceTeam[2] = _createMon(100, 6, aliceMoves); // Reserve + + Mon[] memory bobTeam = new Mon[](3); + bobTeam[0] = _createMon(100, 20, bobMoves); // Fast, KOs Alice slot 0 + bobTeam[1] = _createMon(500, 18, bobMoves); // High HP - will take damage but survive + bobTeam[2] = _createMon(100, 16, bobMoves); + + defaultRegistry.setTeam(ALICE, aliceTeam); + defaultRegistry.setTeam(BOB, bobTeam); + + bytes32 battleKey = _startDoublesBattle(); + vm.warp(block.timestamp + 1); + _doInitialSwitch(battleKey); + + // Turn 1: Bob KOs Alice's slot 0 + _doublesCommitRevealExecute( + battleKey, + NO_OP_MOVE_INDEX, 0, NO_OP_MOVE_INDEX, 0, + 0, 0, NO_OP_MOVE_INDEX, 0 + ); + + assertEq(engine.getMonStateForBattle(battleKey, 0, 0, MonStateIndexName.IsKnockedOut), 1, "Alice mon 0 KO'd"); + + // Verify it's a single-player switch turn + BattleContext memory ctx = engine.getBattleContext(battleKey); + assertEq(ctx.playerSwitchForTurnFlag, 0, "Should be Alice-only switch turn"); + + // Record Bob's slot 1 HP before Alice's attack + int32 bobSlot1HpBefore = engine.getMonStateForBattle(battleKey, 1, 1, MonStateIndexName.Hp); + + // Alice: slot 0 switches to reserve (mon 2), slot 1 attacks Bob's slot 1 + // For DoublesTargetedAttack, extraData=1 means target opponent slot 1 + bytes32 aliceSalt = bytes32("alicesalt"); + vm.startPrank(ALICE); + commitManager.revealMoves(battleKey, SWITCH_MOVE_INDEX, 2, 0, 1, aliceSalt, true); + vm.stopPrank(); + + // Verify switch happened + assertEq(engine.getActiveMonIndexForSlot(battleKey, 0, 0), 2, "Alice slot 0 should now have mon 2"); + + // Verify attack dealt damage to Bob's slot 1 + int32 bobSlot1HpAfter = engine.getMonStateForBattle(battleKey, 1, 1, MonStateIndexName.Hp); + assertTrue(bobSlot1HpAfter < bobSlot1HpBefore, "Bob slot 1 should have taken damage from Alice's attack"); + + // Turn advanced + assertEq(engine.getTurnIdForBattleState(battleKey), 3); + + // Next turn should be normal + ctx = engine.getBattleContext(battleKey); + assertEq(ctx.playerSwitchForTurnFlag, 2, "Should be normal turn now"); + } + + // ========================================= + // P1-Only Switch Turn Tests (mirrors of P0) + // ========================================= + + /** + * @notice Test P1-only switch turn: Bob's slot 0 KO'd with valid target + * @dev Mirror of test_singlePlayerSwitchTurn but for P1 + */ + function test_p1OnlySwitchTurn_slot0KOWithValidTarget() public { + IMoveSet[] memory moves = new IMoveSet[](4); + moves[0] = strongAttack; + moves[1] = strongAttack; + moves[2] = strongAttack; + moves[3] = strongAttack; + + Mon[] memory aliceTeam = new Mon[](3); + aliceTeam[0] = _createMon(100, 20, moves); // Fast, attacks first + aliceTeam[1] = _createMon(100, 18, moves); + aliceTeam[2] = _createMon(100, 16, moves); + + Mon[] memory bobTeam = new Mon[](3); + bobTeam[0] = _createMon(1, 5, moves); // Slow, will be KO'd + bobTeam[1] = _createMon(100, 8, moves); + bobTeam[2] = _createMon(100, 6, moves); // Reserve + + defaultRegistry.setTeam(ALICE, aliceTeam); + defaultRegistry.setTeam(BOB, bobTeam); + + bytes32 battleKey = _startDoublesBattle(); + vm.warp(block.timestamp + 1); + _doInitialSwitch(battleKey); + + // Turn 1: Alice KOs Bob's slot 0 + _doublesCommitRevealExecute( + battleKey, + 0, 0, NO_OP_MOVE_INDEX, 0, // Alice: slot 0 attacks Bob's slot 0 + NO_OP_MOVE_INDEX, 0, NO_OP_MOVE_INDEX, 0 // Bob: both no-op + ); + + assertEq(engine.getMonStateForBattle(battleKey, 1, 0, MonStateIndexName.IsKnockedOut), 1, "Bob mon 0 KO'd"); + + // Verify it's a P1-only switch turn (flag=1) + BattleContext memory ctx = engine.getBattleContext(battleKey); + assertEq(ctx.playerSwitchForTurnFlag, 1, "Should be Bob-only switch turn"); + + // Alice should NOT be able to commit (it's not her turn) + vm.startPrank(ALICE); + bytes32 aliceHash = keccak256(abi.encodePacked(uint8(0), uint240(0), uint8(0), uint240(0), bytes32("alicesalt"))); + vm.expectRevert(BaseCommitManager.PlayerNotAllowed.selector); + commitManager.commitMoves(battleKey, aliceHash); + vm.stopPrank(); + + // Bob reveals his switch (no commit needed for single-player turns) + bytes32 bobSalt = bytes32("bobsalt"); + vm.startPrank(BOB); + commitManager.revealMoves(battleKey, SWITCH_MOVE_INDEX, 2, NO_OP_MOVE_INDEX, 0, bobSalt, true); + vm.stopPrank(); + + // Verify switch happened + assertEq(engine.getActiveMonIndexForSlot(battleKey, 1, 0), 2, "Bob slot 0 should now have mon 2"); + + // Next turn should be normal + ctx = engine.getBattleContext(battleKey); + assertEq(ctx.playerSwitchForTurnFlag, 2, "Should be normal turn now"); + } + + /** + * @notice Test P1 slot 0 KO'd without valid target (2-mon team) + * @dev Mirror of test_onePlayerOneKO_noValidTarget but for P1 + */ + function test_p1OneKO_noValidTarget() public { + // Use 2-mon teams + DefaultValidator validator2Mon = new DefaultValidator( + engine, DefaultValidator.Args({MONS_PER_TEAM: 2, MOVES_PER_MON: 4, TIMEOUT_DURATION: TIMEOUT_DURATION}) + ); + DoublesCommitManager commitManager2 = new DoublesCommitManager(engine); + TestTeamRegistry registry2 = new TestTeamRegistry(); + + IMoveSet[] memory moves = new IMoveSet[](4); + moves[0] = strongAttack; + moves[1] = strongAttack; + moves[2] = strongAttack; + moves[3] = strongAttack; + + Mon[] memory aliceTeam = new Mon[](2); + aliceTeam[0] = _createMon(100, 20, moves); // Fast, attacks first + aliceTeam[1] = _createMon(100, 18, moves); + + Mon[] memory bobTeam = new Mon[](2); + bobTeam[0] = _createMon(1, 5, moves); // Will be KO'd + bobTeam[1] = _createMon(100, 8, moves); // Active in slot 1 + + registry2.setTeam(ALICE, aliceTeam); + registry2.setTeam(BOB, bobTeam); + + // Start battle + bytes32 salt = ""; + uint96 p0TeamIndex = 0; + uint256[] memory p0TeamIndices = registry2.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: registry2, + validator: validator2Mon, + rngOracle: defaultOracle, + ruleset: IRuleset(address(0)), + engineHooks: new IEngineHook[](0), + moveManager: address(commitManager2), + matchmaker: matchmaker, + gameMode: GameMode.Doubles + }); + + vm.startPrank(ALICE); + bytes32 battleKey = matchmaker.proposeBattle(proposal); + bytes32 battleIntegrityHash = matchmaker.getBattleProposalIntegrityHash(proposal); + vm.startPrank(BOB); + matchmaker.acceptBattle(battleKey, 0, battleIntegrityHash); + vm.startPrank(ALICE); + matchmaker.confirmBattle(battleKey, salt, p0TeamIndex); + vm.stopPrank(); + + vm.warp(block.timestamp + 1); + + // Turn 0: Initial switch + { + bytes32 aliceSalt = bytes32("as"); + bytes32 bobSalt = bytes32("bs"); + bytes32 aliceHash = keccak256(abi.encodePacked(SWITCH_MOVE_INDEX, uint240(0), SWITCH_MOVE_INDEX, uint240(1), aliceSalt)); + vm.startPrank(ALICE); + commitManager2.commitMoves(battleKey, aliceHash); + vm.stopPrank(); + vm.startPrank(BOB); + commitManager2.revealMoves(battleKey, SWITCH_MOVE_INDEX, 0, SWITCH_MOVE_INDEX, 1, bobSalt, false); + vm.stopPrank(); + vm.startPrank(ALICE); + commitManager2.revealMoves(battleKey, SWITCH_MOVE_INDEX, 0, SWITCH_MOVE_INDEX, 1, aliceSalt, false); + vm.stopPrank(); + engine.execute(battleKey); + } + + // Turn 1: Alice KOs Bob's slot 0 + { + bytes32 aliceSalt = bytes32("as2"); + bytes32 bobSalt = bytes32("bs2"); + bytes32 bobHash = keccak256(abi.encodePacked(uint8(NO_OP_MOVE_INDEX), uint240(0), uint8(NO_OP_MOVE_INDEX), uint240(0), bobSalt)); + vm.startPrank(BOB); + commitManager2.commitMoves(battleKey, bobHash); + vm.stopPrank(); + vm.startPrank(ALICE); + commitManager2.revealMoves(battleKey, uint8(0), 0, uint8(NO_OP_MOVE_INDEX), 0, aliceSalt, false); + vm.stopPrank(); + vm.startPrank(BOB); + commitManager2.revealMoves(battleKey, uint8(NO_OP_MOVE_INDEX), 0, uint8(NO_OP_MOVE_INDEX), 0, bobSalt, false); + vm.stopPrank(); + engine.execute(battleKey); + } + + // Verify Bob's mon 0 is KO'd + assertEq(engine.getMonStateForBattle(battleKey, 1, 0, MonStateIndexName.IsKnockedOut), 1, "Bob mon 0 KO'd"); + + // Bob has no valid switch target (mon 1 is in slot 1, mon 0 is KO'd) + // So NO_OP should be valid for Bob's slot 0, and it's a normal turn + BattleContext memory ctx = engine.getBattleContext(battleKey); + assertEq(ctx.playerSwitchForTurnFlag, 2, "Should be normal turn (Bob has no valid target)"); + + assertTrue(validator2Mon.validatePlayerMoveForSlot(battleKey, NO_OP_MOVE_INDEX, 1, 0, 0), "Bob NO_OP valid for KO'd slot"); + assertFalse(validator2Mon.validatePlayerMoveForSlot(battleKey, 0, 1, 0, 0), "Bob attack invalid for KO'd slot"); + assertTrue(validator2Mon.validatePlayerMoveForSlot(battleKey, 0, 1, 1, 0), "Bob slot 1 attack valid"); + } + + // ========================================= + // Asymmetric Switch Target Tests + // ========================================= + + /** + * @notice Test: P0 has KO'd slot WITH valid target, P1 has KO'd slot WITHOUT valid target + * @dev Uses 3-mon teams for both, but KOs P1's reserve first so P1 has no valid target + * when the asymmetric situation occurs + */ + function test_asymmetric_p0HasTarget_p1NoTarget() public { + // Use targeted attacks + IMoveSet[] memory targetedMoves = new IMoveSet[](4); + targetedMoves[0] = targetedStrongAttack; + targetedMoves[1] = targetedStrongAttack; + targetedMoves[2] = targetedStrongAttack; + targetedMoves[3] = targetedStrongAttack; + + IMoveSet[] memory regularMoves = new IMoveSet[](4); + regularMoves[0] = strongAttack; + regularMoves[1] = strongAttack; + regularMoves[2] = strongAttack; + regularMoves[3] = strongAttack; + + Mon[] memory aliceTeam = new Mon[](3); + aliceTeam[0] = _createMon(1, 5, regularMoves); // Weak - will be KO'd on turn 2 + aliceTeam[1] = _createMon(100, 30, targetedMoves); // Very fast, with targeting + aliceTeam[2] = _createMon(100, 6, regularMoves); // Reserve + + Mon[] memory bobTeam = new Mon[](3); + bobTeam[0] = _createMon(100, 5, regularMoves); // Slow but sturdy + bobTeam[1] = _createMon(100, 25, targetedMoves); // Fast, with targeting + bobTeam[2] = _createMon(1, 1, regularMoves); // Weak reserve - will be KO'd first + + defaultRegistry.setTeam(ALICE, aliceTeam); + defaultRegistry.setTeam(BOB, bobTeam); + + bytes32 battleKey = _startDoublesBattle(); + vm.warp(block.timestamp + 1); + _doInitialSwitch(battleKey); + + // Turn 1: Alice slot 1 KOs Bob slot 0 + _doublesCommitRevealExecute( + battleKey, + NO_OP_MOVE_INDEX, 0, 0, 0, // Alice: slot 0 no-op, slot 1 attacks Bob slot 0 + NO_OP_MOVE_INDEX, 0, NO_OP_MOVE_INDEX, 0 // Bob: both no-op + ); + + assertEq(engine.getMonStateForBattle(battleKey, 1, 0, MonStateIndexName.IsKnockedOut), 1, "Bob mon 0 KO'd"); + + // Bob-only switch turn (he has reserve mon 2) + BattleContext memory ctx = engine.getBattleContext(battleKey); + assertEq(ctx.playerSwitchForTurnFlag, 1, "Should be Bob-only switch turn"); + + // Bob switches to reserve + vm.startPrank(BOB); + commitManager.revealMoves(battleKey, SWITCH_MOVE_INDEX, 2, NO_OP_MOVE_INDEX, 0, bytes32("bobsalt"), true); + vm.stopPrank(); + + // Now Bob slot 0 = mon 2 (weak reserve) + assertEq(engine.getActiveMonIndexForSlot(battleKey, 1, 0), 2, "Bob slot 0 should have mon 2"); + + // Turn 2: Alice KOs Bob's mon 2 (slot 0), Bob slot 1 KOs Alice's mon 0 (slot 0) + // Bob slot 1 (speed 25) is faster than Bob slot 0 (mon 2, speed 1) + // So Bob slot 1 should attack Alice slot 0 before Bob slot 0 is KO'd + _doublesCommitRevealExecute( + battleKey, + NO_OP_MOVE_INDEX, 0, 0, 0, // Alice: slot 0 no-op, slot 1 attacks Bob slot 0 + NO_OP_MOVE_INDEX, 0, 0, 0 // Bob: slot 0 no-op, slot 1 attacks Alice slot 0 + ); + + // Check KOs + assertEq(engine.getMonStateForBattle(battleKey, 0, 0, MonStateIndexName.IsKnockedOut), 1, "Alice mon 0 KO'd"); + assertEq(engine.getMonStateForBattle(battleKey, 1, 2, MonStateIndexName.IsKnockedOut), 1, "Bob mon 2 KO'd"); + + // Now the state is: + // Alice: slot 0 has mon 0 (KO'd), slot 1 has mon 1 (alive), reserve mon 2 (alive) -> CAN switch + // Bob: slot 0 has mon 2 (KO'd), slot 1 has mon 1 (alive), mon 0 (KO'd) -> CANNOT switch + + // Should be P0-only switch turn + ctx = engine.getBattleContext(battleKey); + assertEq(ctx.playerSwitchForTurnFlag, 0, "Should be Alice-only switch turn (Bob has no valid target)"); + + // Verify Bob can NO_OP his KO'd slot + assertTrue(validator.validatePlayerMoveForSlot(battleKey, NO_OP_MOVE_INDEX, 1, 0, 0), "Bob NO_OP valid for KO'd slot"); + assertFalse(validator.validatePlayerMoveForSlot(battleKey, SWITCH_MOVE_INDEX, 1, 0, 1), "Bob can't switch to slot 1's mon"); + + // Alice must switch + assertTrue(validator.validatePlayerMoveForSlot(battleKey, SWITCH_MOVE_INDEX, 0, 0, 2), "Alice must switch to reserve"); + assertFalse(validator.validatePlayerMoveForSlot(battleKey, NO_OP_MOVE_INDEX, 0, 0, 0), "Alice NO_OP invalid (has target)"); + } + + /** + * @notice Test: P0 has KO'd slot WITHOUT valid target, P1 has KO'd slot WITH valid target + * @dev Mirror of above - should be P1-only switch turn + */ + function test_asymmetric_p0NoTarget_p1HasTarget() public { + IMoveSet[] memory moves = new IMoveSet[](4); + moves[0] = strongAttack; + moves[1] = strongAttack; + moves[2] = strongAttack; + moves[3] = strongAttack; + + // Mirror setup: Alice has weak reserve, Bob has strong reserve + Mon[] memory aliceTeam = new Mon[](3); + aliceTeam[0] = _createMon(100, 5, moves); // Slow but sturdy + aliceTeam[1] = _createMon(100, 25, moves); // Fast + aliceTeam[2] = _createMon(1, 1, moves); // Weak reserve - will be KO'd first + + Mon[] memory bobTeam = new Mon[](3); + bobTeam[0] = _createMon(1, 5, moves); // Weak - will be KO'd on turn 2 + bobTeam[1] = _createMon(100, 30, moves); // Very fast + bobTeam[2] = _createMon(100, 6, moves); // Reserve + + defaultRegistry.setTeam(ALICE, aliceTeam); + defaultRegistry.setTeam(BOB, bobTeam); + + bytes32 battleKey = _startDoublesBattle(); + vm.warp(block.timestamp + 1); + _doInitialSwitch(battleKey); + + // Turn 1: Bob slot 1 KOs Alice slot 0 + _doublesCommitRevealExecute( + battleKey, + NO_OP_MOVE_INDEX, 0, NO_OP_MOVE_INDEX, 0, + NO_OP_MOVE_INDEX, 0, 0, 0 // Bob slot 1 attacks Alice slot 0 + ); + + assertEq(engine.getMonStateForBattle(battleKey, 0, 0, MonStateIndexName.IsKnockedOut), 1, "Alice mon 0 KO'd"); + + // Alice-only switch turn (she has reserve mon 2) + BattleContext memory ctx = engine.getBattleContext(battleKey); + assertEq(ctx.playerSwitchForTurnFlag, 0, "Should be Alice-only switch turn"); + + // Alice switches to reserve + vm.startPrank(ALICE); + commitManager.revealMoves(battleKey, SWITCH_MOVE_INDEX, 2, NO_OP_MOVE_INDEX, 0, bytes32("alicesalt"), true); + vm.stopPrank(); + + assertEq(engine.getActiveMonIndexForSlot(battleKey, 0, 0), 2, "Alice slot 0 should have mon 2"); + + // Turn 2: Bob KOs Alice's mon 2 (now in slot 0), Alice KOs Bob's mon 0 + _doublesCommitRevealExecute( + battleKey, + NO_OP_MOVE_INDEX, 0, 0, 0, // Alice: slot 0 no-op, slot 1 attacks Bob slot 0 + NO_OP_MOVE_INDEX, 0, 0, 0 // Bob: slot 0 no-op, slot 1 attacks Alice slot 0 + ); + + // Check KOs + assertEq(engine.getMonStateForBattle(battleKey, 1, 0, MonStateIndexName.IsKnockedOut), 1, "Bob mon 0 KO'd"); + assertEq(engine.getMonStateForBattle(battleKey, 0, 2, MonStateIndexName.IsKnockedOut), 1, "Alice mon 2 KO'd"); + + // Now: + // Alice: slot 0 has mon 2 (KO'd), slot 1 has mon 1 (alive), mon 0 (KO'd) -> CANNOT switch + // Bob: slot 0 has mon 0 (KO'd), slot 1 has mon 1 (alive), reserve mon 2 (alive) -> CAN switch + + // Should be P1-only switch turn + ctx = engine.getBattleContext(battleKey); + assertEq(ctx.playerSwitchForTurnFlag, 1, "Should be Bob-only switch turn (Alice has no valid target)"); + + // Verify Alice can NO_OP her KO'd slot + assertTrue(validator.validatePlayerMoveForSlot(battleKey, NO_OP_MOVE_INDEX, 0, 0, 0), "Alice NO_OP valid for KO'd slot"); + + // Bob must switch + assertTrue(validator.validatePlayerMoveForSlot(battleKey, SWITCH_MOVE_INDEX, 1, 0, 2), "Bob must switch to reserve"); + assertFalse(validator.validatePlayerMoveForSlot(battleKey, NO_OP_MOVE_INDEX, 1, 0, 0), "Bob NO_OP invalid (has target)"); + } + + // ========================================= + // Slot 1 KO'd Tests + // ========================================= + + /** + * @notice Test: P0 slot 1 KO'd (slot 0 alive) with valid target + * @dev Verifies slot 1 KO handling works the same as slot 0 + */ + function test_slot1KO_withValidTarget() public { + // Use targeted attack for Bob so he can hit slot 1 + IMoveSet[] memory targetedMoves = new IMoveSet[](4); + targetedMoves[0] = targetedStrongAttack; + targetedMoves[1] = targetedStrongAttack; + targetedMoves[2] = targetedStrongAttack; + targetedMoves[3] = targetedStrongAttack; + + IMoveSet[] memory regularMoves = new IMoveSet[](4); + regularMoves[0] = strongAttack; + regularMoves[1] = strongAttack; + regularMoves[2] = strongAttack; + regularMoves[3] = strongAttack; + + Mon[] memory aliceTeam = new Mon[](3); + aliceTeam[0] = _createMon(100, 10, regularMoves); // Healthy + aliceTeam[1] = _createMon(1, 5, regularMoves); // Weak - will be KO'd + aliceTeam[2] = _createMon(100, 6, regularMoves); // Reserve + + Mon[] memory bobTeam = new Mon[](3); + bobTeam[0] = _createMon(100, 20, targetedMoves); // Fast, with targeted attack + bobTeam[1] = _createMon(100, 25, targetedMoves); // Faster + bobTeam[2] = _createMon(100, 16, targetedMoves); + + defaultRegistry.setTeam(ALICE, aliceTeam); + defaultRegistry.setTeam(BOB, bobTeam); + + bytes32 battleKey = _startDoublesBattle(); + vm.warp(block.timestamp + 1); + _doInitialSwitch(battleKey); + + // Turn 1: Bob slot 0 attacks Alice slot 1 (extraData=1 for target slot 1) + _doublesCommitRevealExecute( + battleKey, + NO_OP_MOVE_INDEX, 0, NO_OP_MOVE_INDEX, 0, // Alice: both no-op + 0, 1, NO_OP_MOVE_INDEX, 0 // Bob: slot 0 attacks Alice slot 1 (extraData=1) + ); + + // Check if Alice slot 1 is KO'd + assertEq(engine.getMonStateForBattle(battleKey, 0, 1, MonStateIndexName.IsKnockedOut), 1, "Alice mon 1 (slot 1) KO'd"); + + // Should be Alice-only switch turn + BattleContext memory ctx = engine.getBattleContext(battleKey); + assertEq(ctx.playerSwitchForTurnFlag, 0, "Should be Alice-only switch turn"); + + // Alice must switch slot 1 to reserve + assertTrue(validator.validatePlayerMoveForSlot(battleKey, SWITCH_MOVE_INDEX, 0, 1, 2), "Alice must switch slot 1 to reserve"); + assertFalse(validator.validatePlayerMoveForSlot(battleKey, NO_OP_MOVE_INDEX, 0, 1, 0), "Alice NO_OP invalid for slot 1 (has target)"); + + // Alice slot 0 can do anything (not KO'd) + assertTrue(validator.validatePlayerMoveForSlot(battleKey, 0, 0, 0, 0), "Alice slot 0 can attack"); + assertTrue(validator.validatePlayerMoveForSlot(battleKey, NO_OP_MOVE_INDEX, 0, 0, 0), "Alice slot 0 can NO_OP"); + } + + // ========================================= + // Both Slots KO'd Tests + // ========================================= + + /** + * @notice Test: P0 both slots KO'd with only one reserve (3-mon team) + * @dev When both slots try to switch to same mon, second switch becomes NO_OP. + * Slot 0 switches to mon 2, slot 1 keeps KO'd mon 1 (plays with one mon). + */ + function test_bothSlotsKO_oneReserve() public { + // Use targeted attacks for Bob + IMoveSet[] memory targetedMoves = new IMoveSet[](4); + targetedMoves[0] = targetedStrongAttack; + targetedMoves[1] = targetedStrongAttack; + targetedMoves[2] = targetedStrongAttack; + targetedMoves[3] = targetedStrongAttack; + + IMoveSet[] memory regularMoves = new IMoveSet[](4); + regularMoves[0] = strongAttack; + regularMoves[1] = strongAttack; + regularMoves[2] = strongAttack; + regularMoves[3] = strongAttack; + + Mon[] memory aliceTeam = new Mon[](3); + aliceTeam[0] = _createMon(1, 5, regularMoves); // Weak - will be KO'd + aliceTeam[1] = _createMon(1, 4, regularMoves); // Weak - will be KO'd + aliceTeam[2] = _createMon(100, 6, regularMoves); // Reserve + + Mon[] memory bobTeam = new Mon[](3); + bobTeam[0] = _createMon(100, 20, targetedMoves); // Fast - attacks Alice slot 0 + bobTeam[1] = _createMon(100, 25, targetedMoves); // Faster - attacks Alice slot 1 + bobTeam[2] = _createMon(100, 16, targetedMoves); + + defaultRegistry.setTeam(ALICE, aliceTeam); + defaultRegistry.setTeam(BOB, bobTeam); + + bytes32 battleKey = _startDoublesBattle(); + vm.warp(block.timestamp + 1); + _doInitialSwitch(battleKey); + + // Turn 1: Bob KOs both of Alice's active mons + // Bob slot 0 attacks Alice slot 0 (extraData=0), Bob slot 1 attacks Alice slot 1 (extraData=1) + _doublesCommitRevealExecute( + battleKey, + 0, 0, 0, 0, // Alice: both attack (can't NO_OP while alive) + 0, 0, 0, 1 // Bob: slot 0 attacks Alice slot 0, slot 1 attacks Alice slot 1 + ); + + // Both Alice mons should be KO'd + assertEq(engine.getMonStateForBattle(battleKey, 0, 0, MonStateIndexName.IsKnockedOut), 1, "Alice mon 0 KO'd"); + assertEq(engine.getMonStateForBattle(battleKey, 0, 1, MonStateIndexName.IsKnockedOut), 1, "Alice mon 1 KO'd"); + + // Key assertion: Alice should get a switch turn (she has at least one valid target) + BattleContext memory ctx = engine.getBattleContext(battleKey); + assertEq(ctx.playerSwitchForTurnFlag, 0, "Should be Alice-only switch turn"); + + // Both slots see mon 2 as a valid switch target at validation time (individually) + assertTrue(validator.validatePlayerMoveForSlot(battleKey, SWITCH_MOVE_INDEX, 0, 0, 2), "Alice slot 0 can switch to reserve"); + assertTrue(validator.validatePlayerMoveForSlot(battleKey, SWITCH_MOVE_INDEX, 0, 1, 2), "Alice slot 1 can switch to reserve"); + + // But both slots CANNOT switch to the same mon in the same reveal + // Alice reveals: slot 0 switches to mon 2, slot 1 NO_OPs (no other valid target) + vm.startPrank(ALICE); + commitManager.revealMoves(battleKey, SWITCH_MOVE_INDEX, 2, NO_OP_MOVE_INDEX, 0, bytes32("alicesalt"), true); + vm.stopPrank(); + + // Slot 0 switches to mon 2 + assertEq(engine.getActiveMonIndexForSlot(battleKey, 0, 0), 2, "Alice slot 0 should have mon 2"); + + // Slot 1 keeps its KO'd mon (mon 1) - no valid switch target after slot 0 takes the reserve + assertEq(engine.getActiveMonIndexForSlot(battleKey, 0, 1), 1, "Alice slot 1 should keep mon 1 (NO_OP)"); + assertEq(engine.getMonStateForBattle(battleKey, 0, 1, MonStateIndexName.IsKnockedOut), 1, "Alice slot 1 mon is still KO'd"); + + // Game continues - Alice plays with just one mon in slot 0 + ctx = engine.getBattleContext(battleKey); + assertEq(ctx.playerSwitchForTurnFlag, 2, "Should be normal turn now"); + } + + /** + * @notice Test: P0 both slots KO'd with 2 reserves (4-mon team) + * @dev Both slots can switch to different reserves + */ + function test_bothSlotsKO_twoReserves() public { + // Need 4-mon validator + DefaultValidator validator4Mon = new DefaultValidator( + engine, DefaultValidator.Args({MONS_PER_TEAM: 4, MOVES_PER_MON: 4, TIMEOUT_DURATION: TIMEOUT_DURATION}) + ); + DoublesCommitManager commitManager4 = new DoublesCommitManager(engine); + TestTeamRegistry registry4 = new TestTeamRegistry(); + + // Use targeted attacks for Bob + IMoveSet[] memory targetedMoves = new IMoveSet[](4); + targetedMoves[0] = targetedStrongAttack; + targetedMoves[1] = targetedStrongAttack; + targetedMoves[2] = targetedStrongAttack; + targetedMoves[3] = targetedStrongAttack; + + IMoveSet[] memory regularMoves = new IMoveSet[](4); + regularMoves[0] = strongAttack; + regularMoves[1] = strongAttack; + regularMoves[2] = strongAttack; + regularMoves[3] = strongAttack; + + Mon[] memory aliceTeam = new Mon[](4); + aliceTeam[0] = _createMon(1, 5, regularMoves); // Weak - will be KO'd + aliceTeam[1] = _createMon(1, 4, regularMoves); // Weak - will be KO'd + aliceTeam[2] = _createMon(100, 6, regularMoves); // Reserve 1 + aliceTeam[3] = _createMon(100, 7, regularMoves); // Reserve 2 + + Mon[] memory bobTeam = new Mon[](4); + bobTeam[0] = _createMon(100, 20, targetedMoves); + bobTeam[1] = _createMon(100, 25, targetedMoves); + bobTeam[2] = _createMon(100, 16, targetedMoves); + bobTeam[3] = _createMon(100, 15, targetedMoves); + + registry4.setTeam(ALICE, aliceTeam); + registry4.setTeam(BOB, bobTeam); + + // Start battle with 4-mon validator + bytes32 salt = ""; + uint96 p0TeamIndex = 0; + uint256[] memory p0TeamIndices = registry4.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: registry4, + validator: validator4Mon, + rngOracle: defaultOracle, + ruleset: IRuleset(address(0)), + engineHooks: new IEngineHook[](0), + moveManager: address(commitManager4), + matchmaker: matchmaker, + gameMode: GameMode.Doubles + }); + + vm.startPrank(ALICE); + bytes32 battleKey = matchmaker.proposeBattle(proposal); + bytes32 battleIntegrityHash = matchmaker.getBattleProposalIntegrityHash(proposal); + vm.startPrank(BOB); + matchmaker.acceptBattle(battleKey, 0, battleIntegrityHash); + vm.startPrank(ALICE); + matchmaker.confirmBattle(battleKey, salt, p0TeamIndex); + vm.stopPrank(); + + vm.warp(block.timestamp + 1); + + // Turn 0: Initial switch + { + bytes32 aliceSalt = bytes32("as"); + bytes32 bobSalt = bytes32("bs"); + bytes32 aliceHash = keccak256(abi.encodePacked(SWITCH_MOVE_INDEX, uint240(0), SWITCH_MOVE_INDEX, uint240(1), aliceSalt)); + vm.startPrank(ALICE); + commitManager4.commitMoves(battleKey, aliceHash); + vm.stopPrank(); + vm.startPrank(BOB); + commitManager4.revealMoves(battleKey, SWITCH_MOVE_INDEX, 0, SWITCH_MOVE_INDEX, 1, bobSalt, false); + vm.stopPrank(); + vm.startPrank(ALICE); + commitManager4.revealMoves(battleKey, SWITCH_MOVE_INDEX, 0, SWITCH_MOVE_INDEX, 1, aliceSalt, false); + vm.stopPrank(); + engine.execute(battleKey); + } + + // Turn 1: Bob KOs both of Alice's active mons + { + bytes32 aliceSalt = bytes32("as2"); + bytes32 bobSalt = bytes32("bs2"); + bytes32 bobHash = keccak256(abi.encodePacked(uint8(0), uint240(0), uint8(0), uint240(1), bobSalt)); + vm.startPrank(BOB); + commitManager4.commitMoves(battleKey, bobHash); + vm.stopPrank(); + vm.startPrank(ALICE); + commitManager4.revealMoves(battleKey, uint8(NO_OP_MOVE_INDEX), 0, uint8(NO_OP_MOVE_INDEX), 0, aliceSalt, false); + vm.stopPrank(); + vm.startPrank(BOB); + commitManager4.revealMoves(battleKey, uint8(0), 0, uint8(0), 1, bobSalt, false); + vm.stopPrank(); + engine.execute(battleKey); + } + + // Both Alice mons should be KO'd + assertEq(engine.getMonStateForBattle(battleKey, 0, 0, MonStateIndexName.IsKnockedOut), 1, "Alice mon 0 KO'd"); + assertEq(engine.getMonStateForBattle(battleKey, 0, 1, MonStateIndexName.IsKnockedOut), 1, "Alice mon 1 KO'd"); + + // Alice has 2 reserves, so both slots can switch + BattleContext memory ctx = engine.getBattleContext(battleKey); + assertEq(ctx.playerSwitchForTurnFlag, 0, "Should be Alice-only switch turn"); + + // Both slots can switch to either reserve + assertTrue(validator4Mon.validatePlayerMoveForSlot(battleKey, SWITCH_MOVE_INDEX, 0, 0, 2), "Slot 0 can switch to mon 2"); + assertTrue(validator4Mon.validatePlayerMoveForSlot(battleKey, SWITCH_MOVE_INDEX, 0, 0, 3), "Slot 0 can switch to mon 3"); + assertTrue(validator4Mon.validatePlayerMoveForSlot(battleKey, SWITCH_MOVE_INDEX, 0, 1, 2), "Slot 1 can switch to mon 2"); + assertTrue(validator4Mon.validatePlayerMoveForSlot(battleKey, SWITCH_MOVE_INDEX, 0, 1, 3), "Slot 1 can switch to mon 3"); + + // Alice switches both slots to different reserves + vm.startPrank(ALICE); + commitManager4.revealMoves(battleKey, SWITCH_MOVE_INDEX, 2, SWITCH_MOVE_INDEX, 3, bytes32("alicesalt3"), true); + vm.stopPrank(); + + // Verify both slots switched + assertEq(engine.getActiveMonIndexForSlot(battleKey, 0, 0), 2, "Alice slot 0 should have mon 2"); + assertEq(engine.getActiveMonIndexForSlot(battleKey, 0, 1), 3, "Alice slot 1 should have mon 3"); + + // Normal turn resumes + ctx = engine.getBattleContext(battleKey); + assertEq(ctx.playerSwitchForTurnFlag, 2, "Should be normal turn now"); + } + + /** + * @notice Test: Both slots KO'd, no reserves = Game Over + */ + function test_bothSlotsKO_noReserves_gameOver() public { + // Use 2-mon teams - if both are KO'd, game over + DefaultValidator validator2Mon = new DefaultValidator( + engine, DefaultValidator.Args({MONS_PER_TEAM: 2, MOVES_PER_MON: 4, TIMEOUT_DURATION: TIMEOUT_DURATION}) + ); + DoublesCommitManager commitManager2 = new DoublesCommitManager(engine); + TestTeamRegistry registry2 = new TestTeamRegistry(); + + // Use targeted attacks for Bob + IMoveSet[] memory targetedMoves = new IMoveSet[](4); + targetedMoves[0] = targetedStrongAttack; + targetedMoves[1] = targetedStrongAttack; + targetedMoves[2] = targetedStrongAttack; + targetedMoves[3] = targetedStrongAttack; + + IMoveSet[] memory regularMoves = new IMoveSet[](4); + regularMoves[0] = strongAttack; + regularMoves[1] = strongAttack; + regularMoves[2] = strongAttack; + regularMoves[3] = strongAttack; + + Mon[] memory aliceTeam = new Mon[](2); + aliceTeam[0] = _createMon(1, 5, regularMoves); // Weak - will be KO'd + aliceTeam[1] = _createMon(1, 4, regularMoves); // Weak - will be KO'd + + Mon[] memory bobTeam = new Mon[](2); + bobTeam[0] = _createMon(100, 20, targetedMoves); + bobTeam[1] = _createMon(100, 25, targetedMoves); + + registry2.setTeam(ALICE, aliceTeam); + registry2.setTeam(BOB, bobTeam); + + // Start battle + bytes32 salt = ""; + uint96 p0TeamIndex = 0; + uint256[] memory p0TeamIndices = registry2.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: registry2, + validator: validator2Mon, + rngOracle: defaultOracle, + ruleset: IRuleset(address(0)), + engineHooks: new IEngineHook[](0), + moveManager: address(commitManager2), + matchmaker: matchmaker, + gameMode: GameMode.Doubles + }); + + vm.startPrank(ALICE); + bytes32 battleKey = matchmaker.proposeBattle(proposal); + bytes32 battleIntegrityHash = matchmaker.getBattleProposalIntegrityHash(proposal); + vm.startPrank(BOB); + matchmaker.acceptBattle(battleKey, 0, battleIntegrityHash); + vm.startPrank(ALICE); + matchmaker.confirmBattle(battleKey, salt, p0TeamIndex); + vm.stopPrank(); + + vm.warp(block.timestamp + 1); + + // Turn 0: Initial switch + { + bytes32 aliceSalt = bytes32("as"); + bytes32 bobSalt = bytes32("bs"); + bytes32 aliceHash = keccak256(abi.encodePacked(SWITCH_MOVE_INDEX, uint240(0), SWITCH_MOVE_INDEX, uint240(1), aliceSalt)); + vm.startPrank(ALICE); + commitManager2.commitMoves(battleKey, aliceHash); + vm.stopPrank(); + vm.startPrank(BOB); + commitManager2.revealMoves(battleKey, SWITCH_MOVE_INDEX, 0, SWITCH_MOVE_INDEX, 1, bobSalt, false); + vm.stopPrank(); + vm.startPrank(ALICE); + commitManager2.revealMoves(battleKey, SWITCH_MOVE_INDEX, 0, SWITCH_MOVE_INDEX, 1, aliceSalt, false); + vm.stopPrank(); + engine.execute(battleKey); + } + + // Turn 1: Bob KOs both of Alice's mons - game should end + { + bytes32 aliceSalt = bytes32("as2"); + bytes32 bobSalt = bytes32("bs2"); + bytes32 bobHash = keccak256(abi.encodePacked(uint8(0), uint240(0), uint8(0), uint240(1), bobSalt)); + vm.startPrank(BOB); + commitManager2.commitMoves(battleKey, bobHash); + vm.stopPrank(); + vm.startPrank(ALICE); + commitManager2.revealMoves(battleKey, uint8(NO_OP_MOVE_INDEX), 0, uint8(NO_OP_MOVE_INDEX), 0, aliceSalt, false); + vm.stopPrank(); + vm.startPrank(BOB); + commitManager2.revealMoves(battleKey, uint8(0), 0, uint8(0), 1, bobSalt, false); + vm.stopPrank(); + engine.execute(battleKey); + } + + // Game should be over, Bob wins + assertEq(engine.getWinner(battleKey), BOB, "Bob should win"); + } + + /** + * @notice Test: Continuing with one mon after slot is KO'd with no valid target + * @dev Player should be able to keep playing with their remaining alive mon + */ + function test_continueWithOneMon_afterKONoTarget() public { + // Use 2-mon teams + DefaultValidator validator2Mon = new DefaultValidator( + engine, DefaultValidator.Args({MONS_PER_TEAM: 2, MOVES_PER_MON: 4, TIMEOUT_DURATION: TIMEOUT_DURATION}) + ); + DoublesCommitManager commitManager2 = new DoublesCommitManager(engine); + TestTeamRegistry registry2 = new TestTeamRegistry(); + + IMoveSet[] memory moves = new IMoveSet[](4); + moves[0] = strongAttack; + moves[1] = strongAttack; + moves[2] = strongAttack; + moves[3] = strongAttack; + + Mon[] memory aliceTeam = new Mon[](2); + aliceTeam[0] = _createMon(1, 5, moves); // Weak - will be KO'd + aliceTeam[1] = _createMon(100, 30, moves); // Strong and fast + + Mon[] memory bobTeam = new Mon[](2); + bobTeam[0] = _createMon(100, 20, moves); + bobTeam[1] = _createMon(100, 18, moves); + + registry2.setTeam(ALICE, aliceTeam); + registry2.setTeam(BOB, bobTeam); + + // Start battle + bytes32 salt = ""; + uint96 p0TeamIndex = 0; + uint256[] memory p0TeamIndices = registry2.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: registry2, + validator: validator2Mon, + rngOracle: defaultOracle, + ruleset: IRuleset(address(0)), + engineHooks: new IEngineHook[](0), + moveManager: address(commitManager2), + matchmaker: matchmaker, + gameMode: GameMode.Doubles + }); + + vm.startPrank(ALICE); + bytes32 battleKey = matchmaker.proposeBattle(proposal); + bytes32 battleIntegrityHash = matchmaker.getBattleProposalIntegrityHash(proposal); + vm.startPrank(BOB); + matchmaker.acceptBattle(battleKey, 0, battleIntegrityHash); + vm.startPrank(ALICE); + matchmaker.confirmBattle(battleKey, salt, p0TeamIndex); + vm.stopPrank(); + + vm.warp(block.timestamp + 1); + + // Turn 0: Initial switch + { + bytes32 aliceSalt = bytes32("as"); + bytes32 bobSalt = bytes32("bs"); + bytes32 aliceHash = keccak256(abi.encodePacked(SWITCH_MOVE_INDEX, uint240(0), SWITCH_MOVE_INDEX, uint240(1), aliceSalt)); + vm.startPrank(ALICE); + commitManager2.commitMoves(battleKey, aliceHash); + vm.stopPrank(); + vm.startPrank(BOB); + commitManager2.revealMoves(battleKey, SWITCH_MOVE_INDEX, 0, SWITCH_MOVE_INDEX, 1, bobSalt, false); + vm.stopPrank(); + vm.startPrank(ALICE); + commitManager2.revealMoves(battleKey, SWITCH_MOVE_INDEX, 0, SWITCH_MOVE_INDEX, 1, aliceSalt, false); + vm.stopPrank(); + engine.execute(battleKey); + } + + // Turn 1: Bob KOs Alice's slot 0 + { + bytes32 aliceSalt = bytes32("as2"); + bytes32 bobSalt = bytes32("bs2"); + bytes32 bobHash = keccak256(abi.encodePacked(uint8(0), uint240(0), uint8(NO_OP_MOVE_INDEX), uint240(0), bobSalt)); + vm.startPrank(BOB); + commitManager2.commitMoves(battleKey, bobHash); + vm.stopPrank(); + vm.startPrank(ALICE); + commitManager2.revealMoves(battleKey, uint8(NO_OP_MOVE_INDEX), 0, uint8(NO_OP_MOVE_INDEX), 0, aliceSalt, false); + vm.stopPrank(); + vm.startPrank(BOB); + commitManager2.revealMoves(battleKey, uint8(0), 0, uint8(NO_OP_MOVE_INDEX), 0, bobSalt, false); + vm.stopPrank(); + engine.execute(battleKey); + } + + // Alice's mon 0 is KO'd, no valid switch target + assertEq(engine.getMonStateForBattle(battleKey, 0, 0, MonStateIndexName.IsKnockedOut), 1, "Alice mon 0 KO'd"); + + // Should be normal turn (Alice has no valid switch target) + BattleContext memory ctx = engine.getBattleContext(battleKey); + assertEq(ctx.playerSwitchForTurnFlag, 2, "Should be normal turn"); + + // Game should continue + assertEq(engine.getWinner(battleKey), address(0), "Game should not be over"); + + // Alice slot 0: must NO_OP (KO'd, no target) + assertTrue(validator2Mon.validatePlayerMoveForSlot(battleKey, NO_OP_MOVE_INDEX, 0, 0, 0), "Alice slot 0 NO_OP valid"); + assertFalse(validator2Mon.validatePlayerMoveForSlot(battleKey, 0, 0, 0, 0), "Alice slot 0 attack invalid"); + + // Alice slot 1: can attack normally + assertTrue(validator2Mon.validatePlayerMoveForSlot(battleKey, 0, 0, 1, 0), "Alice slot 1 can attack"); + + // Turn 2: Alice attacks with slot 1, Bob attacks + { + bytes32 aliceSalt = bytes32("as3"); + bytes32 bobSalt = bytes32("bs3"); + bytes32 aliceHash = keccak256(abi.encodePacked(uint8(NO_OP_MOVE_INDEX), uint240(0), uint8(0), uint240(0), aliceSalt)); + vm.startPrank(ALICE); + commitManager2.commitMoves(battleKey, aliceHash); + vm.stopPrank(); + vm.startPrank(BOB); + commitManager2.revealMoves(battleKey, uint8(0), 0, uint8(0), 0, bobSalt, false); + vm.stopPrank(); + vm.startPrank(ALICE); + commitManager2.revealMoves(battleKey, uint8(NO_OP_MOVE_INDEX), 0, uint8(0), 0, aliceSalt, false); + vm.stopPrank(); + engine.execute(battleKey); + } + + // Game should still be ongoing (Alice's slot 1 mon is strong) + assertEq(engine.getWinner(battleKey), address(0), "Game should still be ongoing"); + + // Verify Alice's slot 1 mon is still alive + assertEq(engine.getMonStateForBattle(battleKey, 0, 1, MonStateIndexName.IsKnockedOut), 0, "Alice mon 1 should be alive"); + } + + // ========================================= + // Forced Switch Move Tests (Doubles) + // ========================================= + + /** + * @notice Test: Force switch move cannot switch to mon already active in other slot + * @dev Uses validateSwitch which should check both slots in doubles mode + */ + function test_forceSwitchMove_cannotSwitchToOtherSlotActiveMon() public { + // Create force switch move + ForceSwitchMove forceSwitchMove = new ForceSwitchMove( + engine, ForceSwitchMove.Args({TYPE: Type.Fire, STAMINA_COST: 1, PRIORITY: 0}) + ); + + IMoveSet[] memory movesWithForceSwitch = new IMoveSet[](4); + movesWithForceSwitch[0] = forceSwitchMove; + movesWithForceSwitch[1] = customAttack; + movesWithForceSwitch[2] = customAttack; + movesWithForceSwitch[3] = customAttack; + + IMoveSet[] memory regularMoves = new IMoveSet[](4); + regularMoves[0] = customAttack; + regularMoves[1] = customAttack; + regularMoves[2] = customAttack; + regularMoves[3] = customAttack; + + Mon[] memory aliceTeam = new Mon[](3); + aliceTeam[0] = _createMon(100, 10, movesWithForceSwitch); // Has force switch move + aliceTeam[1] = _createMon(100, 10, regularMoves); + aliceTeam[2] = _createMon(100, 10, regularMoves); // Reserve + + Mon[] memory bobTeam = new Mon[](3); + bobTeam[0] = _createMon(100, 10, regularMoves); + bobTeam[1] = _createMon(100, 10, regularMoves); + bobTeam[2] = _createMon(100, 10, regularMoves); + + defaultRegistry.setTeam(ALICE, aliceTeam); + defaultRegistry.setTeam(BOB, bobTeam); + + bytes32 battleKey = _startDoublesBattle(); + vm.warp(block.timestamp + 1); + _doInitialSwitch(battleKey); + + // After initial switch: Alice has mon 0 in slot 0, mon 1 in slot 1 + assertEq(engine.getActiveMonIndexForSlot(battleKey, 0, 0), 0, "Alice slot 0 has mon 0"); + assertEq(engine.getActiveMonIndexForSlot(battleKey, 0, 1), 1, "Alice slot 1 has mon 1"); + + // validateSwitch should reject switching to mon 1 (already in slot 1) + assertFalse(validator.validateSwitch(battleKey, 0, 1), "Should not allow switching to mon already in slot 1"); + + // validateSwitch should allow switching to mon 2 (reserve) + assertTrue(validator.validateSwitch(battleKey, 0, 2), "Should allow switching to reserve mon 2"); + } + + /** + * @notice Test: validateSwitch rejects switching to slot 0's active mon + * @dev Tests the other direction - can't switch to mon that's in slot 0 + */ + function test_forceSwitchMove_cannotSwitchToSlot0ActiveMon() public { + IMoveSet[] memory regularMoves = new IMoveSet[](4); + regularMoves[0] = customAttack; + regularMoves[1] = customAttack; + regularMoves[2] = customAttack; + regularMoves[3] = customAttack; + + Mon[] memory aliceTeam = new Mon[](3); + aliceTeam[0] = _createMon(100, 10, regularMoves); + aliceTeam[1] = _createMon(100, 10, regularMoves); + aliceTeam[2] = _createMon(100, 10, regularMoves); + + Mon[] memory bobTeam = new Mon[](3); + bobTeam[0] = _createMon(100, 10, regularMoves); + bobTeam[1] = _createMon(100, 10, regularMoves); + bobTeam[2] = _createMon(100, 10, regularMoves); + + defaultRegistry.setTeam(ALICE, aliceTeam); + defaultRegistry.setTeam(BOB, bobTeam); + + bytes32 battleKey = _startDoublesBattle(); + vm.warp(block.timestamp + 1); + _doInitialSwitch(battleKey); + + // After initial switch: Alice has mon 0 in slot 0, mon 1 in slot 1 + assertEq(engine.getActiveMonIndexForSlot(battleKey, 0, 0), 0, "Alice slot 0 has mon 0"); + assertEq(engine.getActiveMonIndexForSlot(battleKey, 0, 1), 1, "Alice slot 1 has mon 1"); + + // validateSwitch should reject switching to mon 0 (already in slot 0) + assertFalse(validator.validateSwitch(battleKey, 0, 0), "Should not allow switching to mon already in slot 0"); + + // validateSwitch should allow switching to mon 2 (reserve) + assertTrue(validator.validateSwitch(battleKey, 0, 2), "Should allow switching to reserve mon 2"); + } + + /** + * @notice Test: validateSwitch allows KO'd mon even if active (for replacement) + * @dev When a slot's mon is KO'd, it's still in that slot but should be switchable away from + */ + function test_validateSwitch_allowsKOdMonReplacement() public { + // Use targeted attacks for Bob to KO Alice slot 0 + IMoveSet[] memory targetedMoves = new IMoveSet[](4); + targetedMoves[0] = targetedStrongAttack; + targetedMoves[1] = targetedStrongAttack; + targetedMoves[2] = targetedStrongAttack; + targetedMoves[3] = targetedStrongAttack; + + IMoveSet[] memory regularMoves = new IMoveSet[](4); + regularMoves[0] = strongAttack; + regularMoves[1] = strongAttack; + regularMoves[2] = strongAttack; + regularMoves[3] = strongAttack; + + Mon[] memory aliceTeam = new Mon[](3); + aliceTeam[0] = _createMon(1, 5, regularMoves); // Weak - will be KO'd + aliceTeam[1] = _createMon(100, 10, regularMoves); + aliceTeam[2] = _createMon(100, 10, regularMoves); + + Mon[] memory bobTeam = new Mon[](3); + bobTeam[0] = _createMon(100, 20, targetedMoves); // Fast - KOs Alice slot 0 + bobTeam[1] = _createMon(100, 10, targetedMoves); + bobTeam[2] = _createMon(100, 10, targetedMoves); + + defaultRegistry.setTeam(ALICE, aliceTeam); + defaultRegistry.setTeam(BOB, bobTeam); + + bytes32 battleKey = _startDoublesBattle(); + vm.warp(block.timestamp + 1); + _doInitialSwitch(battleKey); + + // Turn 1: Bob KOs Alice's slot 0 + _doublesCommitRevealExecute( + battleKey, + 0, 0, 0, 0, // Alice: both attack + 0, 0, 0, 0 // Bob: slot 0 attacks Alice slot 0 + ); + + // Alice mon 0 should be KO'd + assertEq(engine.getMonStateForBattle(battleKey, 0, 0, MonStateIndexName.IsKnockedOut), 1, "Alice mon 0 KO'd"); + + // validateSwitch should NOT allow switching to KO'd mon 0 + assertFalse(validator.validateSwitch(battleKey, 0, 0), "Should not allow switching to KO'd mon"); + + // validateSwitch should allow switching to reserve mon 2 + assertTrue(validator.validateSwitch(battleKey, 0, 2), "Should allow switching to reserve"); + } + + // ========================================= + // Force Switch Tests (switchActiveMonForSlot) + // ========================================= + + /** + * @notice Test: switchActiveMonForSlot correctly switches a specific slot in doubles + * @dev Verifies the new slot-aware switch function doesn't corrupt storage + */ + function test_switchActiveMonForSlot_correctlyUpdatesSingleSlot() public { + // Create a move set with the doubles force switch move + DoublesForceSwitchMove forceSwitchMove = new DoublesForceSwitchMove(engine); + + IMoveSet[] memory aliceMoves = new IMoveSet[](4); + aliceMoves[0] = forceSwitchMove; // Force switch move + aliceMoves[1] = targetedStrongAttack; + aliceMoves[2] = targetedStrongAttack; + aliceMoves[3] = targetedStrongAttack; + + IMoveSet[] memory bobMoves = new IMoveSet[](4); + bobMoves[0] = targetedStrongAttack; + bobMoves[1] = targetedStrongAttack; + bobMoves[2] = targetedStrongAttack; + bobMoves[3] = targetedStrongAttack; + + // Create teams - Alice will force Bob's slot 0 to switch to mon 2 + Mon[] memory aliceTeam = new Mon[](3); + aliceTeam[0] = _createMon(100, 20, aliceMoves); // Fastest - uses force switch + aliceTeam[1] = _createMon(100, 15, aliceMoves); + aliceTeam[2] = _createMon(100, 10, aliceMoves); + + Mon[] memory bobTeam = new Mon[](3); + bobTeam[0] = _createMon(100, 5, bobMoves); // Will be force-switched + bobTeam[1] = _createMon(100, 4, bobMoves); + bobTeam[2] = _createMon(100, 3, bobMoves); // Reserve - will be switched in + + defaultRegistry.setTeam(ALICE, aliceTeam); + defaultRegistry.setTeam(BOB, bobTeam); + + bytes32 battleKey = _startDoublesBattle(); + vm.warp(block.timestamp + 1); + _doInitialSwitch(battleKey); + + // Verify initial state: Bob slot 0 = mon 0, slot 1 = mon 1 + assertEq(engine.getActiveMonIndexForSlot(battleKey, 1, 0), 0, "Bob slot 0 should be mon 0"); + assertEq(engine.getActiveMonIndexForSlot(battleKey, 1, 1), 1, "Bob slot 1 should be mon 1"); + + // Turn 1: Alice slot 0 uses force switch on Bob slot 0, forcing switch to mon 2 + // extraData format: lower 4 bits = target slot (0), next 4 bits = mon to switch to (2) + uint240 forceSlot0ToMon2 = 0 | (2 << 4); // target slot 0, switch to mon 2 + + _doublesCommitRevealExecute( + battleKey, + 0, forceSlot0ToMon2, NO_OP_MOVE_INDEX, 0, // Alice: slot 0 force-switch, slot 1 no-op + NO_OP_MOVE_INDEX, 0, NO_OP_MOVE_INDEX, 0 // Bob: both no-op (won't matter, Alice is faster) + ); + + // Verify: Bob slot 0 should now be mon 2, slot 1 should still be mon 1 + assertEq(engine.getActiveMonIndexForSlot(battleKey, 1, 0), 2, "Bob slot 0 should now be mon 2"); + assertEq(engine.getActiveMonIndexForSlot(battleKey, 1, 1), 1, "Bob slot 1 should still be mon 1"); + + // Verify Alice's slots are unchanged + assertEq(engine.getActiveMonIndexForSlot(battleKey, 0, 0), 0, "Alice slot 0 should still be mon 0"); + assertEq(engine.getActiveMonIndexForSlot(battleKey, 0, 1), 1, "Alice slot 1 should still be mon 1"); + } + + /** + * @notice Test: switchActiveMonForSlot on slot 1 doesn't affect slot 0 + * @dev Ensures slot isolation in force-switch operations + */ + function test_switchActiveMonForSlot_slot1_doesNotAffectSlot0() public { + DoublesForceSwitchMove forceSwitchMove = new DoublesForceSwitchMove(engine); + + IMoveSet[] memory aliceMoves = new IMoveSet[](4); + aliceMoves[0] = forceSwitchMove; + aliceMoves[1] = targetedStrongAttack; + aliceMoves[2] = targetedStrongAttack; + aliceMoves[3] = targetedStrongAttack; + + IMoveSet[] memory bobMoves = new IMoveSet[](4); + bobMoves[0] = targetedStrongAttack; + bobMoves[1] = targetedStrongAttack; + bobMoves[2] = targetedStrongAttack; + bobMoves[3] = targetedStrongAttack; + + Mon[] memory aliceTeam = new Mon[](3); + aliceTeam[0] = _createMon(100, 20, aliceMoves); + aliceTeam[1] = _createMon(100, 15, aliceMoves); + aliceTeam[2] = _createMon(100, 10, aliceMoves); + + Mon[] memory bobTeam = new Mon[](3); + bobTeam[0] = _createMon(100, 5, bobMoves); + bobTeam[1] = _createMon(100, 4, bobMoves); + bobTeam[2] = _createMon(100, 3, bobMoves); + + defaultRegistry.setTeam(ALICE, aliceTeam); + defaultRegistry.setTeam(BOB, bobTeam); + + bytes32 battleKey = _startDoublesBattle(); + vm.warp(block.timestamp + 1); + _doInitialSwitch(battleKey); + + // Force Bob slot 1 to switch to mon 2 + // extraData: target slot 1, switch to mon 2 + uint240 forceSlot1ToMon2 = 1 | (2 << 4); + + _doublesCommitRevealExecute( + battleKey, + 0, forceSlot1ToMon2, NO_OP_MOVE_INDEX, 0, + NO_OP_MOVE_INDEX, 0, NO_OP_MOVE_INDEX, 0 + ); + + // Bob slot 1 should now be mon 2, slot 0 should still be mon 0 + assertEq(engine.getActiveMonIndexForSlot(battleKey, 1, 0), 0, "Bob slot 0 should still be mon 0"); + assertEq(engine.getActiveMonIndexForSlot(battleKey, 1, 1), 2, "Bob slot 1 should now be mon 2"); + } + + // ========================================= + // Simultaneous Switch Validation Tests + // ========================================= + + /** + * @notice Test: Both slots cannot switch to the same reserve mon during reveal + * @dev When both slots are KO'd and try to switch to the same reserve, validation should fail + */ + function test_bothSlotsSwitchToSameMon_reverts() public { + // Need 4-mon validator (2 active + 2 reserves) + DefaultValidator validator4Mon = new DefaultValidator( + engine, DefaultValidator.Args({MONS_PER_TEAM: 4, MOVES_PER_MON: 4, TIMEOUT_DURATION: TIMEOUT_DURATION}) + ); + DoublesCommitManager commitManager4 = new DoublesCommitManager(engine); + TestTeamRegistry registry4 = new TestTeamRegistry(); + + IMoveSet[] memory targetedMoves = new IMoveSet[](4); + targetedMoves[0] = targetedStrongAttack; + targetedMoves[1] = targetedStrongAttack; + targetedMoves[2] = targetedStrongAttack; + targetedMoves[3] = targetedStrongAttack; + + IMoveSet[] memory regularMoves = new IMoveSet[](4); + regularMoves[0] = strongAttack; + regularMoves[1] = strongAttack; + regularMoves[2] = strongAttack; + regularMoves[3] = strongAttack; + + Mon[] memory aliceTeam = new Mon[](4); + aliceTeam[0] = _createMon(1, 5, regularMoves); // Weak - will be KO'd + aliceTeam[1] = _createMon(1, 4, regularMoves); // Weak - will be KO'd + aliceTeam[2] = _createMon(100, 6, regularMoves); // Reserve 1 + aliceTeam[3] = _createMon(100, 7, regularMoves); // Reserve 2 + + Mon[] memory bobTeam = new Mon[](4); + bobTeam[0] = _createMon(100, 20, targetedMoves); + bobTeam[1] = _createMon(100, 25, targetedMoves); + bobTeam[2] = _createMon(100, 16, targetedMoves); + bobTeam[3] = _createMon(100, 15, targetedMoves); + + registry4.setTeam(ALICE, aliceTeam); + registry4.setTeam(BOB, bobTeam); + + // Start battle with 4-mon validator + bytes32 salt = ""; + uint96 p0TeamIndex = 0; + uint256[] memory p0TeamIndices = registry4.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: registry4, + validator: validator4Mon, + rngOracle: defaultOracle, + ruleset: IRuleset(address(0)), + engineHooks: new IEngineHook[](0), + moveManager: address(commitManager4), + matchmaker: matchmaker, + gameMode: GameMode.Doubles + }); + + vm.startPrank(ALICE); + bytes32 battleKey = matchmaker.proposeBattle(proposal); + bytes32 battleIntegrityHash = matchmaker.getBattleProposalIntegrityHash(proposal); + vm.startPrank(BOB); + matchmaker.acceptBattle(battleKey, 0, battleIntegrityHash); + vm.startPrank(ALICE); + matchmaker.confirmBattle(battleKey, salt, p0TeamIndex); + vm.stopPrank(); + + vm.warp(block.timestamp + 1); + + // Turn 0: Initial switch + { + bytes32 aliceSalt = bytes32("as"); + bytes32 bobSalt = bytes32("bs"); + bytes32 aliceHash = keccak256(abi.encodePacked(SWITCH_MOVE_INDEX, uint240(0), SWITCH_MOVE_INDEX, uint240(1), aliceSalt)); + vm.startPrank(ALICE); + commitManager4.commitMoves(battleKey, aliceHash); + vm.stopPrank(); + vm.startPrank(BOB); + commitManager4.revealMoves(battleKey, SWITCH_MOVE_INDEX, 0, SWITCH_MOVE_INDEX, 1, bobSalt, false); + vm.stopPrank(); + vm.startPrank(ALICE); + commitManager4.revealMoves(battleKey, SWITCH_MOVE_INDEX, 0, SWITCH_MOVE_INDEX, 1, aliceSalt, false); + vm.stopPrank(); + engine.execute(battleKey); + } + + // Turn 1: Bob KOs both of Alice's active mons + { + bytes32 aliceSalt = bytes32("as2"); + bytes32 bobSalt = bytes32("bs2"); + bytes32 bobHash = keccak256(abi.encodePacked(uint8(0), uint240(0), uint8(0), uint240(1), bobSalt)); + vm.startPrank(BOB); + commitManager4.commitMoves(battleKey, bobHash); + vm.stopPrank(); + vm.startPrank(ALICE); + commitManager4.revealMoves(battleKey, uint8(NO_OP_MOVE_INDEX), 0, uint8(NO_OP_MOVE_INDEX), 0, aliceSalt, false); + vm.stopPrank(); + vm.startPrank(BOB); + commitManager4.revealMoves(battleKey, uint8(0), 0, uint8(0), 1, bobSalt, false); + vm.stopPrank(); + engine.execute(battleKey); + } + + // Both Alice mons should be KO'd + assertEq(engine.getMonStateForBattle(battleKey, 0, 0, MonStateIndexName.IsKnockedOut), 1, "Alice mon 0 KO'd"); + assertEq(engine.getMonStateForBattle(battleKey, 0, 1, MonStateIndexName.IsKnockedOut), 1, "Alice mon 1 KO'd"); + + // Alice tries to switch BOTH slots to the SAME reserve (mon 2) - should revert + vm.startPrank(ALICE); + vm.expectRevert(); // Should revert because both slots can't switch to same mon + commitManager4.revealMoves(battleKey, SWITCH_MOVE_INDEX, 2, SWITCH_MOVE_INDEX, 2, bytes32("alicesalt3"), true); + vm.stopPrank(); + } + + // ========================================= + // Move Execution Order Tests + // ========================================= + + /** + * @notice Test: A KO'd mon's move doesn't execute in doubles + * @dev Verifies that if a mon is KO'd before its turn, its attack doesn't deal damage + */ + function test_KOdMonMoveDoesNotExecute() public { + IMoveSet[] memory targetedMoves = new IMoveSet[](4); + targetedMoves[0] = targetedStrongAttack; + targetedMoves[1] = targetedStrongAttack; + targetedMoves[2] = targetedStrongAttack; + targetedMoves[3] = targetedStrongAttack; + + // Alice: slot 0 is slow and weak (will be KO'd before attacking) + Mon[] memory aliceTeam = new Mon[](3); + aliceTeam[0] = _createMon(1, 1, targetedMoves); // Very slow, 1 HP - will be KO'd + aliceTeam[1] = _createMon(300, 20, targetedMoves); // Fast, strong + aliceTeam[2] = _createMon(100, 10, targetedMoves); + + // Bob: slot 0 is fast and will KO Alice slot 0 before it can attack + Mon[] memory bobTeam = new Mon[](3); + bobTeam[0] = _createMon(300, 30, targetedMoves); // Fastest - will KO Alice slot 0 + bobTeam[1] = _createMon(300, 5, targetedMoves); // Slow + bobTeam[2] = _createMon(100, 3, targetedMoves); + + defaultRegistry.setTeam(ALICE, aliceTeam); + defaultRegistry.setTeam(BOB, bobTeam); + + bytes32 battleKey = _startDoublesBattle(); + vm.warp(block.timestamp + 1); + _doInitialSwitch(battleKey); + + // Record Bob's HP before the turn + int256 bobSlot0HpBefore = engine.getMonStateForBattle(battleKey, 1, 0, MonStateIndexName.Hp); + + // Turn 1: + // - Alice slot 0 (speed 1) targets Bob slot 0 + // - Alice slot 1 (speed 20) does NO_OP to avoid complications + // - Bob slot 0 (speed 30) targets Alice slot 0 - will KO it first + // - Bob slot 1 (speed 5) does NO_OP + // Order: Bob slot 0 (30) > Alice slot 1 (NO_OP) > Bob slot 1 (NO_OP) > Alice slot 0 (1, but KO'd) + _doublesCommitRevealExecute( + battleKey, + 0, 0, NO_OP_MOVE_INDEX, 0, // Alice: slot 0 attacks Bob slot 0, slot 1 no-op + 0, 0, NO_OP_MOVE_INDEX, 0 // Bob: slot 0 attacks Alice slot 0 (default), slot 1 no-op + ); + + // Verify Alice slot 0 is KO'd + assertEq(engine.getMonStateForBattle(battleKey, 0, 0, MonStateIndexName.IsKnockedOut), 1, "Alice slot 0 should be KO'd"); + + // Bob slot 0 should NOT have taken damage from Alice slot 0 (move didn't execute) + int256 bobSlot0HpAfter = engine.getMonStateForBattle(battleKey, 1, 0, MonStateIndexName.Hp); + assertEq(bobSlot0HpAfter, bobSlot0HpBefore, "Bob slot 0 should not have taken damage from KO'd Alice"); + } + + /** + * @notice Test: Both opponent slots KO'd mid-turn, remaining moves don't target them + * @dev If both opponent mons are KO'd, remaining moves that targeted them shouldn't crash + */ + function test_bothOpponentSlotsKOd_remainingMovesHandled() public { + IMoveSet[] memory targetedMoves = new IMoveSet[](4); + targetedMoves[0] = targetedStrongAttack; + targetedMoves[1] = targetedStrongAttack; + targetedMoves[2] = targetedStrongAttack; + targetedMoves[3] = targetedStrongAttack; + + // Alice: Both slots are very fast and strong + Mon[] memory aliceTeam = new Mon[](3); + aliceTeam[0] = _createMon(300, 50, targetedMoves); // Fastest + aliceTeam[1] = _createMon(300, 45, targetedMoves); // Second fastest + aliceTeam[2] = _createMon(100, 10, targetedMoves); + + // Bob: Both slots are slow and weak (will be KO'd) + Mon[] memory bobTeam = new Mon[](3); + bobTeam[0] = _createMon(1, 5, targetedMoves); // Slow, weak - will be KO'd + bobTeam[1] = _createMon(1, 4, targetedMoves); // Slower, weak - will be KO'd + bobTeam[2] = _createMon(100, 3, targetedMoves); + + defaultRegistry.setTeam(ALICE, aliceTeam); + defaultRegistry.setTeam(BOB, bobTeam); + + bytes32 battleKey = _startDoublesBattle(); + vm.warp(block.timestamp + 1); + _doInitialSwitch(battleKey); + + // Turn 1: + // Alice slot 0 (speed 50) attacks Bob slot 0 -> KO + // Alice slot 1 (speed 45) attacks Bob slot 1 -> KO + // Bob slot 0 (speed 5) - KO'd, shouldn't execute + // Bob slot 1 (speed 4) - KO'd, shouldn't execute + _doublesCommitRevealExecute( + battleKey, + 0, 0, 0, 1, // Alice: slot 0 attacks Bob slot 0, slot 1 attacks Bob slot 1 + 0, 0, 0, 1 // Bob: both attack (won't execute - they'll be KO'd) + ); + + // Both Bob slots should be KO'd + assertEq(engine.getMonStateForBattle(battleKey, 1, 0, MonStateIndexName.IsKnockedOut), 1, "Bob slot 0 should be KO'd"); + assertEq(engine.getMonStateForBattle(battleKey, 1, 1, MonStateIndexName.IsKnockedOut), 1, "Bob slot 1 should be KO'd"); + + // Alice should NOT have taken any damage (Bob's moves didn't execute) + assertEq(engine.getMonStateForBattle(battleKey, 0, 0, MonStateIndexName.Hp), 0, "Alice slot 0 should have no damage"); + assertEq(engine.getMonStateForBattle(battleKey, 0, 1, MonStateIndexName.Hp), 0, "Alice slot 1 should have no damage"); + } + + // ========================================= + // Battle Transition Tests (Doubles <-> Singles) + // ========================================= + + /** + * @notice Test: Doubles battle completes, then singles battle reuses storage correctly + * @dev Verifies storage reuse between game modes with actual damage/effects + */ + function test_doublesThenSingles_storageReuse() public { + // Create singles commit manager + DefaultCommitManager singlesCommitManager = new DefaultCommitManager(engine); + + IMoveSet[] memory targetedMoves = new IMoveSet[](4); + targetedMoves[0] = targetedStrongAttack; + targetedMoves[1] = targetedStrongAttack; + targetedMoves[2] = targetedStrongAttack; + targetedMoves[3] = targetedStrongAttack; + + // Alice with weak slot 0 mon for quick KO + Mon[] memory aliceTeam = new Mon[](3); + aliceTeam[0] = _createMon(1, 5, targetedMoves); // Will be KO'd quickly + aliceTeam[1] = _createMon(1, 4, targetedMoves); // Will be KO'd + aliceTeam[2] = _createMon(1, 3, targetedMoves); // Reserve, also weak + + Mon[] memory bobTeam = new Mon[](3); + bobTeam[0] = _createMon(100, 20, targetedMoves); + bobTeam[1] = _createMon(100, 18, targetedMoves); + bobTeam[2] = _createMon(100, 16, targetedMoves); + + defaultRegistry.setTeam(ALICE, aliceTeam); + defaultRegistry.setTeam(BOB, bobTeam); + + // ---- DOUBLES BATTLE ---- + bytes32 doublesBattleKey = _startDoublesBattle(); + vm.warp(block.timestamp + 1); + _doInitialSwitch(doublesBattleKey); + + assertEq(uint8(engine.getGameMode(doublesBattleKey)), uint8(GameMode.Doubles), "Should be doubles mode"); + + // Turn 1: Bob KOs only Alice slot 0 (mon 0), keeps slot 1 alive + // Alice does NO_OP with both slots to avoid counter-attacking Bob + _doublesCommitRevealExecute( + doublesBattleKey, + NO_OP_MOVE_INDEX, 0, NO_OP_MOVE_INDEX, 0, // Alice: both no-op + 0, 0, NO_OP_MOVE_INDEX, 0 // Bob: slot 0 attacks Alice slot 0 (default target), slot 1 no-op + ); + + // Alice slot 0 KO'd, needs to switch + assertEq(engine.getMonStateForBattle(doublesBattleKey, 0, 0, MonStateIndexName.IsKnockedOut), 1, "Alice mon 0 KO'd"); + + // Alice single-player switch turn: switch slot 0 to reserve (mon 2) + vm.startPrank(ALICE); + commitManager.revealMoves(doublesBattleKey, SWITCH_MOVE_INDEX, 2, NO_OP_MOVE_INDEX, 0, bytes32("as"), true); + vm.stopPrank(); + + // Verify switch happened + assertEq(engine.getActiveMonIndexForSlot(doublesBattleKey, 0, 0), 2, "Alice slot 0 now has mon 2"); + + // Turn 2: Bob KOs both remaining Alice mons (slot 0 has mon 2, slot 1 has mon 1) + _doublesCommitRevealExecute( + doublesBattleKey, + 0, 0, 0, 0, + 0, 0, 0, 1 // Bob: slot 0 attacks default (Alice slot 0), slot 1 attacks Alice slot 1 + ); + + // All Alice mons KO'd, Bob wins + assertEq(engine.getWinner(doublesBattleKey), BOB, "Bob should win doubles"); + + // Record free keys + bytes32[] memory freeKeysBefore = engine.getFreeStorageKeys(); + assertGt(freeKeysBefore.length, 0, "Should have free storage key"); + + // ---- SINGLES BATTLE (reuses storage) ---- + vm.warp(block.timestamp + 2); + + // Fresh teams for singles - HP 300 to survive one hit (attack does ~200 damage) + Mon[] memory aliceSingles = new Mon[](3); + aliceSingles[0] = _createMon(300, 15, targetedMoves); + aliceSingles[1] = _createMon(300, 12, targetedMoves); + aliceSingles[2] = _createMon(300, 10, targetedMoves); + + Mon[] memory bobSingles = new Mon[](3); + bobSingles[0] = _createMon(300, 14, targetedMoves); + bobSingles[1] = _createMon(300, 11, targetedMoves); + bobSingles[2] = _createMon(300, 9, targetedMoves); + + defaultRegistry.setTeam(ALICE, aliceSingles); + defaultRegistry.setTeam(BOB, bobSingles); + + bytes32 singlesBattleKey = _startSinglesBattle(singlesCommitManager); + vm.warp(block.timestamp + 1); + + assertEq(uint8(engine.getGameMode(singlesBattleKey)), uint8(GameMode.Singles), "Should be singles mode"); + + // Verify storage reused + bytes32[] memory freeKeysAfter = engine.getFreeStorageKeys(); + assertEq(freeKeysAfter.length, freeKeysBefore.length - 1, "Should have used free storage key"); + + // Turn 0: Initial switch (P0 commits, P1 reveals first, P0 reveals second) + _singlesInitialSwitch(singlesBattleKey, singlesCommitManager); + + // Verify active mons + uint256[] memory activeIndices = engine.getActiveMonIndexForBattleState(singlesBattleKey); + assertEq(activeIndices[0], 0, "Alice active mon 0"); + assertEq(activeIndices[1], 0, "Bob active mon 0"); + + // Turn 1: Both attack (P1 commits, P0 reveals first, P1 reveals second) + _singlesCommitRevealExecute(singlesBattleKey, singlesCommitManager, 0, 0, 0, 0); + + // Verify damage dealt + int256 aliceHp = engine.getMonStateForBattle(singlesBattleKey, 0, 0, MonStateIndexName.Hp); + int256 bobHp = engine.getMonStateForBattle(singlesBattleKey, 1, 0, MonStateIndexName.Hp); + assertTrue(aliceHp < 0, "Alice took damage"); + assertTrue(bobHp < 0, "Bob took damage"); + + assertEq(engine.getWinner(singlesBattleKey), address(0), "Singles battle ongoing"); + } + + /** + * @notice Test: Singles battle completes, then doubles battle reuses storage correctly + * @dev Verifies storage reuse from singles to doubles with actual damage/effects + */ + function test_singlesThenDoubles_storageReuse() public { + DefaultCommitManager singlesCommitManager = new DefaultCommitManager(engine); + + IMoveSet[] memory targetedMoves = new IMoveSet[](4); + targetedMoves[0] = targetedStrongAttack; + targetedMoves[1] = targetedStrongAttack; + targetedMoves[2] = targetedStrongAttack; + targetedMoves[3] = targetedStrongAttack; + + // Weak Alice for quick singles defeat + Mon[] memory aliceTeam = new Mon[](3); + aliceTeam[0] = _createMon(1, 5, targetedMoves); + aliceTeam[1] = _createMon(1, 4, targetedMoves); + aliceTeam[2] = _createMon(1, 3, targetedMoves); + + Mon[] memory bobTeam = new Mon[](3); + bobTeam[0] = _createMon(100, 20, targetedMoves); + bobTeam[1] = _createMon(100, 18, targetedMoves); + bobTeam[2] = _createMon(100, 16, targetedMoves); + + defaultRegistry.setTeam(ALICE, aliceTeam); + defaultRegistry.setTeam(BOB, bobTeam); + + // ---- SINGLES BATTLE ---- + bytes32 singlesBattleKey = _startSinglesBattle(singlesCommitManager); + vm.warp(block.timestamp + 1); + + assertEq(uint8(engine.getGameMode(singlesBattleKey)), uint8(GameMode.Singles), "Should be singles mode"); + + // Turn 0: Initial switch + _singlesInitialSwitch(singlesBattleKey, singlesCommitManager); + + // Turn 1: Bob KOs Alice mon 0 + _singlesCommitRevealExecute(singlesBattleKey, singlesCommitManager, 0, 0, 0, 0); + + // Alice switch turn (playerSwitchForTurnFlag = 0) + _singlesSwitchTurn(singlesBattleKey, singlesCommitManager, 1); + + // Turn 2: Bob KOs Alice mon 1 + _singlesCommitRevealExecute(singlesBattleKey, singlesCommitManager, 0, 0, 0, 0); + + // Alice switch turn + _singlesSwitchTurn(singlesBattleKey, singlesCommitManager, 2); + + // Turn 3: Bob KOs Alice's last mon + _singlesCommitRevealExecute(singlesBattleKey, singlesCommitManager, 0, 0, 0, 0); + + assertEq(engine.getWinner(singlesBattleKey), BOB, "Bob should win singles"); + + // Record free keys + bytes32[] memory freeKeysBefore = engine.getFreeStorageKeys(); + assertGt(freeKeysBefore.length, 0, "Should have free storage key"); + + // ---- DOUBLES BATTLE (reuses storage) ---- + vm.warp(block.timestamp + 2); + + // Fresh teams for doubles - HP 300 to survive attacks (~200 damage each) + Mon[] memory aliceDoubles = new Mon[](3); + aliceDoubles[0] = _createMon(300, 15, targetedMoves); + aliceDoubles[1] = _createMon(300, 12, targetedMoves); + aliceDoubles[2] = _createMon(300, 10, targetedMoves); + + Mon[] memory bobDoubles = new Mon[](3); + bobDoubles[0] = _createMon(300, 14, targetedMoves); + bobDoubles[1] = _createMon(300, 11, targetedMoves); + bobDoubles[2] = _createMon(300, 9, targetedMoves); + + defaultRegistry.setTeam(ALICE, aliceDoubles); + defaultRegistry.setTeam(BOB, bobDoubles); + + bytes32 doublesBattleKey = _startDoublesBattle(); + vm.warp(block.timestamp + 1); + + assertEq(uint8(engine.getGameMode(doublesBattleKey)), uint8(GameMode.Doubles), "Should be doubles mode"); + + // Verify storage reused + bytes32[] memory freeKeysAfter = engine.getFreeStorageKeys(); + assertEq(freeKeysAfter.length, freeKeysBefore.length - 1, "Should have used free storage key"); + + // Initial switch for doubles + _doInitialSwitch(doublesBattleKey); + + // Verify all 4 slots set correctly + assertEq(engine.getActiveMonIndexForSlot(doublesBattleKey, 0, 0), 0, "Alice slot 0 = mon 0"); + assertEq(engine.getActiveMonIndexForSlot(doublesBattleKey, 0, 1), 1, "Alice slot 1 = mon 1"); + assertEq(engine.getActiveMonIndexForSlot(doublesBattleKey, 1, 0), 0, "Bob slot 0 = mon 0"); + assertEq(engine.getActiveMonIndexForSlot(doublesBattleKey, 1, 1), 1, "Bob slot 1 = mon 1"); + + // Turn 1: Both sides attack (dealing real damage) + _doublesCommitRevealExecute(doublesBattleKey, 0, 0, 0, 0, 0, 0, 0, 1); + + // Verify damage to correct targets + int256 alice0Hp = engine.getMonStateForBattle(doublesBattleKey, 0, 0, MonStateIndexName.Hp); + int256 alice1Hp = engine.getMonStateForBattle(doublesBattleKey, 0, 1, MonStateIndexName.Hp); + assertTrue(alice0Hp < 0, "Alice mon 0 took damage"); + assertTrue(alice1Hp < 0, "Alice mon 1 took damage"); + + assertEq(engine.getWinner(doublesBattleKey), address(0), "Doubles battle ongoing"); + } + + // ========================================= + // Singles Helper Functions + // ========================================= + + function _startSinglesBattle(DefaultCommitManager scm) internal returns (bytes32 battleKey) { + bytes32 salt = ""; + uint96 p0TeamIndex = 0; + uint256[] memory p0TeamIndices = defaultRegistry.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: defaultRegistry, + validator: validator, + rngOracle: defaultOracle, + ruleset: IRuleset(address(0)), + engineHooks: new IEngineHook[](0), + moveManager: address(scm), + matchmaker: matchmaker, + gameMode: GameMode.Singles + }); + + vm.startPrank(ALICE); + battleKey = matchmaker.proposeBattle(proposal); + + bytes32 integrityHash = matchmaker.getBattleProposalIntegrityHash(proposal); + vm.startPrank(BOB); + matchmaker.acceptBattle(battleKey, 0, integrityHash); + + vm.startPrank(ALICE); + matchmaker.confirmBattle(battleKey, salt, p0TeamIndex); + vm.stopPrank(); + } + + // Turn 0 initial switch for singles: P0 commits, P1 reveals, P0 reveals + function _singlesInitialSwitch(bytes32 battleKey, DefaultCommitManager scm) internal { + bytes32 aliceSalt = bytes32("alice_init"); + bytes32 bobSalt = bytes32("bob_init"); + + // P0 (Alice) commits on even turn + bytes32 aliceHash = keccak256(abi.encodePacked(uint8(SWITCH_MOVE_INDEX), aliceSalt, uint240(0))); + vm.prank(ALICE); + scm.commitMove(battleKey, aliceHash); + + // P1 (Bob) reveals first (no commit needed on even turn) + vm.prank(BOB); + scm.revealMove(battleKey, SWITCH_MOVE_INDEX, bobSalt, 0, false); + + // P0 (Alice) reveals second + vm.prank(ALICE); + scm.revealMove(battleKey, SWITCH_MOVE_INDEX, aliceSalt, 0, true); + } + + // Normal turn commit/reveal for singles + function _singlesCommitRevealExecute( + bytes32 battleKey, + DefaultCommitManager scm, + uint8 aliceMove, uint240 aliceExtra, + uint8 bobMove, uint240 bobExtra + ) internal { + uint256 turnId = engine.getTurnIdForBattleState(battleKey); + bytes32 aliceSalt = keccak256(abi.encodePacked("alice", turnId)); + bytes32 bobSalt = keccak256(abi.encodePacked("bob", turnId)); + + if (turnId % 2 == 0) { + // Even turn: P0 commits, P1 reveals first, P0 reveals second + bytes32 aliceHash = keccak256(abi.encodePacked(aliceMove, aliceSalt, aliceExtra)); + vm.prank(ALICE); + scm.commitMove(battleKey, aliceHash); + + vm.prank(BOB); + scm.revealMove(battleKey, bobMove, bobSalt, bobExtra, false); + + vm.prank(ALICE); + scm.revealMove(battleKey, aliceMove, aliceSalt, aliceExtra, true); + } else { + // Odd turn: P1 commits, P0 reveals first, P1 reveals second + bytes32 bobHash = keccak256(abi.encodePacked(bobMove, bobSalt, bobExtra)); + vm.prank(BOB); + scm.commitMove(battleKey, bobHash); + + vm.prank(ALICE); + scm.revealMove(battleKey, aliceMove, aliceSalt, aliceExtra, false); + + vm.prank(BOB); + scm.revealMove(battleKey, bobMove, bobSalt, bobExtra, true); + } + } + + // Switch turn for singles (only switching player acts) + function _singlesSwitchTurn(bytes32 battleKey, DefaultCommitManager scm, uint256 monIndex) internal { + bytes32 salt = keccak256(abi.encodePacked("switch", engine.getTurnIdForBattleState(battleKey))); + vm.prank(ALICE); + scm.revealMove(battleKey, SWITCH_MOVE_INDEX, salt, uint240(monIndex), true); + } + + /** + * @notice Test that effects run correctly for BOTH slots in doubles + * @dev This test validates the fix for the _runEffectsForMon bug where + * effects on slot 1's mon would incorrectly be looked up for slot 0's mon. + * + * Test setup: + * - Alice uses DoublesEffectAttack on both slots to apply InstantDeathEffect + * to Bob's slot 0 (mon 0) and slot 1 (mon 1) + * - At RoundEnd, both effects should run and KO both of Bob's mons + * - If the bug existed, only slot 0's mon would be KO'd + */ + function test_effectsRunOnBothSlots() public { + // Create InstantDeathEffect that KOs mon at RoundEnd + InstantDeathEffect deathEffect = new InstantDeathEffect(engine); + + // Create DoublesEffectAttack that applies the effect to a target slot + DoublesEffectAttack effectAttack = new DoublesEffectAttack( + engine, + IEffect(address(deathEffect)), + DoublesEffectAttack.Args({TYPE: Type.Fire, STAMINA_COST: 1, PRIORITY: 0}) + ); + + // Create teams where Alice has the effect attack + IMoveSet[] memory aliceMoves = new IMoveSet[](4); + aliceMoves[0] = effectAttack; // Apply effect to target slot + aliceMoves[1] = customAttack; + aliceMoves[2] = customAttack; + aliceMoves[3] = customAttack; + + IMoveSet[] memory bobMoves = new IMoveSet[](4); + bobMoves[0] = customAttack; + bobMoves[1] = customAttack; + bobMoves[2] = customAttack; + bobMoves[3] = customAttack; + + Mon[] memory aliceTeam = new Mon[](3); + aliceTeam[0] = _createMon(100, 20, aliceMoves); // Fast, will act first + aliceTeam[1] = _createMon(100, 18, aliceMoves); + aliceTeam[2] = _createMon(100, 16, aliceMoves); + + Mon[] memory bobTeam = new Mon[](3); + bobTeam[0] = _createMon(100, 5, bobMoves); // Slot 0 - will receive death effect + bobTeam[1] = _createMon(100, 4, bobMoves); // Slot 1 - will receive death effect + bobTeam[2] = _createMon(100, 3, bobMoves); // Reserve + + defaultRegistry.setTeam(ALICE, aliceTeam); + defaultRegistry.setTeam(BOB, bobTeam); + + bytes32 battleKey = _startDoublesBattle(); + vm.warp(block.timestamp + 1); + _doInitialSwitch(battleKey); + + // Verify initial state: both of Bob's mons are alive + assertEq(engine.getMonStateForBattle(battleKey, 1, 0, MonStateIndexName.IsKnockedOut), 0, "Bob mon 0 should be alive"); + assertEq(engine.getMonStateForBattle(battleKey, 1, 1, MonStateIndexName.IsKnockedOut), 0, "Bob mon 1 should be alive"); + + // Turn 1: Alice's slot 0 uses effectAttack targeting Bob's slot 0 + // Alice's slot 1 uses effectAttack targeting Bob's slot 1 + // Both of Bob's mons will have InstantDeathEffect applied + // At RoundEnd, both effects should run and KO both mons + _doublesCommitRevealExecute( + battleKey, + 0, 0, // Alice slot 0: move 0, target slot 0 + 0, 1, // Alice slot 1: move 0, target slot 1 + NO_OP_MOVE_INDEX, 0, // Bob slot 0: no-op + NO_OP_MOVE_INDEX, 0 // Bob slot 1: no-op + ); + + // After the turn, both of Bob's mons should be KO'd by the InstantDeathEffect + // If the bug existed (slot 1's effect running for slot 0's mon), only mon 0 would be KO'd + assertEq( + engine.getMonStateForBattle(battleKey, 1, 0, MonStateIndexName.IsKnockedOut), + 1, + "Bob mon 0 should be KO'd by InstantDeathEffect" + ); + assertEq( + engine.getMonStateForBattle(battleKey, 1, 1, MonStateIndexName.IsKnockedOut), + 1, + "Bob mon 1 should be KO'd by InstantDeathEffect (validates slot 1 effect runs correctly)" + ); + } + + /** + * @notice Test that AfterDamage effects run on the correct mon in doubles + * @dev Validates fix for issue #3: AfterDamage effects running on wrong mon + * This test uses an attack that applies an AfterDamage rebound effect to the target, + * then another attack that triggers the effect. If the fix works correctly, + * only the mon that has the effect (slot 1) should be healed. + */ + function test_afterDamageEffectsRunOnCorrectMon() public { + // Create the rebound effect that heals damage + AfterDamageReboundEffect reboundEffect = new AfterDamageReboundEffect(engine); + + // Create an attack that applies the rebound effect to a target slot + EffectApplyingAttack effectApplyAttack = new EffectApplyingAttack( + engine, + IEffect(address(reboundEffect)), + EffectApplyingAttack.Args({STAMINA_COST: 1, PRIORITY: 10}) // High priority to apply effect first + ); + + // Create a targeted attack for dealing damage + DoublesTargetedAttack targetedAttack = new DoublesTargetedAttack( + engine, typeCalc, DoublesTargetedAttack.Args({TYPE: Type.Fire, BASE_POWER: 30, ACCURACY: 100, STAMINA_COST: 1, PRIORITY: 0}) + ); + + // Create teams where Alice has both attacks + IMoveSet[] memory aliceMoves = new IMoveSet[](4); + aliceMoves[0] = effectApplyAttack; // Apply rebound effect + aliceMoves[1] = targetedAttack; // Deal damage + aliceMoves[2] = customAttack; + aliceMoves[3] = customAttack; + + Mon[] memory aliceTeam = new Mon[](3); + aliceTeam[0] = _createMon(100, 20, aliceMoves); // Fast + aliceTeam[1] = _createMon(100, 18, aliceMoves); + aliceTeam[2] = _createMon(100, 16, aliceMoves); + + defaultRegistry.setTeam(ALICE, aliceTeam); + + bytes32 battleKey = _startDoublesBattle(); + vm.warp(block.timestamp + 1); + _doInitialSwitch(battleKey); + + // Turn 1: Alice's slot 0 applies rebound effect to Bob's slot 1 (mon index 1) + // Alice's slot 1 does nothing + _doublesCommitRevealExecute( + battleKey, + 0, 1, // Alice slot 0: apply effect to Bob's slot 1 + NO_OP_MOVE_INDEX, 0, // Alice slot 1: no-op + NO_OP_MOVE_INDEX, 0, // Bob slot 0: no-op + NO_OP_MOVE_INDEX, 0 // Bob slot 1: no-op + ); + + // Get HP deltas for both of Bob's mons after effect is applied + int256 bobMon0HpBefore = engine.getMonStateForBattle(battleKey, 1, 0, MonStateIndexName.Hp); + int256 bobMon1HpBefore = engine.getMonStateForBattle(battleKey, 1, 1, MonStateIndexName.Hp); + + // Turn 2: Alice attacks Bob's slot 1 (which has the rebound effect) + // The rebound effect should heal the damage, but ONLY for mon 1 + _doublesCommitRevealExecute( + battleKey, + 1, 1, // Alice slot 0: attack Bob's slot 1 + NO_OP_MOVE_INDEX, 0, // Alice slot 1: no-op + NO_OP_MOVE_INDEX, 0, // Bob slot 0: no-op + NO_OP_MOVE_INDEX, 0 // Bob slot 1: no-op + ); + + // Get HP deltas after attack + int256 bobMon0HpAfter = engine.getMonStateForBattle(battleKey, 1, 0, MonStateIndexName.Hp); + int256 bobMon1HpAfter = engine.getMonStateForBattle(battleKey, 1, 1, MonStateIndexName.Hp); + + // Bob's mon 0 (slot 0) should NOT have been affected by the rebound effect + assertEq(bobMon0HpAfter, bobMon0HpBefore, "Bob mon 0 HP should be unchanged"); + + // Bob's mon 1 (slot 1) should have taken damage and then healed it back + // With the rebound effect, the net HP delta should be 0 (or close to it) + assertEq(bobMon1HpAfter, bobMon1HpBefore, "Bob mon 1 should be fully healed by rebound effect"); + } + + /** + * @notice Test that move validation uses the correct slot's mon + * @dev Validates fix for issue #2: move validation checking wrong mon's stamina + * This test sets up a situation where slot 0 has low stamina and slot 1 has full stamina. + * If the bug existed, slot 1's move would be incorrectly rejected due to slot 0's low stamina. + */ + function test_moveValidationUsesCorrectSlotMon() public { + // Create a high stamina cost attack + CustomAttack highStaminaAttack = new CustomAttack( + engine, typeCalc, CustomAttack.Args({TYPE: Type.Fire, BASE_POWER: 10, ACCURACY: 100, STAMINA_COST: 8, PRIORITY: 0}) + ); + + // Create teams where Alice has the high stamina attack + IMoveSet[] memory aliceMoves = new IMoveSet[](4); + aliceMoves[0] = highStaminaAttack; // 8 stamina cost + aliceMoves[1] = customAttack; // 1 stamina cost + aliceMoves[2] = customAttack; + aliceMoves[3] = customAttack; + + // Create mons with 10 stamina + Mon[] memory aliceTeam = new Mon[](3); + aliceTeam[0] = _createMon(100, 20, aliceMoves); // This mon will use high stamina attack first + aliceTeam[1] = _createMon(100, 18, aliceMoves); // This mon still has full stamina + aliceTeam[2] = _createMon(100, 16, aliceMoves); + + defaultRegistry.setTeam(ALICE, aliceTeam); + + bytes32 battleKey = _startDoublesBattle(); + vm.warp(block.timestamp + 1); + _doInitialSwitch(battleKey); + + // Turn 1: Alice's slot 0 uses the high stamina attack (costs 8) + // Alice's slot 1 does nothing (saves stamina) + _doublesCommitRevealExecute( + battleKey, + 0, 0, // Alice slot 0: high stamina attack + NO_OP_MOVE_INDEX, 0, // Alice slot 1: no-op + NO_OP_MOVE_INDEX, 0, // Bob slot 0: no-op + NO_OP_MOVE_INDEX, 0 // Bob slot 1: no-op + ); + + // Now Alice's slot 0 has ~2 stamina (10 - 8), slot 1 has ~10 stamina + // Turn 2: Alice's slot 1 should be able to use the high stamina attack + // even though slot 0 doesn't have enough stamina + // If the bug existed, this would fail validation + _doublesCommitRevealExecute( + battleKey, + NO_OP_MOVE_INDEX, 0, // Alice slot 0: no-op (not enough stamina) + 0, 0, // Alice slot 1: high stamina attack (should work!) + NO_OP_MOVE_INDEX, 0, // Bob slot 0: no-op + NO_OP_MOVE_INDEX, 0 // Bob slot 1: no-op + ); + + // If we got here without revert, the validation correctly used slot 1's stamina + // Let's also verify the stamina was actually deducted from slot 1's mon + int256 aliceMon1Stamina = engine.getMonStateForBattle(battleKey, 0, 1, MonStateIndexName.Stamina); + // Mon 1 used high stamina attack (8 cost), so delta should be -8 (plus any regen) + assertLt(aliceMon1Stamina, 0, "Alice mon 1 should have negative stamina delta from using attack"); + } + + // ========================================= + // Slot 1 Damage Calculation Tests + // ========================================= + + /** + * @notice Test that attacking slot 1 uses correct defender stats + * @dev Creates mons with different defense values for slot 0 and slot 1, + * then verifies damage is calculated using slot 1's defense when targeting slot 1 + */ + function test_slot1DamageUsesCorrectDefenderStats() public { + // Create a DoublesSlotAttack that uses AttackCalculator with slot parameters + DoublesSlotAttack slotAttack = new DoublesSlotAttack(engine, typeCalc); + + IMoveSet[] memory moves = new IMoveSet[](4); + moves[0] = slotAttack; + moves[1] = slotAttack; + moves[2] = slotAttack; + moves[3] = slotAttack; + + // Alice: standard mons with same stats + Mon[] memory aliceTeam = new Mon[](3); + aliceTeam[0] = _createMon(100, 20, moves); // Fast, will attack first + aliceTeam[1] = _createMon(100, 18, moves); + aliceTeam[2] = _createMon(100, 16, moves); + + // Bob: slot 0 has HIGH defense, slot 1 has LOW defense + // This lets us verify the correct defender is being used for damage calc + Mon[] memory bobTeam = new Mon[](3); + bobTeam[0] = Mon({ + stats: MonStats({ + hp: 200, + stamina: 50, + speed: 5, + attack: 10, + defense: 100, // Very high defense + specialAttack: 10, + specialDefense: 10, + type1: Type.Fire, + type2: Type.None + }), + ability: IAbility(address(0)), + moves: moves + }); + bobTeam[1] = Mon({ + stats: MonStats({ + hp: 200, + stamina: 50, + speed: 5, + attack: 10, + defense: 10, // Low defense - should take 10x more damage + specialAttack: 10, + specialDefense: 10, + type1: Type.Fire, + type2: Type.None + }), + ability: IAbility(address(0)), + moves: moves + }); + bobTeam[2] = _createMon(100, 3, moves); + + defaultRegistry.setTeam(ALICE, aliceTeam); + defaultRegistry.setTeam(BOB, bobTeam); + + bytes32 battleKey = _startDoublesBattle(); + vm.warp(block.timestamp + 1); + _doInitialSwitch(battleKey); + + // Get initial HP of Bob's mons + int32 bob0HpBefore = engine.getMonStateForBattle(battleKey, 1, 0, MonStateIndexName.Hp); + int32 bob1HpBefore = engine.getMonStateForBattle(battleKey, 1, 1, MonStateIndexName.Hp); + assertEq(bob0HpBefore, 0, "Bob mon 0 should have no HP delta initially"); + assertEq(bob1HpBefore, 0, "Bob mon 1 should have no HP delta initially"); + + // Turn 1: Alice slot 0 attacks Bob slot 1 using DoublesSlotAttack + // extraData format: lower 4 bits = attackerSlot (0), next 4 bits = defenderSlot (1) + // So extraData = 0x10 = (1 << 4) | 0 = 16 + uint240 attackSlot1ExtraData = (1 << 4) | 0; // attacker slot 0, defender slot 1 + + _doublesCommitRevealExecute( + battleKey, + 0, attackSlot1ExtraData, // Alice slot 0: attack targeting Bob slot 1 + NO_OP_MOVE_INDEX, 0, // Alice slot 1: no-op + NO_OP_MOVE_INDEX, 0, // Bob slot 0: no-op + NO_OP_MOVE_INDEX, 0 // Bob slot 1: no-op + ); + + // Verify damage was dealt to slot 1, not slot 0 + int32 bob0HpAfter = engine.getMonStateForBattle(battleKey, 1, 0, MonStateIndexName.Hp); + int32 bob1HpAfter = engine.getMonStateForBattle(battleKey, 1, 1, MonStateIndexName.Hp); + + assertEq(bob0HpAfter, bob0HpBefore, "Bob mon 0 should not have taken damage"); + assertLt(bob1HpAfter, bob1HpBefore, "Bob mon 1 should have taken damage"); + + // Now attack slot 0 to compare damage + // extraData = 0x00 = (0 << 4) | 0 = 0 (attacker slot 0, defender slot 0) + uint240 attackSlot0ExtraData = (0 << 4) | 0; + + _doublesCommitRevealExecute( + battleKey, + 0, attackSlot0ExtraData, // Alice slot 0: attack targeting Bob slot 0 + NO_OP_MOVE_INDEX, 0, // Alice slot 1: no-op + NO_OP_MOVE_INDEX, 0, // Bob slot 0: no-op + NO_OP_MOVE_INDEX, 0 // Bob slot 1: no-op + ); + + int32 bob0HpAfter2 = engine.getMonStateForBattle(battleKey, 1, 0, MonStateIndexName.Hp); + int32 bob1HpAfter2 = engine.getMonStateForBattle(battleKey, 1, 1, MonStateIndexName.Hp); + + // Bob slot 0 should have taken less damage than slot 1 took (due to higher defense) + int32 slot1DamageTaken = bob1HpBefore - bob1HpAfter; // This is negative HP change = positive damage + int32 slot0DamageTaken = bob0HpBefore - bob0HpAfter2; + + // Slot 1 has 10x lower defense, so should have taken ~10x more damage + // Account for some variance, but slot 1 should definitely have taken more damage + assertGt(-bob1HpAfter, -bob0HpAfter2, "Slot 1 (low defense) should have taken more damage than slot 0 (high defense)"); + } + + /** + * @notice Test that slot 1 attacker uses correct attacker stats + * @dev Both slots attack in same turn, targeting same defense values, + * verifying that high attack slot deals more damage + */ + function test_slot1AttackerUsesCorrectStats() public { + DoublesSlotAttack slotAttack = new DoublesSlotAttack(engine, typeCalc); + + IMoveSet[] memory moves = new IMoveSet[](4); + moves[0] = slotAttack; + moves[1] = slotAttack; + moves[2] = slotAttack; + moves[3] = slotAttack; + + // Alice: slot 0 has LOW attack, slot 1 has HIGH attack + Mon[] memory aliceTeam = new Mon[](3); + aliceTeam[0] = Mon({ + stats: MonStats({ + hp: 100, + stamina: 50, + speed: 20, // Fast + attack: 10, // Low attack + defense: 10, + specialAttack: 10, + specialDefense: 10, + type1: Type.Fire, + type2: Type.None + }), + ability: IAbility(address(0)), + moves: moves + }); + aliceTeam[1] = Mon({ + stats: MonStats({ + hp: 100, + stamina: 50, + speed: 19, // Slightly slower + attack: 50, // High attack - 5x more damage (use 50 instead of 100 to avoid KO) + defense: 10, + specialAttack: 10, + specialDefense: 10, + type1: Type.Fire, + type2: Type.None + }), + ability: IAbility(address(0)), + moves: moves + }); + aliceTeam[2] = _createMon(100, 16, moves); + + // Bob: both mons have same defense and high HP to avoid KO + Mon[] memory bobTeam = new Mon[](3); + bobTeam[0] = _createMon(2000, 5, moves); // Very high HP + bobTeam[1] = _createMon(2000, 5, moves); // Very high HP + bobTeam[2] = _createMon(100, 3, moves); + + defaultRegistry.setTeam(ALICE, aliceTeam); + defaultRegistry.setTeam(BOB, bobTeam); + + bytes32 battleKey = _startDoublesBattle(); + vm.warp(block.timestamp + 1); + _doInitialSwitch(battleKey); + + // Turn 1: Both slots attack in the same turn to compare damage + // Alice slot 0 attacks Bob slot 0 (low attack) + // Alice slot 1 attacks Bob slot 1 (high attack) + uint240 slot0AttacksSlot0 = (0 << 4) | 0; // attacker slot 0, defender slot 0 + uint240 slot1AttacksSlot1 = (1 << 4) | 1; // attacker slot 1, defender slot 1 + + _doublesCommitRevealExecute( + battleKey, + 0, slot0AttacksSlot0, // Alice slot 0: attack targeting Bob slot 0 + 0, slot1AttacksSlot1, // Alice slot 1: attack targeting Bob slot 1 + NO_OP_MOVE_INDEX, 0, // Bob slot 0: no-op + NO_OP_MOVE_INDEX, 0 // Bob slot 1: no-op + ); + + int32 bob0HpAfterSlot0Attack = engine.getMonStateForBattle(battleKey, 1, 0, MonStateIndexName.Hp); + int32 bob1HpAfterSlot1Attack = engine.getMonStateForBattle(battleKey, 1, 1, MonStateIndexName.Hp); + + // Both defenders have same defense (10) + // Slot 0 has attack 10, slot 1 has attack 50 + // Slot 1 should deal 5x more damage + assertGt(-bob1HpAfterSlot1Attack, -bob0HpAfterSlot0Attack, "Slot 1 (high attack) should have dealt more damage than slot 0 (low attack)"); + } +} + diff --git a/test/EngineTest.sol b/test/EngineTest.sol index 7e270d8e..d221ad12 100644 --- a/test/EngineTest.sol +++ b/test/EngineTest.sol @@ -9,6 +9,7 @@ import "../src/Structs.sol"; import {DefaultRuleset} from "../src/DefaultRuleset.sol"; +import {BaseCommitManager} from "../src/BaseCommitManager.sol"; import {DefaultCommitManager} from "../src/DefaultCommitManager.sol"; import {Engine} from "../src/Engine.sol"; import {DefaultValidator} from "../src/DefaultValidator.sol"; @@ -375,7 +376,7 @@ contract EngineTest is Test, BattleHelper { // Assert that Bob cannot commit anything because of the turn flag // (we just reuse Alice's move hash bc it doesn't matter) vm.startPrank(BOB); - vm.expectRevert(DefaultCommitManager.PlayerNotAllowed.selector); + vm.expectRevert(BaseCommitManager.PlayerNotAllowed.selector); commitManager.commitMove(battleKey, bytes32(0)); // Reveal Alice's move, and advance game state @@ -2549,7 +2550,7 @@ contract EngineTest is Test, BattleHelper { commitManager.commitMove(battleKey, aliceMoveHash); // Alice cannot commit again - vm.expectRevert(DefaultCommitManager.AlreadyCommited.selector); + vm.expectRevert(BaseCommitManager.AlreadyCommited.selector); commitManager.commitMove(battleKey, aliceMoveHash); // Bob reveals @@ -2557,7 +2558,7 @@ contract EngineTest is Test, BattleHelper { commitManager.revealMove(battleKey, SWITCH_MOVE_INDEX, salt, uint240(1), false); // Bob cannot reveal twice - vm.expectRevert(DefaultCommitManager.AlreadyRevealed.selector); + vm.expectRevert(BaseCommitManager.AlreadyRevealed.selector); commitManager.revealMove(battleKey, SWITCH_MOVE_INDEX, salt, uint240(1), false); // Alice reveals but does not execute @@ -2565,7 +2566,7 @@ contract EngineTest is Test, BattleHelper { commitManager.revealMove(battleKey, SWITCH_MOVE_INDEX, salt, uint240(1), false); // Second reveal should also fail - vm.expectRevert(DefaultCommitManager.AlreadyRevealed.selector); + vm.expectRevert(BaseCommitManager.AlreadyRevealed.selector); commitManager.revealMove(battleKey, SWITCH_MOVE_INDEX, salt, uint240(1), false); } @@ -2613,7 +2614,7 @@ contract EngineTest is Test, BattleHelper { // // Bob should not be able to commit to the ended battle vm.startPrank(BOB); - vm.expectRevert(DefaultCommitManager.BattleAlreadyComplete.selector); + vm.expectRevert(BaseCommitManager.BattleAlreadyComplete.selector); commitManager.commitMove(battleKey, bytes32(0)); } @@ -2776,7 +2777,8 @@ contract EngineTest is Test, BattleHelper { ruleset: IRuleset(address(0)), engineHooks: new IEngineHook[](0), moveManager: address(0), - matchmaker: matchmaker + matchmaker: matchmaker, + gameMode: GameMode.Singles }); bytes32 battleKey = matchmaker.proposeBattle(proposal); vm.startPrank(BOB); @@ -2807,25 +2809,25 @@ contract EngineTest is Test, BattleHelper { // It is turn 0, so Alice must first commit then reveal // We will attempt to calculate the preimage, which will fail vm.startPrank(ALICE); - vm.expectRevert(DefaultCommitManager.WrongPreimage.selector); + vm.expectRevert(BaseCommitManager.WrongPreimage.selector); commitManager.revealMove(battleKey, SWITCH_MOVE_INDEX, salt, extraData, true); // Ensure Bob cannot commit as they only need to reveal vm.startPrank(BOB); - vm.expectRevert(DefaultCommitManager.PlayerNotAllowed.selector); + vm.expectRevert(BaseCommitManager.PlayerNotAllowed.selector); commitManager.commitMove(battleKey, moveHash); // Bob cannot reveal yet as Alice has not committed - vm.expectRevert(DefaultCommitManager.RevealBeforeOtherCommit.selector); + vm.expectRevert(BaseCommitManager.RevealBeforeOtherCommit.selector); commitManager.revealMove(battleKey, SWITCH_MOVE_INDEX, salt, extraData, true); // Ensure Carl cannot commit as they are not in the battle vm.startPrank(CARL); - vm.expectRevert(DefaultCommitManager.NotP0OrP1.selector); + vm.expectRevert(BaseCommitManager.NotP0OrP1.selector); commitManager.commitMove(battleKey, moveHash); // Carl should also be unable to reveal - vm.expectRevert(DefaultCommitManager.NotP0OrP1.selector); + vm.expectRevert(BaseCommitManager.NotP0OrP1.selector); commitManager.revealMove(battleKey, SWITCH_MOVE_INDEX, salt, extraData, true); // Let Alice commit the first move (switching in mon index 0) @@ -2872,7 +2874,7 @@ contract EngineTest is Test, BattleHelper { // Alice reveals her move incorrectly, leading to an error vm.startPrank(ALICE); - vm.expectRevert(DefaultCommitManager.WrongPreimage.selector); + vm.expectRevert(BaseCommitManager.WrongPreimage.selector); commitManager.revealMove(battleKey, 0, salt, extraData, true); // Alice correctly reveals her move, advancing the game state @@ -2886,16 +2888,16 @@ contract EngineTest is Test, BattleHelper { // It is turn 1, so Bob must first commit then reveal // We will attempt to calculate the preimage, which will fail vm.startPrank(BOB); - vm.expectRevert(DefaultCommitManager.WrongPreimage.selector); + vm.expectRevert(BaseCommitManager.WrongPreimage.selector); commitManager.revealMove(battleKey, SWITCH_MOVE_INDEX, salt, extraData, true); // Ensure Alice cannot commit as they only need to reveal vm.startPrank(ALICE); - vm.expectRevert(DefaultCommitManager.PlayerNotAllowed.selector); + vm.expectRevert(BaseCommitManager.PlayerNotAllowed.selector); commitManager.commitMove(battleKey, moveHash); // Alice cannot reveal yet as Bob has not committed - vm.expectRevert(DefaultCommitManager.RevealBeforeOtherCommit.selector); + vm.expectRevert(BaseCommitManager.RevealBeforeOtherCommit.selector); commitManager.revealMove(battleKey, SWITCH_MOVE_INDEX, salt, extraData, true); // Let Bob commit the first move (switching in mon index 0) diff --git a/test/MatchmakerTest.sol b/test/MatchmakerTest.sol index 3729dff3..0a10afb3 100644 --- a/test/MatchmakerTest.sol +++ b/test/MatchmakerTest.sol @@ -93,7 +93,8 @@ contract MatchmakerTest is Test, BattleHelper { ruleset: IRuleset(address(0)), engineHooks: new IEngineHook[](0), moveManager: address(commitManager), - matchmaker: matchmaker + matchmaker: matchmaker, + gameMode: GameMode.Singles }); // Propose battle as Alice @@ -121,7 +122,8 @@ contract MatchmakerTest is Test, BattleHelper { ruleset: IRuleset(address(0)), engineHooks: new IEngineHook[](0), moveManager: address(commitManager), - matchmaker: matchmaker + matchmaker: matchmaker, + gameMode: GameMode.Singles }); // Propose battle as Alice @@ -149,7 +151,8 @@ contract MatchmakerTest is Test, BattleHelper { ruleset: IRuleset(address(0)), engineHooks: new IEngineHook[](0), moveManager: address(commitManager), - matchmaker: matchmaker + matchmaker: matchmaker, + gameMode: GameMode.Singles }); // Propose battle as Alice @@ -180,7 +183,8 @@ contract MatchmakerTest is Test, BattleHelper { ruleset: IRuleset(address(0)), engineHooks: new IEngineHook[](0), moveManager: address(commitManager), - matchmaker: matchmaker + matchmaker: matchmaker, + gameMode: GameMode.Singles }); // Propose battle as Alice @@ -215,7 +219,8 @@ contract MatchmakerTest is Test, BattleHelper { ruleset: IRuleset(address(0)), engineHooks: new IEngineHook[](0), moveManager: address(commitManager), - matchmaker: matchmaker + matchmaker: matchmaker, + gameMode: GameMode.Singles }); // Propose battle as Alice @@ -248,7 +253,8 @@ contract MatchmakerTest is Test, BattleHelper { ruleset: IRuleset(address(0)), engineHooks: new IEngineHook[](0), moveManager: address(commitManager), - matchmaker: matchmaker + matchmaker: matchmaker, + gameMode: GameMode.Singles }); // Propose battle as Alice @@ -284,7 +290,8 @@ contract MatchmakerTest is Test, BattleHelper { ruleset: IRuleset(address(0)), engineHooks: new IEngineHook[](0), moveManager: address(commitManager), - matchmaker: matchmaker + matchmaker: matchmaker, + gameMode: GameMode.Singles }); // Propose battle as Alice @@ -314,7 +321,8 @@ contract MatchmakerTest is Test, BattleHelper { ruleset: IRuleset(address(0)), engineHooks: new IEngineHook[](0), moveManager: address(commitManager), - matchmaker: matchmaker + matchmaker: matchmaker, + gameMode: GameMode.Singles }); // Propose battle as Alice @@ -348,7 +356,8 @@ contract MatchmakerTest is Test, BattleHelper { ruleset: IRuleset(address(0)), engineHooks: new IEngineHook[](0), moveManager: address(commitManager), - matchmaker: matchmaker + matchmaker: matchmaker, + gameMode: GameMode.Singles }); // Propose battle as Alice @@ -376,7 +385,8 @@ contract MatchmakerTest is Test, BattleHelper { ruleset: IRuleset(address(0)), engineHooks: new IEngineHook[](0), moveManager: address(commitManager), - matchmaker: matchmaker + matchmaker: matchmaker, + gameMode: GameMode.Singles }); // Propose battle as Alice @@ -413,7 +423,8 @@ contract MatchmakerTest is Test, BattleHelper { ruleset: IRuleset(address(0)), engineHooks: new IEngineHook[](0), moveManager: address(commitManager), - matchmaker: matchmaker + matchmaker: matchmaker, + gameMode: GameMode.Singles }); // Propose battle as Alice @@ -446,7 +457,8 @@ contract MatchmakerTest is Test, BattleHelper { ruleset: IRuleset(address(0)), engineHooks: new IEngineHook[](0), moveManager: address(commitManager), - matchmaker: matchmaker + matchmaker: matchmaker, + gameMode: GameMode.Singles }); // Propose battle as Alice diff --git a/test/abstract/BattleHelper.sol b/test/abstract/BattleHelper.sol index af8eff4b..627f3f4e 100644 --- a/test/abstract/BattleHelper.sol +++ b/test/abstract/BattleHelper.sol @@ -3,8 +3,10 @@ pragma solidity ^0.8.0; import "../../src/Structs.sol"; +import {GameMode} from "../../src/Enums.sol"; import {DefaultCommitManager} from "../../src/DefaultCommitManager.sol"; +import {DoublesCommitManager} from "../../src/DoublesCommitManager.sol"; import {Engine} from "../../src/Engine.sol"; import {IEngineHook} from "../../src/IEngineHook.sol"; import {IValidator} from "../../src/IValidator.sol"; @@ -112,7 +114,8 @@ abstract contract BattleHelper is Test { ruleset: ruleset, engineHooks: engineHooks, moveManager: moveManager, - matchmaker: matchmaker + matchmaker: matchmaker, + gameMode: GameMode.Singles }); // Propose battle @@ -148,4 +151,123 @@ abstract contract BattleHelper is Test { ability: IAbility(address(0)) }); } + + // ========================================= + // Doubles Battle Helpers + // ========================================= + + function _startDoublesBattle( + IValidator validator, + Engine engine, + IRandomnessOracle rngOracle, + ITeamRegistry defaultRegistry, + DefaultMatchmaker matchmaker, + address moveManager + ) internal returns (bytes32) { + return _startDoublesBattle(validator, engine, rngOracle, defaultRegistry, matchmaker, new IEngineHook[](0), IRuleset(address(0)), moveManager); + } + + function _startDoublesBattle( + IValidator validator, + Engine engine, + IRandomnessOracle rngOracle, + ITeamRegistry defaultRegistry, + DefaultMatchmaker matchmaker, + IEngineHook[] memory engineHooks, + IRuleset ruleset, + address moveManager + ) internal returns (bytes32) { + // Both players authorize the matchmaker + vm.startPrank(ALICE); + address[] memory makersToAdd = new address[](1); + makersToAdd[0] = address(matchmaker); + address[] memory makersToRemove = new address[](0); + engine.updateMatchmakers(makersToAdd, makersToRemove); + + vm.startPrank(BOB); + engine.updateMatchmakers(makersToAdd, makersToRemove); + + // Compute p0 team hash + bytes32 salt = ""; + uint96 p0TeamIndex = 0; + uint256[] memory p0TeamIndices = defaultRegistry.getMonRegistryIndicesForTeam(ALICE, p0TeamIndex); + bytes32 p0TeamHash = keccak256(abi.encodePacked(salt, p0TeamIndex, p0TeamIndices)); + + // Create proposal + ProposedBattle memory proposal = ProposedBattle({ + p0: ALICE, + p0TeamIndex: 0, + p0TeamHash: p0TeamHash, + p1: BOB, + p1TeamIndex: 0, + teamRegistry: defaultRegistry, + validator: validator, + rngOracle: rngOracle, + ruleset: ruleset, + engineHooks: engineHooks, + moveManager: moveManager, + matchmaker: matchmaker, + gameMode: GameMode.Doubles + }); + + // Propose battle + vm.startPrank(ALICE); + bytes32 battleKey = matchmaker.proposeBattle(proposal); + + // Accept battle + bytes32 battleIntegrityHash = matchmaker.getBattleProposalIntegrityHash(proposal); + vm.startPrank(BOB); + matchmaker.acceptBattle(battleKey, 0, battleIntegrityHash); + + // Confirm and start battle + vm.startPrank(ALICE); + matchmaker.confirmBattle(battleKey, salt, p0TeamIndex); + + return battleKey; + } + + // Helper function to commit, reveal, and execute moves for both players in doubles + function _doublesCommitRevealExecute( + Engine engine, + DoublesCommitManager commitManager, + bytes32 battleKey, + uint8 aliceMove0, uint240 aliceExtra0, + uint8 aliceMove1, uint240 aliceExtra1, + uint8 bobMove0, uint240 bobExtra0, + uint8 bobMove1, uint240 bobExtra1 + ) internal { + uint256 turnId = engine.getTurnIdForBattleState(battleKey); + bytes32 aliceSalt = bytes32("alicesalt"); + bytes32 bobSalt = bytes32("bobsalt"); + + if (turnId % 2 == 0) { + bytes32 aliceHash = keccak256(abi.encodePacked(aliceMove0, aliceExtra0, aliceMove1, aliceExtra1, aliceSalt)); + vm.startPrank(ALICE); + commitManager.commitMoves(battleKey, aliceHash); + vm.stopPrank(); + + vm.startPrank(BOB); + commitManager.revealMoves(battleKey, bobMove0, bobExtra0, bobMove1, bobExtra1, bobSalt, false); + vm.stopPrank(); + + vm.startPrank(ALICE); + commitManager.revealMoves(battleKey, aliceMove0, aliceExtra0, aliceMove1, aliceExtra1, aliceSalt, false); + vm.stopPrank(); + } else { + bytes32 bobHash = keccak256(abi.encodePacked(bobMove0, bobExtra0, bobMove1, bobExtra1, bobSalt)); + vm.startPrank(BOB); + commitManager.commitMoves(battleKey, bobHash); + vm.stopPrank(); + + vm.startPrank(ALICE); + commitManager.revealMoves(battleKey, aliceMove0, aliceExtra0, aliceMove1, aliceExtra1, aliceSalt, false); + vm.stopPrank(); + + vm.startPrank(BOB); + commitManager.revealMoves(battleKey, bobMove0, bobExtra0, bobMove1, bobExtra1, bobSalt, false); + vm.stopPrank(); + } + + engine.execute(battleKey); + } } diff --git a/test/mocks/DoublesEffectAttack.sol b/test/mocks/DoublesEffectAttack.sol new file mode 100644 index 00000000..a7fc1ecd --- /dev/null +++ b/test/mocks/DoublesEffectAttack.sol @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: AGPL-3.0 + +pragma solidity ^0.8.0; + +import "../../src/Constants.sol"; +import "../../src/Enums.sol"; +import "../../src/Structs.sol"; + +import {IEngine} from "../../src/IEngine.sol"; +import {IEffect} from "../../src/effects/IEffect.sol"; +import {IMoveSet} from "../../src/moves/IMoveSet.sol"; + +/** + * @title DoublesEffectAttack + * @notice A move that applies an effect to a specific opponent slot in doubles + * @dev extraData contains the target slot index (0 or 1) + */ +contract DoublesEffectAttack is IMoveSet { + struct Args { + Type TYPE; + uint32 STAMINA_COST; + uint32 PRIORITY; + } + + IEngine immutable ENGINE; + IEffect immutable EFFECT; + Type immutable TYPE; + uint32 immutable STAMINA_COST; + uint32 immutable PRIORITY; + + constructor(IEngine _ENGINE, IEffect _EFFECT, Args memory args) { + ENGINE = _ENGINE; + EFFECT = _EFFECT; + TYPE = args.TYPE; + STAMINA_COST = args.STAMINA_COST; + PRIORITY = args.PRIORITY; + } + + function name() external pure returns (string memory) { + return "Doubles Effect Attack"; + } + + function move(bytes32 battleKey, uint256 attackerPlayerIndex, uint240 extraData, uint256) external { + uint256 targetPlayerIndex = (attackerPlayerIndex + 1) % 2; + uint256 targetSlotIndex = uint256(extraData); + uint256 targetMonIndex = ENGINE.getActiveMonIndexForSlot(battleKey, targetPlayerIndex, targetSlotIndex); + ENGINE.addEffect(targetPlayerIndex, targetMonIndex, EFFECT, bytes32(0)); + } + + function priority(bytes32, uint256) external view returns (uint32) { + return PRIORITY; + } + + function stamina(bytes32, uint256, uint256) external view returns (uint32) { + return STAMINA_COST; + } + + function moveType(bytes32) external view returns (Type) { + return TYPE; + } + + function isValidTarget(bytes32, uint240 extraData) external pure returns (bool) { + // Valid target slots are 0 or 1 + return extraData <= 1; + } + + function moveClass(bytes32) external pure returns (MoveClass) { + return MoveClass.Physical; + } + + function basePower(bytes32) external pure returns (uint32) { + return 0; + } + + function extraDataType() external pure returns (ExtraDataType) { + return ExtraDataType.None; + } +} diff --git a/test/mocks/DoublesForceSwitchMove.sol b/test/mocks/DoublesForceSwitchMove.sol new file mode 100644 index 00000000..15c93876 --- /dev/null +++ b/test/mocks/DoublesForceSwitchMove.sol @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity ^0.8.0; + +import "../../src/Structs.sol"; +import "../../src/Enums.sol"; +import "../../src/Constants.sol"; +import "../../src/Engine.sol"; +import "../../src/moves/IMoveSet.sol"; + +/** + * @title DoublesForceSwitchMove + * @notice A mock move for testing switchActiveMonForSlot in doubles battles + * @dev Forces the target slot to switch to a specific mon index (passed via extraData) + * extraData format: lower 4 bits = target slot (0 or 1), next 4 bits = mon index to switch to + */ +contract DoublesForceSwitchMove is IMoveSet { + Engine public immutable ENGINE; + + constructor(Engine engine) { + ENGINE = engine; + } + + function move(bytes32, uint256 attackerPlayerIndex, uint240 extraData, uint256) external { + // Parse extraData: bits 0-3 = target slot, bits 4-7 = mon to switch to + uint256 targetSlot = uint256(extraData) & 0x0F; + uint256 monToSwitchTo = (uint256(extraData) >> 4) & 0x0F; + uint256 defenderPlayerIndex = (attackerPlayerIndex + 1) % 2; + + // Force the target slot to switch using the doubles-aware function + ENGINE.switchActiveMonForSlot(defenderPlayerIndex, targetSlot, monToSwitchTo); + } + + function isValidTarget(bytes32, uint240 extraData) external pure returns (bool) { + uint256 targetSlot = uint256(extraData) & 0x0F; + return targetSlot <= 1; + } + + function priority(bytes32, uint256) external pure returns (uint32) { + return 0; + } + + function stamina(bytes32, uint256, uint256) external pure returns (uint32) { + return 1; + } + + function moveType(bytes32) external pure returns (Type) { + return Type.None; + } + + function moveClass(bytes32) external pure returns (MoveClass) { + return MoveClass.Other; + } + + function basePower(bytes32) external pure returns (uint32) { + return 0; + } + + function accuracy(bytes32) external pure returns (uint32) { + return 100; + } + + function name() external pure returns (string memory) { + return "DoublesForceSwitchMove"; + } + + function extraDataType() external pure returns (ExtraDataType) { + return ExtraDataType.None; + } +} diff --git a/test/mocks/DoublesSlotAttack.sol b/test/mocks/DoublesSlotAttack.sol new file mode 100644 index 00000000..24232a9f --- /dev/null +++ b/test/mocks/DoublesSlotAttack.sol @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity ^0.8.0; + +import "../../src/Structs.sol"; +import "../../src/Enums.sol"; +import "../../src/Constants.sol"; +import {IEngine} from "../../src/IEngine.sol"; +import {IMoveSet} from "../../src/moves/IMoveSet.sol"; +import {ITypeCalculator} from "../../src/types/ITypeCalculator.sol"; +import {AttackCalculator} from "../../src/moves/AttackCalculator.sol"; + +/** + * @title DoublesSlotAttack + * @notice A mock attack for doubles battles that uses AttackCalculator with slot indices + * @dev Uses extraData to specify both attacker and defender slot indices + * Lower 4 bits = attackerSlotIndex, next 4 bits = defenderSlotIndex + */ +contract DoublesSlotAttack is IMoveSet { + IEngine public immutable ENGINE; + ITypeCalculator public immutable TYPE_CALCULATOR; + + uint32 public constant BASE_POWER = 100; + uint32 public constant STAMINA_COST = 1; + uint32 public constant ACCURACY = 100; + uint32 public constant PRIORITY = 0; + + constructor(IEngine engine, ITypeCalculator typeCalc) { + ENGINE = engine; + TYPE_CALCULATOR = typeCalc; + } + + function move(bytes32 battleKey, uint256 attackerPlayerIndex, uint240 extraData, uint256 rng) external { + // Parse slot indices from extraData + // Lower 4 bits = attackerSlotIndex, next 4 bits = defenderSlotIndex + uint256 attackerSlotIndex = uint256(extraData) & 0x0F; + uint256 defenderSlotIndex = (uint256(extraData) >> 4) & 0x0F; + + // Use AttackCalculator with explicit slot indices to test the fix + AttackCalculator._calculateDamage( + ENGINE, + TYPE_CALCULATOR, + battleKey, + attackerPlayerIndex, + attackerSlotIndex, + defenderSlotIndex, + BASE_POWER, + ACCURACY, + DEFAULT_VOL, + moveType(battleKey), + moveClass(battleKey), + rng, + DEFAULT_CRIT_RATE + ); + } + + function isValidTarget(bytes32, uint240) external pure returns (bool) { + return true; + } + + function priority(bytes32, uint256) external pure returns (uint32) { + return PRIORITY; + } + + function stamina(bytes32, uint256, uint256) external pure returns (uint32) { + return STAMINA_COST; + } + + function moveType(bytes32) public pure returns (Type) { + return Type.Fire; + } + + function moveClass(bytes32) public pure returns (MoveClass) { + return MoveClass.Physical; + } + + function name() external pure returns (string memory) { + return "DoublesSlotAttack"; + } + + function extraDataType() external pure returns (ExtraDataType) { + return ExtraDataType.None; + } +} diff --git a/test/mocks/DoublesTargetedAttack.sol b/test/mocks/DoublesTargetedAttack.sol new file mode 100644 index 00000000..45b48c29 --- /dev/null +++ b/test/mocks/DoublesTargetedAttack.sol @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity ^0.8.0; + +import "../../src/Structs.sol"; +import "../../src/Enums.sol"; +import "../../src/Constants.sol"; +import "../../src/Engine.sol"; +import "../../src/moves/IMoveSet.sol"; +import "../../src/types/ITypeCalculator.sol"; + +/** + * @title DoublesTargetedAttack + * @notice A mock attack for doubles battles that uses extraData for target slot selection + * @dev extraData is interpreted as the target slot index (0 or 1) on the opponent's side + */ +contract DoublesTargetedAttack is IMoveSet { + Engine public immutable ENGINE; + ITypeCalculator public immutable TYPE_CALCULATOR; + + uint32 private _basePower; + uint32 private _stamina; + uint32 private _accuracy; + uint32 private _priority; + Type private _moveType; + + struct Args { + Type TYPE; + uint32 BASE_POWER; + uint32 ACCURACY; + uint32 STAMINA_COST; + uint32 PRIORITY; + } + + constructor(Engine engine, ITypeCalculator typeCalc, Args memory args) { + ENGINE = engine; + TYPE_CALCULATOR = typeCalc; + _basePower = args.BASE_POWER; + _stamina = args.STAMINA_COST; + _accuracy = args.ACCURACY; + _priority = args.PRIORITY; + _moveType = args.TYPE; + } + + function move(bytes32 battleKey, uint256 attackerPlayerIndex, uint240 extraData, uint256 rng) external { + // Parse target slot from extraData (0 or 1) + uint256 targetSlot = uint256(extraData) & 0x01; + uint256 defenderPlayerIndex = (attackerPlayerIndex + 1) % 2; + + // Get the target mon index from the specified slot + uint256 defenderMonIndex = ENGINE.getActiveMonIndexForSlot(battleKey, defenderPlayerIndex, targetSlot); + + // Check accuracy + if (rng % 100 >= _accuracy) { + return; // Miss + } + + // Get attacker mon index (slot 0 for simplicity - in a real implementation would need slot info) + uint256 attackerMonIndex = ENGINE.getActiveMonIndexForSlot(battleKey, attackerPlayerIndex, 0); + + // Calculate damage using a simplified formula + // Get attacker's attack stat + int32 attackDelta = ENGINE.getMonStateForBattle(battleKey, attackerPlayerIndex, attackerMonIndex, MonStateIndexName.Attack); + uint32 baseAttack = ENGINE.getMonValueForBattle(battleKey, attackerPlayerIndex, attackerMonIndex, MonStateIndexName.Attack); + uint32 attack = uint32(int32(baseAttack) + attackDelta); + + // Get defender's defense stat + int32 defDelta = ENGINE.getMonStateForBattle(battleKey, defenderPlayerIndex, defenderMonIndex, MonStateIndexName.Defense); + uint32 baseDef = ENGINE.getMonValueForBattle(battleKey, defenderPlayerIndex, defenderMonIndex, MonStateIndexName.Defense); + uint32 defense = uint32(int32(baseDef) + defDelta); + + // Simple damage formula: (attack / defense) * basePower + uint32 damage = (_basePower * attack) / (defense > 0 ? defense : 1); + + // Apply type effectiveness + Type defType1 = Type(ENGINE.getMonValueForBattle(battleKey, defenderPlayerIndex, defenderMonIndex, MonStateIndexName.Type1)); + Type defType2 = Type(ENGINE.getMonValueForBattle(battleKey, defenderPlayerIndex, defenderMonIndex, MonStateIndexName.Type2)); + damage = TYPE_CALCULATOR.getTypeEffectiveness(_moveType, defType1, damage); + damage = TYPE_CALCULATOR.getTypeEffectiveness(_moveType, defType2, damage); + + // Deal damage to the targeted mon + if (damage > 0) { + ENGINE.dealDamage(defenderPlayerIndex, defenderMonIndex, int32(damage)); + } + } + + function isValidTarget(bytes32, uint240 extraData) external pure returns (bool) { + // extraData should be 0 or 1 for slot targeting + return (uint256(extraData) & 0x01) <= 1; + } + + function priority(bytes32, uint256) external view returns (uint32) { + return _priority; + } + + function stamina(bytes32, uint256, uint256) external view returns (uint32) { + return _stamina; + } + + function moveType(bytes32) external view returns (Type) { + return _moveType; + } + + function moveClass(bytes32) external pure returns (MoveClass) { + return MoveClass.Physical; + } + + function basePower(bytes32) external view returns (uint32) { + return _basePower; + } + + function accuracy(bytes32) external view returns (uint32) { + return _accuracy; + } + + function name() external pure returns (string memory) { + return "DoublesTargetedAttack"; + } + + function extraDataType() external pure returns (ExtraDataType) { + return ExtraDataType.None; // Custom targeting logic in this mock + } +} diff --git a/test/mocks/EffectApplyingAttack.sol b/test/mocks/EffectApplyingAttack.sol new file mode 100644 index 00000000..f613059b --- /dev/null +++ b/test/mocks/EffectApplyingAttack.sol @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: AGPL-3.0 + +pragma solidity ^0.8.0; + +import "../../src/Enums.sol"; +import "../../src/Structs.sol"; +import "../../src/IEngine.sol"; +import "../../src/moves/IMoveSet.sol"; +import "../../src/effects/IEffect.sol"; + +/** + * @dev An attack that applies an effect to the target mon + * Used for testing that effects are applied and run on the correct mon + */ +contract EffectApplyingAttack is IMoveSet { + IEngine immutable ENGINE; + IEffect public immutable EFFECT; + + struct Args { + uint32 STAMINA_COST; + uint32 PRIORITY; + } + + Args public args; + + constructor(IEngine _ENGINE, IEffect _effect, Args memory _args) { + ENGINE = _ENGINE; + EFFECT = _effect; + args = _args; + } + + function name() external pure override returns (string memory) { + return "EffectApplyingAttack"; + } + + function move(bytes32 battleKey, uint256 attackerPlayerIndex, uint240 extraData, uint256) external override { + // extraData contains the target slot index + uint256 targetPlayerIndex = (attackerPlayerIndex + 1) % 2; + uint256 targetSlotIndex = uint256(extraData); + uint256 targetMonIndex = ENGINE.getActiveMonIndexForSlot(battleKey, targetPlayerIndex, targetSlotIndex); + + // Apply the effect to the target mon + ENGINE.addEffect(targetPlayerIndex, targetMonIndex, EFFECT, bytes32(0)); + } + + function stamina(bytes32, uint256, uint256) external view override returns (uint32) { + return args.STAMINA_COST; + } + + function priority(bytes32, uint256) external view override returns (uint32) { + return args.PRIORITY; + } + + function moveType(bytes32) external pure override returns (Type) { + return Type.Fire; + } + + function moveClass(bytes32) external pure override returns (MoveClass) { + return MoveClass.Other; + } + + function extraDataType() external pure override returns (ExtraDataType) { + return ExtraDataType.None; + } + + function isValidTarget(bytes32, uint240) external pure override returns (bool) { + return true; + } +} diff --git a/test/mocks/MonIndexTrackingEffect.sol b/test/mocks/MonIndexTrackingEffect.sol new file mode 100644 index 00000000..272d3afd --- /dev/null +++ b/test/mocks/MonIndexTrackingEffect.sol @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: AGPL-3.0 + +pragma solidity ^0.8.0; + +import "../../src/Enums.sol"; +import "../../src/Structs.sol"; + +import {IEngine} from "../../src/IEngine.sol"; +import {BasicEffect} from "../../src/effects/BasicEffect.sol"; + +/** + * @dev A test effect that tracks which mon index it was run on. + * Used to verify effects run on the correct mon in doubles. + */ +contract MonIndexTrackingEffect is BasicEffect { + IEngine immutable ENGINE; + + // Track the last mon index the effect was run on for each player + mapping(bytes32 => mapping(uint256 => uint256)) public lastMonIndexForPlayer; + // Track how many times the effect was run + mapping(bytes32 => uint256) public runCount; + + // Which step this effect should run at + EffectStep public stepToRunAt; + + constructor(IEngine _ENGINE, EffectStep _step) { + ENGINE = _ENGINE; + stepToRunAt = _step; + } + + function name() external pure override returns (string memory) { + return "MonIndexTracker"; + } + + function shouldRunAtStep(EffectStep r) external view override returns (bool) { + return r == stepToRunAt; + } + + // OnMonSwitchIn - track which mon switched in + function onMonSwitchIn(uint256, bytes32 extraData, uint256 targetIndex, uint256 monIndex) + external + override + returns (bytes32, bool) + { + bytes32 battleKey = ENGINE.battleKeyForWrite(); + lastMonIndexForPlayer[battleKey][targetIndex] = monIndex; + runCount[battleKey]++; + return (extraData, false); + } + + // OnMonSwitchOut - track which mon switched out + function onMonSwitchOut(uint256, bytes32 extraData, uint256 targetIndex, uint256 monIndex) + external + override + returns (bytes32, bool) + { + bytes32 battleKey = ENGINE.battleKeyForWrite(); + lastMonIndexForPlayer[battleKey][targetIndex] = monIndex; + runCount[battleKey]++; + return (extraData, false); + } + + // AfterDamage - track which mon took damage + function onAfterDamage(uint256, bytes32 extraData, uint256 targetIndex, uint256 monIndex, int32) + external + override + returns (bytes32, bool) + { + bytes32 battleKey = ENGINE.battleKeyForWrite(); + lastMonIndexForPlayer[battleKey][targetIndex] = monIndex; + runCount[battleKey]++; + return (extraData, false); + } + + // Helper to get last mon index + function getLastMonIndex(bytes32 battleKey, uint256 playerIndex) external view returns (uint256) { + return lastMonIndexForPlayer[battleKey][playerIndex]; + } + + // Helper to get run count + function getRunCount(bytes32 battleKey) external view returns (uint256) { + return runCount[battleKey]; + } +} diff --git a/test/mons/VolthareTest.sol b/test/mons/VolthareTest.sol index 0c5ce9ac..2c013d21 100644 --- a/test/mons/VolthareTest.sol +++ b/test/mons/VolthareTest.sol @@ -8,7 +8,7 @@ import {Test} from "forge-std/Test.sol"; import {DefaultCommitManager} from "../../src/DefaultCommitManager.sol"; import {Engine} from "../../src/Engine.sol"; -import {MonStateIndexName, Type} from "../../src/Enums.sol"; +import {MonStateIndexName, Type, MoveClass, ExtraDataType} from "../../src/Enums.sol"; import {DefaultValidator} from "../../src/DefaultValidator.sol"; import {IEngine} from "../../src/IEngine.sol"; @@ -37,6 +37,8 @@ import {GlobalEffectAttack} from "../mocks/GlobalEffectAttack.sol"; import {DefaultMatchmaker} from "../../src/matchmaker/DefaultMatchmaker.sol"; import {StandardAttackFactory} from "../../src/moves/StandardAttackFactory.sol"; +import {DoublesCommitManager} from "../../src/DoublesCommitManager.sol"; +import {CustomAttack} from "../mocks/CustomAttack.sol"; contract VolthareTest is Test, BattleHelper { Engine engine; @@ -323,4 +325,168 @@ contract VolthareTest is Test, BattleHelper { // Alice's mon should have the skip turn flag set assertEq(engine.getMonStateForBattle(battleKey, 0, 0, MonStateIndexName.ShouldSkipTurn), 1); } + + /** + * @notice Test that Overclock applies stat changes to BOTH slots in doubles + * @dev Previously, Overclock.onApply() only applied to slot 0's mon. + * This test verifies the fix works correctly. + */ + function test_overclockAffectsBothSlotsInDoubles() public { + DoublesCommitManager doublesCommitManager = new DoublesCommitManager(engine); + + // Create a move that triggers Overclock when used + OverclockTriggerAttack overclockAttack = new OverclockTriggerAttack(engine, typeCalc, overclock); + + IMoveSet[] memory moves = new IMoveSet[](1); + moves[0] = overclockAttack; + + // Create mons with known base speed for easy verification + Mon[] memory aliceTeam = new Mon[](2); + aliceTeam[0] = Mon({ + stats: MonStats({ + hp: 100, + stamina: 50, + speed: 100, // Base speed 100 for slot 0 + attack: 10, + defense: 10, + specialAttack: 10, + specialDefense: 100, + type1: Type.Fire, + type2: Type.None + }), + ability: IAbility(address(0)), + moves: moves + }); + aliceTeam[1] = Mon({ + stats: MonStats({ + hp: 100, + stamina: 50, + speed: 100, // Base speed 100 for slot 1 + attack: 10, + defense: 10, + specialAttack: 10, + specialDefense: 100, + type1: Type.Fire, + type2: Type.None + }), + ability: IAbility(address(0)), + moves: moves + }); + + Mon[] memory bobTeam = new Mon[](2); + bobTeam[0] = Mon({ + stats: MonStats({ + hp: 100, + stamina: 50, + speed: 10, + attack: 10, + defense: 10, + specialAttack: 10, + specialDefense: 10, + type1: Type.Fire, + type2: Type.None + }), + ability: IAbility(address(0)), + moves: moves + }); + bobTeam[1] = bobTeam[0]; + + defaultRegistry.setTeam(ALICE, aliceTeam); + defaultRegistry.setTeam(BOB, bobTeam); + + DefaultValidator doublesValidator = new DefaultValidator( + engine, DefaultValidator.Args({MONS_PER_TEAM: 2, MOVES_PER_MON: 1, TIMEOUT_DURATION: 10}) + ); + + bytes32 battleKey = _startDoublesBattle( + doublesValidator, + engine, + mockOracle, + defaultRegistry, + matchmaker, + address(doublesCommitManager) + ); + vm.warp(block.timestamp + 1); + + // Initial switch + _doublesCommitRevealExecute( + engine, doublesCommitManager, battleKey, + SWITCH_MOVE_INDEX, 0, SWITCH_MOVE_INDEX, 1, + SWITCH_MOVE_INDEX, 0, SWITCH_MOVE_INDEX, 1 + ); + + // Turn 1: Alice slot 0 uses Overclock attack (triggers overclock for Alice's team) + _doublesCommitRevealExecute( + engine, doublesCommitManager, battleKey, + 0, 0, // Alice slot 0: overclock attack + NO_OP_MOVE_INDEX, 0, // Alice slot 1: no-op + NO_OP_MOVE_INDEX, 0, // Bob slot 0: no-op + NO_OP_MOVE_INDEX, 0 // Bob slot 1: no-op + ); + + // After Overclock is applied, both of Alice's active mons should have: + // - Speed boosted by 25% (100 -> 125, so delta = +25) + int32 aliceSlot0SpeedDelta = engine.getMonStateForBattle(battleKey, 0, 0, MonStateIndexName.Speed); + int32 aliceSlot1SpeedDelta = engine.getMonStateForBattle(battleKey, 0, 1, MonStateIndexName.Speed); + + // Both slots should have speed boost + assertEq(aliceSlot0SpeedDelta, 25, "Slot 0 should have +25 speed from Overclock"); + assertEq(aliceSlot1SpeedDelta, 25, "Slot 1 should have +25 speed from Overclock"); + } +} + +/** + * @title OverclockTriggerAttack + * @notice A mock attack that triggers Overclock when used + */ +contract OverclockTriggerAttack is IMoveSet { + IEngine public immutable ENGINE; + ITypeCalculator public immutable TYPE_CALCULATOR; + Overclock public immutable OVERCLOCK; + + constructor(IEngine _engine, ITypeCalculator _typeCalc, Overclock _overclock) { + ENGINE = _engine; + TYPE_CALCULATOR = _typeCalc; + OVERCLOCK = _overclock; + } + + function move(bytes32, uint256 attackerPlayerIndex, uint240, uint256) external { + OVERCLOCK.applyOverclock(attackerPlayerIndex); + } + + function isValidTarget(bytes32, uint240) external pure returns (bool) { + return true; + } + + function priority(bytes32, uint256) external pure returns (uint32) { + return 0; + } + + function stamina(bytes32, uint256, uint256) external pure returns (uint32) { + return 1; + } + + function moveType(bytes32) external pure returns (Type) { + return Type.Fire; + } + + function moveClass(bytes32) external pure returns (MoveClass) { + return MoveClass.Other; + } + + function name() external pure returns (string memory) { + return "OverclockTrigger"; + } + + function basePower(bytes32) external pure returns (uint32) { + return 0; + } + + function accuracy(bytes32) external pure returns (uint32) { + return 100; + } + + function extraDataType() external pure returns (ExtraDataType) { + return ExtraDataType.None; + } } diff --git a/test/moves/AttackCalculatorTest.sol b/test/moves/AttackCalculatorTest.sol index 0d63f5bb..6381550c 100644 --- a/test/moves/AttackCalculatorTest.sol +++ b/test/moves/AttackCalculatorTest.sol @@ -110,7 +110,9 @@ contract AttackCalculatorTest is Test, BattleHelper { typeCalc, battleKey, 0, // Alice's index + 0, // attackerSlotIndex (singles = slot 0) 1, // Bob's index + 0, // defenderSlotIndex (singles = slot 0) basePower, accuracy, volatility, @@ -143,7 +145,9 @@ contract AttackCalculatorTest is Test, BattleHelper { typeCalc, battleKey, 1, // Bob's index - 0, // ALice's index + 0, // attackerSlotIndex (singles = slot 0) + 0, // Alice's index + 0, // defenderSlotIndex (singles = slot 0) basePower, accuracy, volatility, @@ -174,8 +178,10 @@ contract AttackCalculatorTest is Test, BattleHelper { engine, typeCalc, battleKey, - 0, - 1, + 0, // attackerPlayerIndex + 0, // attackerSlotIndex (singles = slot 0) + 1, // defenderPlayerIndex + 0, // defenderSlotIndex (singles = slot 0) basePower, accuracy, volatility, @@ -190,8 +196,10 @@ contract AttackCalculatorTest is Test, BattleHelper { engine, typeCalc, battleKey, - 0, - 1, + 0, // attackerPlayerIndex + 0, // attackerSlotIndex (singles = slot 0) + 1, // defenderPlayerIndex + 0, // defenderSlotIndex (singles = slot 0) basePower, accuracy, volatility, @@ -226,8 +234,10 @@ contract AttackCalculatorTest is Test, BattleHelper { engine, typeCalc, battleKey, - 0, - 1, + 0, // attackerPlayerIndex + 0, // attackerSlotIndex (singles = slot 0) + 1, // defenderPlayerIndex + 0, // defenderSlotIndex (singles = slot 0) basePower, accuracy, volatility, @@ -242,8 +252,10 @@ contract AttackCalculatorTest is Test, BattleHelper { engine, typeCalc, battleKey, - 0, - 1, + 0, // attackerPlayerIndex + 0, // attackerSlotIndex (singles = slot 0) + 1, // defenderPlayerIndex + 0, // defenderSlotIndex (singles = slot 0) basePower, accuracy, volatility, @@ -272,8 +284,10 @@ contract AttackCalculatorTest is Test, BattleHelper { engine, typeCalc, battleKey, - 0, - 1, + 0, // attackerPlayerIndex + 0, // attackerSlotIndex (singles = slot 0) + 1, // defenderPlayerIndex + 0, // defenderSlotIndex (singles = slot 0) basePower, accuracy, volatility, @@ -288,8 +302,10 @@ contract AttackCalculatorTest is Test, BattleHelper { engine, typeCalc, battleKey, - 0, - 1, + 0, // attackerPlayerIndex + 0, // attackerSlotIndex (singles = slot 0) + 1, // defenderPlayerIndex + 0, // defenderSlotIndex (singles = slot 0) basePower, accuracy, volatility, @@ -304,8 +320,10 @@ contract AttackCalculatorTest is Test, BattleHelper { engine, typeCalc, battleKey, - 0, - 1, + 0, // attackerPlayerIndex + 0, // attackerSlotIndex (singles = slot 0) + 1, // defenderPlayerIndex + 0, // defenderSlotIndex (singles = slot 0) basePower, accuracy, 0, // No volatility