diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 00000000..dc4d1647 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,230 @@ +# Plan: Re-implement Doubles Battle Support on Current Main + +## Design Principles + +1. **Work WITH main's architecture** - Keep `getStepsBitmap()`, keep `battleKey` + `p0ActiveMonIndex`/`p1ActiveMonIndex` in effect signatures, keep bitmap-based hook filtering, keep `ValidatorLogic` library +2. **Additive, not destructive** - Singles behavior must remain identical; doubles is a new code path +3. **Minimal interface changes** - Add new functions/getters rather than changing existing ones where possible +4. **Incremental commits** - Each commit should compile and not break existing tests + +## Phase 1: Core Data Structures + +### 1a. Add GameMode enum to Enums.sol +```solidity +enum GameMode { + Singles, // 0 + Doubles // 1 +} +``` + +### 1b. Update Structs.sol + +**BattleData** - Add `slotSwitchFlagsAndGameMode` field: +```solidity +struct BattleData { + address p1; + uint64 turnId; + address p0; + uint8 winnerIndex; + uint8 prevPlayerSwitchForTurnFlag; + uint8 playerSwitchForTurnFlag; + uint16 activeMonIndex; // Singles: 8+8 bit packing; Doubles: 4+4+4+4 bit packing + uint8 slotSwitchFlagsAndGameMode; // bits 0-3: slot switch flags, bit 4: gameMode +} +``` + +**BattleConfig** - Add second move pair for doubles: +```solidity +MoveDecision p0Move; // Slot 0 for p0 +MoveDecision p1Move; // Slot 0 for p1 +MoveDecision p0Move2; // Slot 1 for p0 (doubles only) +MoveDecision p1Move2; // Slot 1 for p1 (doubles only) +``` + +**Battle/ProposedBattle** - Add `GameMode gameMode` field + +**BattleContext** - Keep existing `p0ActiveMonIndex`/`p1ActiveMonIndex` for singles backward compatibility + +**CommitContext** - Add `uint8 slotSwitchFlags` and `GameMode gameMode` + +**DamageCalcContext** - No change needed (callers will provide correct mon indices) + +**New struct: RevealedMovesPair** - For DoublesCommitManager: +```solidity +struct RevealedMovesPair { + uint8 moveIndex0; + uint240 extraData0; + uint8 moveIndex1; + uint240 extraData1; + bytes32 salt; +} +``` + +### 1c. Update Constants.sol +Add doubles-specific constants: +```solidity +uint8 constant ACTIVE_MON_INDEX_BITS = 4; +uint8 constant ACTIVE_MON_INDEX_MASK = 0x0F; +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; +``` + +## Phase 2: Engine.sol Changes + +### 2a. Doubles active mon index packing helpers +Add alongside existing `_packActiveMonIndices`/`_unpackActiveMonIndex`/`_setActiveMonIndex`: + +```solidity +function _unpackActiveMonIndexForSlot(uint16 packed, uint256 playerIndex, uint256 slotIndex) internal pure returns (uint256) +function _setActiveMonIndexForSlot(uint16 packed, uint256 playerIndex, uint256 slotIndex, uint256 monIndex) internal pure returns (uint16) +``` + +Packing layout for doubles: bits [0-3]=p0slot0, [4-7]=p0slot1, [8-11]=p1slot0, [12-15]=p1slot1 + +### 2b. Slot switch flag helpers +```solidity +function _getSlotSwitchFlags(BattleData storage battle) internal view returns (uint8) +function _setSlotSwitchFlag(BattleData storage battle, uint256 playerIndex, uint256 slotIndex) internal +function _clearSlotSwitchFlags(BattleData storage battle) internal +function _isDoublesMode(BattleData storage battle) internal view returns (bool) +``` + +### 2c. New getters (IEngine + Engine) +```solidity +function getGameMode(bytes32 battleKey) external view returns (GameMode) +function getActiveMonIndexForSlot(bytes32 battleKey, uint256 playerIndex, uint256 slotIndex) external view returns (uint256) +function getDamageCalcContextForSlot(bytes32 battleKey, uint256 attackerPlayerIndex, uint256 attackerSlotIndex, uint256 defenderPlayerIndex, uint256 defenderSlotIndex) external view returns (DamageCalcContext memory) +``` + +### 2d. Doubles move helpers +```solidity +function setMoveForSlot(bytes32 battleKey, uint256 playerIndex, uint256 slotIndex, uint8 moveIndex, bytes32 salt, uint240 extraData) external +function _getMoveDecisionForSlot(BattleConfig storage config, uint256 playerIndex, uint256 slotIndex) internal view returns (MoveDecision memory) +``` + +### 2e. startBattle updates +- Accept `GameMode` from Battle struct +- When doubles: initialize activeMonIndex with 4-bit packing (p0slot0=0, p0slot1=1, p1slot0=0, p1slot1=1) +- Store gameMode in `slotSwitchFlagsAndGameMode` + +### 2f. Doubles execution path +In `execute()` / `_executeInternal()`: +- Check game mode; if doubles, branch to `_executeDoubles()` +- `_executeDoubles()` handles: + - Move order calculation across 4 slots (priority + speed tiebreaker) + - Per-slot move execution via `_handleMoveForSlot()` + - Per-slot switch handling via `_handleSwitchForSlot()` + - Doubles-specific KO/game-over checks + - Slot switch flag management for forced switches + +### 2g. switchActiveMonForSlot +New external function for doubles force-switch moves: +```solidity +function switchActiveMonForSlot(uint256 playerIndex, uint256 slotIndex, uint256 monToSwitchIndex) external +``` + +## Phase 3: AttackCalculator.sol Updates + +Add a slot-aware overload of `_calculateDamage`: +```solidity +function _calculateDamageForSlot( + IEngine ENGINE, + ITypeCalculator TYPE_CALCULATOR, + bytes32 battleKey, + uint256 attackerPlayerIndex, + uint256 attackerSlotIndex, + uint256 defenderSlotIndex, + uint32 basePower, + ... // same other params +) internal returns (int32, bytes32) +``` + +This calls `ENGINE.getDamageCalcContextForSlot()` instead of `ENGINE.getDamageCalcContext()`. + +Keep existing `_calculateDamage` unchanged for singles backward compatibility. + +## Phase 4: Slot-Aware Effects + +### 4a. StaminaRegen.sol +Update `onRoundEnd`: +- Check `ENGINE.getGameMode(battleKey)` +- If doubles: iterate both slots for each player using `ENGINE.getActiveMonIndexForSlot()` +- If singles: use existing logic with `p0ActiveMonIndex`/`p1ActiveMonIndex` + +### 4b. Overclock.sol +Update `onApply` and `onRemove`: +- Check game mode +- If doubles: apply/remove stat changes for both slots using `ENGINE.getActiveMonIndexForSlot()` +- If singles: use existing `p0ActiveMonIndex`/`p1ActiveMonIndex` logic + +**Key insight**: These effects keep all their existing signatures. They just need to internally query extra data from the engine when in doubles mode. + +## Phase 5: DoublesCommitManager + +New contract `src/commit-manager/DoublesCommitManager.sol`: +- Extends the same pattern as DefaultCommitManager +- Handles committing/revealing 2 moves per turn (one per slot) +- Uses a single hash covering both moves +- Validates both moves are legal for their respective slots +- Prevents both slots from switching to the same mon +- Calls `ENGINE.setMoveForSlot()` for each slot + +## Phase 6: IEngine.sol Updates + +Add new function signatures (don't remove existing ones): +- `getGameMode()` +- `getActiveMonIndexForSlot()` +- `getDamageCalcContextForSlot()` +- `setMoveForSlot()` +- `switchActiveMonForSlot()` + +## Phase 7: Matchmaker/Battle struct updates + +- Add `GameMode gameMode` to `Battle` and `ProposedBattle` +- DefaultMatchmaker passes it through +- Default to `Singles` for backward compatibility + +## Phase 8: Test Infrastructure + +### 8a. BattleHelper.sol additions +- `_startDoublesBattle()` - starts a doubles mode battle +- `_doublesCommitRevealExecute()` - commit/reveal/execute for doubles (4 moves per turn) + +### 8b. Test files +- Doubles validation tests (DoublesValidationTest.sol or inline in EngineTest.sol) +- StaminaRegen doubles test +- Overclock doubles test +- DoublesCommitManager test + +## File Change Summary + +| File | Change Type | Description | +|------|-------------|-------------| +| `src/Enums.sol` | Modify | Add `GameMode` enum | +| `src/Constants.sol` | Modify | Add doubles packing constants | +| `src/Structs.sol` | Modify | Add fields to BattleData, BattleConfig, Battle, ProposedBattle; add RevealedMovesPair | +| `src/IEngine.sol` | Modify | Add new getter/setter signatures | +| `src/Engine.sol` | Modify | Add doubles execution path, slot helpers, new getters | +| `src/moves/AttackCalculator.sol` | Modify | Add slot-aware damage calc function | +| `src/effects/StaminaRegen.sol` | Modify | Make doubles-aware | +| `src/effects/battlefield/Overclock.sol` | Modify | Make doubles-aware | +| `src/commit-manager/DoublesCommitManager.sol` | New | Doubles commit/reveal manager | +| `src/matchmaker/DefaultMatchmaker.sol` | Modify | Pass through GameMode | +| `test/abstract/BattleHelper.sol` | Modify | Add doubles helpers | +| `test/DoublesTest.sol` (or similar) | New | Doubles test suite | + +## Commit Order + +1. **Data structures + constants** - Enums, Constants, Structs changes +2. **IEngine interface** - Add new function signatures +3. **Engine core** - Slot packing helpers, getters, startBattle updates +4. **Engine doubles execution** - _executeDoubles and related functions +5. **AttackCalculator** - Slot-aware damage calculation +6. **Effects** - StaminaRegen and Overclock doubles awareness +7. **DoublesCommitManager** - New commit manager +8. **Matchmaker** - GameMode passthrough +9. **Tests** - Test infrastructure and test cases diff --git a/snapshots/EngineGasTest.json b/snapshots/EngineGasTest.json index 3570d228..551ef6cd 100644 --- a/snapshots/EngineGasTest.json +++ b/snapshots/EngineGasTest.json @@ -1,21 +1,21 @@ { - "B1_Execute": "941274", - "B1_Setup": "818934", - "B2_Execute": "698961", - "B2_Setup": "280868", - "Battle1_Execute": "505716", - "Battle1_Setup": "794244", - "Battle2_Execute": "398431", - "Battle2_Setup": "235908", - "External_Execute": "502798", - "External_Setup": "784959", - "FirstBattle": "3192359", - "Inline_Execute": "348349", - "Inline_Setup": "224525", - "Intermediary stuff": "46798", - "SecondBattle": "3270181", - "Setup 1": "1675284", - "Setup 2": "298117", - "Setup 3": "340710", - "ThirdBattle": "2580771" + "B1_Execute": "974979", + "B1_Setup": "823882", + "B2_Execute": "726954", + "B2_Setup": "285841", + "Battle1_Execute": "517262", + "Battle1_Setup": "799095", + "Battle2_Execute": "409993", + "Battle2_Setup": "238802", + "External_Execute": "514344", + "External_Setup": "789810", + "FirstBattle": "3308817", + "Inline_Execute": "357126", + "Inline_Setup": "227374", + "Intermediary stuff": "47028", + "SecondBattle": "3407003", + "Setup 1": "1680587", + "Setup 2": "301690", + "Setup 3": "344290", + "ThirdBattle": "2697363" } \ No newline at end of file diff --git a/snapshots/EngineTest.json b/snapshots/EngineTest.json new file mode 100644 index 00000000..86757e51 --- /dev/null +++ b/snapshots/EngineTest.json @@ -0,0 +1,7 @@ +{ + "FirstBattle": "6745022", + "Intermediary stuff": "47879", + "SecondBattle": "6826261", + "Setup 1": "1906149", + "Setup 2": "363778" +} \ No newline at end of file diff --git a/snapshots/InlineEngineGasTest.json b/snapshots/InlineEngineGasTest.json index d18cf576..3ff46cbc 100644 --- a/snapshots/InlineEngineGasTest.json +++ b/snapshots/InlineEngineGasTest.json @@ -1,16 +1,16 @@ { - "B1_Execute": "923868", - "B1_Setup": "757634", - "B2_Execute": "663118", - "B2_Setup": "267074", - "Battle1_Execute": "453108", - "Battle1_Setup": "732936", - "Battle2_Execute": "348301", - "Battle2_Setup": "223872", - "FirstBattle": "2907336", - "SecondBattle": "2950149", - "Setup 1": "1612799", - "Setup 2": "322001", - "Setup 3": "318466", - "ThirdBattle": "2295522" + "B1_Execute": "952063", + "B1_Setup": "762525", + "B2_Execute": "685452", + "B2_Setup": "271875", + "Battle1_Execute": "461885", + "Battle1_Setup": "737730", + "Battle2_Execute": "357078", + "Battle2_Setup": "226715", + "FirstBattle": "3005143", + "SecondBattle": "3066262", + "Setup 1": "1618034", + "Setup 2": "325488", + "Setup 3": "321959", + "ThirdBattle": "2393386" } \ No newline at end of file diff --git a/snapshots/MatchmakerTest.json b/snapshots/MatchmakerTest.json index 578e23fc..77f615ef 100644 --- a/snapshots/MatchmakerTest.json +++ b/snapshots/MatchmakerTest.json @@ -1,5 +1,5 @@ { - "Accept1": "312196", - "Accept2": "34057", - "Propose1": "197214" + "Accept1": "314219", + "Accept2": "34531", + "Propose1": "199690" } \ No newline at end of file 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/DefaultValidator.sol b/src/DefaultValidator.sol index cb0f7331..6f1d7fd5 100644 --- a/src/DefaultValidator.sol +++ b/src/DefaultValidator.sol @@ -79,6 +79,7 @@ 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 @@ -87,15 +88,46 @@ contract DefaultValidator is IValidator { BattleContext memory ctx = ENGINE.getBattleContext(battleKey); uint256 activeMonIndex = (playerIndex == 0) ? ctx.p0ActiveMonIndex : ctx.p1ActiveMonIndex; - bool isTargetKnockedOut = + if (monToSwitchIndex >= MONS_PER_TEAM) { + return false; + } + bool isNewMonKnockedOut = ENGINE.getMonStateForBattle(battleKey, playerIndex, monToSwitchIndex, MonStateIndexName.IsKnockedOut) == 1; + if (isNewMonKnockedOut) { + return false; + } + if (ctx.turnId != 0) { + 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; + } - return ValidatorLogic.validateSwitch( - ctx.turnId, - activeMonIndex, - monToSwitchIndex, - isTargetKnockedOut, - MONS_PER_TEAM + // A slot-aware switch validation for doubles + // Uses the slot index to determine which active mon is the "current" vs "other" slot + function validateSwitchForSlot(bytes32 battleKey, uint256 playerIndex, uint256 slotIndex, uint256 monToSwitchIndex) + public + view + returns (bool) + { + BattleContext memory ctx = ENGINE.getBattleContext(battleKey); + uint256 activeMonIndex = _getActiveMonForSlot(ctx, playerIndex, slotIndex); + uint256 otherSlotActiveMonIndex = _getActiveMonForSlot(ctx, playerIndex, 1 - slotIndex); + + bool isTargetKnockedOut = ENGINE.getMonStateForBattle( + battleKey, playerIndex, monToSwitchIndex, MonStateIndexName.IsKnockedOut + ) == 1; + + return ValidatorLogic.validateSwitchForSlot( + ctx.turnId, monToSwitchIndex, activeMonIndex, otherSlotActiveMonIndex, type(uint256).max, isTargetKnockedOut, MONS_PER_TEAM ); } @@ -212,6 +244,136 @@ contract DefaultValidator is IValidator { ); } + // Validates a move for a specific slot in doubles mode + 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); + } + + // Validates a move for a specific slot, preventing the same mon from being claimed by both slots + 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); + } + + // Shared implementation for slot move validation + function _validatePlayerMoveForSlotImpl( + bytes32 battleKey, + uint256 moveIndex, + uint256 playerIndex, + uint256 slotIndex, + uint240 extraData, + uint256 claimedByOtherSlot + ) internal view returns (bool) { + BattleContext memory ctx = ENGINE.getBattleContext(battleKey); + + uint256 activeMonIndex = _getActiveMonForSlot(ctx, playerIndex, slotIndex); + uint256 otherSlotActiveMonIndex = _getActiveMonForSlot(ctx, playerIndex, 1 - slotIndex); + + bool isActiveMonKnockedOut = ENGINE.getMonStateForBattle( + battleKey, playerIndex, activeMonIndex, MonStateIndexName.IsKnockedOut + ) == 1; + + // Only compute hasValidSwitchTarget when needed (forced switch + non-switch move) + bool hasValidSwitchTarget = true; + if ((ctx.turnId == 0 || isActiveMonKnockedOut) && moveIndex != SWITCH_MOVE_INDEX) { + hasValidSwitchTarget = _hasValidSwitchTargetForSlot(battleKey, playerIndex, otherSlotActiveMonIndex, claimedByOtherSlot); + } + + // Use library for basic validation + (, bool isNoOp, bool isSwitch, bool isRegularMove, bool basicValid) = + ValidatorLogic.validatePlayerMoveBasicsForSlot(moveIndex, ctx.turnId, isActiveMonKnockedOut, hasValidSwitchTarget, MOVES_PER_MON); + + if (!basicValid) { + return false; + } + + // No-op is always valid (if basic validation passed) + if (isNoOp) { + return true; + } + + // Switch validation using library + if (isSwitch) { + uint256 monToSwitchIndex = uint256(extraData); + bool isTargetKnockedOut = ENGINE.getMonStateForBattle( + battleKey, playerIndex, monToSwitchIndex, MonStateIndexName.IsKnockedOut + ) == 1; + return ValidatorLogic.validateSwitchForSlot( + ctx.turnId, monToSwitchIndex, activeMonIndex, otherSlotActiveMonIndex, claimedByOtherSlot, isTargetKnockedOut, MONS_PER_TEAM + ); + } + + // Regular move validation + if (isRegularMove) { + return _validateSpecificMoveSelectionInternal(battleKey, moveIndex, playerIndex, extraData, activeMonIndex); + } + + return true; + } + + // Checks if there's any valid switch target for a slot in doubles (loop with early return for gas efficiency) + function _hasValidSwitchTargetForSlot( + bytes32 battleKey, + uint256 playerIndex, + uint256 otherSlotActiveMonIndex, + uint256 claimedByOtherSlot + ) internal view returns (bool) { + for (uint256 i = 0; i < MONS_PER_TEAM; i++) { + if (i == otherSlotActiveMonIndex) continue; + if (i == claimedByOtherSlot) continue; + bool isKnockedOut = ENGINE.getMonStateForBattle( + battleKey, playerIndex, i, MonStateIndexName.IsKnockedOut + ) == 1; + if (!isKnockedOut) { + return true; + } + } + return false; + } + + // Internal version for specific move selection validation + function _validateSpecificMoveSelectionInternal( + bytes32 battleKey, + uint256 moveIndex, + uint256 playerIndex, + uint240 extraData, + uint256 activeMonIndex + ) internal view returns (bool) { + IMoveSet moveSet = ENGINE.getMoveForMonForBattle(battleKey, playerIndex, activeMonIndex, moveIndex); + int32 staminaDelta = + ENGINE.getMonStateForBattle(battleKey, playerIndex, activeMonIndex, MonStateIndexName.Stamina); + uint32 baseStamina = + ENGINE.getMonValueForBattle(battleKey, playerIndex, activeMonIndex, MonStateIndexName.Stamina); + return ValidatorLogic.validateSpecificMoveSelection( + battleKey, moveSet, playerIndex, activeMonIndex, extraData, baseStamina, staminaDelta + ); + } + + // Helper to get active mon index for a specific slot from BattleContext + function _getActiveMonForSlot(BattleContext memory ctx, uint256 playerIndex, uint256 slotIndex) + internal + pure + returns (uint256) + { + if (playerIndex == 0) { + return slotIndex == 0 ? ctx.p0ActiveMonIndex : ctx.p0ActiveMonIndex1; + } else { + return slotIndex == 0 ? ctx.p1ActiveMonIndex : ctx.p1ActiveMonIndex1; + } + } + /* Check switch for turn flag: diff --git a/src/Engine.sol b/src/Engine.sol index c99eb3df..d9f6d079 100644 --- a/src/Engine.sol +++ b/src/Engine.sol @@ -212,14 +212,23 @@ contract Engine is IEngine, MappingAllocator { config.koBitmaps = 0; // Store the battle data with initial state + // activeMonIndex uses 4-bit-per-slot packing for 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: p0s0=0, p0s1=1, p1s0=0, p1s1=1 + // For singles: all 0 (backward compatible with 8-bit packing) + uint16 initialActiveMonIndex = battle.gameMode == GameMode.Doubles + ? uint16(0) | (uint16(1) << 4) | (uint16(0) << 8) | (uint16(1) << 12) + : uint16(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: battle.gameMode == GameMode.Doubles ? GAME_MODE_BIT : 0 }); // Set the team for p0 and p1 in the reusable config storage @@ -298,14 +307,7 @@ contract Engine is IEngine, MappingAllocator { } } - for (uint256 i = 0; i < numHooks;) { - if ((config.engineHooks[i].stepsBitmap & (1 << uint8(EngineHookStep.OnBattleStart))) != 0) { - config.engineHooks[i].hook.onBattleStart(battleKey); - } - unchecked { - ++i; - } - } + _runEngineHooks(config, battleKey, EngineHookStep.OnBattleStart); emit BattleStart(battleKey, battle.p0, battle.p1); } @@ -352,14 +354,11 @@ contract Engine is IEngine, MappingAllocator { } // Set both moves inline (same as setMove but without external call overhead) - // Pack moveIndex with isRealTurn bit and apply +1 offset for regular moves - uint8 p0Stored = p0MoveIndex < SWITCH_MOVE_INDEX ? p0MoveIndex + MOVE_INDEX_OFFSET : p0MoveIndex; - config.p0Move = MoveDecision({packedMoveIndex: p0Stored | IS_REAL_TURN_BIT, extraData: p0ExtraData}); + config.p0Move = _packMoveDecision(p0MoveIndex, p0ExtraData); config.p0Salt = p0Salt; emit P0MoveSet(battleKey, uint256(p0MoveIndex) | (uint256(p0ExtraData) << 8)); - uint8 p1Stored = p1MoveIndex < SWITCH_MOVE_INDEX ? p1MoveIndex + MOVE_INDEX_OFFSET : p1MoveIndex; - config.p1Move = MoveDecision({packedMoveIndex: p1Stored | IS_REAL_TURN_BIT, extraData: p1ExtraData}); + config.p1Move = _packMoveDecision(p1MoveIndex, p1ExtraData); config.p1Salt = p1Salt; emit P1MoveSet(battleKey, uint256(p1MoveIndex) | (uint256(p1ExtraData) << 8)); @@ -367,6 +366,49 @@ contract Engine is IEngine, MappingAllocator { _executeInternal(battleKey, storageKey); } + /// @notice Sets all 4 slot moves for doubles and executes in a single call + /// @dev Like executeWithMoves but for doubles battles — sets p0Move, p0Move2, p1Move, p1Move2. + /// Salt is per-player (not per-slot), matching the revealMovePair convention. + function executeWithMovesForDoubles( + bytes32 battleKey, + uint8 p0MoveIndex0, + uint240 p0ExtraData0, + uint8 p0MoveIndex1, + uint240 p0ExtraData1, + bytes32 p0Salt, + uint8 p1MoveIndex0, + uint240 p1ExtraData0, + uint8 p1MoveIndex1, + uint240 p1ExtraData1, + bytes32 p1Salt + ) external { + // Cache storage key + bytes32 storageKey = _getStorageKey(battleKey); + storageKeyForWrite = storageKey; + + BattleConfig storage config = battleConfig[storageKey]; + + // Only moveManager can call this + if (msg.sender != config.moveManager) { + revert WrongCaller(); + } + + // Set p0 slot 0 + slot 1 moves + config.p0Move = _packMoveDecision(p0MoveIndex0, p0ExtraData0); + config.p0Move2 = _packMoveDecision(p0MoveIndex1, p0ExtraData1); + config.p0Salt = p0Salt; + emit P0MoveSet(battleKey, uint256(p0MoveIndex0) | (uint256(p0ExtraData0) << 8)); + + // Set p1 slot 0 + slot 1 moves + config.p1Move = _packMoveDecision(p1MoveIndex0, p1ExtraData0); + config.p1Move2 = _packMoveDecision(p1MoveIndex1, p1ExtraData1); + config.p1Salt = p1Salt; + emit P1MoveSet(battleKey, uint256(p1MoveIndex0) | (uint256(p1ExtraData0) << 8)); + + // Execute (skip MovesNotSet check since we just set them) + _executeInternal(battleKey, storageKey); + } + /// @notice Internal execution logic shared by execute() and executeWithMoves() function _executeInternal(bytes32 battleKey, bytes32 storageKey) internal { // Load storage vars @@ -390,14 +432,12 @@ contract Engine is IEngine, MappingAllocator { // (gets cleared at the end of the transaction) battleKeyForWrite = battleKey; - uint256 numHooks = config.engineHooksLength; - for (uint256 i = 0; i < numHooks;) { - if ((config.engineHooks[i].stepsBitmap & (1 << uint8(EngineHookStep.OnRoundStart))) != 0) { - config.engineHooks[i].hook.onRoundStart(battleKey); - } - unchecked { - ++i; - } + _runEngineHooks(config, battleKey, EngineHookStep.OnRoundStart); + + // Branch for doubles mode + if (_isDoublesMode(battle)) { + _executeDoubles(battleKey, config, battle, turnId, config.engineHooksLength); + return; } // If only a single player has a move to submit, then we don't trigger any effects @@ -595,14 +635,7 @@ contract Engine is IEngine, MappingAllocator { } // Run the round end hooks - for (uint256 i = 0; i < numHooks;) { - if ((config.engineHooks[i].stepsBitmap & (1 << uint8(EngineHookStep.OnRoundEnd))) != 0) { - config.engineHooks[i].hook.onRoundEnd(battleKey); - } - unchecked { - ++i; - } - } + _runEngineHooks(config, battleKey, EngineHookStep.OnRoundEnd); // If a winner has been set, handle the game over if (battle.winnerIndex != 2) { @@ -744,14 +777,7 @@ contract Engine is IEngine, MappingAllocator { revert GameStartsAndEndsSameBlock(); } - for (uint256 i = 0; i < config.engineHooksLength;) { - if ((config.engineHooks[i].stepsBitmap & (1 << uint8(EngineHookStep.OnBattleEnd))) != 0) { - config.engineHooks[i].hook.onBattleEnd(battleKey); - } - unchecked { - ++i; - } - } + _runEngineHooks(config, battleKey, EngineHookStep.OnBattleEnd); // Free the key used for battle configs so other battles can use it _freeStorageKey(battleKey, storageKey); @@ -819,13 +845,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 ); } @@ -1047,7 +1075,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 { @@ -1089,27 +1118,51 @@ contract Engine is IEngine, MappingAllocator { // 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 - { - // Use cached key if called during execute(), otherwise lookup - bool isForCurrentBattle = battleKeyForWrite == battleKey; - bytes32 storageKey = isForCurrentBattle ? storageKeyForWrite : _getStorageKey(battleKey); + /** + * @notice Switch active mon for a specific slot in doubles battles + * @param playerIndex 0 or 1 + * @param slotIndex 0 or 1 + * @param monToSwitchIndex The mon index to switch to + */ + function switchActiveMonForSlot(uint256 playerIndex, uint256 slotIndex, uint256 monToSwitchIndex) external { + bytes32 battleKey = battleKeyForWrite; + if (battleKey == bytes32(0)) { + revert NoWriteAllowed(); + } - // Cache storage pointer to avoid repeated mapping lookups - BattleConfig storage config = battleConfig[storageKey]; + BattleConfig storage config = battleConfig[storageKeyForWrite]; + BattleData storage battle = battleData[battleKey]; - bool isMoveManager = msg.sender == address(config.moveManager); - if (!isMoveManager && !isForCurrentBattle) { - revert NoWriteAllowed(); + // Validate switch (use slot-aware validation for both external and inline paths) + bool isValid; + if (address(config.validator) != address(0)) { + isValid = config.validator.validateSwitchForSlot(battleKey, playerIndex, slotIndex, monToSwitchIndex); + } else { + // Use inline validation via library (no external call) + uint256 activeMonIndex = _getActiveMonIndexForSlot(battle.activeMonIndex, playerIndex, slotIndex); + uint256 otherSlotActiveMonIndex = _getActiveMonIndexForSlot(battle.activeMonIndex, playerIndex, 1 - slotIndex); + bool isTargetKnockedOut = _getMonState(config, playerIndex, monToSwitchIndex).isKnockedOut; + isValid = ValidatorLogic.validateSwitchForSlot( + battle.turnId, monToSwitchIndex, activeMonIndex, otherSlotActiveMonIndex, type(uint256).max, isTargetKnockedOut, DEFAULT_MONS_PER_TEAM + ); } - // Pack moveIndex with isRealTurn bit and apply +1 offset for regular moves - // Regular moves (< SWITCH_MOVE_INDEX) are stored as moveIndex + 1 to avoid zero ambiguity - uint8 storedMoveIndex = moveIndex < SWITCH_MOVE_INDEX ? moveIndex + MOVE_INDEX_OFFSET : moveIndex; - uint8 packedMoveIndex = storedMoveIndex | IS_REAL_TURN_BIT; + if (isValid) { + // Uses _handleSwitchForSlot which handles slot packing internally + _handleSwitchForSlot(battleKey, playerIndex, slotIndex, monToSwitchIndex, msg.sender); + + // Use doubles-specific game over check + bool isGameOver = _checkForGameOverOrKO_Doubles(config, battle); + if (isGameOver) return; + // playerSwitchForTurnFlag was already set by _checkForGameOverOrKO_Doubles + } + } - MoveDecision memory newMove = MoveDecision({packedMoveIndex: packedMoveIndex, extraData: extraData}); + function setMove(bytes32 battleKey, uint256 playerIndex, uint8 moveIndex, bytes32 salt, uint240 extraData) + external + { + BattleConfig storage config = _prepareMoveSet(battleKey); + MoveDecision memory newMove = _packMoveDecision(moveIndex, extraData); if (playerIndex == 0) { config.p0Move = newMove; @@ -1122,6 +1175,62 @@ contract Engine is IEngine, MappingAllocator { } } + /** + * @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 (applied per-player, not per-slot — slot 1 shares the player's salt set via slot 0) + * @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 { + BattleConfig storage config = _prepareMoveSet(battleKey); + MoveDecision memory newMove = _packMoveDecision(moveIndex, extraData); + + if (playerIndex == 0) { + if (slotIndex == 0) { + config.p0Move = newMove; + config.p0Salt = salt; + } else { + config.p0Move2 = newMove; + // Salt is per-player, not per-slot; slot 0 sets p0Salt for RNG derivation + } + } else { + if (slotIndex == 0) { + config.p1Move = newMove; + config.p1Salt = salt; + } else { + config.p1Move2 = newMove; + // Salt is per-player, not per-slot; slot 0 sets p1Salt for RNG derivation + } + } + } + + /// @dev Validates caller permissions and returns the BattleConfig storage pointer for move-setting + function _prepareMoveSet(bytes32 battleKey) private view returns (BattleConfig storage config) { + bool isForCurrentBattle = battleKeyForWrite == battleKey; + bytes32 storageKey = isForCurrentBattle ? storageKeyForWrite : _getStorageKey(battleKey); + config = battleConfig[storageKey]; + bool isMoveManager = msg.sender == address(config.moveManager); + if (!isMoveManager && !isForCurrentBattle) { + revert NoWriteAllowed(); + } + } + + /// @dev Packs a moveIndex + extraData into a MoveDecision with IS_REAL_TURN_BIT set + function _packMoveDecision(uint8 moveIndex, uint240 extraData) private pure returns (MoveDecision memory) { + uint8 storedMoveIndex = moveIndex < SWITCH_MOVE_INDEX ? moveIndex + MOVE_INDEX_OFFSET : moveIndex; + return MoveDecision({packedMoveIndex: storedMoveIndex | IS_REAL_TURN_BIT, extraData: extraData}); + } + function emitEngineEvent(bytes32 eventType, bytes memory eventData) external { bytes32 battleKey = battleKeyForWrite; emit EngineEvent(battleKey, eventType, eventData, _getUpstreamCallerAndResetValue(), currentStep); @@ -1144,94 +1253,73 @@ contract Engine is IEngine, MappingAllocator { internal returns (uint256 playerSwitchForTurnFlag, bool isGameOver) { - uint256 otherPlayerIndex = (priorityPlayerIndex + 1) % 2; - uint8 existingWinnerIndex = battle.winnerIndex; + // Use shared game over check (loads KO bitmaps once) + (uint256 winnerIndex, uint256 p0KOBitmap, uint256 p1KOBitmap) = _checkForGameOver(config, battle); - // First check if we already calculated a winner - if (existingWinnerIndex != 2) { + if (winnerIndex != 2) { + battle.winnerIndex = uint8(winnerIndex); return (playerSwitchForTurnFlag, true); } - // 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; - uint256 p0TeamSize = config.teamSizes & 0x0F; - uint256 p1TeamSize = config.teamSizes >> 4; - uint256 p0KOBitmap = _getKOBitmap(config, 0); - uint256 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; - - if (p0KOBitmap == p0FullMask) { - newWinnerIndex = 1; // p1 wins - } else if (p1KOBitmap == p1FullMask) { - newWinnerIndex = 0; // p0 wins - } - // If we found a winner, set it on the battle data and return - if (newWinnerIndex != 2) { - battle.winnerIndex = uint8(newWinnerIndex); - return (playerSwitchForTurnFlag, true); + // No game over — check active mons for KOs to set the player switch for turn flag + uint256 otherPlayerIndex = (priorityPlayerIndex + 1) % 2; + 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; } - // 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; - } + // 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; } } - 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) - - BattleData storage battle = battleData[battleKey]; + // Core switch-out logic shared between singles and doubles + function _handleSwitchCore( + bytes32 battleKey, + uint256 playerIndex, + uint256 currentActiveMonIndex, + uint256 monToSwitchIndex, + address source + ) internal { 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); } + // Note: Caller is responsible for updating activeMonIndex with appropriate packing + } - // Update to new active mon (we assume validateSwitch already resolved and gives us a valid target) - battle.activeMonIndex = _setActiveMonIndex(battle.activeMonIndex, playerIndex, monToSwitchIndex); + // 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 (skip on turn 0 - execute() handles that) Mon memory mon = _getTeamMon(config, playerIndex, monToSwitchIndex); if ( address(mon.ability) != address(0) && battle.turnId != 0 @@ -1241,6 +1329,38 @@ contract Engine is IEngine, MappingAllocator { } } + // Singles switch: uses 8-bit packing + function _handleSwitch(bytes32 battleKey, uint256 playerIndex, uint256 monToSwitchIndex, address source) internal { + BattleData storage battle = battleData[battleKey]; + uint256 currentActiveMonIndex = _unpackActiveMonIndex(battle.activeMonIndex, playerIndex); + + _handleSwitchCore(battleKey, playerIndex, currentActiveMonIndex, monToSwitchIndex, source); + + // Update to new active mon using 8-bit packing (singles) + battle.activeMonIndex = _setActiveMonIndex(battle.activeMonIndex, playerIndex, monToSwitchIndex); + + _completeSwitchIn(battleKey, playerIndex, monToSwitchIndex); + } + + // Doubles switch: uses 4-bit-per-slot packing + function _handleSwitchForSlot( + bytes32 battleKey, + uint256 playerIndex, + uint256 slotIndex, + uint256 monToSwitchIndex, + address source + ) internal { + BattleData storage battle = battleData[battleKey]; + uint256 currentActiveMonIndex = _getActiveMonIndexForSlot(battle.activeMonIndex, playerIndex, slotIndex); + + _handleSwitchCore(battleKey, playerIndex, currentActiveMonIndex, monToSwitchIndex, source); + + // Update active mon for this slot using 4-bit packing (doubles) + battle.activeMonIndex = _setActiveMonIndexForSlot(battle.activeMonIndex, playerIndex, slotIndex, monToSwitchIndex); + + _completeSwitchIn(battleKey, playerIndex, monToSwitchIndex); + } + function _handleMove( bytes32 battleKey, BattleConfig storage config, @@ -1252,9 +1372,7 @@ contract Engine is IEngine, MappingAllocator { int32 staminaCost; playerSwitchForTurnFlag = prevPlayerSwitchForTurnFlag; - // Unpack moveIndex from packedMoveIndex (lower 7 bits, with +1 offset for regular moves) - uint8 storedMoveIndex = move.packedMoveIndex & MOVE_INDEX_MASK; - uint8 moveIndex = storedMoveIndex >= SWITCH_MOVE_INDEX ? storedMoveIndex : storedMoveIndex - MOVE_INDEX_OFFSET; + uint8 moveIndex = _unpackMoveIndex(move.packedMoveIndex); // Handle shouldSkipTurn flag first and toggle it off if set uint256 activeMonIndex = _unpackActiveMonIndex(battle.activeMonIndex, playerIndex); @@ -1281,28 +1399,14 @@ contract Engine is IEngine, MappingAllocator { } // Execute the move and then set updated state, active mons, and effects/data else { - IMoveSet moveSet = _getTeamMon(config, playerIndex, activeMonIndex).moves[moveIndex]; - - // Call validateSpecificMoveSelection again to ensure it is still valid to execute - // If not, then we just return early + // Validate the move is still valid to execute // Handles cases where e.g. some condition outside of the player's control leads to an invalid move - bool isValid; - if (address(config.validator) == address(0)) { - // Use inline validation (no external call) - uint32 baseStamina = _getTeamMon(config, playerIndex, activeMonIndex).stats.stamina; - int32 staminaDelta = currentMonState.staminaDelta; - isValid = ValidatorLogic.validateSpecificMoveSelection( - battleKey, moveSet, playerIndex, activeMonIndex, move.extraData, baseStamina, staminaDelta - ); - } else { - // Use external validator - isValid = - config.validator.validateSpecificMoveSelection(battleKey, moveIndex, playerIndex, move.extraData); - } - if (!isValid) { + if (!_validateMoveSelection(config, battleKey, playerIndex, activeMonIndex, moveIndex, move.extraData)) { return playerSwitchForTurnFlag; } + IMoveSet moveSet = _getTeamMon(config, playerIndex, activeMonIndex).moves[moveIndex]; + // Update the mon state directly to account for the stamina cost of the move staminaCost = int32(moveSet.stamina(battleKey, playerIndex, activeMonIndex)); currentMonState.staminaDelta = (currentMonState.staminaDelta == CLEARED_MON_STATE_SENTINEL) @@ -1333,6 +1437,32 @@ 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]; @@ -1342,19 +1472,20 @@ contract Engine is IEngine, MappingAllocator { uint256 p1ActiveMonIndex = _unpackActiveMonIndex(battle.activeMonIndex, 1); 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 + if (explicitMonIndex != type(uint256).max) { + monIndex = explicitMonIndex; + } else if (playerIndex != 2) { + // Specific player - get their active mon (this takes priority over effectIndex) + monIndex = _unpackActiveMonIndex(battle.activeMonIndex, playerIndex); + } else if (effectIndex == 2) { + // Global effects with global playerIndex - monIndex doesn't matter for filtering monIndex = 0; } else { + // effectIndex is player-specific but playerIndex is global - use effectIndex 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); - } - // Iterate directly over storage, skipping tombstones // With tombstones, indices are stable so no snapshot needed uint256 baseSlot; @@ -1582,33 +1713,15 @@ contract Engine is IEngine, MappingAllocator { BattleConfig storage config = battleConfig[storageKey]; BattleData storage battle = battleData[battleKey]; - // Unpack move indices from packed format - uint8 p0StoredIndex = config.p0Move.packedMoveIndex & MOVE_INDEX_MASK; - uint8 p1StoredIndex = config.p1Move.packedMoveIndex & MOVE_INDEX_MASK; - uint8 p0MoveIndex = p0StoredIndex >= SWITCH_MOVE_INDEX ? p0StoredIndex : p0StoredIndex - MOVE_INDEX_OFFSET; - uint8 p1MoveIndex = p1StoredIndex >= SWITCH_MOVE_INDEX ? p1StoredIndex : p1StoredIndex - MOVE_INDEX_OFFSET; + uint8 p0MoveIndex = _unpackMoveIndex(config.p0Move.packedMoveIndex); + uint8 p1MoveIndex = _unpackMoveIndex(config.p1Move.packedMoveIndex); uint256 p0ActiveMonIndex = _unpackActiveMonIndex(battle.activeMonIndex, 0); uint256 p1ActiveMonIndex = _unpackActiveMonIndex(battle.activeMonIndex, 1); - uint256 p0Priority; - uint256 p1Priority; - - // Call the move for its priority, unless it's the switch or no op move index - { - if (p0MoveIndex == SWITCH_MOVE_INDEX || p0MoveIndex == NO_OP_MOVE_INDEX) { - p0Priority = SWITCH_PRIORITY; - } else { - IMoveSet p0MoveSet = _getTeamMon(config, 0, p0ActiveMonIndex).moves[p0MoveIndex]; - p0Priority = p0MoveSet.priority(battleKey, 0); - } - if (p1MoveIndex == SWITCH_MOVE_INDEX || p1MoveIndex == NO_OP_MOVE_INDEX) { - p1Priority = SWITCH_PRIORITY; - } else { - IMoveSet p1MoveSet = _getTeamMon(config, 1, p1ActiveMonIndex).moves[p1MoveIndex]; - p1Priority = p1MoveSet.priority(battleKey, 1); - } - } + // Get priority and speed for each player's move + (uint256 p0Priority, uint32 p0MonSpeed) = _getPriorityAndSpeed(config, battleKey, 0, p0ActiveMonIndex, p0MoveIndex); + (uint256 p1Priority, uint32 p1MonSpeed) = _getPriorityAndSpeed(config, battleKey, 1, p1ActiveMonIndex, p1MoveIndex); // Determine priority based on (in descending order of importance): // - the higher priority tier @@ -1619,18 +1732,6 @@ contract Engine is IEngine, MappingAllocator { } else if (p0Priority < p1Priority) { return 1; } else { - // Calculate speeds by combining base stats with deltas - // Note: speedDelta may be sentinel value (CLEARED_MON_STATE_SENTINEL) which should be treated as 0 - int32 p0SpeedDelta = _getMonState(config, 0, p0ActiveMonIndex).speedDelta; - int32 p1SpeedDelta = _getMonState(config, 1, p1ActiveMonIndex).speedDelta; - uint32 p0MonSpeed = uint32( - int32(_getTeamMon(config, 0, p0ActiveMonIndex).stats.speed) - + (p0SpeedDelta == CLEARED_MON_STATE_SENTINEL ? int32(0) : p0SpeedDelta) - ); - uint32 p1MonSpeed = uint32( - int32(_getTeamMon(config, 1, p1ActiveMonIndex).stats.speed) - + (p1SpeedDelta == CLEARED_MON_STATE_SENTINEL ? int32(0) : p1SpeedDelta) - ); if (p0MonSpeed > p1MonSpeed) { return 0; } else if (p0MonSpeed < p1MonSpeed) { @@ -1657,10 +1758,12 @@ contract Engine is IEngine, MappingAllocator { } function _unpackActiveMonIndex(uint16 packed, uint256 playerIndex) internal pure returns (uint256) { + // Use 4-bit mask (0x0F) to be compatible with both 8-bit (singles) and 4-bit (doubles) packing + // Mon indices are always < 16, so this is safe for both formats if (playerIndex == 0) { - return uint256(uint8(packed)); + return uint256(packed) & ACTIVE_MON_INDEX_MASK; } else { - return uint256(uint8(packed >> 8)); + return (uint256(packed) >> 8) & ACTIVE_MON_INDEX_MASK; } } @@ -1672,6 +1775,31 @@ contract Engine is IEngine, MappingAllocator { } } + // Doubles-specific helper functions for slot-based active mon packing + // Layout: 4 bits per slot - [p0s0][p0s1][p1s0][p1s1] from LSB to MSB + function _getActiveMonIndexForSlot(uint16 packed, uint256 playerIndex, uint256 slotIndex) + internal + pure + returns (uint256) + { + uint256 shift = (playerIndex * 2 + slotIndex) * ACTIVE_MON_INDEX_BITS; + return (uint256(packed) >> shift) & ACTIVE_MON_INDEX_MASK; + } + + function _setActiveMonIndexForSlot(uint16 packed, uint256 playerIndex, uint256 slotIndex, uint256 monIndex) + internal + pure + returns (uint16) + { + uint256 shift = (playerIndex * 2 + slotIndex) * ACTIVE_MON_INDEX_BITS; + uint256 cleared = uint256(packed) & ~(uint256(ACTIVE_MON_INDEX_MASK) << shift); + return uint16(cleared | (monIndex << shift)); + } + + function _isDoublesMode(BattleData storage data) internal view returns (bool) { + return (data.slotSwitchFlagsAndGameMode & GAME_MODE_BIT) != 0; + } + // 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; @@ -1704,6 +1832,80 @@ contract Engine is IEngine, MappingAllocator { return playerIndex == 0 ? config.p0States[monIndex] : config.p1States[monIndex]; } + // Unpack moveIndex from packedMoveIndex (lower 7 bits, with +1 offset for regular moves) + function _unpackMoveIndex(uint8 packedMoveIndex) private pure returns (uint8) { + uint8 storedMoveIndex = packedMoveIndex & MOVE_INDEX_MASK; + return storedMoveIndex >= SWITCH_MOVE_INDEX ? storedMoveIndex : storedMoveIndex - MOVE_INDEX_OFFSET; + } + + // Get priority and effective speed for a player's move (shared between singles and doubles) + function _getPriorityAndSpeed( + BattleConfig storage config, + bytes32 battleKey, + uint256 playerIndex, + uint256 monIndex, + uint8 moveIndex + ) private view returns (uint256 priority, uint32 speed) { + if (moveIndex == SWITCH_MOVE_INDEX || moveIndex == NO_OP_MOVE_INDEX) { + priority = SWITCH_PRIORITY; + } else { + IMoveSet moveSet = _getTeamMon(config, playerIndex, monIndex).moves[moveIndex]; + priority = moveSet.priority(battleKey, playerIndex); + } + + int32 speedDelta = _getMonState(config, playerIndex, monIndex).speedDelta; + speed = uint32( + int32(_getTeamMon(config, playerIndex, monIndex).stats.speed) + + (speedDelta == CLEARED_MON_STATE_SENTINEL ? int32(0) : speedDelta) + ); + } + + // Validate a specific move selection using either inline or external validator + function _validateMoveSelection( + BattleConfig storage config, + bytes32 battleKey, + uint256 playerIndex, + uint256 monIndex, + uint8 moveIndex, + uint240 extraData + ) private returns (bool) { + if (address(config.validator) == address(0)) { + // Use inline validation (no external call) + IMoveSet moveSet = _getTeamMon(config, playerIndex, monIndex).moves[moveIndex]; + uint32 baseStamina = _getTeamMon(config, playerIndex, monIndex).stats.stamina; + int32 staminaDelta = _getMonState(config, playerIndex, monIndex).staminaDelta; + return ValidatorLogic.validateSpecificMoveSelection( + battleKey, moveSet, playerIndex, monIndex, extraData, baseStamina, staminaDelta + ); + } else { + // Use external validator + return config.validator.validateSpecificMoveSelection(battleKey, moveIndex, playerIndex, extraData); + } + } + + // Run engine hooks for a specific step + function _runEngineHooks(BattleConfig storage config, bytes32 battleKey, EngineHookStep step) internal { + uint256 numHooks = config.engineHooksLength; + uint256 stepBit = 1 << uint8(step); + for (uint256 i = 0; i < numHooks;) { + if ((config.engineHooks[i].stepsBitmap & stepBit) != 0) { + IEngineHook hook = config.engineHooks[i].hook; + if (step == EngineHookStep.OnBattleStart) { + hook.onBattleStart(battleKey); + } else if (step == EngineHookStep.OnRoundStart) { + hook.onRoundStart(battleKey); + } else if (step == EngineHookStep.OnRoundEnd) { + hook.onRoundEnd(battleKey); + } else { + hook.onBattleEnd(battleKey); + } + } + unchecked { + ++i; + } + } + } + // Helper functions for KO bitmap management (packed: lower 8 bits = p0, upper 8 bits = p1) function _getKOBitmap(BattleConfig storage config, uint256 playerIndex) private view returns (uint256) { return playerIndex == 0 ? (config.koBitmaps & 0xFF) : (config.koBitmaps >> 8); @@ -1878,6 +2080,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, @@ -2142,6 +2346,18 @@ contract Engine is IEngine, MappingAllocator { return battleConfig[_getStorageKey(battleKey)].moveManager; } + function getGameMode(bytes32 battleKey) external view returns (GameMode) { + return _isDoublesMode(battleData[battleKey]) ? GameMode.Doubles : GameMode.Singles; + } + + function getActiveMonIndexForSlot(bytes32 battleKey, uint256 playerIndex, uint256 slotIndex) + external + view + returns (uint256) + { + return _getActiveMonIndexForSlot(battleData[battleKey].activeMonIndex, playerIndex, slotIndex); + } + function getBattleContext(bytes32 battleKey) external view returns (BattleContext memory ctx) { bytes32 storageKey = _getStorageKey(battleKey); BattleData storage data = battleData[battleKey]; @@ -2154,10 +2370,25 @@ 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); ctx.validator = address(config.validator); ctx.moveManager = config.moveManager; + + uint8 flags = data.slotSwitchFlagsAndGameMode; + if ((flags & GAME_MODE_BIT) != 0) { + // Doubles mode: use 4-bit slot packing + ctx.gameMode = GameMode.Doubles; + ctx.slotSwitchFlags = flags & SWITCH_FLAGS_MASK; + uint16 packed = data.activeMonIndex; + ctx.p0ActiveMonIndex = uint8(_getActiveMonIndexForSlot(packed, 0, 0)); + ctx.p1ActiveMonIndex = uint8(_getActiveMonIndexForSlot(packed, 1, 0)); + ctx.p0ActiveMonIndex1 = uint8(_getActiveMonIndexForSlot(packed, 0, 1)); + ctx.p1ActiveMonIndex1 = uint8(_getActiveMonIndexForSlot(packed, 1, 1)); + } else { + // Singles mode: use 8-bit packing (backward compatible) + ctx.gameMode = GameMode.Singles; + ctx.p0ActiveMonIndex = uint8(data.activeMonIndex & 0xFF); + ctx.p1ActiveMonIndex = uint8(data.activeMonIndex >> 8); + } } function getCommitContext(bytes32 battleKey) external view returns (CommitContext memory ctx) { @@ -2172,23 +2403,32 @@ contract Engine is IEngine, MappingAllocator { ctx.turnId = data.turnId; ctx.playerSwitchForTurnFlag = data.playerSwitchForTurnFlag; ctx.validator = address(config.validator); + + uint8 flags = data.slotSwitchFlagsAndGameMode; + ctx.slotSwitchFlags = flags & SWITCH_FLAGS_MASK; + ctx.gameMode = (flags & GAME_MODE_BIT) != 0 ? GameMode.Doubles : GameMode.Singles; } /// @notice Lightweight getter for dual-signed flow that validates state and returns only needed fields - /// @dev Reverts internally if battle not started, already complete, or not a two-player turn + /// @dev Reverts if battle already complete or not a two-player turn. + /// Skips startTimestamp check (saves 2 SLOADs): if battle was never started, + /// winnerIndex defaults to 0 (not 2), so the GameAlreadyOver check catches it. + /// Returns gameMode for free since slotSwitchFlagsAndGameMode is in the same + /// storage slot as winnerIndex/playerSwitchForTurnFlag. function getCommitAuthForDualSigned(bytes32 battleKey) external view - returns (address committer, address revealer, uint64 turnId) + returns (address committer, address revealer, uint64 turnId, GameMode gameMode) { - bytes32 storageKey = _getStorageKey(battleKey); BattleData storage data = battleData[battleKey]; - BattleConfig storage config = battleConfig[storageKey]; - if (config.startTimestamp == 0) revert BattleNotStarted(); + // winnerIndex == 0 for never-started battles (all zeros), != 2 catches both cases if (data.winnerIndex != 2) revert GameAlreadyOver(); if (data.playerSwitchForTurnFlag != 2) revert NotTwoPlayerTurn(); + // gameMode is free: slotSwitchFlagsAndGameMode is in the same slot as winnerIndex + gameMode = (data.slotSwitchFlagsAndGameMode & GAME_MODE_BIT) != 0 ? GameMode.Doubles : GameMode.Singles; + turnId = data.turnId; if (turnId % 2 == 0) { committer = data.p0; @@ -2240,6 +2480,445 @@ contract Engine is IEngine, MappingAllocator { ctx.defenderType2 = defenderMon.stats.type2; } + // Slot-aware overload for doubles - uses explicit slot indices to get correct mon + 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 using slot-aware lookup + uint256 attackerMonIndex = _getActiveMonIndexForSlot(data.activeMonIndex, attackerPlayerIndex, attackerSlotIndex); + uint256 defenderMonIndex = _getActiveMonIndexForSlot(data.activeMonIndex, defenderPlayerIndex, defenderSlotIndex); + + ctx.attackerMonIndex = uint8(attackerMonIndex); + ctx.defenderMonIndex = uint8(defenderMonIndex); + + // Get attacker stats + Mon storage attackerMon = _getTeamMon(config, attackerPlayerIndex, attackerMonIndex); + MonState storage attackerState = _getMonState(config, attackerPlayerIndex, attackerMonIndex); + ctx.attackerAttack = attackerMon.stats.attack; + ctx.attackerAttackDelta = + attackerState.attackDelta == CLEARED_MON_STATE_SENTINEL ? int32(0) : attackerState.attackDelta; + ctx.attackerSpAtk = attackerMon.stats.specialAttack; + ctx.attackerSpAtkDelta = attackerState.specialAttackDelta == CLEARED_MON_STATE_SENTINEL + ? int32(0) + : attackerState.specialAttackDelta; + + // Get defender stats and types + Mon storage defenderMon = _getTeamMon(config, defenderPlayerIndex, defenderMonIndex); + MonState storage defenderState = _getMonState(config, defenderPlayerIndex, defenderMonIndex); + ctx.defenderDef = defenderMon.stats.defense; + ctx.defenderDefDelta = + defenderState.defenceDelta == CLEARED_MON_STATE_SENTINEL ? int32(0) : defenderState.defenceDelta; + ctx.defenderSpDef = defenderMon.stats.specialDefense; + ctx.defenderSpDefDelta = defenderState.specialDefenceDelta == CLEARED_MON_STATE_SENTINEL + ? int32(0) + : defenderState.specialDefenceDelta; + ctx.defenderType1 = defenderMon.stats.type1; + ctx.defenderType2 = defenderMon.stats.type2; + } + + // ==================== Doubles Support Functions ==================== + + // Struct for tracking move order in doubles + struct MoveOrder { + uint256 playerIndex; + uint256 slotIndex; + uint256 priority; + uint256 speed; + } + + // 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 = _getActiveMonIndexForSlot(battle.activeMonIndex, p, s); + if (!_getMonState(config, p, monIndex).isKnockedOut) { + _runEffects(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); + for (uint256 j = 0; j < numHooks;) { + if ((config.engineHooks[j].stepsBitmap & (1 << uint8(EngineHookStep.OnRoundEnd))) != 0) { + config.engineHooks[j].hook.onRoundEnd(battleKey); + } + unchecked { ++j; } + } + 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 = _getActiveMonIndexForSlot(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 = _getActiveMonIndexForSlot(battle.activeMonIndex, p, s); + if (!_getMonState(config, p, monIndex).isKnockedOut) { + _runEffects(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;) { + if ((config.engineHooks[j].stepsBitmap & (1 << uint8(EngineHookStep.OnRoundEnd))) != 0) { + config.engineHooks[j].hook.onRoundEnd(battleKey); + } + unchecked { ++j; } + } + 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 = _getActiveMonIndexForSlot(battle.activeMonIndex, p, s); + if (!_getMonState(config, p, monIndex).isKnockedOut) { + _runEffects(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;) { + if ((config.engineHooks[j].stepsBitmap & (1 << uint8(EngineHookStep.OnRoundEnd))) != 0) { + config.engineHooks[j].hook.onRoundEnd(battleKey); + } + unchecked { ++j; } + } + emit EngineExecute(battleKey, turnId, 2, moveOrder[0].playerIndex); + return; + } + + // Run round end hooks + for (uint256 i = 0; i < numHooks;) { + if ((config.engineHooks[i].stepsBitmap & (1 << uint8(EngineHookStep.OnRoundEnd))) != 0) { + config.engineHooks[i].hook.onRoundEnd(battleKey); + } + unchecked { ++i; } + } + + // End of turn cleanup + battle.turnId += 1; + // playerSwitchForTurnFlag was already set by _checkForGameOverOrKO_Doubles + + // 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); + } + + // 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; + } + + uint8 moveIndex = _unpackMoveIndex(move.packedMoveIndex); + + // Get active mon for this slot + uint256 activeMonIndex = _getActiveMonIndexForSlot(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 + uint256 otherSlotIndex = 1 - slotIndex; + uint256 otherSlotActiveMonIndex = _getActiveMonIndexForSlot(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 the move is still valid to execute + if (!_validateMoveSelection(config, battleKey, playerIndex, activeMonIndex, moveIndex, move.extraData)) { + return false; + } + + IMoveSet moveSet = _getTeamMon(config, playerIndex, activeMonIndex).moves[moveIndex]; + + // Deduct stamina + staminaCost = int32(moveSet.stamina(battleKey, playerIndex, activeMonIndex)); + currentMonState.staminaDelta = (currentMonState.staminaDelta == CLEARED_MON_STATE_SENTINEL) + ? -staminaCost + : currentMonState.staminaDelta - staminaCost; + + emit MonMove(battleKey, playerIndex, activeMonIndex, moveIndex, move.extraData, staminaCost); + + // Execute the move - use slot 0 of opponent as default defender + uint256 defenderMonIndex = _getActiveMonIndexForSlot(battle.activeMonIndex, 1 - playerIndex, 0); + moveSet.move(battleKey, playerIndex, activeMonIndex, defenderMonIndex, move.extraData, tempRNG); + } + + // Check if mon got KO'd as a result of this move + return currentMonState.isKnockedOut; + } + + // 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; + } + } + + // Compute move order for all 4 slots in doubles (sorted by priority desc, then speed desc) + 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, treat as lowest priority + if ((move.packedMoveIndex & IS_REAL_TURN_BIT) == 0) { + moveOrder[idx].priority = 0; + moveOrder[idx].speed = 0; + continue; + } + + uint8 moveIndex = _unpackMoveIndex(move.packedMoveIndex); + uint256 monIndex = _getActiveMonIndexForSlot(battle.activeMonIndex, p, s); + + (uint256 priority, uint32 speed) = _getPriorityAndSpeed(config, battleKey, p, monIndex, moveIndex); + moveOrder[idx].priority = priority; + moveOrder[idx].speed = speed; + } + } + + // Sort by priority (desc), then speed (desc), position as tiebreaker (implicit) + // 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 (shouldSwap) { + MoveOrder memory temp = moveOrder[j]; + moveOrder[j] = moveOrder[j + 1]; + moveOrder[j + 1] = temp; + } + } + } + } + + // Shared game over check - returns winner index (0, 1, or 2 if no winner) and KO bitmaps + 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 (battle.winnerIndex != 2) { + return (battle.winnerIndex, 0, 0); + } + + // Load KO bitmaps and team sizes, delegate pure comparison to library + p0KOBitmap = _getKOBitmap(config, 0); + p1KOBitmap = _getKOBitmap(config, 1); + winnerIndex = ValidatorLogic.checkGameOver( + p0KOBitmap, p1KOBitmap, config.teamSizes & 0x0F, config.teamSizes >> 4 + ); + } + + // Check for game over or KO in doubles mode + 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 = _getActiveMonIndexForSlot(battle.activeMonIndex, p, s); + bool isKOed = (koBitmap & (1 << activeMonIndex)) != 0; + if (isKOed) { + _setSlotSwitchFlag(battle, p, s); + } + } + } + + // Determine if either player needs a switch turn + bool p0NeedsSwitch = _playerNeedsSwitchTurn(config, battle, 0, p0KOBitmap); + bool p1NeedsSwitch = _playerNeedsSwitchTurn(config, battle, 1, p1KOBitmap); + + // Set playerSwitchForTurnFlag + if (p0NeedsSwitch && p1NeedsSwitch) { + battle.playerSwitchForTurnFlag = 2; // Both act (switch-only turn) + } else if (p0NeedsSwitch) { + battle.playerSwitchForTurnFlag = 0; // Only p0 + } else if (p1NeedsSwitch) { + battle.playerSwitchForTurnFlag = 1; // Only p1 + } else { + battle.playerSwitchForTurnFlag = 2; // Normal turn (both act) + } + + return false; + } + + // Check if a player has any KO'd slot with a 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); + + for (uint256 s = 0; s < 2; s++) { + uint256 activeMonIndex = _getActiveMonIndexForSlot(battle.activeMonIndex, playerIndex, s); + bool isSlotKOed = (koBitmap & (1 << activeMonIndex)) != 0; + + if (isSlotKOed) { + // Check if there's a valid switch target + uint256 otherSlotMonIndex = _getActiveMonIndexForSlot(battle.activeMonIndex, playerIndex, 1 - s); + for (uint256 m = 0; m < teamSize; m++) { + if ((koBitmap & (1 << m)) != 0) continue; // Skip KO'd + if (m == otherSlotMonIndex) continue; // Skip other slot's active mon + return true; // Found valid target + } + } + } + return false; + } + + // 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; + } + + // ==================== End Doubles Support Functions ==================== + function getValidationContext(bytes32 battleKey) external view returns (ValidationContext memory ctx) { bytes32 storageKey = _getStorageKey(battleKey); BattleData storage data = battleData[battleKey]; diff --git a/src/Enums.sol b/src/Enums.sol index 23fb3a52..5ca25289 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 6c165011..d3a66296 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 executeWithMoves( bytes32 battleKey, @@ -34,6 +36,19 @@ interface IEngine { bytes32 p1Salt, uint240 p1ExtraData ) external; + function executeWithMovesForDoubles( + bytes32 battleKey, + uint8 p0MoveIndex0, + uint240 p0ExtraData0, + uint8 p0MoveIndex1, + uint240 p0ExtraData1, + bytes32 p0Salt, + uint8 p1MoveIndex0, + uint240 p1ExtraData0, + uint8 p1MoveIndex1, + uint240 p1ExtraData1, + bytes32 p1Salt + ) external; function emitEngineEvent(bytes32 eventType, bytes memory extraData) external; function setUpstreamCaller(address caller) external; @@ -94,10 +109,25 @@ interface IEngine { function getCommitAuthForDualSigned(bytes32 battleKey) external view - returns (address committer, address revealer, uint64 turnId); + returns (address committer, address revealer, uint64 turnId, GameMode gameMode); function getDamageCalcContext(bytes32 battleKey, uint256 attackerPlayerIndex, uint256 defenderPlayerIndex) external view returns (DamageCalcContext memory); + // Slot-aware overload for doubles - uses explicit slot indices to get correct mon + function getDamageCalcContext( + bytes32 battleKey, + uint256 attackerPlayerIndex, + uint256 attackerSlotIndex, + uint256 defenderPlayerIndex, + uint256 defenderSlotIndex + ) external view returns (DamageCalcContext memory); function getValidationContext(bytes32 battleKey) external view returns (ValidationContext memory); + + // Doubles-specific + function getGameMode(bytes32 battleKey) external view returns (GameMode); + function getActiveMonIndexForSlot(bytes32 battleKey, uint256 playerIndex, uint256 slotIndex) + external + view + returns (uint256); } diff --git a/src/IValidator.sol b/src/IValidator.sol index 8dea5f1d..812d862b 100644 --- a/src/IValidator.sol +++ b/src/IValidator.sol @@ -26,6 +26,29 @@ interface IValidator { // Validates that a switch is valid function validateSwitch(bytes32 battleKey, uint256 playerIndex, uint256 monToSwitchIndex) external returns (bool); + // Validates that a switch is valid for a specific slot in doubles mode + function validateSwitchForSlot(bytes32 battleKey, uint256 playerIndex, uint256 slotIndex, uint256 monToSwitchIndex) 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 there is a valid timeout, returns address(0) if no winner, otherwise returns the winner function validateTimeout(bytes32 battleKey, uint256 presumedAFKPlayerIndex) external returns (address); } diff --git a/src/Structs.sol b/src/Structs.sol index b72c6748..33f77c74 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 SignedMatchmaker @@ -47,6 +48,7 @@ struct Battle { address moveManager; IMatchmaker matchmaker; IEngineHook[] engineHooks; + GameMode gameMode; // Singles or Doubles } // Packed into 1 storage slot (8 + 240 = 248 bits) @@ -64,7 +66,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 @@ -82,8 +89,10 @@ struct BattleConfig { uint48 lastExecuteTimestamp; // Written at end of every execute() for timeout tracking 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; @@ -120,6 +129,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; @@ -132,7 +143,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; } @@ -204,6 +218,10 @@ struct BattleContext { uint8 prevPlayerSwitchForTurnFlag; uint8 p0ActiveMonIndex; uint8 p1ActiveMonIndex; + 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; } @@ -216,9 +234,20 @@ struct CommitContext { uint8 winnerIndex; uint64 turnId; uint8 playerSwitchForTurnFlag; + uint8 slotSwitchFlags; // Per-slot switch flags (doubles) + GameMode gameMode; address validator; } +// 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 +} + // Batch context for damage calculation to reduce external calls (7 -> 1) struct DamageCalcContext { uint8 attackerMonIndex; diff --git a/src/commit-manager/DefaultCommitManager.sol b/src/commit-manager/DefaultCommitManager.sol index daf70b29..a0e4b0c5 100644 --- a/src/commit-manager/DefaultCommitManager.sol +++ b/src/commit-manager/DefaultCommitManager.sol @@ -7,6 +7,7 @@ import "../Structs.sol"; import {ICommitManager} from "./ICommitManager.sol"; import {IEngine} from "../IEngine.sol"; +import {IValidator} from "../IValidator.sol"; contract DefaultCommitManager is ICommitManager { IEngine internal immutable ENGINE; @@ -252,6 +253,94 @@ contract DefaultCommitManager is ICommitManager { } } + /// @notice Reveal both slot moves for doubles battles + /// @dev The hash preimage is: keccak256(abi.encodePacked(moveIndex0, moveIndex1, salt, extraData0, extraData1)) + function revealMovePair(bytes32 battleKey, RevealedMovesPair calldata moves, bool autoExecute) external { + CommitContext memory 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(); + + uint256 currentPlayerIndex = msg.sender == ctx.p0 ? 0 : 1; + uint256 otherPlayerIndex = 1 - currentPlayerIndex; + + PlayerDecisionData storage currentPd = playerData[battleKey][currentPlayerIndex]; + PlayerDecisionData storage otherPd = playerData[battleKey][otherPlayerIndex]; + + uint64 turnId = ctx.turnId; + uint8 playerSwitchForTurnFlag = ctx.playerSwitchForTurnFlag; + + // Same commit/reveal ordering logic as revealMove + bool playerSkipsPreimageCheck; + if (playerSwitchForTurnFlag == 2) { + playerSkipsPreimageCheck = + (((turnId % 2 == 1) && (currentPlayerIndex == 0)) || ((turnId % 2 == 0) && (currentPlayerIndex == 1))); + } else { + playerSkipsPreimageCheck = (playerSwitchForTurnFlag == currentPlayerIndex); + if (!playerSkipsPreimageCheck) revert PlayerNotAllowed(); + } + + if (playerSkipsPreimageCheck) { + if (playerSwitchForTurnFlag == 2) { + if (turnId != 0) { + if (otherPd.lastCommitmentTurnId != turnId) revert RevealBeforeOtherCommit(); + } else { + if (otherPd.moveHash == bytes32(0)) revert RevealBeforeOtherCommit(); + } + } + } else { + // Validate preimage for doubles: hash is over both slot moves packed + bytes32 expectedHash = keccak256( + abi.encodePacked(moves.moveIndex0, moves.moveIndex1, moves.salt, moves.extraData0, moves.extraData1) + ); + if (expectedHash != currentPd.moveHash) revert WrongPreimage(); + if (currentPd.lastCommitmentTurnId != turnId) revert RevealBeforeSelfCommit(); + if (otherPd.numMovesRevealed < turnId || otherPd.lastMoveTimestamp == 0) revert NotYetRevealed(); + } + + if (currentPd.numMovesRevealed > turnId) revert AlreadyRevealed(); + + // Validate both slot moves if validator is set + if (ctx.validator != address(0)) { + // Validate slot 0 + if (!IValidator(ctx.validator).validatePlayerMoveForSlot( + battleKey, moves.moveIndex0, currentPlayerIndex, 0, moves.extraData0 + )) { + revert InvalidMove(msg.sender); + } + // Validate slot 1, with slot 0's switch target as claimed + uint256 claimedBySlot0 = moves.moveIndex0 == SWITCH_MOVE_INDEX ? uint256(moves.extraData0) : type(uint256).max; + if (!IValidator(ctx.validator).validatePlayerMoveForSlotWithClaimed( + battleKey, moves.moveIndex1, currentPlayerIndex, 1, moves.extraData1, claimedBySlot0 + )) { + revert InvalidMove(msg.sender); + } + } + + // Set both slot moves on the engine + ENGINE.setMoveForSlot(battleKey, currentPlayerIndex, 0, moves.moveIndex0, moves.salt, moves.extraData0); + ENGINE.setMoveForSlot(battleKey, currentPlayerIndex, 1, moves.moveIndex1, moves.salt, moves.extraData1); + + currentPd.lastMoveTimestamp = uint96(block.timestamp); + unchecked { + currentPd.numMovesRevealed += 1; + } + + if (playerSwitchForTurnFlag == 0 || playerSwitchForTurnFlag == 1) { + otherPd.lastMoveTimestamp = uint96(block.timestamp); + unchecked { + otherPd.numMovesRevealed += 1; + } + } + + if (autoExecute) { + if ((playerSwitchForTurnFlag == currentPlayerIndex) || (!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); diff --git a/src/commit-manager/ICommitManager.sol b/src/commit-manager/ICommitManager.sol index f9e25bb5..9624f779 100644 --- a/src/commit-manager/ICommitManager.sol +++ b/src/commit-manager/ICommitManager.sol @@ -7,6 +7,8 @@ interface ICommitManager { function commitMove(bytes32 battleKey, bytes32 moveHash) external; function revealMove(bytes32 battleKey, uint8 moveIndex, bytes32 salt, uint240 extraData, bool autoExecute) external; + // Doubles: reveal both slot moves at once (hash is over both moves packed) + function revealMovePair(bytes32 battleKey, RevealedMovesPair calldata moves, bool autoExecute) external; function getCommitment(bytes32 battleKey, address player) external view returns (bytes32 moveHash, uint256 turnId); function getMoveCountForBattleState(bytes32 battleKey, address player) external view returns (uint256); function getLastMoveTimestampForPlayer(bytes32 battleKey, address player) external view returns (uint256); diff --git a/src/commit-manager/SignedCommitLib.sol b/src/commit-manager/SignedCommitLib.sol index 4646d4ee..e542b217 100644 --- a/src/commit-manager/SignedCommitLib.sol +++ b/src/commit-manager/SignedCommitLib.sol @@ -69,4 +69,40 @@ library SignedCommitLib { ) ); } + + /// @notice Struct for the dual-signed reveal flow in doubles battles + /// @dev Like DualSignedReveal but covers 2 slot moves per player. + /// The committer's hash covers both their slot moves (matching revealMovePair preimage). + /// The revealer signs over both their slot moves + the committer's hash. + struct DualSignedRevealDoubles { + bytes32 battleKey; + uint64 turnId; + bytes32 committerMoveHash; // hash(moveIndex0, moveIndex1, salt, extraData0, extraData1) + uint8 revealerMoveIndex0; // Slot 0 move + uint8 revealerMoveIndex1; // Slot 1 move + bytes32 revealerSalt; + uint240 revealerExtraData0; // Slot 0 extra data + uint240 revealerExtraData1; // Slot 1 extra data + } + + /// @notice Hashes a DualSignedRevealDoubles struct according to EIP-712 + /// @param reveal The DualSignedRevealDoubles struct to hash + /// @return The EIP-712 struct hash + function hashDualSignedRevealDoubles(DualSignedRevealDoubles memory reveal) internal pure returns (bytes32) { + return keccak256( + abi.encode( + keccak256( + "DualSignedRevealDoubles(bytes32 battleKey,uint64 turnId,bytes32 committerMoveHash,uint8 revealerMoveIndex0,uint8 revealerMoveIndex1,bytes32 revealerSalt,uint240 revealerExtraData0,uint240 revealerExtraData1)" + ), + reveal.battleKey, + reveal.turnId, + reveal.committerMoveHash, + reveal.revealerMoveIndex0, + reveal.revealerMoveIndex1, + reveal.revealerSalt, + reveal.revealerExtraData0, + reveal.revealerExtraData1 + ) + ); + } } diff --git a/src/commit-manager/SignedCommitManager.sol b/src/commit-manager/SignedCommitManager.sol index ccd76818..6cf90e37 100644 --- a/src/commit-manager/SignedCommitManager.sol +++ b/src/commit-manager/SignedCommitManager.sol @@ -7,6 +7,7 @@ import {ECDSA} from "../lib/ECDSA.sol"; import {SignedCommitLib} from "./SignedCommitLib.sol"; import {IEngine} from "../IEngine.sol"; import {CommitContext, PlayerDecisionData} from "../Structs.sol"; +import {GameMode} from "../Enums.sol"; /// @title SignedCommitManager /// @notice Extends DefaultCommitManager with optimistic dual-signed commit flow @@ -41,6 +42,9 @@ contract SignedCommitManager is DefaultCommitManager, EIP712 { /// @notice Thrown when trying to use dual-signed flow on a single-player turn error NotTwoPlayerTurn(); + /// @notice Thrown when using singles function for a doubles battle or vice versa + error WrongGameMode(); + constructor(IEngine engine) DefaultCommitManager(engine) {} /// @inheritdoc EIP712 @@ -54,7 +58,7 @@ contract SignedCommitManager is DefaultCommitManager, EIP712 { version = "1"; } - /// @notice Executes a turn using dual-signed moves from both players (gas-optimized) + /// @notice Executes a turn using dual-signed moves from both players (singles only) /// @dev The committer (A) submits both moves. The revealer (B) has signed over /// their move and A's move hash, binding both players to their moves. /// @param battleKey The battle identifier @@ -77,9 +81,11 @@ contract SignedCommitManager is DefaultCommitManager, EIP712 { bytes memory revealerSignature ) external { // Use lightweight getter (validates internally, reverts on bad state) - (address committer, address revealer, uint64 turnId) = + (address committer, address revealer, uint64 turnId, GameMode gameMode) = ENGINE.getCommitAuthForDualSigned(battleKey); + if (gameMode != GameMode.Singles) revert WrongGameMode(); + // Caller must be the committing player if (msg.sender != committer) { revert CallerNotCommitter(); @@ -123,10 +129,102 @@ contract SignedCommitManager is DefaultCommitManager, EIP712 { } } + /// @notice Executes a turn using dual-signed moves for doubles battles + /// @dev Same security model as executeWithDualSignedMoves but each player has 2 slot moves. + /// The committer's hash covers both slot moves: keccak256(moveIndex0, moveIndex1, salt, extraData0, extraData1) + /// matching the revealMovePair preimage format. + /// @param battleKey The battle identifier + /// @param committerMoveIndex0 The committer's slot 0 move index + /// @param committerExtraData0 The committer's slot 0 extra data + /// @param committerMoveIndex1 The committer's slot 1 move index + /// @param committerExtraData1 The committer's slot 1 extra data + /// @param committerSalt The committer's salt (shared across both slots) + /// @param revealerMoveIndex0 The revealer's slot 0 move index + /// @param revealerExtraData0 The revealer's slot 0 extra data + /// @param revealerMoveIndex1 The revealer's slot 1 move index + /// @param revealerExtraData1 The revealer's slot 1 extra data + /// @param revealerSalt The revealer's salt (shared across both slots) + /// @param revealerSignature EIP-712 signature from the revealer over DualSignedRevealDoubles + function executeWithDualSignedMovesForDoubles( + bytes32 battleKey, + uint8 committerMoveIndex0, + uint240 committerExtraData0, + uint8 committerMoveIndex1, + uint240 committerExtraData1, + bytes32 committerSalt, + uint8 revealerMoveIndex0, + uint240 revealerExtraData0, + uint8 revealerMoveIndex1, + uint240 revealerExtraData1, + bytes32 revealerSalt, + bytes memory revealerSignature + ) external { + // Use lightweight getter (validates internally, reverts on bad state) + (address committer, address revealer, uint64 turnId, GameMode gameMode) = + ENGINE.getCommitAuthForDualSigned(battleKey); + + if (gameMode != GameMode.Doubles) revert WrongGameMode(); + + // Caller must be the committing player + if (msg.sender != committer) { + revert CallerNotCommitter(); + } + + // Compute the committer's move hash (matches revealMovePair preimage format) + bytes32 committerMoveHash = keccak256( + abi.encodePacked( + committerMoveIndex0, committerMoveIndex1, committerSalt, committerExtraData0, committerExtraData1 + ) + ); + + // Verify the revealer's signature over DualSignedRevealDoubles + SignedCommitLib.DualSignedRevealDoubles memory reveal = SignedCommitLib.DualSignedRevealDoubles({ + battleKey: battleKey, + turnId: turnId, + committerMoveHash: committerMoveHash, + revealerMoveIndex0: revealerMoveIndex0, + revealerMoveIndex1: revealerMoveIndex1, + revealerSalt: revealerSalt, + revealerExtraData0: revealerExtraData0, + revealerExtraData1: revealerExtraData1 + }); + + bytes32 digest = _hashTypedData(SignedCommitLib.hashDualSignedRevealDoubles(reveal)); + if (ECDSA.recover(digest, revealerSignature) != revealer) { + revert InvalidSignature(); + } + + // Execute with all 4 slot moves in a single call + if (turnId % 2 == 0) { + // Committer is p0 + ENGINE.executeWithMovesForDoubles( + battleKey, + committerMoveIndex0, committerExtraData0, + committerMoveIndex1, committerExtraData1, + committerSalt, + revealerMoveIndex0, revealerExtraData0, + revealerMoveIndex1, revealerExtraData1, + revealerSalt + ); + } else { + // Committer is p1 + ENGINE.executeWithMovesForDoubles( + battleKey, + revealerMoveIndex0, revealerExtraData0, + revealerMoveIndex1, revealerExtraData1, + revealerSalt, + committerMoveIndex0, committerExtraData0, + committerMoveIndex1, committerExtraData1, + committerSalt + ); + } + } + /// @notice Allows anyone to publish the committer's signed commitment on-chain /// @dev This is a fallback mechanism if the committer (A) doesn't submit via /// executeWithDualSignedMoves. The revealer (B) can use this to force A's /// commitment on-chain, then proceed with the normal reveal flow. + /// Works for both singles and doubles - the moveHash is format-agnostic. /// @param battleKey The battle identifier /// @param moveHash The committer's move hash /// @param committerSignature EIP-712 signature from the committer over diff --git a/src/cpu/CPU.sol b/src/cpu/CPU.sol index 77bd2b8f..46d6ad6b 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 { @@ -171,7 +171,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: GameMode.Singles }) ); } diff --git a/src/effects/StaminaRegen.sol b/src/effects/StaminaRegen.sol index e8a6801c..769fcaf8 100644 --- a/src/effects/StaminaRegen.sol +++ b/src/effects/StaminaRegen.sol @@ -33,7 +33,7 @@ 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( bytes32 battleKey, uint256, @@ -44,10 +44,17 @@ contract StaminaRegen is BasicEffect { uint256 p1ActiveMonIndex ) external override returns (bytes32, bool) { uint256 playerSwitchForTurnFlag = ENGINE.getPlayerSwitchForTurnFlagForBattleState(battleKey); - // Update stamina for both active mons only if it's a 2 player turn + // Update stamina for active mons only if it's a 2 player turn if (playerSwitchForTurnFlag == 2) { _regenStamina(battleKey, 0, p0ActiveMonIndex); _regenStamina(battleKey, 1, p1ActiveMonIndex); + // For doubles, also regen stamina for slot 1 mons + if (ENGINE.getGameMode(battleKey) == GameMode.Doubles) { + uint256 p0Slot1MonIndex = ENGINE.getActiveMonIndexForSlot(battleKey, 0, 1); + uint256 p1Slot1MonIndex = ENGINE.getActiveMonIndexForSlot(battleKey, 1, 1); + _regenStamina(battleKey, 0, p0Slot1MonIndex); + _regenStamina(battleKey, 1, p1Slot1MonIndex); + } } return (bytes32(0), false); } diff --git a/src/lib/ValidatorLogic.sol b/src/lib/ValidatorLogic.sol index 2f57c320..25098045 100644 --- a/src/lib/ValidatorLogic.sol +++ b/src/lib/ValidatorLogic.sol @@ -115,4 +115,138 @@ library ValidatorLogic { return (requiresSwitch, isNoOp, isSwitch, isRegularMove, true); } + + /// @notice Validates a switch for a specific slot in doubles + /// @dev Extends validateSwitch with doubles-specific checks (other slot active, claimed by other slot) + /// @param turnId Current turn ID + /// @param monToSwitchIndex The mon index to switch to + /// @param currentSlotActiveMonIndex The current active mon index for this slot + /// @param otherSlotActiveMonIndex The active mon index for the other slot + /// @param claimedByOtherSlot Mon index claimed by other slot (type(uint256).max if none) + /// @param isTargetKnockedOut Whether the target mon is knocked out + /// @param monsPerTeam Maximum mons per team + /// @return valid Whether the switch is valid + function validateSwitchForSlot( + uint64 turnId, + uint256 monToSwitchIndex, + uint256 currentSlotActiveMonIndex, + uint256 otherSlotActiveMonIndex, + uint256 claimedByOtherSlot, + bool isTargetKnockedOut, + uint256 monsPerTeam + ) internal pure returns (bool valid) { + // Check bounds + if (monToSwitchIndex >= monsPerTeam) { + return false; + } + + // Cannot switch to a knocked out mon + if (isTargetKnockedOut) { + return false; + } + + // Cannot switch to mon already active in the other slot + if (monToSwitchIndex == otherSlotActiveMonIndex) { + return false; + } + + // Cannot switch to mon being claimed by the other slot + if (monToSwitchIndex == claimedByOtherSlot) { + return false; + } + + // Cannot switch to same mon (except on turn 0 for initial switch-in) + if (turnId != 0 && monToSwitchIndex == currentSlotActiveMonIndex) { + return false; + } + + return true; + } + + /// @notice Validates basic player move selection for a slot in doubles + /// @dev Extends validatePlayerMoveBasics with NO_OP fallback when no valid switch targets exist + /// @param moveIndex The move index + /// @param turnId Current turn ID + /// @param isActiveMonKnockedOut Whether the active mon is knocked out + /// @param hasValidSwitchTarget Whether there is at least one valid switch target + /// @param movesPerMon Maximum moves per mon + /// @return requiresSwitch Whether the validation requires a switch + /// @return isNoOp Whether this is a no-op move + /// @return isSwitch Whether this is a switch move + /// @return isRegularMove Whether this is a regular move (0-3) + /// @return valid Whether basic validation passes + function validatePlayerMoveBasicsForSlot( + uint256 moveIndex, + uint64 turnId, + bool isActiveMonKnockedOut, + bool hasValidSwitchTarget, + uint256 movesPerMon + ) + internal + pure + returns (bool requiresSwitch, bool isNoOp, bool isSwitch, bool isRegularMove, bool valid) + { + // On turn 0 or if active mon is KO'd, must switch + requiresSwitch = (turnId == 0) || isActiveMonKnockedOut; + + if (requiresSwitch && moveIndex != SWITCH_MOVE_INDEX) { + // In doubles, NO_OP is allowed if there are no valid switch targets + if (moveIndex == NO_OP_MOVE_INDEX && !hasValidSwitchTarget) { + return (requiresSwitch, true, false, false, true); + } + return (requiresSwitch, false, false, false, false); + } + + // Identify move type + isNoOp = (moveIndex == NO_OP_MOVE_INDEX); + isSwitch = (moveIndex == SWITCH_MOVE_INDEX); + isRegularMove = !isNoOp && !isSwitch; + + // Bounds check for regular moves + if (isRegularMove && moveIndex >= movesPerMon) { + return (requiresSwitch, isNoOp, isSwitch, isRegularMove, false); + } + + return (requiresSwitch, isNoOp, isSwitch, isRegularMove, true); + } + + /// @notice Pure game-over check using KO bitmaps and team sizes + /// @param p0KOBitmap Bitmap where bit i is set if p0's mon i is knocked out + /// @param p1KOBitmap Bitmap where bit i is set if p1's mon i is knocked out + /// @param p0TeamSize Number of mons on p0's team + /// @param p1TeamSize Number of mons on p1's team + /// @return winnerIndex 0 if p0 wins, 1 if p1 wins, 2 if no winner yet + function checkGameOver( + uint256 p0KOBitmap, + uint256 p1KOBitmap, + uint256 p0TeamSize, + uint256 p1TeamSize + ) internal pure returns (uint256 winnerIndex) { + uint256 p0FullMask = (1 << p0TeamSize) - 1; + uint256 p1FullMask = (1 << p1TeamSize) - 1; + if (p0KOBitmap == p0FullMask) return 1; // p1 wins (all p0 mons KO'd) + if (p1KOBitmap == p1FullMask) return 0; // p0 wins (all p1 mons KO'd) + return 2; // No winner yet + } + + /// @notice Checks if there's a valid switch target for a slot using a KO bitmap + /// @param koBitmap Bitmap where bit i is set if mon i is knocked out + /// @param otherSlotActiveMonIndex Mon index active in the other slot + /// @param claimedByOtherSlot Mon index claimed by other slot (type(uint256).max if none) + /// @param monsPerTeam Maximum mons per team + /// @return Whether at least one valid switch target exists + function hasValidSwitchTargetForSlotBitmap( + uint256 koBitmap, + uint256 otherSlotActiveMonIndex, + uint256 claimedByOtherSlot, + uint256 monsPerTeam + ) internal pure returns (bool) { + for (uint256 i = 0; i < monsPerTeam; i++) { + if (i == otherSlotActiveMonIndex) continue; + if (i == claimedByOtherSlot) continue; + // Bit not set means mon is alive + if ((koBitmap & (1 << i)) == 0) return true; + } + return false; + } } diff --git a/src/matchmaker/DefaultMatchmaker.sol b/src/matchmaker/DefaultMatchmaker.sol index b8cd9095..886d0376 100644 --- a/src/matchmaker/DefaultMatchmaker.sol +++ b/src/matchmaker/DefaultMatchmaker.sol @@ -95,6 +95,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; @@ -135,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); @@ -172,7 +176,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/moves/AttackCalculator.sol b/src/moves/AttackCalculator.sol index 600172b5..c7be8049 100644 --- a/src/moves/AttackCalculator.sol +++ b/src/moves/AttackCalculator.sol @@ -76,6 +76,38 @@ library AttackCalculator { ); } + // Slot-aware overload for doubles - uses explicit slot indices for correct mon lookup + function _calculateDamage( + IEngine ENGINE, + ITypeCalculator TYPE_CALCULATOR, + bytes32 battleKey, + uint256 attackerPlayerIndex, + uint256 attackerSlotIndex, + uint256 defenderSlotIndex, + uint32 basePower, + uint32 accuracy, + uint256 volatility, + Type attackType, + MoveClass attackSupertype, + uint256 rng, + uint256 critRate + ) internal returns (int32, bytes32) { + uint256 defenderPlayerIndex = (attackerPlayerIndex + 1) % 2; + DamageCalcContext memory ctx = ENGINE.getDamageCalcContext( + battleKey, attackerPlayerIndex, attackerSlotIndex, defenderPlayerIndex, defenderSlotIndex + ); + (int32 damage, bytes32 eventType) = _calculateDamageFromContext( + TYPE_CALCULATOR, ctx, basePower, accuracy, volatility, attackType, attackSupertype, rng, critRate + ); + if (damage != 0) { + ENGINE.dealDamage(defenderPlayerIndex, ctx.defenderMonIndex, damage); + } + if (eventType != bytes32(0)) { + ENGINE.emitEngineEvent(eventType, ""); + } + return (damage, eventType); + } + function _calculateDamageFromContext( ITypeCalculator TYPE_CALCULATOR, DamageCalcContext memory ctx, diff --git a/test/BattleHistoryTest.sol b/test/BattleHistoryTest.sol index 8b1c18eb..591495a1 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/BetterCPUTest.sol b/test/BetterCPUTest.sol index ec4afb6b..6a90168a 100644 --- a/test/BetterCPUTest.sol +++ b/test/BetterCPUTest.sol @@ -129,7 +129,8 @@ contract BetterCPUTest is Test { teamRegistry: teamRegistry, engineHooks: new IEngineHook[](0), moveManager: address(cpu), - matchmaker: cpu + matchmaker: cpu, + gameMode: GameMode.Singles }); vm.startPrank(ALICE); diff --git a/test/CPUTest.sol b/test/CPUTest.sol index 87b46040..08dd256d 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/DoublesCommitManagerTest.sol b/test/DoublesCommitManagerTest.sol new file mode 100644 index 00000000..13748f92 --- /dev/null +++ b/test/DoublesCommitManagerTest.sol @@ -0,0 +1,837 @@ +// 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 {DefaultCommitManager} from "../src/commit-manager/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"; + +contract DoublesCommitManagerTest is Test { + address constant ALICE = address(0x1); + address constant BOB = address(0x2); + + DefaultCommitManager 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(0, 0, 0); + 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 DefaultCommitManager(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, aliceMove1, salt, aliceExtra0, aliceExtra1)); + + vm.startPrank(ALICE); + commitManager.commitMove(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.revealMovePair(battleKey, RevealedMovesPair({moveIndex0: bobMove0, extraData0: bobExtra0, moveIndex1: bobMove1, extraData1: bobExtra1, salt: bobSalt}), false); + vm.stopPrank(); + + // Alice reveals (committing player reveals second) + vm.startPrank(ALICE); + commitManager.revealMovePair(battleKey, RevealedMovesPair({moveIndex0: aliceMove0, extraData0: aliceExtra0, moveIndex1: aliceMove1, extraData1: aliceExtra1, salt: 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_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, aliceMove1, salt, aliceExtra0, aliceExtra1)); + + vm.startPrank(ALICE); + commitManager.commitMove(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.revealMovePair(battleKey, RevealedMovesPair({moveIndex0: bobMove0, extraData0: bobExtra0, moveIndex1: bobMove1, extraData1: bobExtra1, salt: bobSalt}), false); + vm.stopPrank(); + + // Alice reveals + vm.startPrank(ALICE); + commitManager.revealMovePair(battleKey, RevealedMovesPair({moveIndex0: aliceMove0, extraData0: aliceExtra0, moveIndex1: aliceMove1, extraData1: aliceExtra1, salt: 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 (slot 0) + assertEq(engine.getActiveMonIndexForSlot(battleKey, 0, 0), 0); // p0 slot 0 = mon 0 + assertEq(engine.getActiveMonIndexForSlot(battleKey, 1, 0), 0); // p1 slot 0 = mon 0 + + // 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; + + 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, bobAttack1, bobSalt2, bobTarget0, bobTarget1)); + commitManager.commitMove(battleKey, bobHash2); + vm.stopPrank(); + + // Alice reveals first (non-committing player) + vm.startPrank(ALICE); + commitManager.revealMovePair(battleKey, RevealedMovesPair({moveIndex0: aliceAttack0, extraData0: aliceTarget0, moveIndex1: aliceAttack1, extraData1: aliceTarget1, salt: salt2}), false); + vm.stopPrank(); + + // Bob reveals + vm.startPrank(BOB); + commitManager.revealMovePair(battleKey, RevealedMovesPair({moveIndex0: bobAttack0, extraData0: bobTarget0, moveIndex1: bobAttack1, extraData1: bobTarget1, salt: 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, aliceMove1, salt, aliceExtra0, aliceExtra1)); + + vm.startPrank(ALICE); + commitManager.commitMove(battleKey, aliceHash); + vm.stopPrank(); + + // Bob reveals first (also must use SWITCH_MOVE_INDEX on turn 0) + vm.startPrank(BOB); + commitManager.revealMovePair(battleKey, RevealedMovesPair({moveIndex0: SWITCH_MOVE_INDEX, extraData0: 0, moveIndex1: SWITCH_MOVE_INDEX, extraData1: 1, salt: bytes32("bobsalt")}), false); + vm.stopPrank(); + + // Alice tries to reveal with wrong moves - should fail + vm.startPrank(ALICE); + vm.expectRevert(DefaultCommitManager.WrongPreimage.selector); + commitManager.revealMovePair(battleKey, RevealedMovesPair({moveIndex0: SWITCH_MOVE_INDEX, extraData0: 1, moveIndex1: SWITCH_MOVE_INDEX, extraData1: 0, salt: 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, aliceMove1, aliceSalt, aliceExtra0, aliceExtra1)); + vm.startPrank(ALICE); + commitManager.commitMove(battleKey, aliceHash); + vm.stopPrank(); + + // Bob reveals first + vm.startPrank(BOB); + commitManager.revealMovePair(battleKey, RevealedMovesPair({moveIndex0: bobMove0, extraData0: bobExtra0, moveIndex1: bobMove1, extraData1: bobExtra1, salt: bobSalt}), false); + vm.stopPrank(); + + // Alice reveals + vm.startPrank(ALICE); + commitManager.revealMovePair(battleKey, RevealedMovesPair({moveIndex0: aliceMove0, extraData0: aliceExtra0, moveIndex1: aliceMove1, extraData1: aliceExtra1, salt: aliceSalt}), false); + vm.stopPrank(); + } else { + // Bob commits first on odd turns + bytes32 bobHash = keccak256(abi.encodePacked(bobMove0, bobMove1, bobSalt, bobExtra0, bobExtra1)); + vm.startPrank(BOB); + commitManager.commitMove(battleKey, bobHash); + vm.stopPrank(); + + // Alice reveals first + vm.startPrank(ALICE); + commitManager.revealMovePair(battleKey, RevealedMovesPair({moveIndex0: aliceMove0, extraData0: aliceExtra0, moveIndex1: aliceMove1, extraData1: aliceExtra1, salt: aliceSalt}), false); + vm.stopPrank(); + + // Bob reveals + vm.startPrank(BOB); + commitManager.revealMovePair(battleKey, RevealedMovesPair({moveIndex0: bobMove0, extraData0: bobExtra0, moveIndex1: bobMove1, extraData1: bobExtra1, salt: 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: Both Alice slots target Bob slot 0 (engine currently processes slot 0 targets) + _doublesCommitRevealExecute( + battleKey, + 0, 0, 0, 0, // Alice: both slots target Bob slot 0 + 0, 0, 0, 0 // Bob: both attack + ); + + // Bob's slot 0 should be KO'd from the massive damage, battle continues + assertEq(engine.getWinner(battleKey), address(0)); + } + + 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 (slot 0) + assertEq(engine.getActiveMonIndexForSlot(battleKey, 0, 0), 0); // Alice slot 0 = mon 0 + + // 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..af04ee88 --- /dev/null +++ b/test/DoublesValidationTest.sol @@ -0,0 +1,3061 @@ +// 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 {DefaultCommitManager} from "../src/commit-manager/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); + + DefaultCommitManager 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(0, 0, 0); + 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 DefaultCommitManager(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, aliceMove1, aliceSalt, aliceExtra0, aliceExtra1)); + vm.startPrank(ALICE); + commitManager.commitMove(battleKey, aliceHash); + vm.stopPrank(); + + vm.startPrank(BOB); + commitManager.revealMovePair(battleKey, RevealedMovesPair({moveIndex0: bobMove0, extraData0: bobExtra0, moveIndex1: bobMove1, extraData1: bobExtra1, salt: bobSalt}), false); + vm.stopPrank(); + + vm.startPrank(ALICE); + commitManager.revealMovePair(battleKey, RevealedMovesPair({moveIndex0: aliceMove0, extraData0: aliceExtra0, moveIndex1: aliceMove1, extraData1: aliceExtra1, salt: aliceSalt}), false); + vm.stopPrank(); + } else { + bytes32 bobHash = keccak256(abi.encodePacked(bobMove0, bobMove1, bobSalt, bobExtra0, bobExtra1)); + vm.startPrank(BOB); + commitManager.commitMove(battleKey, bobHash); + vm.stopPrank(); + + vm.startPrank(ALICE); + commitManager.revealMovePair(battleKey, RevealedMovesPair({moveIndex0: aliceMove0, extraData0: aliceExtra0, moveIndex1: aliceMove1, extraData1: aliceExtra1, salt: aliceSalt}), false); + vm.stopPrank(); + + vm.startPrank(BOB); + commitManager.revealMovePair(battleKey, RevealedMovesPair({moveIndex0: bobMove0, extraData0: bobExtra0, moveIndex1: bobMove1, extraData1: bobExtra1, salt: 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}) + ); + DefaultCommitManager commitManager2 = new DefaultCommitManager(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, SWITCH_MOVE_INDEX, aliceSalt, uint240(0), uint240(1))); + vm.startPrank(ALICE); + commitManager2.commitMove(battleKey, aliceHash); + vm.stopPrank(); + vm.startPrank(BOB); + commitManager2.revealMovePair(battleKey, RevealedMovesPair({moveIndex0: SWITCH_MOVE_INDEX, extraData0: 0, moveIndex1: SWITCH_MOVE_INDEX, extraData1: 1, salt: bobSalt}), false); + vm.stopPrank(); + vm.startPrank(ALICE); + commitManager2.revealMovePair(battleKey, RevealedMovesPair({moveIndex0: SWITCH_MOVE_INDEX, extraData0: 0, moveIndex1: SWITCH_MOVE_INDEX, extraData1: 1, salt: 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), uint8(NO_OP_MOVE_INDEX), bobSalt, uint240(0), uint240(0))); + vm.startPrank(BOB); + commitManager2.commitMove(battleKey, bobHash); + vm.stopPrank(); + vm.startPrank(ALICE); + commitManager2.revealMovePair(battleKey, RevealedMovesPair({moveIndex0: uint8(0), extraData0: 0, moveIndex1: uint8(NO_OP_MOVE_INDEX), extraData1: 0, salt: aliceSalt}), false); + vm.stopPrank(); + vm.startPrank(BOB); + commitManager2.revealMovePair(battleKey, RevealedMovesPair({moveIndex0: uint8(0), extraData0: 0, moveIndex1: uint8(NO_OP_MOVE_INDEX), extraData1: 0, salt: 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}) + ); + DefaultCommitManager commitManager2 = new DefaultCommitManager(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, SWITCH_MOVE_INDEX, aliceSalt, uint240(0), uint240(1))); + vm.startPrank(ALICE); + commitManager2.commitMove(battleKey, aliceHash); + vm.stopPrank(); + vm.startPrank(BOB); + commitManager2.revealMovePair(battleKey, RevealedMovesPair({moveIndex0: SWITCH_MOVE_INDEX, extraData0: 0, moveIndex1: SWITCH_MOVE_INDEX, extraData1: 1, salt: bobSalt}), false); + vm.stopPrank(); + vm.startPrank(ALICE); + commitManager2.revealMovePair(battleKey, RevealedMovesPair({moveIndex0: SWITCH_MOVE_INDEX, extraData0: 0, moveIndex1: SWITCH_MOVE_INDEX, extraData1: 1, salt: 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), uint8(0), bobSalt, uint240(0), uint240(0))); + vm.startPrank(BOB); + commitManager2.commitMove(battleKey, bobHash); + vm.stopPrank(); + vm.startPrank(ALICE); + commitManager2.revealMovePair(battleKey, RevealedMovesPair({moveIndex0: uint8(NO_OP_MOVE_INDEX), extraData0: 0, moveIndex1: uint8(0), extraData1: 0, salt: aliceSalt}), false); + vm.stopPrank(); + vm.startPrank(BOB); + commitManager2.revealMovePair(battleKey, RevealedMovesPair({moveIndex0: uint8(NO_OP_MOVE_INDEX), extraData0: 0, moveIndex1: uint8(0), extraData1: 0, salt: 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(DefaultCommitManager.InvalidMove.selector, ALICE)); + commitManager.revealMovePair(battleKey, RevealedMovesPair({moveIndex0: uint8(0), extraData0: 0, moveIndex1: uint8(NO_OP_MOVE_INDEX), extraData1: 0, salt: 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), uint8(0), bytes32("bobsalt"), uint240(0), uint240(0))); + vm.expectRevert(DefaultCommitManager.PlayerNotAllowed.selector); + commitManager.commitMove(battleKey, bobHash); + vm.stopPrank(); + + // Alice reveals her switch (no commit needed for single-player turns) + bytes32 aliceSalt = bytes32("alicesalt"); + vm.startPrank(ALICE); + commitManager.revealMovePair(battleKey, RevealedMovesPair({moveIndex0: SWITCH_MOVE_INDEX, extraData0: 2, moveIndex1: NO_OP_MOVE_INDEX, extraData1: 0, salt: 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.revealMovePair(battleKey, RevealedMovesPair({moveIndex0: SWITCH_MOVE_INDEX, extraData0: 2, moveIndex1: 0, extraData1: 1, salt: 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), uint8(0), bytes32("alicesalt"), uint240(0), uint240(0))); + vm.expectRevert(DefaultCommitManager.PlayerNotAllowed.selector); + commitManager.commitMove(battleKey, aliceHash); + vm.stopPrank(); + + // Bob reveals his switch (no commit needed for single-player turns) + bytes32 bobSalt = bytes32("bobsalt"); + vm.startPrank(BOB); + commitManager.revealMovePair(battleKey, RevealedMovesPair({moveIndex0: SWITCH_MOVE_INDEX, extraData0: 2, moveIndex1: NO_OP_MOVE_INDEX, extraData1: 0, salt: 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}) + ); + DefaultCommitManager commitManager2 = new DefaultCommitManager(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, SWITCH_MOVE_INDEX, aliceSalt, uint240(0), uint240(1))); + vm.startPrank(ALICE); + commitManager2.commitMove(battleKey, aliceHash); + vm.stopPrank(); + vm.startPrank(BOB); + commitManager2.revealMovePair(battleKey, RevealedMovesPair({moveIndex0: SWITCH_MOVE_INDEX, extraData0: 0, moveIndex1: SWITCH_MOVE_INDEX, extraData1: 1, salt: bobSalt}), false); + vm.stopPrank(); + vm.startPrank(ALICE); + commitManager2.revealMovePair(battleKey, RevealedMovesPair({moveIndex0: SWITCH_MOVE_INDEX, extraData0: 0, moveIndex1: SWITCH_MOVE_INDEX, extraData1: 1, salt: 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), uint8(NO_OP_MOVE_INDEX), bobSalt, uint240(0), uint240(0))); + vm.startPrank(BOB); + commitManager2.commitMove(battleKey, bobHash); + vm.stopPrank(); + vm.startPrank(ALICE); + commitManager2.revealMovePair(battleKey, RevealedMovesPair({moveIndex0: uint8(0), extraData0: 0, moveIndex1: uint8(NO_OP_MOVE_INDEX), extraData1: 0, salt: aliceSalt}), false); + vm.stopPrank(); + vm.startPrank(BOB); + commitManager2.revealMovePair(battleKey, RevealedMovesPair({moveIndex0: uint8(NO_OP_MOVE_INDEX), extraData0: 0, moveIndex1: uint8(NO_OP_MOVE_INDEX), extraData1: 0, salt: 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.revealMovePair(battleKey, RevealedMovesPair({moveIndex0: SWITCH_MOVE_INDEX, extraData0: 2, moveIndex1: NO_OP_MOVE_INDEX, extraData1: 0, salt: 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.revealMovePair(battleKey, RevealedMovesPair({moveIndex0: SWITCH_MOVE_INDEX, extraData0: 2, moveIndex1: NO_OP_MOVE_INDEX, extraData1: 0, salt: 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.revealMovePair(battleKey, RevealedMovesPair({moveIndex0: SWITCH_MOVE_INDEX, extraData0: 2, moveIndex1: NO_OP_MOVE_INDEX, extraData1: 0, salt: 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}) + ); + DefaultCommitManager commitManager4 = new DefaultCommitManager(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, SWITCH_MOVE_INDEX, aliceSalt, uint240(0), uint240(1))); + vm.startPrank(ALICE); + commitManager4.commitMove(battleKey, aliceHash); + vm.stopPrank(); + vm.startPrank(BOB); + commitManager4.revealMovePair(battleKey, RevealedMovesPair({moveIndex0: SWITCH_MOVE_INDEX, extraData0: 0, moveIndex1: SWITCH_MOVE_INDEX, extraData1: 1, salt: bobSalt}), false); + vm.stopPrank(); + vm.startPrank(ALICE); + commitManager4.revealMovePair(battleKey, RevealedMovesPair({moveIndex0: SWITCH_MOVE_INDEX, extraData0: 0, moveIndex1: SWITCH_MOVE_INDEX, extraData1: 1, salt: 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), uint8(0), bobSalt, uint240(0), uint240(1))); + vm.startPrank(BOB); + commitManager4.commitMove(battleKey, bobHash); + vm.stopPrank(); + vm.startPrank(ALICE); + commitManager4.revealMovePair(battleKey, RevealedMovesPair({moveIndex0: uint8(NO_OP_MOVE_INDEX), extraData0: 0, moveIndex1: uint8(NO_OP_MOVE_INDEX), extraData1: 0, salt: aliceSalt}), false); + vm.stopPrank(); + vm.startPrank(BOB); + commitManager4.revealMovePair(battleKey, RevealedMovesPair({moveIndex0: uint8(0), extraData0: 0, moveIndex1: uint8(0), extraData1: 1, salt: 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.revealMovePair(battleKey, RevealedMovesPair({moveIndex0: SWITCH_MOVE_INDEX, extraData0: 2, moveIndex1: SWITCH_MOVE_INDEX, extraData1: 3, salt: 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}) + ); + DefaultCommitManager commitManager2 = new DefaultCommitManager(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, SWITCH_MOVE_INDEX, aliceSalt, uint240(0), uint240(1))); + vm.startPrank(ALICE); + commitManager2.commitMove(battleKey, aliceHash); + vm.stopPrank(); + vm.startPrank(BOB); + commitManager2.revealMovePair(battleKey, RevealedMovesPair({moveIndex0: SWITCH_MOVE_INDEX, extraData0: 0, moveIndex1: SWITCH_MOVE_INDEX, extraData1: 1, salt: bobSalt}), false); + vm.stopPrank(); + vm.startPrank(ALICE); + commitManager2.revealMovePair(battleKey, RevealedMovesPair({moveIndex0: SWITCH_MOVE_INDEX, extraData0: 0, moveIndex1: SWITCH_MOVE_INDEX, extraData1: 1, salt: 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), uint8(0), bobSalt, uint240(0), uint240(1))); + vm.startPrank(BOB); + commitManager2.commitMove(battleKey, bobHash); + vm.stopPrank(); + vm.startPrank(ALICE); + commitManager2.revealMovePair(battleKey, RevealedMovesPair({moveIndex0: uint8(NO_OP_MOVE_INDEX), extraData0: 0, moveIndex1: uint8(NO_OP_MOVE_INDEX), extraData1: 0, salt: aliceSalt}), false); + vm.stopPrank(); + vm.startPrank(BOB); + commitManager2.revealMovePair(battleKey, RevealedMovesPair({moveIndex0: uint8(0), extraData0: 0, moveIndex1: uint8(0), extraData1: 1, salt: 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}) + ); + DefaultCommitManager commitManager2 = new DefaultCommitManager(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, SWITCH_MOVE_INDEX, aliceSalt, uint240(0), uint240(1))); + vm.startPrank(ALICE); + commitManager2.commitMove(battleKey, aliceHash); + vm.stopPrank(); + vm.startPrank(BOB); + commitManager2.revealMovePair(battleKey, RevealedMovesPair({moveIndex0: SWITCH_MOVE_INDEX, extraData0: 0, moveIndex1: SWITCH_MOVE_INDEX, extraData1: 1, salt: bobSalt}), false); + vm.stopPrank(); + vm.startPrank(ALICE); + commitManager2.revealMovePair(battleKey, RevealedMovesPair({moveIndex0: SWITCH_MOVE_INDEX, extraData0: 0, moveIndex1: SWITCH_MOVE_INDEX, extraData1: 1, salt: 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), uint8(NO_OP_MOVE_INDEX), bobSalt, uint240(0), uint240(0))); + vm.startPrank(BOB); + commitManager2.commitMove(battleKey, bobHash); + vm.stopPrank(); + vm.startPrank(ALICE); + commitManager2.revealMovePair(battleKey, RevealedMovesPair({moveIndex0: uint8(NO_OP_MOVE_INDEX), extraData0: 0, moveIndex1: uint8(NO_OP_MOVE_INDEX), extraData1: 0, salt: aliceSalt}), false); + vm.stopPrank(); + vm.startPrank(BOB); + commitManager2.revealMovePair(battleKey, RevealedMovesPair({moveIndex0: uint8(0), extraData0: 0, moveIndex1: uint8(NO_OP_MOVE_INDEX), extraData1: 0, salt: 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), uint8(0), aliceSalt, uint240(0), uint240(0))); + vm.startPrank(ALICE); + commitManager2.commitMove(battleKey, aliceHash); + vm.stopPrank(); + vm.startPrank(BOB); + commitManager2.revealMovePair(battleKey, RevealedMovesPair({moveIndex0: uint8(0), extraData0: 0, moveIndex1: uint8(0), extraData1: 0, salt: bobSalt}), false); + vm.stopPrank(); + vm.startPrank(ALICE); + commitManager2.revealMovePair(battleKey, RevealedMovesPair({moveIndex0: uint8(NO_OP_MOVE_INDEX), extraData0: 0, moveIndex1: uint8(0), extraData1: 0, salt: 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}) + ); + DefaultCommitManager commitManager4 = new DefaultCommitManager(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, SWITCH_MOVE_INDEX, aliceSalt, uint240(0), uint240(1))); + vm.startPrank(ALICE); + commitManager4.commitMove(battleKey, aliceHash); + vm.stopPrank(); + vm.startPrank(BOB); + commitManager4.revealMovePair(battleKey, RevealedMovesPair({moveIndex0: SWITCH_MOVE_INDEX, extraData0: 0, moveIndex1: SWITCH_MOVE_INDEX, extraData1: 1, salt: bobSalt}), false); + vm.stopPrank(); + vm.startPrank(ALICE); + commitManager4.revealMovePair(battleKey, RevealedMovesPair({moveIndex0: SWITCH_MOVE_INDEX, extraData0: 0, moveIndex1: SWITCH_MOVE_INDEX, extraData1: 1, salt: 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), uint8(0), bobSalt, uint240(0), uint240(1))); + vm.startPrank(BOB); + commitManager4.commitMove(battleKey, bobHash); + vm.stopPrank(); + vm.startPrank(ALICE); + commitManager4.revealMovePair(battleKey, RevealedMovesPair({moveIndex0: uint8(NO_OP_MOVE_INDEX), extraData0: 0, moveIndex1: uint8(NO_OP_MOVE_INDEX), extraData1: 0, salt: aliceSalt}), false); + vm.stopPrank(); + vm.startPrank(BOB); + commitManager4.revealMovePair(battleKey, RevealedMovesPair({moveIndex0: uint8(0), extraData0: 0, moveIndex1: uint8(0), extraData1: 1, salt: 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.revealMovePair(battleKey, RevealedMovesPair({moveIndex0: SWITCH_MOVE_INDEX, extraData0: 2, moveIndex1: SWITCH_MOVE_INDEX, extraData1: 2, salt: 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.revealMovePair(doublesBattleKey, RevealedMovesPair({moveIndex0: SWITCH_MOVE_INDEX, extraData0: 2, moveIndex1: NO_OP_MOVE_INDEX, extraData1: 0, salt: 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/EngineGasTest.sol b/test/EngineGasTest.sol index f420ebb6..e227dd2e 100644 --- a/test/EngineGasTest.sol +++ b/test/EngineGasTest.sol @@ -658,7 +658,8 @@ contract EngineGasTest is Test, BattleHelper { ruleset: ruleset, engineHooks: hooks, moveManager: moveManager, - matchmaker: maker + matchmaker: maker, + gameMode: GameMode.Singles }); vm.startPrank(ALICE); diff --git a/test/EngineTest.sol b/test/EngineTest.sol index f2ebc5f0..14896c08 100644 --- a/test/EngineTest.sol +++ b/test/EngineTest.sol @@ -2776,7 +2776,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); diff --git a/test/InlineEngineGasTest.sol b/test/InlineEngineGasTest.sol index 3b7f7c23..93a1a375 100644 --- a/test/InlineEngineGasTest.sol +++ b/test/InlineEngineGasTest.sol @@ -106,7 +106,8 @@ contract InlineEngineGasTest is Test, BattleHelper { ruleset: ruleset, engineHooks: hooks, moveManager: moveManager, - matchmaker: maker + matchmaker: maker, + gameMode: GameMode.Singles }); vm.startPrank(ALICE); @@ -487,7 +488,8 @@ contract InlineEngineGasTest is Test, BattleHelper { ruleset: ruleset, engineHooks: hooks, moveManager: moveManager, - matchmaker: maker + matchmaker: maker, + gameMode: GameMode.Singles }); vm.startPrank(ALICE); diff --git a/test/InlineValidationTest.sol b/test/InlineValidationTest.sol index 2209cb55..42f47ac1 100644 --- a/test/InlineValidationTest.sol +++ b/test/InlineValidationTest.sol @@ -101,7 +101,8 @@ contract InlineValidationTest is Test, BattleHelper { ruleset: IRuleset(address(0)), engineHooks: new IEngineHook[](0), moveManager: address(commitManager), - matchmaker: matchmaker + matchmaker: matchmaker, + gameMode: GameMode.Singles }); vm.startPrank(p0); diff --git a/test/MatchmakerTest.sol b/test/MatchmakerTest.sol index a4a7575d..c7f7135d 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/SignedCommitManager.t.sol b/test/SignedCommitManager.t.sol index e353d2a2..a3309fb1 100644 --- a/test/SignedCommitManager.t.sol +++ b/test/SignedCommitManager.t.sol @@ -114,7 +114,8 @@ abstract contract SignedCommitManagerTestBase is Test, BattleHelper, EIP712 { ruleset: IRuleset(address(0)), engineHooks: new IEngineHook[](0), moveManager: commitManager, - matchmaker: matchmaker + matchmaker: matchmaker, + gameMode: GameMode.Singles }); vm.startPrank(p0); diff --git a/test/SignedMatchmakerTest.sol b/test/SignedMatchmakerTest.sol index dcd52f90..3f559251 100644 --- a/test/SignedMatchmakerTest.sol +++ b/test/SignedMatchmakerTest.sol @@ -4,6 +4,7 @@ pragma solidity ^0.8.0; import "forge-std/Test.sol"; import {BattleOffer, Battle, Mon, MonStats, Type, BattleData} from "../src/Structs.sol"; +import {GameMode} from "../src/Enums.sol"; import {IRuleset} from "../src/IRuleset.sol"; import {IEngineHook} from "../src/IEngineHook.sol"; import {IMoveSet} from "../src/moves/IMoveSet.sol"; @@ -83,7 +84,8 @@ contract SignedMatchmakerTest is Test, BattleHelper { ruleset: IRuleset(address(0)), moveManager: address(commitManager), matchmaker: matchmaker, - engineHooks: new IEngineHook[](0) + engineHooks: new IEngineHook[](0), + gameMode: GameMode.Singles }), pairHashNonce: pairHashNonce }); @@ -105,7 +107,8 @@ contract SignedMatchmakerTest is Test, BattleHelper { ruleset: offer.battle.ruleset, moveManager: offer.battle.moveManager, matchmaker: offer.battle.matchmaker, - engineHooks: offer.battle.engineHooks + engineHooks: offer.battle.engineHooks, + gameMode: offer.battle.gameMode }), pairHashNonce: offer.pairHashNonce }); @@ -272,7 +275,8 @@ contract SignedMatchmakerTest is Test, BattleHelper { ruleset: IRuleset(address(0)), moveManager: address(commitManager), matchmaker: matchmaker, - engineHooks: new IEngineHook[](0) + engineHooks: new IEngineHook[](0), + gameMode: GameMode.Singles }), pairHashNonce: 0 }); @@ -302,7 +306,8 @@ contract SignedMatchmakerTest is Test, BattleHelper { ruleset: IRuleset(address(0)), moveManager: address(commitManager), matchmaker: matchmaker, - engineHooks: new IEngineHook[](0) + engineHooks: new IEngineHook[](0), + gameMode: GameMode.Singles }), pairHashNonce: 0 }); @@ -334,7 +339,8 @@ contract SignedMatchmakerTest is Test, BattleHelper { ruleset: IRuleset(address(0)), moveManager: address(commitManager), matchmaker: matchmaker, - engineHooks: new IEngineHook[](0) + engineHooks: new IEngineHook[](0), + gameMode: GameMode.Singles }), pairHashNonce: 0 }); diff --git a/test/abstract/BattleHelper.sol b/test/abstract/BattleHelper.sol index 01f91c1d..43a1d776 100644 --- a/test/abstract/BattleHelper.sol +++ b/test/abstract/BattleHelper.sol @@ -112,7 +112,8 @@ abstract contract BattleHelper is Test { ruleset: ruleset, engineHooks: engineHooks, moveManager: moveManager, - matchmaker: matchmaker + matchmaker: matchmaker, + gameMode: GameMode.Singles }); // Propose battle diff --git a/test/mocks/DoublesEffectAttack.sol b/test/mocks/DoublesEffectAttack.sol new file mode 100644 index 00000000..8d250941 --- /dev/null +++ b/test/mocks/DoublesEffectAttack.sol @@ -0,0 +1,77 @@ +// 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, uint256, uint256, 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) { + 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..47ad2e74 --- /dev/null +++ b/test/mocks/DoublesForceSwitchMove.sol @@ -0,0 +1,65 @@ +// 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, uint256, uint256, 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 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..461c9a13 --- /dev/null +++ b/test/mocks/DoublesSlotAttack.sol @@ -0,0 +1,80 @@ +// 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 + */ +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, uint256, uint256, uint240 extraData, uint256 rng) external { + // Extract slot indices from extraData: lower 4 bits = attacker slot, next 4 bits = defender slot + uint256 attackerSlotIndex = uint256(extraData) & 0x0F; + uint256 defenderSlotIndex = (uint256(extraData) >> 4) & 0x0F; + + // Use slot-aware AttackCalculator overload for correct doubles targeting + 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..9f0e3077 --- /dev/null +++ b/test/mocks/DoublesTargetedAttack.sol @@ -0,0 +1,114 @@ +// 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, uint256 attackerMonIndex, uint256, 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 + } + + // 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) { + 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 name() external pure returns (string memory) { + return "DoublesTargetedAttack"; + } + + function extraDataType() external pure returns (ExtraDataType) { + return ExtraDataType.None; + } +} diff --git a/test/mocks/EffectApplyingAttack.sol b/test/mocks/EffectApplyingAttack.sol new file mode 100644 index 00000000..d2ceb61e --- /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 returns (string memory) { + return "EffectApplyingAttack"; + } + + function move(bytes32 battleKey, uint256 attackerPlayerIndex, uint256, uint256, uint240 extraData, uint256) external { + // 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 returns (uint32) { + return args.STAMINA_COST; + } + + function priority(bytes32, uint256) external view returns (uint32) { + return args.PRIORITY; + } + + function moveType(bytes32) external pure returns (Type) { + return Type.Fire; + } + + function moveClass(bytes32) external pure returns (MoveClass) { + return MoveClass.Other; + } + + function extraDataType() external pure returns (ExtraDataType) { + return ExtraDataType.None; + } + + function isValidTarget(bytes32, uint240) external pure returns (bool) { + return true; + } +} diff --git a/test/mocks/MonIndexTrackingEffect.sol b/test/mocks/MonIndexTrackingEffect.sol new file mode 100644 index 00000000..8c60a456 --- /dev/null +++ b/test/mocks/MonIndexTrackingEffect.sol @@ -0,0 +1,99 @@ +// 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; + + // Bitmap of which steps this effect should run at + uint16 public stepsBitmap; + + // Bitmap constants matching IEffect + uint16 constant ON_MON_SWITCH_IN = 0x10; + uint16 constant ON_MON_SWITCH_OUT = 0x20; + uint16 constant AFTER_DAMAGE = 0x40; + + constructor(IEngine _ENGINE, EffectStep _step) { + ENGINE = _ENGINE; + // Convert EffectStep to bitmap + if (_step == EffectStep.OnMonSwitchIn) { + stepsBitmap = ON_MON_SWITCH_IN; + } else if (_step == EffectStep.OnMonSwitchOut) { + stepsBitmap = ON_MON_SWITCH_OUT; + } else if (_step == EffectStep.AfterDamage) { + stepsBitmap = AFTER_DAMAGE; + } + } + + function name() external pure override returns (string memory) { + return "MonIndexTracker"; + } + + function getStepsBitmap() external pure override returns (uint16) { + // Return all steps we might want (will be filtered by the bitmap in the constructor) + // Actually we need to return the stored value, but pure won't allow storage reads. + // Use a different approach - return all the steps we implement + return ON_MON_SWITCH_IN | ON_MON_SWITCH_OUT | AFTER_DAMAGE; + } + + // OnMonSwitchIn - track which mon switched in + function onMonSwitchIn(bytes32, uint256, bytes32 extraData, uint256 targetIndex, uint256 monIndex, uint256, uint256) + 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(bytes32, uint256, bytes32 extraData, uint256 targetIndex, uint256 monIndex, uint256, uint256) + 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(bytes32, uint256, bytes32 extraData, uint256 targetIndex, uint256 monIndex, uint256, uint256, 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]; + } +}