diff --git a/snapshots/EngineGasTest.json b/snapshots/EngineGasTest.json index ccd0bb6..721efce 100644 --- a/snapshots/EngineGasTest.json +++ b/snapshots/EngineGasTest.json @@ -1,17 +1,17 @@ { - "B1_Execute": "973988", - "B1_Setup": "812723", - "B2_Execute": "754167", - "B2_Setup": "278155", - "Battle1_Execute": "494124", - "Battle1_Setup": "789140", - "Battle2_Execute": "408814", - "Battle2_Setup": "234804", - "FirstBattle": "3495916", - "Intermediary stuff": "44162", - "SecondBattle": "3589364", - "Setup 1": "1668829", - "Setup 2": "295644", - "Setup 3": "335644", - "ThirdBattle": "2906543" + "B1_Execute": "1003703", + "B1_Setup": "817752", + "B2_Execute": "778056", + "B2_Setup": "283130", + "Battle1_Execute": "505822", + "Battle1_Setup": "794141", + "Battle2_Execute": "420512", + "Battle2_Setup": "237842", + "FirstBattle": "3583299", + "Intermediary stuff": "43924", + "SecondBattle": "3692660", + "Setup 1": "1674199", + "Setup 2": "299218", + "Setup 3": "338942", + "ThirdBattle": "2994000" } \ No newline at end of file diff --git a/snapshots/MatchmakerTest.json b/snapshots/MatchmakerTest.json index f9b54b9..5ab01c1 100644 --- a/snapshots/MatchmakerTest.json +++ b/snapshots/MatchmakerTest.json @@ -1,5 +1,5 @@ { - "Accept1": "305380", - "Accept2": "33991", - "Propose1": "197148" + "Accept1": "307847", + "Accept2": "34356", + "Propose1": "199515" } \ No newline at end of file diff --git a/src/Constants.sol b/src/Constants.sol index aebe930..262e4e8 100644 --- a/src/Constants.sol +++ b/src/Constants.sol @@ -42,4 +42,27 @@ uint256 constant EFFECT_COUNT_MASK = 0x3F; // 6 bits = max count of 63 address constant TOMBSTONE_ADDRESS = address(0xdead); -uint256 constant MAX_BATTLE_DURATION = 1 hours; \ No newline at end of file +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 \ No newline at end of file diff --git a/src/DefaultValidator.sol b/src/DefaultValidator.sol index a52b398..c195969 100644 --- a/src/DefaultValidator.sol +++ b/src/DefaultValidator.sol @@ -235,6 +235,125 @@ contract DefaultValidator is IValidator { return true; } + /** + * @notice Validates a move for a specific slot in doubles mode + * @dev Enforces: + * - If slot's mon is KO'd, must switch (unless no valid targets → NO_OP allowed) + * - Switch target can't be KO'd or already active in another slot + * - Standard move validation for non-switch moves + */ + function validatePlayerMoveForSlot( + bytes32 battleKey, + uint256 moveIndex, + uint256 playerIndex, + uint256 slotIndex, + uint240 extraData + ) external view returns (bool) { + // Get the active mon index for this slot + uint256 activeMonIndex = ENGINE.getActiveMonIndexForSlot(battleKey, playerIndex, slotIndex); + uint256 otherSlotActiveMonIndex = ENGINE.getActiveMonIndexForSlot(battleKey, playerIndex, 1 - slotIndex); + + BattleContext memory ctx = ENGINE.getBattleContext(battleKey); + + // Check if this slot's mon is KO'd + bool isActiveMonKnockedOut = ENGINE.getMonStateForBattle( + battleKey, playerIndex, activeMonIndex, MonStateIndexName.IsKnockedOut + ) == 1; + + // Turn 0: must switch to set initial mon + // KO'd mon: must switch (unless no valid targets) + if (ctx.turnId == 0 || isActiveMonKnockedOut) { + if (moveIndex != SWITCH_MOVE_INDEX) { + // Check if NO_OP is allowed (no valid switch targets) + if (moveIndex == NO_OP_MOVE_INDEX && !_hasValidSwitchTargetForSlot(battleKey, playerIndex, otherSlotActiveMonIndex)) { + return true; + } + return false; + } + } + + // Validate move index range + if (moveIndex != NO_OP_MOVE_INDEX && moveIndex != SWITCH_MOVE_INDEX) { + if (moveIndex >= MOVES_PER_MON) { + return false; + } + } + // NO_OP is always valid (if we got past the KO check) + else if (moveIndex == NO_OP_MOVE_INDEX) { + return true; + } + // Switch validation + else if (moveIndex == SWITCH_MOVE_INDEX) { + uint256 monToSwitchIndex = uint256(extraData); + return _validateSwitchForSlot(battleKey, playerIndex, monToSwitchIndex, activeMonIndex, otherSlotActiveMonIndex, ctx); + } + + // Validate specific move selection + return _validateSpecificMoveSelectionInternal(battleKey, moveIndex, playerIndex, extraData, activeMonIndex); + } + + /** + * @dev Checks if there's any valid switch target for a slot (excluding other slot's active mon) + */ + function _hasValidSwitchTargetForSlot( + bytes32 battleKey, + uint256 playerIndex, + uint256 otherSlotActiveMonIndex + ) internal view returns (bool) { + for (uint256 i = 0; i < MONS_PER_TEAM; i++) { + // Skip if it's the other slot's active mon + if (i == otherSlotActiveMonIndex) { + continue; + } + // Check if mon is not KO'd + bool isKnockedOut = ENGINE.getMonStateForBattle( + battleKey, playerIndex, i, MonStateIndexName.IsKnockedOut + ) == 1; + if (!isKnockedOut) { + return true; + } + } + return false; + } + + /** + * @dev Validates switch for a specific slot in doubles (can't switch to other slot's active mon) + */ + function _validateSwitchForSlot( + bytes32 battleKey, + uint256 playerIndex, + uint256 monToSwitchIndex, + uint256 currentSlotActiveMonIndex, + uint256 otherSlotActiveMonIndex, + BattleContext memory ctx + ) internal view returns (bool) { + if (monToSwitchIndex >= MONS_PER_TEAM) { + return false; + } + + // Can't switch to a KO'd mon + bool isNewMonKnockedOut = ENGINE.getMonStateForBattle( + battleKey, playerIndex, monToSwitchIndex, MonStateIndexName.IsKnockedOut + ) == 1; + if (isNewMonKnockedOut) { + return false; + } + + // Can't switch to mon already active in the other slot + if (monToSwitchIndex == otherSlotActiveMonIndex) { + return false; + } + + // Can't switch to same mon (except turn 0) + if (ctx.turnId != 0) { + if (monToSwitchIndex == currentSlotActiveMonIndex) { + return false; + } + } + + return true; + } + /* Check switch for turn flag: diff --git a/src/DoublesCommitManager.sol b/src/DoublesCommitManager.sol new file mode 100644 index 0000000..7705d33 --- /dev/null +++ b/src/DoublesCommitManager.sol @@ -0,0 +1,269 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity ^0.8.0; + +import "./Constants.sol"; +import "./Enums.sol"; +import "./Structs.sol"; + +import {IEngine} from "./IEngine.sol"; +import {IValidator} from "./IValidator.sol"; + +/** + * @title DoublesCommitManager + * @notice Commit/reveal manager for double battles where each player commits 2 moves per turn + * @dev Follows same alternating commit scheme as DefaultCommitManager: + * - p0 commits on even turns, p1 commits on odd turns + * - Non-committing player reveals first, then committing player reveals + * - Each commit/reveal handles both slot 0 and slot 1 moves together + */ +contract DoublesCommitManager { + IEngine private immutable ENGINE; + + // Player decision data - same structure as singles, but hash covers 2 moves + mapping(bytes32 battleKey => mapping(uint256 playerIndex => PlayerDecisionData)) private playerData; + + error NotP0OrP1(); + error AlreadyCommited(); + error AlreadyRevealed(); + error NotYetRevealed(); + error RevealBeforeOtherCommit(); + error RevealBeforeSelfCommit(); + error WrongPreimage(); + error PlayerNotAllowed(); + error InvalidMove(address player, uint256 slotIndex); + error BattleNotYetStarted(); + error BattleAlreadyComplete(); + error NotDoublesMode(); + + event MoveCommit(bytes32 indexed battleKey, address player); + event MoveReveal(bytes32 indexed battleKey, address player, uint256 moveIndex0, uint256 moveIndex1); + + constructor(IEngine engine) { + ENGINE = engine; + } + + /** + * @notice Commit a hash of both moves for a doubles battle + * @param battleKey The battle identifier + * @param moveHash Hash of (moveIndex0, extraData0, moveIndex1, extraData1, salt) + */ + function commitMoves(bytes32 battleKey, bytes32 moveHash) external { + CommitContext memory ctx = ENGINE.getCommitContext(battleKey); + + // Validate battle state + if (ctx.startTimestamp == 0) { + revert BattleNotYetStarted(); + } + if (ctx.gameMode != GameMode.Doubles) { + revert NotDoublesMode(); + } + + address caller = msg.sender; + uint256 playerIndex = (caller == ctx.p0) ? 0 : 1; + + if (caller != ctx.p0 && caller != ctx.p1) { + revert NotP0OrP1(); + } + + if (ctx.winnerIndex != 2) { + revert BattleAlreadyComplete(); + } + + PlayerDecisionData storage pd = playerData[battleKey][playerIndex]; + uint64 turnId = ctx.turnId; + + // Check no commitment exists for this turn + if (turnId == 0) { + if (pd.moveHash != bytes32(0)) { + revert AlreadyCommited(); + } + } else if (pd.lastCommitmentTurnId == turnId) { + revert AlreadyCommited(); + } + + // Cannot commit if it's a single-player switch turn + if (ctx.playerSwitchForTurnFlag != 2) { + revert PlayerNotAllowed(); + } + + // Alternating commit: p0 on even turns, p1 on odd turns + if (caller == ctx.p0 && turnId % 2 == 1) { + revert PlayerNotAllowed(); + } else if (caller == ctx.p1 && turnId % 2 == 0) { + revert PlayerNotAllowed(); + } + + // Store commitment + pd.lastCommitmentTurnId = uint16(turnId); + pd.moveHash = moveHash; + pd.lastMoveTimestamp = uint96(block.timestamp); + + emit MoveCommit(battleKey, caller); + } + + /** + * @notice Reveal both moves for a doubles battle + * @param battleKey The battle identifier + * @param moveIndex0 Move index for slot 0 mon + * @param extraData0 Extra data for slot 0 move (includes target) + * @param moveIndex1 Move index for slot 1 mon + * @param extraData1 Extra data for slot 1 move (includes target) + * @param salt Salt used in the commitment hash + * @param autoExecute Whether to auto-execute after both players reveal + */ + function revealMoves( + bytes32 battleKey, + uint8 moveIndex0, + uint240 extraData0, + uint8 moveIndex1, + uint240 extraData1, + bytes32 salt, + bool autoExecute + ) external { + CommitContext memory ctx = ENGINE.getCommitContext(battleKey); + + // Validate battle state + if (ctx.startTimestamp == 0) { + revert BattleNotYetStarted(); + } + if (ctx.gameMode != GameMode.Doubles) { + revert NotDoublesMode(); + } + 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; + + // Determine if player skips preimage check (same logic as singles) + 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) { + // Must wait for other player's commitment + if (playerSwitchForTurnFlag == 2) { + if (turnId != 0) { + if (otherPd.lastCommitmentTurnId != turnId) { + revert RevealBeforeOtherCommit(); + } + } else { + if (otherPd.moveHash == bytes32(0)) { + revert RevealBeforeOtherCommit(); + } + } + } + } else { + // Validate preimage for BOTH moves + bytes32 expectedHash = keccak256(abi.encodePacked(moveIndex0, extraData0, moveIndex1, extraData1, salt)); + if (expectedHash != currentPd.moveHash) { + revert WrongPreimage(); + } + + // Ensure reveal happens after caller commits + if (currentPd.lastCommitmentTurnId != turnId) { + revert RevealBeforeSelfCommit(); + } + + // Check that other player has already revealed + if (otherPd.numMovesRevealed < turnId || otherPd.lastMoveTimestamp == 0) { + revert NotYetRevealed(); + } + } + + // Prevent double revealing + if (currentPd.numMovesRevealed > turnId) { + revert AlreadyRevealed(); + } + + // Validate both moves are legal for their respective slots + IValidator validator = IValidator(ctx.validator); + if (!validator.validatePlayerMoveForSlot(battleKey, moveIndex0, currentPlayerIndex, 0, extraData0)) { + revert InvalidMove(msg.sender, 0); + } + if (!validator.validatePlayerMoveForSlot(battleKey, moveIndex1, currentPlayerIndex, 1, extraData1)) { + revert InvalidMove(msg.sender, 1); + } + + // Store both revealed moves + // Slot 0 move uses standard setMove + ENGINE.setMove(battleKey, currentPlayerIndex, moveIndex0, salt, extraData0); + // Slot 1 move uses setMove with slot indicator (we'll add this to Engine) + // For now, we encode slot 1 by using a different approach - store in p0Move2/p1Move2 + _setSlot1Move(battleKey, currentPlayerIndex, moveIndex1, salt, extraData1); + + currentPd.lastMoveTimestamp = uint96(block.timestamp); + currentPd.numMovesRevealed += 1; + + // Handle single-player turns + if (playerSwitchForTurnFlag == 0 || playerSwitchForTurnFlag == 1) { + otherPd.lastMoveTimestamp = uint96(block.timestamp); + otherPd.numMovesRevealed += 1; + } + + emit MoveReveal(battleKey, msg.sender, moveIndex0, moveIndex1); + + // Auto execute if desired + if (autoExecute) { + if ((playerSwitchForTurnFlag == currentPlayerIndex) || (!playerSkipsPreimageCheck)) { + ENGINE.execute(battleKey); + } + } + } + + /** + * @dev Internal function to set the slot 1 move + * This calls ENGINE.setMove with a special encoding or we need to add a new Engine method + * For now, we'll use a workaround - set slot 1 move through the engine + */ + function _setSlot1Move( + bytes32 battleKey, + uint256 playerIndex, + uint8 moveIndex, + bytes32 salt, + uint240 extraData + ) internal { + // We need Engine to have a setMoveForSlot function + // For now, we'll call setMove with playerIndex + 2 to indicate slot 1 + // Engine will need to interpret this (playerIndex 2 = p0 slot 1, playerIndex 3 = p1 slot 1) + ENGINE.setMove(battleKey, playerIndex + 2, moveIndex, salt, extraData); + } + + // View functions (compatible with ICommitManager pattern) + + function getCommitment(bytes32 battleKey, address player) external view returns (bytes32 moveHash, uint256 turnId) { + address[] memory players = ENGINE.getPlayersForBattle(battleKey); + uint256 playerIndex = (player == players[0]) ? 0 : 1; + PlayerDecisionData storage pd = playerData[battleKey][playerIndex]; + return (pd.moveHash, pd.lastCommitmentTurnId); + } + + function getMoveCountForBattleState(bytes32 battleKey, address player) external view returns (uint256) { + address[] memory players = ENGINE.getPlayersForBattle(battleKey); + uint256 playerIndex = (player == players[0]) ? 0 : 1; + return playerData[battleKey][playerIndex].numMovesRevealed; + } + + function getLastMoveTimestampForPlayer(bytes32 battleKey, address player) external view returns (uint256) { + address[] memory players = ENGINE.getPlayersForBattle(battleKey); + uint256 playerIndex = (player == players[0]) ? 0 : 1; + return playerData[battleKey][playerIndex].lastMoveTimestamp; + } +} diff --git a/src/Engine.sol b/src/Engine.sol index 0f340ce..9116483 100644 --- a/src/Engine.sol +++ b/src/Engine.sol @@ -177,14 +177,24 @@ contract Engine is IEngine, MappingAllocator { config.koBitmaps = 0; // Store the battle data with initial state + // For doubles: activeMonIndex packs 4 slots (p0s0=0, p0s1=1, p1s0=0, p1s1=1) + // For singles: only lower 8 bits used (p0=0, p1=0) + uint16 initialActiveMonIndex = battle.gameMode == GameMode.Doubles + ? uint16(0) | (uint16(1) << 4) | (uint16(0) << 8) | (uint16(1) << 12) // p0s0=0, p0s1=1, p1s0=0, p1s1=1 + : uint16(0); // Singles: both players start with mon 0 + + // Pack game mode into slotSwitchFlagsAndGameMode (bit 4 = game mode) + uint8 slotSwitchFlagsAndGameMode = battle.gameMode == GameMode.Doubles ? GAME_MODE_BIT : 0; + battleData[battleKey] = BattleData({ p0: battle.p0, p1: battle.p1, winnerIndex: 2, // Initialize to 2 (uninitialized/no winner) prevPlayerSwitchForTurnFlag: 0, playerSwitchForTurnFlag: 2, // Set flag to be 2 which means both players act - activeMonIndex: 0, // Defaults to 0 (both players start with mon index 0) - turnId: 0 + activeMonIndex: initialActiveMonIndex, + turnId: 0, + slotSwitchFlagsAndGameMode: slotSwitchFlagsAndGameMode }); // Set the team for p0 and p1 in the reusable config storage @@ -292,6 +302,12 @@ contract Engine is IEngine, MappingAllocator { config.engineHooks[i].onRoundStart(battleKey); } + // Branch for doubles mode + if (_isDoublesMode(battle)) { + _executeDoubles(battleKey, config, battle, turnId, numHooks); + return; + } + // If only a single player has a move to submit, then we don't trigger any effects // (Basically this only handles switching mons for now) if (battle.playerSwitchForTurnFlag == 0 || battle.playerSwitchForTurnFlag == 1) { @@ -845,12 +861,19 @@ contract Engine is IEngine, MappingAllocator { MoveDecision memory newMove = MoveDecision({packedMoveIndex: packedMoveIndex, extraData: extraData}); + // playerIndex 0-1: slot 0 moves, playerIndex 2-3: slot 1 moves (for doubles) if (playerIndex == 0) { config.p0Move = newMove; config.p0Salt = salt; - } else { + } else if (playerIndex == 1) { config.p1Move = newMove; config.p1Salt = salt; + } else if (playerIndex == 2) { + // p0 slot 1 move (doubles) + config.p0Move2 = newMove; + } else if (playerIndex == 3) { + // p1 slot 1 move (doubles) + config.p1Move2 = newMove; } } @@ -872,91 +895,118 @@ contract Engine is IEngine, MappingAllocator { battleKey = keccak256(abi.encode(pairHash, pairHashNonce)); } - function _checkForGameOverOrKO( - BattleConfig storage config, - BattleData storage battle, - uint256 priorityPlayerIndex - ) internal returns (uint256 playerSwitchForTurnFlag, bool isGameOver) { - uint256 otherPlayerIndex = (priorityPlayerIndex + 1) % 2; - uint8 existingWinnerIndex = battle.winnerIndex; - + // Shared game over check - returns winner index (0, 1, or 2 if no winner) + function _checkForGameOver(BattleConfig storage config, BattleData storage battle) + internal + view + returns (uint256 winnerIndex, uint256 p0KOBitmap, uint256 p1KOBitmap) + { // First check if we already calculated a winner - if (existingWinnerIndex != 2) { - return (playerSwitchForTurnFlag, true); + if (battle.winnerIndex != 2) { + return (battle.winnerIndex, 0, 0); } - // Check for game over using KO bitmaps (O(1) instead of O(n) loop) - // A game is over if all of a player's mons are KOed (all bits set up to teamSize) - uint256 newWinnerIndex = 2; + // Load KO bitmaps and team sizes uint256 p0TeamSize = config.teamSizes & 0x0F; uint256 p1TeamSize = config.teamSizes >> 4; - uint256 p0KOBitmap = _getKOBitmap(config, 0); - uint256 p1KOBitmap = _getKOBitmap(config, 1); + p0KOBitmap = _getKOBitmap(config, 0); + p1KOBitmap = _getKOBitmap(config, 1); + // Full team mask: (1 << teamSize) - 1, e.g. teamSize=3 -> 0b111 uint256 p0FullMask = (1 << p0TeamSize) - 1; uint256 p1FullMask = (1 << p1TeamSize) - 1; + // Check if all mons are KO'd for either player if (p0KOBitmap == p0FullMask) { - newWinnerIndex = 1; // p1 wins + winnerIndex = 1; // p1 wins } else if (p1KOBitmap == p1FullMask) { - newWinnerIndex = 0; // p0 wins + winnerIndex = 0; // p0 wins + } else { + winnerIndex = 2; // No winner yet } - // If we found a winner, set it on the battle data and return - if (newWinnerIndex != 2) { - battle.winnerIndex = uint8(newWinnerIndex); + } + + function _checkForGameOverOrKO( + BattleConfig storage config, + BattleData storage battle, + uint256 priorityPlayerIndex + ) internal returns (uint256 playerSwitchForTurnFlag, bool isGameOver) { + uint256 otherPlayerIndex = (priorityPlayerIndex + 1) % 2; + + // Use shared game over check + (uint256 winnerIndex, uint256 p0KOBitmap, uint256 p1KOBitmap) = _checkForGameOver(config, battle); + + if (winnerIndex != 2) { + battle.winnerIndex = uint8(winnerIndex); return (playerSwitchForTurnFlag, true); } - // Otherwise if it isn't a game over, we check for KOs and set the player switch for turn flag - else { - // Always set default switch to be 2 (allow both players to make a move) - playerSwitchForTurnFlag = 2; - - // Use already-loaded KO bitmaps to check active mon KO status (avoids SLOAD) - uint256 priorityActiveMonIndex = _unpackActiveMonIndex(battle.activeMonIndex, priorityPlayerIndex); - uint256 otherActiveMonIndex = _unpackActiveMonIndex(battle.activeMonIndex, otherPlayerIndex); - uint256 priorityKOBitmap = priorityPlayerIndex == 0 ? p0KOBitmap : p1KOBitmap; - uint256 otherKOBitmap = priorityPlayerIndex == 0 ? p1KOBitmap : p0KOBitmap; - bool isPriorityPlayerActiveMonKnockedOut = (priorityKOBitmap & (1 << priorityActiveMonIndex)) != 0; - bool isNonPriorityPlayerActiveMonKnockedOut = (otherKOBitmap & (1 << otherActiveMonIndex)) != 0; - - // If the priority player mon is KO'ed (and the other player isn't), then next turn we tenatively set it to be just the other player - if (isPriorityPlayerActiveMonKnockedOut && !isNonPriorityPlayerActiveMonKnockedOut) { - playerSwitchForTurnFlag = priorityPlayerIndex; - } - // If the non priority player mon is KO'ed (and the other player isn't), then next turn we tenatively set it to be just the priority player - if (!isPriorityPlayerActiveMonKnockedOut && isNonPriorityPlayerActiveMonKnockedOut) { - playerSwitchForTurnFlag = otherPlayerIndex; - } + // No game over - check for KOs and set player switch for turn flag + playerSwitchForTurnFlag = 2; + + // Use already-loaded KO bitmaps to check active mon KO status + 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), next turn only other player acts + if (isPriorityPlayerActiveMonKnockedOut && !isNonPriorityPlayerActiveMonKnockedOut) { + playerSwitchForTurnFlag = priorityPlayerIndex; + } + + // If the non priority player mon is KO'ed (and the other player isn't), next turn only priority player acts + if (!isPriorityPlayerActiveMonKnockedOut && isNonPriorityPlayerActiveMonKnockedOut) { + playerSwitchForTurnFlag = otherPlayerIndex; } } 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]; + uint256 currentActiveMonIndex = _unpackActiveMonIndex(battle.activeMonIndex, playerIndex); + // Run switch-out effects + _handleSwitchCore(battleKey, playerIndex, currentActiveMonIndex, monToSwitchIndex, source); + + // Update active mon index (singles packing) + battle.activeMonIndex = _setActiveMonIndex(battle.activeMonIndex, playerIndex, monToSwitchIndex); + + // Run switch-in effects + _completeSwitchIn(battleKey, playerIndex, monToSwitchIndex); + } + + // Core switch logic shared between singles and doubles + function _handleSwitchCore( + bytes32 battleKey, + uint256 playerIndex, + uint256 currentActiveMonIndex, + uint256 monToSwitchIndex, + address source + ) internal { BattleData storage battle = battleData[battleKey]; BattleConfig storage config = battleConfig[storageKeyForWrite]; - uint256 currentActiveMonIndex = _unpackActiveMonIndex(battle.activeMonIndex, playerIndex); MonState storage currentMonState = _getMonState(config, playerIndex, currentActiveMonIndex); // Emit event first, then run effects emit MonSwitch(battleKey, playerIndex, monToSwitchIndex, source); - // If the current mon is not KO'ed - // Go through each effect to see if it should be cleared after a switch, - // If so, remove the effect and the extra data + // If the current mon is not KO'ed, run switch-out effects 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, ""); } - // Update to new active mon (we assume validateSwitch already resolved and gives us a valid target) - battle.activeMonIndex = _setActiveMonIndex(battle.activeMonIndex, playerIndex, monToSwitchIndex); + // Note: Caller is responsible for updating activeMonIndex with appropriate packing + + // Run onMonSwitchIn hooks (these run after the index is updated by the caller) + } + + // Complete switch-in effects (called after activeMonIndex is updated) + function _completeSwitchIn(bytes32 battleKey, uint256 playerIndex, uint256 monToSwitchIndex) internal { + BattleData storage battle = battleData[battleKey]; + BattleConfig storage config = battleConfig[storageKeyForWrite]; // Run onMonSwitchIn hook for local effects _runEffects(battleKey, tempRNG, playerIndex, playerIndex, EffectStep.OnMonSwitchIn, ""); @@ -964,7 +1014,7 @@ contract Engine is IEngine, MappingAllocator { // Run onMonSwitchIn hook for global effects _runEffects(battleKey, tempRNG, 2, playerIndex, EffectStep.OnMonSwitchIn, ""); - // Run ability for the newly switched in mon as long as it's not KO'ed and as long as it's not turn 0, (execute() has a special case to run activateOnSwitch after both moves are handled) + // Run ability for the newly switched in mon Mon memory mon = _getTeamMon(config, playerIndex, monToSwitchIndex); if ( address(mon.ability) != address(0) && battle.turnId != 0 @@ -1502,6 +1552,8 @@ contract Engine is IEngine, MappingAllocator { p1Salt: config.p1Salt, p0Move: config.p0Move, p1Move: config.p1Move, + p0Move2: config.p0Move2, + p1Move2: config.p1Move2, globalEffects: globalEffects, p0Effects: p0Effects, p1Effects: p1Effects, @@ -1696,13 +1748,49 @@ contract Engine is IEngine, MappingAllocator { } function getActiveMonIndexForBattleState(bytes32 battleKey) external view returns (uint256[] memory) { - uint16 packed = battleData[battleKey].activeMonIndex; + BattleData storage data = battleData[battleKey]; + uint16 packed = data.activeMonIndex; + bool isDoubles = (data.slotSwitchFlagsAndGameMode & GAME_MODE_BIT) != 0; + uint256[] memory result = new uint256[](2); - result[0] = _unpackActiveMonIndex(packed, 0); - result[1] = _unpackActiveMonIndex(packed, 1); + if (isDoubles) { + // For doubles, return slot 0 active mon for each player + result[0] = _unpackActiveMonIndexForSlot(packed, 0, 0); + result[1] = _unpackActiveMonIndexForSlot(packed, 1, 0); + } else { + // For singles, use original unpacking + result[0] = _unpackActiveMonIndex(packed, 0); + result[1] = _unpackActiveMonIndex(packed, 1); + } return result; } + function getGameMode(bytes32 battleKey) external view returns (GameMode) { + uint8 slotSwitchFlagsAndGameMode = battleData[battleKey].slotSwitchFlagsAndGameMode; + return (slotSwitchFlagsAndGameMode & GAME_MODE_BIT) != 0 ? GameMode.Doubles : GameMode.Singles; + } + + function getActiveMonIndexForSlot(bytes32 battleKey, uint256 playerIndex, uint256 slotIndex) + external + view + returns (uint256) + { + BattleData storage data = battleData[battleKey]; + uint8 slotSwitchFlagsAndGameMode = data.slotSwitchFlagsAndGameMode; + bool isDoubles = (slotSwitchFlagsAndGameMode & GAME_MODE_BIT) != 0; + + if (isDoubles) { + // Doubles: 4 bits per slot + // Bits 0-3: p0 slot 0, Bits 4-7: p0 slot 1, Bits 8-11: p1 slot 0, Bits 12-15: p1 slot 1 + uint256 shift = (playerIndex * 2 + slotIndex) * ACTIVE_MON_INDEX_BITS; + return (data.activeMonIndex >> shift) & ACTIVE_MON_INDEX_MASK; + } else { + // Singles: only slot 0 is valid, 8 bits per player + if (slotIndex != 0) return 0; + return _unpackActiveMonIndex(data.activeMonIndex, playerIndex); + } + } + function getPlayerSwitchForTurnFlagForBattleState(bytes32 battleKey) external view returns (uint256) { return battleData[battleKey].playerSwitchForTurnFlag; } @@ -1761,8 +1849,26 @@ contract Engine is IEngine, MappingAllocator { ctx.turnId = data.turnId; ctx.playerSwitchForTurnFlag = data.playerSwitchForTurnFlag; ctx.prevPlayerSwitchForTurnFlag = data.prevPlayerSwitchForTurnFlag; - ctx.p0ActiveMonIndex = uint8(data.activeMonIndex & 0xFF); - ctx.p1ActiveMonIndex = uint8(data.activeMonIndex >> 8); + + // Extract game mode and active mon indices based on mode + uint8 slotSwitchFlagsAndGameMode = data.slotSwitchFlagsAndGameMode; + ctx.gameMode = (slotSwitchFlagsAndGameMode & GAME_MODE_BIT) != 0 ? GameMode.Doubles : GameMode.Singles; + ctx.slotSwitchFlags = slotSwitchFlagsAndGameMode & SWITCH_FLAGS_MASK; + + if (ctx.gameMode == GameMode.Doubles) { + // Doubles: 4 bits per slot + ctx.p0ActiveMonIndex = uint8(data.activeMonIndex & ACTIVE_MON_INDEX_MASK); + ctx.p0ActiveMonIndex2 = uint8((data.activeMonIndex >> 4) & ACTIVE_MON_INDEX_MASK); + ctx.p1ActiveMonIndex = uint8((data.activeMonIndex >> 8) & ACTIVE_MON_INDEX_MASK); + ctx.p1ActiveMonIndex2 = uint8((data.activeMonIndex >> 12) & ACTIVE_MON_INDEX_MASK); + } else { + // Singles: 8 bits per player (backwards compatible) + ctx.p0ActiveMonIndex = uint8(data.activeMonIndex & 0xFF); + ctx.p1ActiveMonIndex = uint8(data.activeMonIndex >> 8); + ctx.p0ActiveMonIndex2 = 0; + ctx.p1ActiveMonIndex2 = 0; + } + ctx.validator = address(config.validator); ctx.moveManager = config.moveManager; } @@ -1778,6 +1884,12 @@ contract Engine is IEngine, MappingAllocator { ctx.winnerIndex = data.winnerIndex; ctx.turnId = data.turnId; ctx.playerSwitchForTurnFlag = data.playerSwitchForTurnFlag; + + // Extract game mode and slot switch flags + uint8 slotSwitchFlagsAndGameMode = data.slotSwitchFlagsAndGameMode; + ctx.gameMode = (slotSwitchFlagsAndGameMode & GAME_MODE_BIT) != 0 ? GameMode.Doubles : GameMode.Singles; + ctx.slotSwitchFlags = slotSwitchFlagsAndGameMode & SWITCH_FLAGS_MASK; + ctx.validator = address(config.validator); } @@ -1790,9 +1902,18 @@ contract Engine is IEngine, MappingAllocator { BattleData storage data = battleData[battleKey]; BattleConfig storage config = battleConfig[storageKey]; - // Get active mon indices - uint256 attackerMonIndex = _unpackActiveMonIndex(data.activeMonIndex, attackerPlayerIndex); - uint256 defenderMonIndex = _unpackActiveMonIndex(data.activeMonIndex, defenderPlayerIndex); + // Get active mon indices (doubles-aware) + bool isDoubles = (data.slotSwitchFlagsAndGameMode & GAME_MODE_BIT) != 0; + uint256 attackerMonIndex; + uint256 defenderMonIndex; + if (isDoubles) { + // For doubles, use slot 0 as default (targeting via extraData handled elsewhere) + attackerMonIndex = _unpackActiveMonIndexForSlot(data.activeMonIndex, attackerPlayerIndex, 0); + defenderMonIndex = _unpackActiveMonIndexForSlot(data.activeMonIndex, defenderPlayerIndex, 0); + } else { + attackerMonIndex = _unpackActiveMonIndex(data.activeMonIndex, attackerPlayerIndex); + defenderMonIndex = _unpackActiveMonIndex(data.activeMonIndex, defenderPlayerIndex); + } ctx.attackerMonIndex = uint8(attackerMonIndex); ctx.defenderMonIndex = uint8(defenderMonIndex); @@ -1815,4 +1936,435 @@ contract Engine is IEngine, MappingAllocator { ctx.defenderType1 = defenderMon.stats.type1; ctx.defenderType2 = defenderMon.stats.type2; } + + /** + * - Doubles helper functions + */ + + // Unpack active mon index for a specific slot in doubles mode + // Doubles packing: bits 0-3 = p0s0, 4-7 = p0s1, 8-11 = p1s0, 12-15 = p1s1 + function _unpackActiveMonIndexForSlot(uint16 packed, uint256 playerIndex, uint256 slotIndex) internal pure returns (uint256) { + uint256 shift = (playerIndex * 2 + slotIndex) * ACTIVE_MON_INDEX_BITS; + return (packed >> shift) & ACTIVE_MON_INDEX_MASK; + } + + // Set active mon index for a specific slot in doubles mode + function _setActiveMonIndexForSlot(uint16 packed, uint256 playerIndex, uint256 slotIndex, uint256 monIndex) internal pure returns (uint16) { + uint256 shift = (playerIndex * 2 + slotIndex) * ACTIVE_MON_INDEX_BITS; + uint16 mask = uint16(uint256(ACTIVE_MON_INDEX_MASK) << shift); + return (packed & ~mask) | uint16((monIndex & ACTIVE_MON_INDEX_MASK) << shift); + } + + // Get the move decision for a specific player and slot + function _getMoveDecisionForSlot(BattleConfig storage config, uint256 playerIndex, uint256 slotIndex) internal view returns (MoveDecision memory) { + if (playerIndex == 0) { + return slotIndex == 0 ? config.p0Move : config.p0Move2; + } else { + return slotIndex == 0 ? config.p1Move : config.p1Move2; + } + } + + // Check if game mode is doubles + function _isDoublesMode(BattleData storage battle) internal view returns (bool) { + return (battle.slotSwitchFlagsAndGameMode & GAME_MODE_BIT) != 0; + } + + // Get slot switch flags (lower 4 bits of slotSwitchFlagsAndGameMode) + function _getSlotSwitchFlags(BattleData storage battle) internal view returns (uint8) { + return battle.slotSwitchFlagsAndGameMode & SWITCH_FLAGS_MASK; + } + + // Set slot switch flag for a specific slot + function _setSlotSwitchFlag(BattleData storage battle, uint256 playerIndex, uint256 slotIndex) internal { + uint8 flagBit; + if (playerIndex == 0) { + flagBit = slotIndex == 0 ? SWITCH_FLAG_P0_SLOT0 : SWITCH_FLAG_P0_SLOT1; + } else { + flagBit = slotIndex == 0 ? SWITCH_FLAG_P1_SLOT0 : SWITCH_FLAG_P1_SLOT1; + } + battle.slotSwitchFlagsAndGameMode |= flagBit; + } + + // Clear all slot switch flags (keep game mode bit) + function _clearSlotSwitchFlags(BattleData storage battle) internal { + battle.slotSwitchFlagsAndGameMode &= ~SWITCH_FLAGS_MASK; + } + + /** + * @dev Check if a player has any KO'd slot that has a valid switch target + * @param config Battle config + * @param battle Battle data + * @param playerIndex Which player to check (0 or 1) + * @param koBitmap Bitmap of KO'd mons for this player + * @return needsSwitch True if player has a KO'd slot with valid switch target + */ + function _playerNeedsSwitchTurn( + BattleConfig storage config, + BattleData storage battle, + uint256 playerIndex, + uint256 koBitmap + ) internal view returns (bool needsSwitch) { + uint256 teamSize = playerIndex == 0 ? (config.teamSizes & 0x0F) : (config.teamSizes >> 4); + + // Check each slot + for (uint256 s = 0; s < 2; s++) { + uint256 activeMonIndex = _unpackActiveMonIndexForSlot(battle.activeMonIndex, playerIndex, s); + bool isSlotKOed = (koBitmap & (1 << activeMonIndex)) != 0; + + if (isSlotKOed) { + // This slot is KO'd - check if there's a valid switch target + uint256 otherSlotMonIndex = _unpackActiveMonIndexForSlot(battle.activeMonIndex, playerIndex, 1 - s); + + for (uint256 m = 0; m < teamSize; m++) { + // Skip if mon is KO'd + if ((koBitmap & (1 << m)) != 0) continue; + // Skip if mon is active in other slot + if (m == otherSlotMonIndex) continue; + // Found a valid switch target + return true; + } + } + } + return false; + } + + // Struct for tracking move order in doubles + struct MoveOrder { + uint256 playerIndex; + uint256 slotIndex; + uint256 priority; + uint256 speed; + } + + // Compute move order for all 4 slots in doubles (sorted by priority desc, then speed desc, then position) + // Position tiebreaker: p0s0 > p0s1 > p1s0 > p1s1 (lower position index = higher priority) + function _computeMoveOrderForDoubles( + bytes32 battleKey, + BattleConfig storage config, + BattleData storage battle + ) internal view returns (MoveOrder[4] memory moveOrder) { + // Collect move info for all 4 slots + for (uint256 p = 0; p < 2; p++) { + for (uint256 s = 0; s < 2; s++) { + uint256 idx = p * 2 + s; + moveOrder[idx].playerIndex = p; + moveOrder[idx].slotIndex = s; + + MoveDecision memory move = _getMoveDecisionForSlot(config, p, s); + + // If move wasn't set (single-player turn), treat as NO_OP for ordering + if ((move.packedMoveIndex & IS_REAL_TURN_BIT) == 0) { + moveOrder[idx].priority = 0; // Lowest priority - will be skipped anyway + moveOrder[idx].speed = 0; + continue; + } + + uint8 storedMoveIndex = move.packedMoveIndex & MOVE_INDEX_MASK; + uint8 moveIndex = storedMoveIndex >= SWITCH_MOVE_INDEX ? storedMoveIndex : storedMoveIndex - MOVE_INDEX_OFFSET; + + uint256 monIndex = _unpackActiveMonIndexForSlot(battle.activeMonIndex, p, s); + + // Get priority + if (moveIndex == SWITCH_MOVE_INDEX || moveIndex == NO_OP_MOVE_INDEX) { + moveOrder[idx].priority = SWITCH_PRIORITY; + } else { + IMoveSet moveSet = _getTeamMon(config, p, monIndex).moves[moveIndex]; + moveOrder[idx].priority = moveSet.priority(battleKey, p); + } + + // Get speed + int32 speedDelta = _getMonState(config, p, monIndex).speedDelta; + uint32 monSpeed = uint32( + int32(_getTeamMon(config, p, monIndex).stats.speed) + + (speedDelta == CLEARED_MON_STATE_SENTINEL ? int32(0) : speedDelta) + ); + moveOrder[idx].speed = monSpeed; + } + } + + // Sort by priority (desc), then speed (desc), then position (asc, implicit from initial order) + // Simple bubble sort (only 4 elements) + for (uint256 i = 0; i < 3; i++) { + for (uint256 j = 0; j < 3 - i; j++) { + bool shouldSwap = false; + if (moveOrder[j].priority < moveOrder[j + 1].priority) { + shouldSwap = true; + } else if (moveOrder[j].priority == moveOrder[j + 1].priority) { + if (moveOrder[j].speed < moveOrder[j + 1].speed) { + shouldSwap = true; + } + // If both priority and speed are equal, keep original order (position tiebreaker) + } + + if (shouldSwap) { + MoveOrder memory temp = moveOrder[j]; + moveOrder[j] = moveOrder[j + 1]; + moveOrder[j + 1] = temp; + } + } + } + } + + // Handle a move for a specific slot in doubles + function _handleMoveForSlot( + bytes32 battleKey, + BattleConfig storage config, + BattleData storage battle, + uint256 playerIndex, + uint256 slotIndex + ) internal returns (bool monKOed) { + MoveDecision memory move = _getMoveDecisionForSlot(config, playerIndex, slotIndex); + int32 staminaCost; + + // Check if move was set (isRealTurn bit) + if ((move.packedMoveIndex & IS_REAL_TURN_BIT) == 0) { + return false; + } + + // Unpack moveIndex from packedMoveIndex + uint8 storedMoveIndex = move.packedMoveIndex & MOVE_INDEX_MASK; + uint8 moveIndex = storedMoveIndex >= SWITCH_MOVE_INDEX ? storedMoveIndex : storedMoveIndex - MOVE_INDEX_OFFSET; + + // Get active mon for this slot + uint256 activeMonIndex = _unpackActiveMonIndexForSlot(battle.activeMonIndex, playerIndex, slotIndex); + MonState storage currentMonState = _getMonState(config, playerIndex, activeMonIndex); + + // Handle shouldSkipTurn flag + if (currentMonState.shouldSkipTurn) { + currentMonState.shouldSkipTurn = false; + return false; + } + + // Skip if mon is already KO'd (unless it's a switch - switching away from KO'd mon is allowed) + if (currentMonState.isKnockedOut && moveIndex != SWITCH_MOVE_INDEX) { + return false; + } + + // Handle switch, no-op, or regular move + if (moveIndex == SWITCH_MOVE_INDEX) { + _handleSwitchForSlot(battleKey, playerIndex, slotIndex, uint256(move.extraData), address(0)); + } else if (moveIndex == NO_OP_MOVE_INDEX) { + emit MonMove(battleKey, playerIndex, activeMonIndex, moveIndex, move.extraData, staminaCost); + } else { + // Validate move is still valid + if (!config.validator.validateSpecificMoveSelection(battleKey, moveIndex, playerIndex, move.extraData)) { + return false; + } + + IMoveSet moveSet = _getTeamMon(config, playerIndex, activeMonIndex).moves[moveIndex]; + + // Deduct stamina + staminaCost = int32(moveSet.stamina(battleKey, playerIndex, activeMonIndex)); + MonState storage monState = _getMonState(config, playerIndex, activeMonIndex); + monState.staminaDelta = (monState.staminaDelta == CLEARED_MON_STATE_SENTINEL) ? -staminaCost : monState.staminaDelta - staminaCost; + + emit MonMove(battleKey, playerIndex, activeMonIndex, moveIndex, move.extraData, staminaCost); + + // Execute the move + moveSet.move(battleKey, playerIndex, move.extraData, tempRNG); + } + + // Check if mon got KO'd as a result of this move + return currentMonState.isKnockedOut; + } + + // Handle switch for a specific slot in doubles (uses shared core functions) + function _handleSwitchForSlot(bytes32 battleKey, uint256 playerIndex, uint256 slotIndex, uint256 monToSwitchIndex, address source) internal { + BattleData storage battle = battleData[battleKey]; + uint256 currentActiveMonIndex = _unpackActiveMonIndexForSlot(battle.activeMonIndex, playerIndex, slotIndex); + + // Run switch-out effects (shared) + _handleSwitchCore(battleKey, playerIndex, currentActiveMonIndex, monToSwitchIndex, source); + + // Update active mon for this slot (doubles packing) + battle.activeMonIndex = _setActiveMonIndexForSlot(battle.activeMonIndex, playerIndex, slotIndex, monToSwitchIndex); + + // Run switch-in effects (shared) + _completeSwitchIn(battleKey, playerIndex, monToSwitchIndex); + } + + // Check for game over or KO in doubles mode (uses shared game over check) + function _checkForGameOverOrKO_Doubles( + BattleConfig storage config, + BattleData storage battle + ) internal returns (bool isGameOver) { + // Use shared game over check + (uint256 winnerIndex, uint256 p0KOBitmap, uint256 p1KOBitmap) = _checkForGameOver(config, battle); + + if (winnerIndex != 2) { + battle.winnerIndex = uint8(winnerIndex); + return true; + } + + // No game over - check each slot for KO and set switch flags + _clearSlotSwitchFlags(battle); + for (uint256 p = 0; p < 2; p++) { + uint256 koBitmap = p == 0 ? p0KOBitmap : p1KOBitmap; + for (uint256 s = 0; s < 2; s++) { + uint256 activeMonIndex = _unpackActiveMonIndexForSlot(battle.activeMonIndex, p, s); + bool isKOed = (koBitmap & (1 << activeMonIndex)) != 0; + if (isKOed) { + _setSlotSwitchFlag(battle, p, s); + } + } + } + + // Determine if either player needs a switch turn (has KO'd slot with valid target) + bool p0NeedsSwitch = _playerNeedsSwitchTurn(config, battle, 0, p0KOBitmap); + bool p1NeedsSwitch = _playerNeedsSwitchTurn(config, battle, 1, p1KOBitmap); + + // Set playerSwitchForTurnFlag based on who needs to switch + if (p0NeedsSwitch && p1NeedsSwitch) { + // Both players have KO'd mons with valid targets - both act (switch-only turn) + battle.playerSwitchForTurnFlag = 2; + } else if (p0NeedsSwitch) { + // Only p0 needs to switch + battle.playerSwitchForTurnFlag = 0; + } else if (p1NeedsSwitch) { + // Only p1 needs to switch + battle.playerSwitchForTurnFlag = 1; + } else { + // Neither needs switch - normal turn (both act) + battle.playerSwitchForTurnFlag = 2; + } + + return false; + } + + // Main execution function for doubles mode + function _executeDoubles( + bytes32 battleKey, + BattleConfig storage config, + BattleData storage battle, + uint256 turnId, + uint256 numHooks + ) internal { + // Update the temporary RNG + uint256 rng = config.rngOracle.getRNG(config.p0Salt, config.p1Salt); + tempRNG = rng; + + // Compute move order for all 4 slots + MoveOrder[4] memory moveOrder = _computeMoveOrderForDoubles(battleKey, config, battle); + + // Run beginning of round effects (global) + _runEffects(battleKey, rng, 2, 2, EffectStep.RoundStart, ""); + + // Run beginning of round effects for each slot's mon (if not KO'd) + for (uint256 i = 0; i < 4; i++) { + uint256 p = moveOrder[i].playerIndex; + uint256 s = moveOrder[i].slotIndex; + uint256 monIndex = _unpackActiveMonIndexForSlot(battle.activeMonIndex, p, s); + if (!_getMonState(config, p, monIndex).isKnockedOut) { + _runEffects(battleKey, rng, p, p, EffectStep.RoundStart, ""); + } + } + + // Execute moves in priority order + for (uint256 i = 0; i < 4; i++) { + uint256 p = moveOrder[i].playerIndex; + uint256 s = moveOrder[i].slotIndex; + + // Execute the move for this slot + _handleMoveForSlot(battleKey, config, battle, p, s); + + // Check for game over after each move + if (_checkForGameOverOrKO_Doubles(config, battle)) { + // Game is over, handle cleanup and return + address winner = (battle.winnerIndex == 0) ? battle.p0 : battle.p1; + _handleGameOver(battleKey, winner); + + // Run round end hooks + for (uint256 j = 0; j < numHooks; ++j) { + config.engineHooks[j].onRoundEnd(battleKey); + } + + emit EngineExecute(battleKey, turnId, 2, moveOrder[0].playerIndex); + return; + } + } + + // For turn 0 only: handle ability activateOnSwitch for all 4 mons + if (turnId == 0) { + for (uint256 p = 0; p < 2; p++) { + for (uint256 s = 0; s < 2; s++) { + uint256 monIndex = _unpackActiveMonIndexForSlot(battle.activeMonIndex, p, s); + Mon memory mon = _getTeamMon(config, p, monIndex); + if (address(mon.ability) != address(0)) { + mon.ability.activateOnSwitch(battleKey, p, monIndex); + } + } + } + } + + // Run afterMove effects for each slot (in move order) + for (uint256 i = 0; i < 4; i++) { + uint256 p = moveOrder[i].playerIndex; + uint256 s = moveOrder[i].slotIndex; + uint256 monIndex = _unpackActiveMonIndexForSlot(battle.activeMonIndex, p, s); + if (!_getMonState(config, p, monIndex).isKnockedOut) { + _runEffects(battleKey, rng, p, p, EffectStep.AfterMove, ""); + } + } + + // Run global afterMove effects + _runEffects(battleKey, rng, 2, 2, EffectStep.AfterMove, ""); + + // Check for game over after effects + if (_checkForGameOverOrKO_Doubles(config, battle)) { + address winner = (battle.winnerIndex == 0) ? battle.p0 : battle.p1; + _handleGameOver(battleKey, winner); + + for (uint256 j = 0; j < numHooks; ++j) { + config.engineHooks[j].onRoundEnd(battleKey); + } + + emit EngineExecute(battleKey, turnId, 2, moveOrder[0].playerIndex); + return; + } + + // Run global roundEnd effects + _runEffects(battleKey, rng, 2, 2, EffectStep.RoundEnd, ""); + + // Run roundEnd effects for each slot (in move order) + for (uint256 i = 0; i < 4; i++) { + uint256 p = moveOrder[i].playerIndex; + uint256 s = moveOrder[i].slotIndex; + uint256 monIndex = _unpackActiveMonIndexForSlot(battle.activeMonIndex, p, s); + if (!_getMonState(config, p, monIndex).isKnockedOut) { + _runEffects(battleKey, rng, p, p, EffectStep.RoundEnd, ""); + } + } + + // Final game over check after round end effects + if (_checkForGameOverOrKO_Doubles(config, battle)) { + address winner = (battle.winnerIndex == 0) ? battle.p0 : battle.p1; + _handleGameOver(battleKey, winner); + + for (uint256 j = 0; j < numHooks; ++j) { + config.engineHooks[j].onRoundEnd(battleKey); + } + + emit EngineExecute(battleKey, turnId, 2, moveOrder[0].playerIndex); + return; + } + + // Run round end hooks + for (uint256 i = 0; i < numHooks; ++i) { + config.engineHooks[i].onRoundEnd(battleKey); + } + + // End of turn cleanup + battle.turnId += 1; + + // playerSwitchForTurnFlag was already set by _checkForGameOverOrKO_Doubles + // based on whether players need to switch (have KO'd slots with valid targets) + + // Clear move flags for next turn + config.p0Move.packedMoveIndex = 0; + config.p1Move.packedMoveIndex = 0; + config.p0Move2.packedMoveIndex = 0; + config.p1Move2.packedMoveIndex = 0; + + emit EngineExecute(battleKey, turnId, 2, moveOrder[0].playerIndex); + } } diff --git a/src/Enums.sol b/src/Enums.sol index a8fd26b..efc9bcb 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 975f2f4..4203723 100644 --- a/src/IEngine.sol +++ b/src/IEngine.sol @@ -83,4 +83,11 @@ interface IEngine { external view returns (DamageCalcContext memory); + + // Doubles-specific getters + function getGameMode(bytes32 battleKey) external view returns (GameMode); + function getActiveMonIndexForSlot(bytes32 battleKey, uint256 playerIndex, uint256 slotIndex) + external + view + returns (uint256); } diff --git a/src/IValidator.sol b/src/IValidator.sol index 8dea5f1..853cab3 100644 --- a/src/IValidator.sol +++ b/src/IValidator.sol @@ -15,6 +15,15 @@ interface IValidator { external returns (bool); + // Validates a move for a specific slot in doubles mode + function validatePlayerMoveForSlot( + bytes32 battleKey, + uint256 moveIndex, + uint256 playerIndex, + uint256 slotIndex, + uint240 extraData + ) external returns (bool); + // Validates that a move selection is valid (specifically wrt stamina) function validateSpecificMoveSelection( bytes32 battleKey, diff --git a/src/Structs.sol b/src/Structs.sol index 4146dfb..5b549bd 100644 --- a/src/Structs.sol +++ b/src/Structs.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0 pragma solidity ^0.8.0; -import {Type, MonStateIndexName, StatBoostType} from "./Enums.sol"; +import {Type, MonStateIndexName, StatBoostType, GameMode} from "./Enums.sol"; import {IEngineHook} from "./IEngineHook.sol"; import {IRuleset} from "./IRuleset.sol"; import {IValidator} from "./IValidator.sol"; @@ -26,6 +26,7 @@ struct ProposedBattle { address moveManager; IMatchmaker matchmaker; IEngineHook[] engineHooks; + GameMode gameMode; // Singles or Doubles } // Used by Engine to initialize a battle's parameters @@ -41,6 +42,7 @@ struct Battle { address moveManager; IMatchmaker matchmaker; IEngineHook[] engineHooks; + GameMode gameMode; // Singles or Doubles } // Packed into 1 storage slot (8 + 240 = 248 bits) @@ -58,7 +60,12 @@ struct BattleData { uint8 winnerIndex; // 2 = uninitialized (no winner), 0 = p0 winner, 1 = p1 winner uint8 prevPlayerSwitchForTurnFlag; uint8 playerSwitchForTurnFlag; - uint16 activeMonIndex; // Packed: lower 8 bits = player0, upper 8 bits = player1 + // Packed active mon indices: + // Singles: lower 8 bits = p0 active, upper 8 bits = p1 active + // Doubles: 4 bits per slot (p0s0, p0s1, p1s0, p1s1) + uint16 activeMonIndex; + // Packed: lower 4 bits = per-slot switch flags, bit 4 = game mode (0=singles, 1=doubles) + uint8 slotSwitchFlagsAndGameMode; } // Stored by the Engine for a battle, is overwritten after a battle is over @@ -75,8 +82,10 @@ struct BattleConfig { uint48 startTimestamp; bytes32 p0Salt; bytes32 p1Salt; - MoveDecision p0Move; - MoveDecision p1Move; + MoveDecision p0Move; // Slot 0 move for p0 (singles: only move, doubles: first mon's move) + MoveDecision p1Move; // Slot 0 move for p1 + MoveDecision p0Move2; // Slot 1 move for p0 (doubles only) + MoveDecision p1Move2; // Slot 1 move for p1 (doubles only) mapping(uint256 index => Mon) p0Team; mapping(uint256 index => Mon) p1Team; mapping(uint256 index => MonState) p0States; @@ -105,6 +114,8 @@ struct BattleConfigView { bytes32 p1Salt; MoveDecision p0Move; MoveDecision p1Move; + MoveDecision p0Move2; // Doubles only + MoveDecision p1Move2; // Doubles only EffectInstance[] globalEffects; EffectInstance[][] p0Effects; // Returns effects per mon in team EffectInstance[][] p1Effects; @@ -117,7 +128,10 @@ struct BattleState { uint8 winnerIndex; // 2 = uninitialized (no winner), 0 = p0 winner, 1 = p1 winner uint8 prevPlayerSwitchForTurnFlag; uint8 playerSwitchForTurnFlag; - uint16 activeMonIndex; // Packed: lower 8 bits = player0, upper 8 bits = player1 + // Packed active mon indices (see BattleData for layout) + uint16 activeMonIndex; + // Packed: lower 4 bits = per-slot switch flags, bit 4 = game mode (0=singles, 1=doubles) + uint8 slotSwitchFlagsAndGameMode; uint64 turnId; } @@ -165,6 +179,15 @@ struct RevealedMove { bytes32 salt; } +// Used for Doubles commit manager - reveals both slot moves at once +struct RevealedMovesPair { + uint8 moveIndex0; // Slot 0 move index + uint240 extraData0; // Slot 0 extra data (includes target) + uint8 moveIndex1; // Slot 1 move index + uint240 extraData1; // Slot 1 extra data (includes target) + bytes32 salt; // Single salt for both moves +} + // Used for StatBoosts struct StatBoostToApply { MonStateIndexName stat; @@ -187,8 +210,12 @@ struct BattleContext { uint64 turnId; uint8 playerSwitchForTurnFlag; uint8 prevPlayerSwitchForTurnFlag; - uint8 p0ActiveMonIndex; - uint8 p1ActiveMonIndex; + uint8 p0ActiveMonIndex; // Slot 0 active mon for p0 + uint8 p1ActiveMonIndex; // Slot 0 active mon for p1 + uint8 p0ActiveMonIndex2; // Slot 1 active mon for p0 (doubles only) + uint8 p1ActiveMonIndex2; // Slot 1 active mon for p1 (doubles only) + uint8 slotSwitchFlags; // Per-slot switch flags (doubles) + GameMode gameMode; address validator; address moveManager; } @@ -201,6 +228,8 @@ struct CommitContext { uint8 winnerIndex; uint64 turnId; uint8 playerSwitchForTurnFlag; + uint8 slotSwitchFlags; // Per-slot switch flags (doubles) + GameMode gameMode; address validator; } diff --git a/src/cpu/CPU.sol b/src/cpu/CPU.sol index 42197ed..285023f 100644 --- a/src/cpu/CPU.sol +++ b/src/cpu/CPU.sol @@ -11,7 +11,7 @@ import {CPUMoveManager} from "./CPUMoveManager.sol"; import {IValidator} from "../IValidator.sol"; import {NO_OP_MOVE_INDEX, SWITCH_MOVE_INDEX} from "../Constants.sol"; -import {ExtraDataType} from "../Enums.sol"; +import {ExtraDataType, GameMode} from "../Enums.sol"; import {Battle, ProposedBattle, RevealedMove} from "../Structs.sol"; abstract contract CPU is CPUMoveManager, ICPU, ICPURNG, IMatchmaker { @@ -153,7 +153,8 @@ abstract contract CPU is CPUMoveManager, ICPU, ICPURNG, IMatchmaker { ruleset: proposal.ruleset, engineHooks: proposal.engineHooks, moveManager: proposal.moveManager, - matchmaker: proposal.matchmaker + matchmaker: proposal.matchmaker, + gameMode: proposal.gameMode }) ); } diff --git a/src/matchmaker/DefaultMatchmaker.sol b/src/matchmaker/DefaultMatchmaker.sol index 3fbf549..ce86e09 100644 --- a/src/matchmaker/DefaultMatchmaker.sol +++ b/src/matchmaker/DefaultMatchmaker.sol @@ -2,6 +2,7 @@ pragma solidity ^0.8.0; import {IEngine} from "../IEngine.sol"; +import {GameMode} from "../Enums.sol"; import {ProposedBattle, Battle} from "../Structs.sol"; import {IMatchmaker} from "./IMatchmaker.sol"; import {MappingAllocator} from "../lib/MappingAllocator.sol"; @@ -95,6 +96,9 @@ contract DefaultMatchmaker is IMatchmaker, MappingAllocator { if (existingBattle.engineHooks.length != proposal.engineHooks.length && proposal.engineHooks.length != 0) { existingBattle.engineHooks = proposal.engineHooks; } + if (existingBattle.gameMode != proposal.gameMode) { + existingBattle.gameMode = proposal.gameMode; + } proposals[storageKey].p1TeamIndex = UNSET_P1_TEAM_INDEX; emit BattleProposal(battleKey, proposal.p0, proposal.p1, proposal.p0TeamHash == FAST_BATTLE_SENTINAL_HASH, proposal.p0TeamHash); return battleKey; @@ -134,7 +138,8 @@ contract DefaultMatchmaker is IMatchmaker, MappingAllocator { ruleset: proposal.ruleset, engineHooks: proposal.engineHooks, moveManager: proposal.moveManager, - matchmaker: proposal.matchmaker + matchmaker: proposal.matchmaker, + gameMode: proposal.gameMode }) ); _cleanUpBattleProposal(battleKey); @@ -174,7 +179,8 @@ contract DefaultMatchmaker is IMatchmaker, MappingAllocator { ruleset: proposal.ruleset, engineHooks: proposal.engineHooks, moveManager: proposal.moveManager, - matchmaker: proposal.matchmaker + matchmaker: proposal.matchmaker, + gameMode: proposal.gameMode }) ); _cleanUpBattleProposal(battleKey); diff --git a/test/BattleHistoryTest.sol b/test/BattleHistoryTest.sol index 6e015bc..2bd57c3 100644 --- a/test/BattleHistoryTest.sol +++ b/test/BattleHistoryTest.sol @@ -138,7 +138,8 @@ contract BattleHistoryTest is Test, BattleHelper { ruleset: IRuleset(address(0)), engineHooks: hooks, moveManager: address(commitManager), - matchmaker: matchmaker + matchmaker: matchmaker, + gameMode: GameMode.Singles }); // Propose battle diff --git a/test/CPUTest.sol b/test/CPUTest.sol index 1189617..c7703f0 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 0000000..5a02792 --- /dev/null +++ b/test/DoublesCommitManagerTest.sol @@ -0,0 +1,883 @@ +// 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 {DoublesCommitManager} from "../src/DoublesCommitManager.sol"; +import {Engine} from "../src/Engine.sol"; +import {DefaultValidator} from "../src/DefaultValidator.sol"; +import {IEngineHook} from "../src/IEngineHook.sol"; +import {DefaultMatchmaker} from "../src/matchmaker/DefaultMatchmaker.sol"; +import {IMoveSet} from "../src/moves/IMoveSet.sol"; +import {DefaultRandomnessOracle} from "../src/rng/DefaultRandomnessOracle.sol"; +import {ITypeCalculator} from "../src/types/ITypeCalculator.sol"; +import {TestTeamRegistry} from "./mocks/TestTeamRegistry.sol"; +import {TestTypeCalculator} from "./mocks/TestTypeCalculator.sol"; +import {CustomAttack} from "./mocks/CustomAttack.sol"; +import {DoublesTargetedAttack} from "./mocks/DoublesTargetedAttack.sol"; + +contract DoublesCommitManagerTest is Test { + address constant ALICE = address(0x1); + address constant BOB = address(0x2); + + DoublesCommitManager commitManager; + Engine engine; + DefaultValidator validator; + ITypeCalculator typeCalc; + DefaultRandomnessOracle defaultOracle; + DefaultMatchmaker matchmaker; + TestTeamRegistry defaultRegistry; + CustomAttack customAttack; + + uint256 constant TIMEOUT_DURATION = 100; + + function setUp() public { + // Deploy core contracts + engine = new Engine(); + typeCalc = new TestTypeCalculator(); + defaultOracle = new DefaultRandomnessOracle(); + validator = new DefaultValidator( + engine, DefaultValidator.Args({MONS_PER_TEAM: 2, MOVES_PER_MON: 4, TIMEOUT_DURATION: TIMEOUT_DURATION}) + ); + matchmaker = new DefaultMatchmaker(engine); + commitManager = new DoublesCommitManager(engine); + defaultRegistry = new TestTeamRegistry(); + + // Create a simple attack for testing + customAttack = new CustomAttack( + engine, typeCalc, CustomAttack.Args({TYPE: Type.Fire, BASE_POWER: 10, ACCURACY: 100, STAMINA_COST: 1, PRIORITY: 0}) + ); + + // Register teams for Alice and Bob (need at least 2 mons for doubles) + IMoveSet[] memory moves = new IMoveSet[](4); + moves[0] = customAttack; + moves[1] = customAttack; + moves[2] = customAttack; + moves[3] = customAttack; + + Mon[] memory team = new Mon[](2); + team[0] = Mon({ + stats: MonStats({ + hp: 100, + stamina: 50, + speed: 10, + attack: 10, + defense: 10, + specialAttack: 10, + specialDefense: 10, + type1: Type.Fire, + type2: Type.None + }), + ability: IAbility(address(0)), + moves: moves + }); + team[1] = Mon({ + stats: MonStats({ + hp: 100, + stamina: 50, + speed: 8, + attack: 10, + defense: 10, + specialAttack: 10, + specialDefense: 10, + type1: Type.Liquid, + type2: Type.None + }), + ability: IAbility(address(0)), + moves: moves + }); + + defaultRegistry.setTeam(ALICE, team); + defaultRegistry.setTeam(BOB, team); + + // Authorize matchmaker for both players + vm.startPrank(ALICE); + address[] memory makersToAdd = new address[](1); + makersToAdd[0] = address(matchmaker); + address[] memory makersToRemove = new address[](0); + engine.updateMatchmakers(makersToAdd, makersToRemove); + vm.stopPrank(); + + vm.startPrank(BOB); + engine.updateMatchmakers(makersToAdd, makersToRemove); + vm.stopPrank(); + } + + function _startDoublesBattle() internal returns (bytes32 battleKey) { + // Compute p0 team hash + bytes32 salt = ""; + uint96 p0TeamIndex = 0; + uint256[] memory p0TeamIndices = defaultRegistry.getMonRegistryIndicesForTeam(ALICE, p0TeamIndex); + bytes32 p0TeamHash = keccak256(abi.encodePacked(salt, p0TeamIndex, p0TeamIndices)); + + // Create proposal for DOUBLES + ProposedBattle memory proposal = ProposedBattle({ + p0: ALICE, + p0TeamIndex: 0, + p0TeamHash: p0TeamHash, + p1: BOB, + p1TeamIndex: 0, + teamRegistry: defaultRegistry, + validator: validator, + rngOracle: defaultOracle, + ruleset: IRuleset(address(0)), + engineHooks: new IEngineHook[](0), + moveManager: address(commitManager), + matchmaker: matchmaker, + gameMode: GameMode.Doubles // KEY: This is a doubles battle + }); + + // Propose battle + vm.startPrank(ALICE); + battleKey = matchmaker.proposeBattle(proposal); + + // Accept battle + bytes32 battleIntegrityHash = matchmaker.getBattleProposalIntegrityHash(proposal); + vm.startPrank(BOB); + matchmaker.acceptBattle(battleKey, 0, battleIntegrityHash); + + // Confirm and start battle + vm.startPrank(ALICE); + matchmaker.confirmBattle(battleKey, salt, p0TeamIndex); + + vm.stopPrank(); + } + + function test_doublesCommitAndReveal() public { + bytes32 battleKey = _startDoublesBattle(); + + // Verify it's a doubles battle + assertEq(uint256(engine.getGameMode(battleKey)), uint256(GameMode.Doubles)); + + // Turn 0: Both players must switch to select initial active mons + // Alice commits (even turn = p0 commits) + bytes32 salt = bytes32("secret"); + uint8 aliceMove0 = SWITCH_MOVE_INDEX; // Switch to mon index 0 for slot 0 + uint240 aliceExtra0 = 0; // Mon index 0 + uint8 aliceMove1 = SWITCH_MOVE_INDEX; // Switch to mon index 1 for slot 1 + uint240 aliceExtra1 = 1; // Mon index 1 + + bytes32 aliceHash = keccak256(abi.encodePacked(aliceMove0, aliceExtra0, aliceMove1, aliceExtra1, salt)); + + vm.startPrank(ALICE); + commitManager.commitMoves(battleKey, aliceHash); + vm.stopPrank(); + + // Bob reveals first (non-committing player reveals first) + uint8 bobMove0 = SWITCH_MOVE_INDEX; + uint240 bobExtra0 = 0; // Mon index 0 + uint8 bobMove1 = SWITCH_MOVE_INDEX; + uint240 bobExtra1 = 1; // Mon index 1 + bytes32 bobSalt = bytes32("bobsalt"); + + vm.startPrank(BOB); + commitManager.revealMoves(battleKey, bobMove0, bobExtra0, bobMove1, bobExtra1, bobSalt, false); + vm.stopPrank(); + + // Alice reveals (committing player reveals second) + vm.startPrank(ALICE); + commitManager.revealMoves(battleKey, aliceMove0, aliceExtra0, aliceMove1, aliceExtra1, salt, false); + vm.stopPrank(); + + // Verify moves were set correctly + MoveDecision memory p0Move = engine.getMoveDecisionForBattleState(battleKey, 0); + MoveDecision memory p1Move = engine.getMoveDecisionForBattleState(battleKey, 1); + + // Check that moves were set (packedMoveIndex should have IS_REAL_TURN_BIT set) + assertTrue(p0Move.packedMoveIndex & IS_REAL_TURN_BIT != 0, "Alice slot 0 move should be set"); + assertTrue(p1Move.packedMoveIndex & IS_REAL_TURN_BIT != 0, "Bob slot 0 move should be set"); + } + + function test_doublesCannotCommitToSinglesBattle() public { + // Start a SINGLES battle instead + bytes32 salt = ""; + uint96 p0TeamIndex = 0; + uint256[] memory p0TeamIndices = defaultRegistry.getMonRegistryIndicesForTeam(ALICE, p0TeamIndex); + bytes32 p0TeamHash = keccak256(abi.encodePacked(salt, p0TeamIndex, p0TeamIndices)); + + ProposedBattle memory proposal = ProposedBattle({ + p0: ALICE, + p0TeamIndex: 0, + p0TeamHash: p0TeamHash, + p1: BOB, + p1TeamIndex: 0, + teamRegistry: defaultRegistry, + validator: validator, + rngOracle: defaultOracle, + ruleset: IRuleset(address(0)), + engineHooks: new IEngineHook[](0), + moveManager: address(commitManager), + matchmaker: matchmaker, + gameMode: GameMode.Singles // Singles battle + }); + + vm.startPrank(ALICE); + bytes32 battleKey = matchmaker.proposeBattle(proposal); + + bytes32 battleIntegrityHash = matchmaker.getBattleProposalIntegrityHash(proposal); + vm.startPrank(BOB); + matchmaker.acceptBattle(battleKey, 0, battleIntegrityHash); + + vm.startPrank(ALICE); + matchmaker.confirmBattle(battleKey, salt, p0TeamIndex); + + // Try to commit with DoublesCommitManager - should fail + bytes32 moveHash = keccak256(abi.encodePacked(uint8(0), uint240(0), uint8(0), uint240(0), bytes32("salt"))); + vm.expectRevert(DoublesCommitManager.NotDoublesMode.selector); + commitManager.commitMoves(battleKey, moveHash); + vm.stopPrank(); + } + + function test_doublesExecutionWithAllFourMoves() public { + bytes32 battleKey = _startDoublesBattle(); + + // Turn 0: Both players must switch to select initial active mons + bytes32 salt = bytes32("secret"); + uint8 aliceMove0 = SWITCH_MOVE_INDEX; + uint240 aliceExtra0 = 0; // Mon index 0 for slot 0 + uint8 aliceMove1 = SWITCH_MOVE_INDEX; + uint240 aliceExtra1 = 1; // Mon index 1 for slot 1 + + bytes32 aliceHash = keccak256(abi.encodePacked(aliceMove0, aliceExtra0, aliceMove1, aliceExtra1, salt)); + + vm.startPrank(ALICE); + commitManager.commitMoves(battleKey, aliceHash); + vm.stopPrank(); + + // Bob reveals first + uint8 bobMove0 = SWITCH_MOVE_INDEX; + uint240 bobExtra0 = 0; + uint8 bobMove1 = SWITCH_MOVE_INDEX; + uint240 bobExtra1 = 1; + bytes32 bobSalt = bytes32("bobsalt"); + + vm.startPrank(BOB); + commitManager.revealMoves(battleKey, bobMove0, bobExtra0, bobMove1, bobExtra1, bobSalt, false); + vm.stopPrank(); + + // Alice reveals + vm.startPrank(ALICE); + commitManager.revealMoves(battleKey, aliceMove0, aliceExtra0, aliceMove1, aliceExtra1, salt, false); + vm.stopPrank(); + + // Execute turn 0 (initial mon selection) + engine.execute(battleKey); + + // Verify the game advanced to turn 1 + assertEq(engine.getTurnIdForBattleState(battleKey), 1); + + // Verify active mon indices are set correctly for doubles + assertEq(engine.getActiveMonIndexForSlot(battleKey, 0, 0), 0); // p0 slot 0 = mon 0 + assertEq(engine.getActiveMonIndexForSlot(battleKey, 0, 1), 1); // p0 slot 1 = mon 1 + assertEq(engine.getActiveMonIndexForSlot(battleKey, 1, 0), 0); // p1 slot 0 = mon 0 + assertEq(engine.getActiveMonIndexForSlot(battleKey, 1, 1), 1); // p1 slot 1 = mon 1 + + // Turn 1: Both players use attack moves + bytes32 salt2 = bytes32("secret2"); + uint8 aliceAttack0 = 0; // Move index 0 (attack) + uint240 aliceTarget0 = 0; // Target opponent slot 0 + uint8 aliceAttack1 = 0; + uint240 aliceTarget1 = 0; + + bytes32 aliceHash2 = keccak256(abi.encodePacked(aliceAttack0, aliceTarget0, aliceAttack1, aliceTarget1, salt2)); + + vm.startPrank(BOB); + // Bob commits this turn (odd turn = p1 commits) + bytes32 bobSalt2 = bytes32("bobsalt2"); + uint8 bobAttack0 = 0; + uint240 bobTarget0 = 0; + uint8 bobAttack1 = 0; + uint240 bobTarget1 = 0; + bytes32 bobHash2 = keccak256(abi.encodePacked(bobAttack0, bobTarget0, bobAttack1, bobTarget1, bobSalt2)); + commitManager.commitMoves(battleKey, bobHash2); + vm.stopPrank(); + + // Alice reveals first (non-committing player) + vm.startPrank(ALICE); + commitManager.revealMoves(battleKey, aliceAttack0, aliceTarget0, aliceAttack1, aliceTarget1, salt2, false); + vm.stopPrank(); + + // Bob reveals + vm.startPrank(BOB); + commitManager.revealMoves(battleKey, bobAttack0, bobTarget0, bobAttack1, bobTarget1, bobSalt2, false); + vm.stopPrank(); + + // Execute turn 1 (attacks) + engine.execute(battleKey); + + // Verify the game advanced to turn 2 + assertEq(engine.getTurnIdForBattleState(battleKey), 2); + + // Battle should still be ongoing (no winner yet) + assertEq(engine.getWinner(battleKey), address(0)); + } + + function test_doublesWrongPreimageReverts() public { + bytes32 battleKey = _startDoublesBattle(); + + // Alice commits (turn 0 - must use SWITCH_MOVE_INDEX) + bytes32 salt = bytes32("secret"); + uint8 aliceMove0 = SWITCH_MOVE_INDEX; + uint240 aliceExtra0 = 0; + uint8 aliceMove1 = SWITCH_MOVE_INDEX; + uint240 aliceExtra1 = 1; + + bytes32 aliceHash = keccak256(abi.encodePacked(aliceMove0, aliceExtra0, aliceMove1, aliceExtra1, salt)); + + vm.startPrank(ALICE); + commitManager.commitMoves(battleKey, aliceHash); + vm.stopPrank(); + + // Bob reveals first (also must use SWITCH_MOVE_INDEX on turn 0) + vm.startPrank(BOB); + commitManager.revealMoves(battleKey, SWITCH_MOVE_INDEX, 0, SWITCH_MOVE_INDEX, 1, bytes32("bobsalt"), false); + vm.stopPrank(); + + // Alice tries to reveal with wrong moves - should fail + vm.startPrank(ALICE); + vm.expectRevert(DoublesCommitManager.WrongPreimage.selector); + commitManager.revealMoves(battleKey, SWITCH_MOVE_INDEX, 1, SWITCH_MOVE_INDEX, 0, salt, false); // Wrong extraData values + vm.stopPrank(); + } + + // ========================================= + // Helper functions for doubles tests + // ========================================= + + // Helper to commit and reveal moves for both players in doubles, then execute + function _doublesCommitRevealExecute( + bytes32 battleKey, + uint8 aliceMove0, + uint240 aliceExtra0, + uint8 aliceMove1, + uint240 aliceExtra1, + uint8 bobMove0, + uint240 bobExtra0, + uint8 bobMove1, + uint240 bobExtra1 + ) internal { + uint256 turnId = engine.getTurnIdForBattleState(battleKey); + bytes32 aliceSalt = bytes32("alicesalt"); + bytes32 bobSalt = bytes32("bobsalt"); + + if (turnId % 2 == 0) { + // Alice commits first on even turns + bytes32 aliceHash = keccak256(abi.encodePacked(aliceMove0, aliceExtra0, aliceMove1, aliceExtra1, aliceSalt)); + vm.startPrank(ALICE); + commitManager.commitMoves(battleKey, aliceHash); + vm.stopPrank(); + + // Bob reveals first + vm.startPrank(BOB); + commitManager.revealMoves(battleKey, bobMove0, bobExtra0, bobMove1, bobExtra1, bobSalt, false); + vm.stopPrank(); + + // Alice reveals + vm.startPrank(ALICE); + commitManager.revealMoves(battleKey, aliceMove0, aliceExtra0, aliceMove1, aliceExtra1, aliceSalt, false); + vm.stopPrank(); + } else { + // Bob commits first on odd turns + bytes32 bobHash = keccak256(abi.encodePacked(bobMove0, bobExtra0, bobMove1, bobExtra1, bobSalt)); + vm.startPrank(BOB); + commitManager.commitMoves(battleKey, bobHash); + vm.stopPrank(); + + // Alice reveals first + vm.startPrank(ALICE); + commitManager.revealMoves(battleKey, aliceMove0, aliceExtra0, aliceMove1, aliceExtra1, aliceSalt, false); + vm.stopPrank(); + + // Bob reveals + vm.startPrank(BOB); + commitManager.revealMoves(battleKey, bobMove0, bobExtra0, bobMove1, bobExtra1, bobSalt, false); + vm.stopPrank(); + } + + // Execute the turn + engine.execute(battleKey); + } + + // Helper to do initial switch on turn 0 + function _doInitialSwitch(bytes32 battleKey) internal { + _doublesCommitRevealExecute( + battleKey, + SWITCH_MOVE_INDEX, 0, SWITCH_MOVE_INDEX, 1, // Alice: slot 0 -> mon 0, slot 1 -> mon 1 + SWITCH_MOVE_INDEX, 0, SWITCH_MOVE_INDEX, 1 // Bob: slot 0 -> mon 0, slot 1 -> mon 1 + ); + } + + // ========================================= + // Doubles Boundary Condition Tests + // ========================================= + + function test_doublesFasterSpeedExecutesFirst() public { + // Test that faster mons execute first in doubles + // NOTE: Current StandardAttack always targets opponent slot 0, so we test + // that faster mon KOs opponent's slot 0 before slower opponent can attack + + IMoveSet[] memory moves = new IMoveSet[](4); + CustomAttack strongAttack = new CustomAttack( + engine, typeCalc, CustomAttack.Args({TYPE: Type.Fire, BASE_POWER: 200, ACCURACY: 100, STAMINA_COST: 1, PRIORITY: 0}) + ); + moves[0] = strongAttack; + moves[1] = strongAttack; + moves[2] = strongAttack; + moves[3] = strongAttack; + + // Alice has faster mons (speed 20 and 18) + Mon[] memory aliceTeam = new Mon[](2); + aliceTeam[0] = Mon({ + stats: MonStats({ + hp: 100, stamina: 50, speed: 20, attack: 100, defense: 10, + specialAttack: 10, specialDefense: 10, type1: Type.Fire, type2: Type.None + }), + ability: IAbility(address(0)), + moves: moves + }); + aliceTeam[1] = Mon({ + stats: MonStats({ + hp: 100, stamina: 50, speed: 18, attack: 100, defense: 10, + specialAttack: 10, specialDefense: 10, type1: Type.Fire, type2: Type.None + }), + ability: IAbility(address(0)), + moves: moves + }); + + // Bob has slower mons (speed 10 and 8) with low HP + Mon[] memory bobTeam = new Mon[](2); + bobTeam[0] = Mon({ + stats: MonStats({ + hp: 10, stamina: 50, speed: 10, attack: 100, defense: 10, + specialAttack: 10, specialDefense: 10, type1: Type.Fire, type2: Type.None + }), + ability: IAbility(address(0)), + moves: moves + }); + bobTeam[1] = Mon({ + stats: MonStats({ + hp: 10, stamina: 50, speed: 8, attack: 100, defense: 10, + specialAttack: 10, specialDefense: 10, type1: Type.Fire, type2: Type.None + }), + ability: IAbility(address(0)), + moves: moves + }); + + defaultRegistry.setTeam(ALICE, aliceTeam); + defaultRegistry.setTeam(BOB, bobTeam); + + bytes32 battleKey = _startDoublesBattle(); + vm.warp(block.timestamp + 1); + + // Turn 0: Initial switch + _doInitialSwitch(battleKey); + + // Turn 1: All attack - Alice's faster slot 0 mon attacks before Bob's slot 0 can act + // Both Alice mons attack Bob slot 0 (default targeting), KO'ing it + // Bob's slot 0 mon is KO'd before it can attack + _doublesCommitRevealExecute( + battleKey, + 0, 0, 0, 0, // Alice: both slots use move 0 + 0, 0, 0, 0 // Bob: both slots use move 0 + ); + + // Bob's slot 0 should be KO'd, game continues + assertEq(engine.getWinner(battleKey), address(0)); // Game not over yet + + // Turn 2: Alice attacks again, Bob's slot 1 now in slot 0 position after forced switch + // Since Bob has no more mons to switch, game should end + // Actually, Bob still has slot 1 alive, so he needs to switch slot 0 to a new mon + // But with only 2 mons and slot 1 still having mon index 1, Bob can't switch + // The game continues with Bob's surviving slot 1 mon + + // Verify turn advanced + assertEq(engine.getTurnIdForBattleState(battleKey), 2); + } + + function test_doublesFasterPriorityExecutesFirst() public { + // Test that higher priority moves execute before lower priority, regardless of speed + // NOTE: All attacks target opponent slot 0 by default + + CustomAttack lowPriorityAttack = new CustomAttack( + engine, typeCalc, CustomAttack.Args({TYPE: Type.Fire, BASE_POWER: 200, ACCURACY: 100, STAMINA_COST: 1, PRIORITY: 0}) + ); + CustomAttack highPriorityAttack = new CustomAttack( + engine, typeCalc, CustomAttack.Args({TYPE: Type.Fire, BASE_POWER: 200, ACCURACY: 100, STAMINA_COST: 1, PRIORITY: 1}) + ); + + IMoveSet[] memory aliceMoves = new IMoveSet[](4); + aliceMoves[0] = highPriorityAttack; // Alice has high priority + aliceMoves[1] = highPriorityAttack; + aliceMoves[2] = highPriorityAttack; + aliceMoves[3] = highPriorityAttack; + + IMoveSet[] memory bobMoves = new IMoveSet[](4); + bobMoves[0] = lowPriorityAttack; // Bob has low priority + bobMoves[1] = lowPriorityAttack; + bobMoves[2] = lowPriorityAttack; + bobMoves[3] = lowPriorityAttack; + + // Alice has SLOWER mons but higher priority moves, high HP to survive + Mon[] memory aliceTeam = new Mon[](2); + aliceTeam[0] = Mon({ + stats: MonStats({ + hp: 100, stamina: 50, speed: 1, attack: 100, defense: 10, + specialAttack: 10, specialDefense: 10, type1: Type.Fire, type2: Type.None + }), + ability: IAbility(address(0)), + moves: aliceMoves + }); + aliceTeam[1] = Mon({ + stats: MonStats({ + hp: 100, stamina: 50, speed: 1, attack: 100, defense: 10, + specialAttack: 10, specialDefense: 10, type1: Type.Fire, type2: Type.None + }), + ability: IAbility(address(0)), + moves: aliceMoves + }); + + // Bob has FASTER mons but lower priority moves, low HP to get KO'd + Mon[] memory bobTeam = new Mon[](2); + bobTeam[0] = Mon({ + stats: MonStats({ + hp: 10, stamina: 50, speed: 100, attack: 100, defense: 10, + specialAttack: 10, specialDefense: 10, type1: Type.Fire, type2: Type.None + }), + ability: IAbility(address(0)), + moves: bobMoves + }); + bobTeam[1] = Mon({ + stats: MonStats({ + hp: 10, stamina: 50, speed: 100, attack: 100, defense: 10, + specialAttack: 10, specialDefense: 10, type1: Type.Fire, type2: Type.None + }), + ability: IAbility(address(0)), + moves: bobMoves + }); + + defaultRegistry.setTeam(ALICE, aliceTeam); + defaultRegistry.setTeam(BOB, bobTeam); + + bytes32 battleKey = _startDoublesBattle(); + vm.warp(block.timestamp + 1); + + _doInitialSwitch(battleKey); + + // Turn 1: Alice's high priority moves execute first, KO'ing Bob's slot 0 + _doublesCommitRevealExecute( + battleKey, + 0, 0, 0, 0, + 0, 0, 0, 0 + ); + + // Bob's slot 0 should be KO'd before it could attack (due to priority) + // Game continues with Bob's slot 1 still alive + assertEq(engine.getWinner(battleKey), address(0)); + assertEq(engine.getTurnIdForBattleState(battleKey), 2); + } + + function test_doublesPositionTiebreaker() public { + // All mons have same speed and priority, test position tiebreaker + // Expected order: p0s0 (Alice slot 0) > p0s1 (Alice slot 1) > p1s0 (Bob slot 0) > p1s1 (Bob slot 1) + + // Create a weak attack that won't KO (to see all 4 moves execute) + CustomAttack weakAttack = new CustomAttack( + engine, typeCalc, CustomAttack.Args({TYPE: Type.Fire, BASE_POWER: 1, ACCURACY: 100, STAMINA_COST: 1, PRIORITY: 0}) + ); + + IMoveSet[] memory moves = new IMoveSet[](4); + moves[0] = weakAttack; + moves[1] = weakAttack; + moves[2] = weakAttack; + moves[3] = weakAttack; + + // All mons have same speed (10) + Mon[] memory team = new Mon[](2); + team[0] = Mon({ + stats: MonStats({ + hp: 100, stamina: 50, speed: 10, attack: 10, defense: 10, + specialAttack: 10, specialDefense: 10, type1: Type.Fire, type2: Type.None + }), + ability: IAbility(address(0)), + moves: moves + }); + team[1] = Mon({ + stats: MonStats({ + hp: 100, stamina: 50, speed: 10, attack: 10, defense: 10, + specialAttack: 10, specialDefense: 10, type1: Type.Fire, type2: Type.None + }), + ability: IAbility(address(0)), + moves: moves + }); + + defaultRegistry.setTeam(ALICE, team); + defaultRegistry.setTeam(BOB, team); + + bytes32 battleKey = _startDoublesBattle(); + vm.warp(block.timestamp + 1); + + _doInitialSwitch(battleKey); + + // Turn 1: All attack with weak attacks (no KOs expected) + _doublesCommitRevealExecute( + battleKey, + 0, 0, 0, 0, + 0, 0, 0, 0 + ); + + // Battle should still be ongoing (all 4 moves executed, no KOs) + assertEq(engine.getWinner(battleKey), address(0)); + assertEq(engine.getTurnIdForBattleState(battleKey), 2); + } + + function test_doublesPartialKOContinuesBattle() public { + // Test that if only 1 mon per player is KO'd, battle continues + + CustomAttack strongAttack = new CustomAttack( + engine, typeCalc, CustomAttack.Args({TYPE: Type.Fire, BASE_POWER: 200, ACCURACY: 100, STAMINA_COST: 1, PRIORITY: 0}) + ); + CustomAttack weakAttack = new CustomAttack( + engine, typeCalc, CustomAttack.Args({TYPE: Type.Fire, BASE_POWER: 1, ACCURACY: 100, STAMINA_COST: 1, PRIORITY: 0}) + ); + + // Slot 0 has strong attack, slot 1 has weak attack + IMoveSet[] memory strongMoves = new IMoveSet[](4); + strongMoves[0] = strongAttack; + strongMoves[1] = strongAttack; + strongMoves[2] = strongAttack; + strongMoves[3] = strongAttack; + + IMoveSet[] memory weakMoves = new IMoveSet[](4); + weakMoves[0] = weakAttack; + weakMoves[1] = weakAttack; + weakMoves[2] = weakAttack; + weakMoves[3] = weakAttack; + + Mon[] memory team = new Mon[](2); + // Slot 0: High HP, strong attack (will KO opponent's slot 0) + team[0] = Mon({ + stats: MonStats({ + hp: 100, stamina: 50, speed: 10, attack: 100, defense: 10, + specialAttack: 10, specialDefense: 10, type1: Type.Fire, type2: Type.None + }), + ability: IAbility(address(0)), + moves: strongMoves + }); + // Slot 1: Low HP, weak attack (won't KO anything, but could get KO'd) + team[1] = Mon({ + stats: MonStats({ + hp: 10, stamina: 50, speed: 5, attack: 10, defense: 10, + specialAttack: 10, specialDefense: 10, type1: Type.Fire, type2: Type.None + }), + ability: IAbility(address(0)), + moves: weakMoves + }); + + defaultRegistry.setTeam(ALICE, team); + defaultRegistry.setTeam(BOB, team); + + bytes32 battleKey = _startDoublesBattle(); + vm.warp(block.timestamp + 1); + + _doInitialSwitch(battleKey); + + // Turn 1: Both slot 0s attack each other (mutual KO), slot 1s use weak attack + // After this, both players should have their slot 0 mons KO'd but slot 1 alive + _doublesCommitRevealExecute( + battleKey, + 0, 0, 0, 0, // Alice: both attack + 0, 0, 0, 0 // Bob: both attack + ); + + // Battle should continue (both still have slot 1 alive) + assertEq(engine.getWinner(battleKey), address(0)); + } + + function test_doublesGameOverWhenAllMonsKOed() public { + // Test that game ends when ALL of one player's mons are KO'd + // Using DoublesTargetedAttack to target specific slots via extraData + + DoublesTargetedAttack targetedAttack = new DoublesTargetedAttack( + engine, typeCalc, DoublesTargetedAttack.Args({TYPE: Type.Fire, BASE_POWER: 500, ACCURACY: 100, STAMINA_COST: 1, PRIORITY: 0}) + ); + + IMoveSet[] memory moves = new IMoveSet[](4); + moves[0] = targetedAttack; + moves[1] = targetedAttack; + moves[2] = targetedAttack; + moves[3] = targetedAttack; + + // Alice has fast mons with high HP + Mon[] memory aliceTeam = new Mon[](2); + aliceTeam[0] = Mon({ + stats: MonStats({ + hp: 1000, stamina: 50, speed: 100, attack: 100, defense: 10, + specialAttack: 10, specialDefense: 10, type1: Type.Fire, type2: Type.None + }), + ability: IAbility(address(0)), + moves: moves + }); + aliceTeam[1] = Mon({ + stats: MonStats({ + hp: 1000, stamina: 50, speed: 99, attack: 100, defense: 10, + specialAttack: 10, specialDefense: 10, type1: Type.Fire, type2: Type.None + }), + ability: IAbility(address(0)), + moves: moves + }); + + // Bob has slow mons with low HP that will be KO'd + Mon[] memory bobTeam = new Mon[](2); + bobTeam[0] = Mon({ + stats: MonStats({ + hp: 10, stamina: 50, speed: 1, attack: 10, defense: 10, + specialAttack: 10, specialDefense: 10, type1: Type.Fire, type2: Type.None + }), + ability: IAbility(address(0)), + moves: moves + }); + bobTeam[1] = Mon({ + stats: MonStats({ + hp: 10, stamina: 50, speed: 1, attack: 10, defense: 10, + specialAttack: 10, specialDefense: 10, type1: Type.Fire, type2: Type.None + }), + ability: IAbility(address(0)), + moves: moves + }); + + defaultRegistry.setTeam(ALICE, aliceTeam); + defaultRegistry.setTeam(BOB, bobTeam); + + bytes32 battleKey = _startDoublesBattle(); + vm.warp(block.timestamp + 1); + + _doInitialSwitch(battleKey); + + // Turn 1: Alice's slot 0 targets Bob slot 0, Alice's slot 1 targets Bob slot 1 + // extraData = 0 means target opponent slot 0, extraData = 1 means target opponent slot 1 + _doublesCommitRevealExecute( + battleKey, + 0, 0, 0, 1, // Alice: slot 0 targets Bob slot 0, slot 1 targets Bob slot 1 + 0, 0, 0, 0 // Bob: both attack (but won't execute - KO'd first) + ); + + // Alice should win because both of Bob's mons are KO'd + assertEq(engine.getWinner(battleKey), ALICE); + } + + function test_doublesSwitchPriorityBeforeAttacks() public { + // Test that switches happen before regular attacks in doubles + + CustomAttack strongAttack = new CustomAttack( + engine, typeCalc, CustomAttack.Args({TYPE: Type.Fire, BASE_POWER: 200, ACCURACY: 100, STAMINA_COST: 1, PRIORITY: 0}) + ); + + IMoveSet[] memory moves = new IMoveSet[](4); + moves[0] = strongAttack; + moves[1] = strongAttack; + moves[2] = strongAttack; + moves[3] = strongAttack; + + // Both players have same stats + Mon[] memory team = new Mon[](2); + team[0] = Mon({ + stats: MonStats({ + hp: 100, stamina: 50, speed: 10, attack: 100, defense: 10, + specialAttack: 10, specialDefense: 10, type1: Type.Fire, type2: Type.None + }), + ability: IAbility(address(0)), + moves: moves + }); + team[1] = Mon({ + stats: MonStats({ + hp: 100, stamina: 50, speed: 10, attack: 100, defense: 10, + specialAttack: 10, specialDefense: 10, type1: Type.Fire, type2: Type.None + }), + ability: IAbility(address(0)), + moves: moves + }); + + defaultRegistry.setTeam(ALICE, team); + defaultRegistry.setTeam(BOB, team); + + bytes32 battleKey = _startDoublesBattle(); + vm.warp(block.timestamp + 1); + + _doInitialSwitch(battleKey); + + // Verify initial state + assertEq(engine.getActiveMonIndexForSlot(battleKey, 0, 0), 0); // Alice slot 0 = mon 0 + assertEq(engine.getActiveMonIndexForSlot(battleKey, 0, 1), 1); // Alice slot 1 = mon 1 + + // Turn 1: Alice switches slot 0 (switching to self is allowed on turn > 0? Let's switch slot indices) + // Actually, for a valid switch, need to switch to a different mon. Since we only have 2 mons + // and both are active, this test needs adjustment. Let me use NO_OP for one slot and attack for others + _doublesCommitRevealExecute( + battleKey, + NO_OP_MOVE_INDEX, 0, 0, 0, // Alice: slot 0 no-op, slot 1 attacks + 0, 0, 0, 0 // Bob: both attack + ); + + // Battle continues (no KOs with these HP values) + assertEq(engine.getWinner(battleKey), address(0)); + assertEq(engine.getTurnIdForBattleState(battleKey), 2); + } + + function test_doublesNonKOSubsequentMoves() public { + // Test that non-KO moves properly advance the game state + + CustomAttack weakAttack = new CustomAttack( + engine, typeCalc, CustomAttack.Args({TYPE: Type.Fire, BASE_POWER: 5, ACCURACY: 100, STAMINA_COST: 1, PRIORITY: 0}) + ); + + IMoveSet[] memory moves = new IMoveSet[](4); + moves[0] = weakAttack; + moves[1] = weakAttack; + moves[2] = weakAttack; + moves[3] = weakAttack; + + Mon[] memory team = new Mon[](2); + team[0] = Mon({ + stats: MonStats({ + hp: 100, stamina: 50, speed: 10, attack: 10, defense: 10, + specialAttack: 10, specialDefense: 10, type1: Type.Fire, type2: Type.None + }), + ability: IAbility(address(0)), + moves: moves + }); + team[1] = Mon({ + stats: MonStats({ + hp: 100, stamina: 50, speed: 8, attack: 10, defense: 10, + specialAttack: 10, specialDefense: 10, type1: Type.Fire, type2: Type.None + }), + ability: IAbility(address(0)), + moves: moves + }); + + defaultRegistry.setTeam(ALICE, team); + defaultRegistry.setTeam(BOB, team); + + bytes32 battleKey = _startDoublesBattle(); + vm.warp(block.timestamp + 1); + + _doInitialSwitch(battleKey); + assertEq(engine.getTurnIdForBattleState(battleKey), 1); + + // Multiple turns of weak attacks + for (uint256 i = 0; i < 3; i++) { + _doublesCommitRevealExecute( + battleKey, + 0, 0, 0, 0, + 0, 0, 0, 0 + ); + } + + // Should have advanced 3 turns + assertEq(engine.getTurnIdForBattleState(battleKey), 4); + assertEq(engine.getWinner(battleKey), address(0)); // No winner yet + } +} diff --git a/test/DoublesValidationTest.sol b/test/DoublesValidationTest.sol new file mode 100644 index 0000000..d6c60c5 --- /dev/null +++ b/test/DoublesValidationTest.sol @@ -0,0 +1,787 @@ +// 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 {DoublesCommitManager} from "../src/DoublesCommitManager.sol"; +import {Engine} from "../src/Engine.sol"; +import {DefaultValidator} from "../src/DefaultValidator.sol"; +import {IEngineHook} from "../src/IEngineHook.sol"; +import {DefaultMatchmaker} from "../src/matchmaker/DefaultMatchmaker.sol"; +import {IMoveSet} from "../src/moves/IMoveSet.sol"; +import {DefaultRandomnessOracle} from "../src/rng/DefaultRandomnessOracle.sol"; +import {ITypeCalculator} from "../src/types/ITypeCalculator.sol"; +import {TestTeamRegistry} from "./mocks/TestTeamRegistry.sol"; +import {TestTypeCalculator} from "./mocks/TestTypeCalculator.sol"; +import {CustomAttack} from "./mocks/CustomAttack.sol"; + +/** + * @title DoublesValidationTest + * @notice Tests for doubles battle validation boundary conditions + * @dev Tests scenarios: + * - One player has 1 KO'd mon (with/without valid switch targets) + * - Both players have 1 KO'd mon each (various combinations) + * - Switch target validation (can't switch to other slot's active mon) + * - NO_OP allowed only when no valid switch targets + */ +contract DoublesValidationTest is Test { + address constant ALICE = address(0x1); + address constant BOB = address(0x2); + + DoublesCommitManager commitManager; + Engine engine; + DefaultValidator validator; + ITypeCalculator typeCalc; + DefaultRandomnessOracle defaultOracle; + DefaultMatchmaker matchmaker; + TestTeamRegistry defaultRegistry; + CustomAttack customAttack; + CustomAttack strongAttack; + + uint256 constant TIMEOUT_DURATION = 100; + + function setUp() public { + engine = new Engine(); + typeCalc = new TestTypeCalculator(); + defaultOracle = new DefaultRandomnessOracle(); + // Use 3 mons per team to test switch target scenarios + validator = new DefaultValidator( + engine, DefaultValidator.Args({MONS_PER_TEAM: 3, MOVES_PER_MON: 4, TIMEOUT_DURATION: TIMEOUT_DURATION}) + ); + matchmaker = new DefaultMatchmaker(engine); + commitManager = new DoublesCommitManager(engine); + defaultRegistry = new TestTeamRegistry(); + + customAttack = new CustomAttack( + engine, typeCalc, CustomAttack.Args({TYPE: Type.Fire, BASE_POWER: 10, ACCURACY: 100, STAMINA_COST: 1, PRIORITY: 0}) + ); + strongAttack = new CustomAttack( + engine, typeCalc, CustomAttack.Args({TYPE: Type.Fire, BASE_POWER: 200, ACCURACY: 100, STAMINA_COST: 1, PRIORITY: 0}) + ); + + // Register teams for Alice and Bob (3 mons for doubles with switch options) + IMoveSet[] memory moves = new IMoveSet[](4); + moves[0] = customAttack; + moves[1] = customAttack; + moves[2] = customAttack; + moves[3] = customAttack; + + Mon[] memory team = new Mon[](3); + team[0] = _createMon(100, 10, moves); // Mon 0: 100 HP, speed 10 + team[1] = _createMon(100, 8, moves); // Mon 1: 100 HP, speed 8 + team[2] = _createMon(100, 6, moves); // Mon 2: 100 HP, speed 6 (reserve) + + defaultRegistry.setTeam(ALICE, team); + defaultRegistry.setTeam(BOB, team); + + // Authorize matchmaker + vm.startPrank(ALICE); + address[] memory makersToAdd = new address[](1); + makersToAdd[0] = address(matchmaker); + address[] memory makersToRemove = new address[](0); + engine.updateMatchmakers(makersToAdd, makersToRemove); + vm.stopPrank(); + + vm.startPrank(BOB); + engine.updateMatchmakers(makersToAdd, makersToRemove); + vm.stopPrank(); + } + + function _createMon(uint32 hp, uint32 speed, IMoveSet[] memory moves) internal pure returns (Mon memory) { + return Mon({ + stats: MonStats({ + hp: hp, + stamina: 50, + speed: speed, + attack: 10, + defense: 10, + specialAttack: 10, + specialDefense: 10, + type1: Type.Fire, + type2: Type.None + }), + ability: IAbility(address(0)), + moves: moves + }); + } + + function _startDoublesBattle() internal returns (bytes32 battleKey) { + bytes32 salt = ""; + uint96 p0TeamIndex = 0; + uint256[] memory p0TeamIndices = defaultRegistry.getMonRegistryIndicesForTeam(ALICE, p0TeamIndex); + bytes32 p0TeamHash = keccak256(abi.encodePacked(salt, p0TeamIndex, p0TeamIndices)); + + ProposedBattle memory proposal = ProposedBattle({ + p0: ALICE, + p0TeamIndex: 0, + p0TeamHash: p0TeamHash, + p1: BOB, + p1TeamIndex: 0, + teamRegistry: defaultRegistry, + validator: validator, + rngOracle: defaultOracle, + ruleset: IRuleset(address(0)), + engineHooks: new IEngineHook[](0), + moveManager: address(commitManager), + matchmaker: matchmaker, + gameMode: GameMode.Doubles + }); + + vm.startPrank(ALICE); + battleKey = matchmaker.proposeBattle(proposal); + + bytes32 battleIntegrityHash = matchmaker.getBattleProposalIntegrityHash(proposal); + vm.startPrank(BOB); + matchmaker.acceptBattle(battleKey, 0, battleIntegrityHash); + + vm.startPrank(ALICE); + matchmaker.confirmBattle(battleKey, salt, p0TeamIndex); + vm.stopPrank(); + } + + function _doublesCommitRevealExecute( + bytes32 battleKey, + uint8 aliceMove0, uint240 aliceExtra0, + uint8 aliceMove1, uint240 aliceExtra1, + uint8 bobMove0, uint240 bobExtra0, + uint8 bobMove1, uint240 bobExtra1 + ) internal { + uint256 turnId = engine.getTurnIdForBattleState(battleKey); + bytes32 aliceSalt = bytes32("alicesalt"); + bytes32 bobSalt = bytes32("bobsalt"); + + if (turnId % 2 == 0) { + bytes32 aliceHash = keccak256(abi.encodePacked(aliceMove0, aliceExtra0, aliceMove1, aliceExtra1, aliceSalt)); + vm.startPrank(ALICE); + commitManager.commitMoves(battleKey, aliceHash); + vm.stopPrank(); + + vm.startPrank(BOB); + commitManager.revealMoves(battleKey, bobMove0, bobExtra0, bobMove1, bobExtra1, bobSalt, false); + vm.stopPrank(); + + vm.startPrank(ALICE); + commitManager.revealMoves(battleKey, aliceMove0, aliceExtra0, aliceMove1, aliceExtra1, aliceSalt, false); + vm.stopPrank(); + } else { + bytes32 bobHash = keccak256(abi.encodePacked(bobMove0, bobExtra0, bobMove1, bobExtra1, bobSalt)); + vm.startPrank(BOB); + commitManager.commitMoves(battleKey, bobHash); + vm.stopPrank(); + + vm.startPrank(ALICE); + commitManager.revealMoves(battleKey, aliceMove0, aliceExtra0, aliceMove1, aliceExtra1, aliceSalt, false); + vm.stopPrank(); + + vm.startPrank(BOB); + commitManager.revealMoves(battleKey, bobMove0, bobExtra0, bobMove1, bobExtra1, bobSalt, false); + vm.stopPrank(); + } + + engine.execute(battleKey); + } + + function _doInitialSwitch(bytes32 battleKey) internal { + _doublesCommitRevealExecute( + battleKey, + SWITCH_MOVE_INDEX, 0, SWITCH_MOVE_INDEX, 1, + SWITCH_MOVE_INDEX, 0, SWITCH_MOVE_INDEX, 1 + ); + } + + // ========================================= + // Direct Validator Tests + // ========================================= + + /** + * @notice Test that on turn 0, only SWITCH_MOVE_INDEX is valid for all slots + */ + function test_turn0_onlySwitchAllowed() public { + bytes32 battleKey = _startDoublesBattle(); + + // Turn 0: validatePlayerMoveForSlot should only accept SWITCH_MOVE_INDEX + // Test slot 0 + assertTrue(validator.validatePlayerMoveForSlot(battleKey, SWITCH_MOVE_INDEX, 0, 0, 0), "SWITCH should be valid on turn 0"); + assertFalse(validator.validatePlayerMoveForSlot(battleKey, 0, 0, 0, 0), "Attack should be invalid on turn 0"); + assertFalse(validator.validatePlayerMoveForSlot(battleKey, NO_OP_MOVE_INDEX, 0, 0, 0), "NO_OP should be invalid on turn 0 (valid targets exist)"); + + // Test slot 1 + assertTrue(validator.validatePlayerMoveForSlot(battleKey, SWITCH_MOVE_INDEX, 0, 1, 1), "SWITCH should be valid on turn 0 slot 1"); + assertFalse(validator.validatePlayerMoveForSlot(battleKey, 0, 0, 1, 0), "Attack should be invalid on turn 0 slot 1"); + } + + /** + * @notice Test that after initial switch, attacks are valid for non-KO'd mons + */ + function test_afterTurn0_attacksAllowed() public { + bytes32 battleKey = _startDoublesBattle(); + vm.warp(block.timestamp + 1); + _doInitialSwitch(battleKey); + + // Turn 1: Attacks should be valid + assertTrue(validator.validatePlayerMoveForSlot(battleKey, 0, 0, 0, 0), "Attack should be valid after turn 0"); + assertTrue(validator.validatePlayerMoveForSlot(battleKey, 0, 0, 1, 0), "Attack should be valid for slot 1"); + assertTrue(validator.validatePlayerMoveForSlot(battleKey, NO_OP_MOVE_INDEX, 0, 0, 0), "NO_OP should be valid"); + + // Switch should also still be valid (to mon index 2, the reserve) + assertTrue(validator.validatePlayerMoveForSlot(battleKey, SWITCH_MOVE_INDEX, 0, 0, 2), "Switch to reserve should be valid"); + } + + /** + * @notice Test that switch to same mon is invalid (except turn 0) + */ + function test_switchToSameMonInvalid() public { + bytes32 battleKey = _startDoublesBattle(); + vm.warp(block.timestamp + 1); + _doInitialSwitch(battleKey); + + // Turn 1: Trying to switch slot 0 (which has mon 0) to mon 0 should fail + assertFalse(validator.validatePlayerMoveForSlot(battleKey, SWITCH_MOVE_INDEX, 0, 0, 0), "Switch to same mon should be invalid"); + + // Trying to switch slot 1 (which has mon 1) to mon 1 should fail + assertFalse(validator.validatePlayerMoveForSlot(battleKey, SWITCH_MOVE_INDEX, 0, 1, 1), "Switch to same mon should be invalid for slot 1"); + } + + /** + * @notice Test that switch to mon active in other slot is invalid + */ + function test_switchToOtherSlotActiveMonInvalid() public { + bytes32 battleKey = _startDoublesBattle(); + vm.warp(block.timestamp + 1); + _doInitialSwitch(battleKey); + + // After initial switch: slot 0 has mon 0, slot 1 has mon 1 + // Trying to switch slot 0 to mon 1 (active in slot 1) should fail + assertFalse(validator.validatePlayerMoveForSlot(battleKey, SWITCH_MOVE_INDEX, 0, 0, 1), "Switch to other slot's active mon should be invalid"); + + // Trying to switch slot 1 to mon 0 (active in slot 0) should fail + assertFalse(validator.validatePlayerMoveForSlot(battleKey, SWITCH_MOVE_INDEX, 0, 1, 0), "Switch to other slot's active mon should be invalid"); + + // But switch to reserve mon (index 2) should be valid + assertTrue(validator.validatePlayerMoveForSlot(battleKey, SWITCH_MOVE_INDEX, 0, 0, 2), "Switch to reserve should be valid"); + assertTrue(validator.validatePlayerMoveForSlot(battleKey, SWITCH_MOVE_INDEX, 0, 1, 2), "Switch to reserve from slot 1 should be valid"); + } + + // ========================================= + // One Player Has 1 KO'd Mon Tests + // ========================================= + + /** + * @notice Setup: Alice's slot 0 mon is KO'd, but she has a reserve mon to switch to + * Expected: Alice must switch slot 0, can use any move for slot 1 + */ + function test_onePlayerOneKO_withValidTarget() public { + // Create teams where Alice's mon 0 has very low HP + IMoveSet[] memory moves = new IMoveSet[](4); + moves[0] = strongAttack; + moves[1] = strongAttack; + moves[2] = strongAttack; + moves[3] = strongAttack; + + Mon[] memory aliceTeam = new Mon[](3); + aliceTeam[0] = _createMon(1, 10, moves); // Will be KO'd easily + aliceTeam[1] = _createMon(100, 8, moves); + aliceTeam[2] = _createMon(100, 6, moves); // Reserve + + Mon[] memory bobTeam = new Mon[](3); + bobTeam[0] = _createMon(100, 20, moves); // Faster to attack first + bobTeam[1] = _createMon(100, 18, moves); + bobTeam[2] = _createMon(100, 16, moves); + + defaultRegistry.setTeam(ALICE, aliceTeam); + defaultRegistry.setTeam(BOB, bobTeam); + + bytes32 battleKey = _startDoublesBattle(); + vm.warp(block.timestamp + 1); + _doInitialSwitch(battleKey); + + // Turn 1: Bob attacks Alice's slot 0, KO'ing it + _doublesCommitRevealExecute( + battleKey, + 0, 0, NO_OP_MOVE_INDEX, 0, // Alice: slot 0 attacks, slot 1 no-op + 0, 0, NO_OP_MOVE_INDEX, 0 // Bob: slot 0 attacks (will KO Alice slot 0), slot 1 no-op + ); + + // Verify Alice's slot 0 mon is KO'd + int32 isKO = engine.getMonStateForBattle(battleKey, 0, 0, MonStateIndexName.IsKnockedOut); + assertEq(isKO, 1, "Alice's mon 0 should be KO'd"); + + // Now validate: Alice slot 0 must switch (to reserve mon 2) + assertFalse(validator.validatePlayerMoveForSlot(battleKey, 0, 0, 0, 0), "Attack should be invalid for KO'd slot"); + assertFalse(validator.validatePlayerMoveForSlot(battleKey, NO_OP_MOVE_INDEX, 0, 0, 0), "NO_OP should be invalid when valid switch exists"); + assertTrue(validator.validatePlayerMoveForSlot(battleKey, SWITCH_MOVE_INDEX, 0, 0, 2), "Switch to reserve should be valid"); + + // Alice slot 1 can use any move (not KO'd) + assertTrue(validator.validatePlayerMoveForSlot(battleKey, 0, 0, 1, 0), "Attack should be valid for non-KO'd slot"); + assertTrue(validator.validatePlayerMoveForSlot(battleKey, NO_OP_MOVE_INDEX, 0, 1, 0), "NO_OP should be valid for non-KO'd slot"); + + // Bob's slots should be able to use any move + assertTrue(validator.validatePlayerMoveForSlot(battleKey, 0, 1, 0, 0), "Bob slot 0 attack should be valid"); + assertTrue(validator.validatePlayerMoveForSlot(battleKey, 0, 1, 1, 0), "Bob slot 1 attack should be valid"); + } + + /** + * @notice Setup: Alice's slot 0 mon is KO'd, and her only other mon is in slot 1 (no reserve) + * Expected: Alice can use NO_OP for slot 0 since no valid switch target + */ + function test_onePlayerOneKO_noValidTarget() public { + // Use only 2 mons per team for this test + DefaultValidator validator2Mon = new DefaultValidator( + engine, DefaultValidator.Args({MONS_PER_TEAM: 2, MOVES_PER_MON: 4, TIMEOUT_DURATION: TIMEOUT_DURATION}) + ); + DoublesCommitManager commitManager2 = new DoublesCommitManager(engine); + TestTeamRegistry registry2 = new TestTeamRegistry(); + + IMoveSet[] memory moves = new IMoveSet[](4); + moves[0] = strongAttack; + moves[1] = strongAttack; + moves[2] = strongAttack; + moves[3] = strongAttack; + + Mon[] memory aliceTeam = new Mon[](2); + aliceTeam[0] = _createMon(1, 10, moves); // Will be KO'd + aliceTeam[1] = _createMon(100, 8, moves); // Active in slot 1 + + Mon[] memory bobTeam = new Mon[](2); + bobTeam[0] = _createMon(100, 20, moves); + bobTeam[1] = _createMon(100, 18, moves); + + registry2.setTeam(ALICE, aliceTeam); + registry2.setTeam(BOB, bobTeam); + + // Start battle with 2-mon validator + bytes32 salt = ""; + uint96 p0TeamIndex = 0; + uint256[] memory p0TeamIndices = registry2.getMonRegistryIndicesForTeam(ALICE, p0TeamIndex); + bytes32 p0TeamHash = keccak256(abi.encodePacked(salt, p0TeamIndex, p0TeamIndices)); + + ProposedBattle memory proposal = ProposedBattle({ + p0: ALICE, + p0TeamIndex: 0, + p0TeamHash: p0TeamHash, + p1: BOB, + p1TeamIndex: 0, + teamRegistry: registry2, + validator: validator2Mon, + rngOracle: defaultOracle, + ruleset: IRuleset(address(0)), + engineHooks: new IEngineHook[](0), + moveManager: address(commitManager2), + matchmaker: matchmaker, + gameMode: GameMode.Doubles + }); + + vm.startPrank(ALICE); + bytes32 battleKey = matchmaker.proposeBattle(proposal); + bytes32 battleIntegrityHash = matchmaker.getBattleProposalIntegrityHash(proposal); + vm.startPrank(BOB); + matchmaker.acceptBattle(battleKey, 0, battleIntegrityHash); + vm.startPrank(ALICE); + matchmaker.confirmBattle(battleKey, salt, p0TeamIndex); + vm.stopPrank(); + + vm.warp(block.timestamp + 1); + + // Turn 0: Initial switch + { + uint256 turnId = engine.getTurnIdForBattleState(battleKey); + bytes32 aliceSalt = bytes32("alicesalt"); + bytes32 bobSalt = bytes32("bobsalt"); + bytes32 aliceHash = keccak256(abi.encodePacked(SWITCH_MOVE_INDEX, uint240(0), SWITCH_MOVE_INDEX, uint240(1), aliceSalt)); + vm.startPrank(ALICE); + commitManager2.commitMoves(battleKey, aliceHash); + vm.stopPrank(); + vm.startPrank(BOB); + commitManager2.revealMoves(battleKey, SWITCH_MOVE_INDEX, 0, SWITCH_MOVE_INDEX, 1, bobSalt, false); + vm.stopPrank(); + vm.startPrank(ALICE); + commitManager2.revealMoves(battleKey, SWITCH_MOVE_INDEX, 0, SWITCH_MOVE_INDEX, 1, aliceSalt, false); + vm.stopPrank(); + engine.execute(battleKey); + } + + // Turn 1: Bob KOs Alice's slot 0 + { + bytes32 aliceSalt = bytes32("alicesalt2"); + bytes32 bobSalt = bytes32("bobsalt2"); + bytes32 bobHash = keccak256(abi.encodePacked(uint8(0), uint240(0), uint8(NO_OP_MOVE_INDEX), uint240(0), bobSalt)); + vm.startPrank(BOB); + commitManager2.commitMoves(battleKey, bobHash); + vm.stopPrank(); + vm.startPrank(ALICE); + commitManager2.revealMoves(battleKey, uint8(0), 0, uint8(NO_OP_MOVE_INDEX), 0, aliceSalt, false); + vm.stopPrank(); + vm.startPrank(BOB); + commitManager2.revealMoves(battleKey, uint8(0), 0, uint8(NO_OP_MOVE_INDEX), 0, bobSalt, false); + vm.stopPrank(); + engine.execute(battleKey); + } + + // Verify Alice's mon 0 is KO'd + int32 isKO = engine.getMonStateForBattle(battleKey, 0, 0, MonStateIndexName.IsKnockedOut); + assertEq(isKO, 1, "Alice's mon 0 should be KO'd"); + + // Now Alice's slot 0 is KO'd, and slot 1 has mon 1 + // There's no valid switch target (mon 0 is KO'd, mon 1 is in other slot) + // Therefore NO_OP should be valid + assertTrue(validator2Mon.validatePlayerMoveForSlot(battleKey, NO_OP_MOVE_INDEX, 0, 0, 0), "NO_OP should be valid when no switch targets"); + assertFalse(validator2Mon.validatePlayerMoveForSlot(battleKey, 0, 0, 0, 0), "Attack should be invalid for KO'd slot"); + assertFalse(validator2Mon.validatePlayerMoveForSlot(battleKey, SWITCH_MOVE_INDEX, 0, 0, 1), "Can't switch to other slot's mon"); + } + + // ========================================= + // Both Players Have 1 KO'd Mon Tests + // ========================================= + + /** + * @notice Setup: Both Alice and Bob have their slot 0 mons KO'd, both have reserves + * Expected: Both must switch their slot 0 + */ + function test_bothPlayersOneKO_bothHaveValidTargets() public { + IMoveSet[] memory moves = new IMoveSet[](4); + moves[0] = strongAttack; + moves[1] = strongAttack; + moves[2] = strongAttack; + moves[3] = strongAttack; + + // Both teams have weak slot 0 mons, and fast slot 1 mons that will KO opponent's slot 0 + Mon[] memory aliceTeam = new Mon[](3); + aliceTeam[0] = _createMon(1, 5, moves); // Weak, slow - will be KO'd + aliceTeam[1] = _createMon(100, 20, moves); // Fast - attacks first + aliceTeam[2] = _createMon(100, 6, moves); // Reserve + + Mon[] memory bobTeam = new Mon[](3); + bobTeam[0] = _createMon(1, 5, moves); // Weak, slow - will be KO'd + bobTeam[1] = _createMon(100, 18, moves); // Fast - attacks second + bobTeam[2] = _createMon(100, 6, moves); // Reserve + + defaultRegistry.setTeam(ALICE, aliceTeam); + defaultRegistry.setTeam(BOB, bobTeam); + + bytes32 battleKey = _startDoublesBattle(); + vm.warp(block.timestamp + 1); + _doInitialSwitch(battleKey); + + // Turn 1: Slot 1 mons attack opponent's slot 0 (default targeting), KO'ing both slot 0s + // Order: Alice slot 1 (speed 20) → Bob slot 1 (speed 18) → both slot 0s too slow to matter + _doublesCommitRevealExecute( + battleKey, + NO_OP_MOVE_INDEX, 0, 0, 0, // Alice: slot 0 no-op, slot 1 attacks + NO_OP_MOVE_INDEX, 0, 0, 0 // Bob: slot 0 no-op, slot 1 attacks + ); + + // Verify both slot 0 mons are KO'd + assertEq(engine.getMonStateForBattle(battleKey, 0, 0, MonStateIndexName.IsKnockedOut), 1, "Alice mon 0 KO'd"); + assertEq(engine.getMonStateForBattle(battleKey, 1, 0, MonStateIndexName.IsKnockedOut), 1, "Bob mon 0 KO'd"); + + // Both must switch slot 0 to reserve (mon 2) + assertTrue(validator.validatePlayerMoveForSlot(battleKey, SWITCH_MOVE_INDEX, 0, 0, 2), "Alice must switch to reserve"); + assertTrue(validator.validatePlayerMoveForSlot(battleKey, SWITCH_MOVE_INDEX, 1, 0, 2), "Bob must switch to reserve"); + assertFalse(validator.validatePlayerMoveForSlot(battleKey, 0, 0, 0, 0), "Alice attack invalid"); + assertFalse(validator.validatePlayerMoveForSlot(battleKey, 0, 1, 0, 0), "Bob attack invalid"); + assertFalse(validator.validatePlayerMoveForSlot(battleKey, NO_OP_MOVE_INDEX, 0, 0, 0), "Alice NO_OP invalid (has target)"); + assertFalse(validator.validatePlayerMoveForSlot(battleKey, NO_OP_MOVE_INDEX, 1, 0, 0), "Bob NO_OP invalid (has target)"); + + // Slot 1 for both can use any move + assertTrue(validator.validatePlayerMoveForSlot(battleKey, 0, 0, 1, 0), "Alice slot 1 attack valid"); + assertTrue(validator.validatePlayerMoveForSlot(battleKey, 0, 1, 1, 0), "Bob slot 1 attack valid"); + } + + /** + * @notice Setup: Both players have slot 0 KO'd, only 2 mons per team (no reserve) + * Expected: Both can use NO_OP for slot 0 + */ + function test_bothPlayersOneKO_neitherHasValidTarget() public { + // Use 2-mon teams + DefaultValidator validator2Mon = new DefaultValidator( + engine, DefaultValidator.Args({MONS_PER_TEAM: 2, MOVES_PER_MON: 4, TIMEOUT_DURATION: TIMEOUT_DURATION}) + ); + DoublesCommitManager commitManager2 = new DoublesCommitManager(engine); + TestTeamRegistry registry2 = new TestTeamRegistry(); + + IMoveSet[] memory moves = new IMoveSet[](4); + moves[0] = strongAttack; + moves[1] = strongAttack; + moves[2] = strongAttack; + moves[3] = strongAttack; + + // Both teams: weak slot 0, fast slot 1 that will KO opponent's slot 0 + Mon[] memory aliceTeam = new Mon[](2); + aliceTeam[0] = _createMon(1, 5, moves); // Will be KO'd + aliceTeam[1] = _createMon(100, 20, moves); // Fast, attacks first + + Mon[] memory bobTeam = new Mon[](2); + bobTeam[0] = _createMon(1, 5, moves); // Will be KO'd + bobTeam[1] = _createMon(100, 18, moves); // Fast, attacks second + + registry2.setTeam(ALICE, aliceTeam); + registry2.setTeam(BOB, bobTeam); + + // Start battle + bytes32 salt = ""; + uint96 p0TeamIndex = 0; + uint256[] memory p0TeamIndices = registry2.getMonRegistryIndicesForTeam(ALICE, p0TeamIndex); + bytes32 p0TeamHash = keccak256(abi.encodePacked(salt, p0TeamIndex, p0TeamIndices)); + + ProposedBattle memory proposal = ProposedBattle({ + p0: ALICE, + p0TeamIndex: 0, + p0TeamHash: p0TeamHash, + p1: BOB, + p1TeamIndex: 0, + teamRegistry: registry2, + validator: validator2Mon, + rngOracle: defaultOracle, + ruleset: IRuleset(address(0)), + engineHooks: new IEngineHook[](0), + moveManager: address(commitManager2), + matchmaker: matchmaker, + gameMode: GameMode.Doubles + }); + + vm.startPrank(ALICE); + bytes32 battleKey = matchmaker.proposeBattle(proposal); + bytes32 battleIntegrityHash = matchmaker.getBattleProposalIntegrityHash(proposal); + vm.startPrank(BOB); + matchmaker.acceptBattle(battleKey, 0, battleIntegrityHash); + vm.startPrank(ALICE); + matchmaker.confirmBattle(battleKey, salt, p0TeamIndex); + vm.stopPrank(); + + vm.warp(block.timestamp + 1); + + // Turn 0: Initial switch + { + bytes32 aliceSalt = bytes32("as"); + bytes32 bobSalt = bytes32("bs"); + bytes32 aliceHash = keccak256(abi.encodePacked(SWITCH_MOVE_INDEX, uint240(0), SWITCH_MOVE_INDEX, uint240(1), aliceSalt)); + vm.startPrank(ALICE); + commitManager2.commitMoves(battleKey, aliceHash); + vm.stopPrank(); + vm.startPrank(BOB); + commitManager2.revealMoves(battleKey, SWITCH_MOVE_INDEX, 0, SWITCH_MOVE_INDEX, 1, bobSalt, false); + vm.stopPrank(); + vm.startPrank(ALICE); + commitManager2.revealMoves(battleKey, SWITCH_MOVE_INDEX, 0, SWITCH_MOVE_INDEX, 1, aliceSalt, false); + vm.stopPrank(); + engine.execute(battleKey); + } + + // Turn 1: Both slot 1 mons attack opponent's slot 0, KO'ing both + { + bytes32 aliceSalt = bytes32("as2"); + bytes32 bobSalt = bytes32("bs2"); + bytes32 bobHash = keccak256(abi.encodePacked(uint8(NO_OP_MOVE_INDEX), uint240(0), uint8(0), uint240(0), bobSalt)); + vm.startPrank(BOB); + commitManager2.commitMoves(battleKey, bobHash); + vm.stopPrank(); + vm.startPrank(ALICE); + commitManager2.revealMoves(battleKey, uint8(NO_OP_MOVE_INDEX), 0, uint8(0), 0, aliceSalt, false); + vm.stopPrank(); + vm.startPrank(BOB); + commitManager2.revealMoves(battleKey, uint8(NO_OP_MOVE_INDEX), 0, uint8(0), 0, bobSalt, false); + vm.stopPrank(); + engine.execute(battleKey); + } + + // Verify both slot 0 mons KO'd + assertEq(engine.getMonStateForBattle(battleKey, 0, 0, MonStateIndexName.IsKnockedOut), 1, "Alice mon 0 KO'd"); + assertEq(engine.getMonStateForBattle(battleKey, 1, 0, MonStateIndexName.IsKnockedOut), 1, "Bob mon 0 KO'd"); + + // Both should be able to NO_OP slot 0 (no valid switch targets) + assertTrue(validator2Mon.validatePlayerMoveForSlot(battleKey, NO_OP_MOVE_INDEX, 0, 0, 0), "Alice NO_OP valid"); + assertTrue(validator2Mon.validatePlayerMoveForSlot(battleKey, NO_OP_MOVE_INDEX, 1, 0, 0), "Bob NO_OP valid"); + + // Attacks still invalid for KO'd slot + assertFalse(validator2Mon.validatePlayerMoveForSlot(battleKey, 0, 0, 0, 0), "Alice attack invalid"); + assertFalse(validator2Mon.validatePlayerMoveForSlot(battleKey, 0, 1, 0, 0), "Bob attack invalid"); + + // Can't switch to other slot's mon + assertFalse(validator2Mon.validatePlayerMoveForSlot(battleKey, SWITCH_MOVE_INDEX, 0, 0, 1), "Alice can't switch to slot 1 mon"); + assertFalse(validator2Mon.validatePlayerMoveForSlot(battleKey, SWITCH_MOVE_INDEX, 1, 0, 1), "Bob can't switch to slot 1 mon"); + + // Slot 1 can still attack + assertTrue(validator2Mon.validatePlayerMoveForSlot(battleKey, 0, 0, 1, 0), "Alice slot 1 attack valid"); + assertTrue(validator2Mon.validatePlayerMoveForSlot(battleKey, 0, 1, 1, 0), "Bob slot 1 attack valid"); + } + + // ========================================= + // Integration Test: Full Flow with KO and Forced Switch + // ========================================= + + /** + * @notice Full integration test: Verify validation rejects attack for KO'd slot with valid targets + * And accepts switch to reserve + */ + function test_fullFlow_KOAndForcedSwitch() public { + IMoveSet[] memory moves = new IMoveSet[](4); + moves[0] = strongAttack; + moves[1] = strongAttack; + moves[2] = strongAttack; + moves[3] = strongAttack; + + Mon[] memory aliceTeam = new Mon[](3); + aliceTeam[0] = _createMon(1, 5, moves); // Will be KO'd (slow) + aliceTeam[1] = _createMon(100, 8, moves); + aliceTeam[2] = _createMon(100, 6, moves); // Reserve + + Mon[] memory bobTeam = new Mon[](3); + bobTeam[0] = _createMon(100, 20, moves); // Fast - attacks first + bobTeam[1] = _createMon(100, 18, moves); + bobTeam[2] = _createMon(100, 16, moves); + + defaultRegistry.setTeam(ALICE, aliceTeam); + defaultRegistry.setTeam(BOB, bobTeam); + + bytes32 battleKey = _startDoublesBattle(); + vm.warp(block.timestamp + 1); + + // Turn 0: Initial switch + _doInitialSwitch(battleKey); + assertEq(engine.getTurnIdForBattleState(battleKey), 1); + + // Turn 1: Bob KOs Alice's slot 0 + _doublesCommitRevealExecute( + battleKey, + NO_OP_MOVE_INDEX, 0, NO_OP_MOVE_INDEX, 0, // Alice: both no-op + 0, 0, NO_OP_MOVE_INDEX, 0 // Bob: slot 0 attacks Alice's slot 0 + ); + + // Verify turn advanced and mon is KO'd + assertEq(engine.getTurnIdForBattleState(battleKey), 2); + assertEq(engine.getMonStateForBattle(battleKey, 0, 0, MonStateIndexName.IsKnockedOut), 1, "Alice mon 0 KO'd"); + + // Verify validation state after KO: + // - Alice slot 0: must switch (attack invalid, NO_OP invalid since reserve exists) + assertFalse(validator.validatePlayerMoveForSlot(battleKey, 0, 0, 0, 0), "Attack invalid for KO'd slot"); + assertFalse(validator.validatePlayerMoveForSlot(battleKey, NO_OP_MOVE_INDEX, 0, 0, 0), "NO_OP invalid (reserve exists)"); + assertTrue(validator.validatePlayerMoveForSlot(battleKey, SWITCH_MOVE_INDEX, 0, 0, 2), "Switch to reserve valid"); + + // - Alice slot 1: can use any move + assertTrue(validator.validatePlayerMoveForSlot(battleKey, 0, 0, 1, 0), "Alice slot 1 attack valid"); + + // - Bob: both slots can use any move + assertTrue(validator.validatePlayerMoveForSlot(battleKey, 0, 1, 0, 0), "Bob slot 0 attack valid"); + assertTrue(validator.validatePlayerMoveForSlot(battleKey, 0, 1, 1, 0), "Bob slot 1 attack valid"); + + // Game should still be ongoing + assertEq(engine.getWinner(battleKey), address(0)); + } + + /** + * @notice Test that reveal fails when trying to use attack for KO'd slot with valid targets + * @dev After KO with valid switch target, it's a single-player switch turn (Alice only) + */ + function test_revealFailsForInvalidMoveOnKOdSlot() public { + IMoveSet[] memory moves = new IMoveSet[](4); + moves[0] = strongAttack; + moves[1] = strongAttack; + moves[2] = strongAttack; + moves[3] = strongAttack; + + Mon[] memory aliceTeam = new Mon[](3); + aliceTeam[0] = _createMon(1, 5, moves); // Slow, will be KO'd + aliceTeam[1] = _createMon(100, 8, moves); + aliceTeam[2] = _createMon(100, 6, moves); // Reserve + + Mon[] memory bobTeam = new Mon[](3); + bobTeam[0] = _createMon(100, 20, moves); // Fast, attacks first + bobTeam[1] = _createMon(100, 18, moves); + bobTeam[2] = _createMon(100, 16, moves); + + defaultRegistry.setTeam(ALICE, aliceTeam); + defaultRegistry.setTeam(BOB, bobTeam); + + bytes32 battleKey = _startDoublesBattle(); + vm.warp(block.timestamp + 1); + _doInitialSwitch(battleKey); + + // Turn 1: Bob KOs Alice's slot 0 + _doublesCommitRevealExecute( + battleKey, + NO_OP_MOVE_INDEX, 0, NO_OP_MOVE_INDEX, 0, // Alice: both no-op + 0, 0, NO_OP_MOVE_INDEX, 0 // Bob: slot 0 attacks + ); + + assertEq(engine.getMonStateForBattle(battleKey, 0, 0, MonStateIndexName.IsKnockedOut), 1, "Alice mon 0 KO'd"); + + // Verify it's a single-player switch turn (playerSwitchForTurnFlag = 0 for Alice only) + BattleContext memory ctx = engine.getBattleContext(battleKey); + assertEq(ctx.playerSwitchForTurnFlag, 0, "Should be Alice-only switch turn"); + + // Turn 2: Single-player switch turn - only Alice acts (no commits needed) + // Alice tries to reveal with attack for KO'd slot 0 - should fail with InvalidMove + bytes32 aliceSalt = bytes32("alicesalt"); + + vm.startPrank(ALICE); + vm.expectRevert(abi.encodeWithSelector(DoublesCommitManager.InvalidMove.selector, ALICE, 0)); + commitManager.revealMoves(battleKey, uint8(0), 0, uint8(NO_OP_MOVE_INDEX), 0, aliceSalt, false); + vm.stopPrank(); + } + + /** + * @notice Test single-player switch turn: only the player with KO'd mon acts + */ + function test_singlePlayerSwitchTurn() public { + IMoveSet[] memory moves = new IMoveSet[](4); + moves[0] = strongAttack; + moves[1] = strongAttack; + moves[2] = strongAttack; + moves[3] = strongAttack; + + Mon[] memory aliceTeam = new Mon[](3); + aliceTeam[0] = _createMon(1, 5, moves); // Slow, will be KO'd + aliceTeam[1] = _createMon(100, 8, moves); + aliceTeam[2] = _createMon(100, 6, moves); // Reserve + + Mon[] memory bobTeam = new Mon[](3); + bobTeam[0] = _createMon(100, 20, moves); // Fast + bobTeam[1] = _createMon(100, 18, moves); + bobTeam[2] = _createMon(100, 16, moves); + + defaultRegistry.setTeam(ALICE, aliceTeam); + defaultRegistry.setTeam(BOB, bobTeam); + + bytes32 battleKey = _startDoublesBattle(); + vm.warp(block.timestamp + 1); + _doInitialSwitch(battleKey); + + // Turn 1: Bob KOs Alice's slot 0 + _doublesCommitRevealExecute( + battleKey, + NO_OP_MOVE_INDEX, 0, NO_OP_MOVE_INDEX, 0, + 0, 0, NO_OP_MOVE_INDEX, 0 + ); + + assertEq(engine.getMonStateForBattle(battleKey, 0, 0, MonStateIndexName.IsKnockedOut), 1, "Alice mon 0 KO'd"); + + // Verify it's a single-player switch turn + BattleContext memory ctx = engine.getBattleContext(battleKey); + assertEq(ctx.playerSwitchForTurnFlag, 0, "Should be Alice-only switch turn"); + + // Bob should NOT be able to commit (it's not his turn) + vm.startPrank(BOB); + bytes32 bobHash = keccak256(abi.encodePacked(uint8(0), uint240(0), uint8(0), uint240(0), bytes32("bobsalt"))); + vm.expectRevert(DoublesCommitManager.PlayerNotAllowed.selector); + commitManager.commitMoves(battleKey, bobHash); + vm.stopPrank(); + + // Alice reveals her switch (no commit needed for single-player turns) + bytes32 aliceSalt = bytes32("alicesalt"); + vm.startPrank(ALICE); + commitManager.revealMoves(battleKey, SWITCH_MOVE_INDEX, 2, NO_OP_MOVE_INDEX, 0, aliceSalt, true); + vm.stopPrank(); + + // Verify switch happened and turn advanced + assertEq(engine.getActiveMonIndexForSlot(battleKey, 0, 0), 2, "Alice slot 0 should now have mon 2"); + assertEq(engine.getTurnIdForBattleState(battleKey), 3); + + // Next turn should be normal (both players act) + ctx = engine.getBattleContext(battleKey); + assertEq(ctx.playerSwitchForTurnFlag, 2, "Should be normal turn now"); + } +} diff --git a/test/EngineTest.sol b/test/EngineTest.sol index 7e270d8..1d7deba 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/MatchmakerTest.sol b/test/MatchmakerTest.sol index 3729dff..0a10afb 100644 --- a/test/MatchmakerTest.sol +++ b/test/MatchmakerTest.sol @@ -93,7 +93,8 @@ contract MatchmakerTest is Test, BattleHelper { ruleset: IRuleset(address(0)), engineHooks: new IEngineHook[](0), moveManager: address(commitManager), - matchmaker: matchmaker + matchmaker: matchmaker, + gameMode: GameMode.Singles }); // Propose battle as Alice @@ -121,7 +122,8 @@ contract MatchmakerTest is Test, BattleHelper { ruleset: IRuleset(address(0)), engineHooks: new IEngineHook[](0), moveManager: address(commitManager), - matchmaker: matchmaker + matchmaker: matchmaker, + gameMode: GameMode.Singles }); // Propose battle as Alice @@ -149,7 +151,8 @@ contract MatchmakerTest is Test, BattleHelper { ruleset: IRuleset(address(0)), engineHooks: new IEngineHook[](0), moveManager: address(commitManager), - matchmaker: matchmaker + matchmaker: matchmaker, + gameMode: GameMode.Singles }); // Propose battle as Alice @@ -180,7 +183,8 @@ contract MatchmakerTest is Test, BattleHelper { ruleset: IRuleset(address(0)), engineHooks: new IEngineHook[](0), moveManager: address(commitManager), - matchmaker: matchmaker + matchmaker: matchmaker, + gameMode: GameMode.Singles }); // Propose battle as Alice @@ -215,7 +219,8 @@ contract MatchmakerTest is Test, BattleHelper { ruleset: IRuleset(address(0)), engineHooks: new IEngineHook[](0), moveManager: address(commitManager), - matchmaker: matchmaker + matchmaker: matchmaker, + gameMode: GameMode.Singles }); // Propose battle as Alice @@ -248,7 +253,8 @@ contract MatchmakerTest is Test, BattleHelper { ruleset: IRuleset(address(0)), engineHooks: new IEngineHook[](0), moveManager: address(commitManager), - matchmaker: matchmaker + matchmaker: matchmaker, + gameMode: GameMode.Singles }); // Propose battle as Alice @@ -284,7 +290,8 @@ contract MatchmakerTest is Test, BattleHelper { ruleset: IRuleset(address(0)), engineHooks: new IEngineHook[](0), moveManager: address(commitManager), - matchmaker: matchmaker + matchmaker: matchmaker, + gameMode: GameMode.Singles }); // Propose battle as Alice @@ -314,7 +321,8 @@ contract MatchmakerTest is Test, BattleHelper { ruleset: IRuleset(address(0)), engineHooks: new IEngineHook[](0), moveManager: address(commitManager), - matchmaker: matchmaker + matchmaker: matchmaker, + gameMode: GameMode.Singles }); // Propose battle as Alice @@ -348,7 +356,8 @@ contract MatchmakerTest is Test, BattleHelper { ruleset: IRuleset(address(0)), engineHooks: new IEngineHook[](0), moveManager: address(commitManager), - matchmaker: matchmaker + matchmaker: matchmaker, + gameMode: GameMode.Singles }); // Propose battle as Alice @@ -376,7 +385,8 @@ contract MatchmakerTest is Test, BattleHelper { ruleset: IRuleset(address(0)), engineHooks: new IEngineHook[](0), moveManager: address(commitManager), - matchmaker: matchmaker + matchmaker: matchmaker, + gameMode: GameMode.Singles }); // Propose battle as Alice @@ -413,7 +423,8 @@ contract MatchmakerTest is Test, BattleHelper { ruleset: IRuleset(address(0)), engineHooks: new IEngineHook[](0), moveManager: address(commitManager), - matchmaker: matchmaker + matchmaker: matchmaker, + gameMode: GameMode.Singles }); // Propose battle as Alice @@ -446,7 +457,8 @@ contract MatchmakerTest is Test, BattleHelper { ruleset: IRuleset(address(0)), engineHooks: new IEngineHook[](0), moveManager: address(commitManager), - matchmaker: matchmaker + matchmaker: matchmaker, + gameMode: GameMode.Singles }); // Propose battle as Alice diff --git a/test/abstract/BattleHelper.sol b/test/abstract/BattleHelper.sol index af8eff4..a877873 100644 --- a/test/abstract/BattleHelper.sol +++ b/test/abstract/BattleHelper.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.0; import "../../src/Structs.sol"; +import {GameMode} from "../../src/Enums.sol"; import {DefaultCommitManager} from "../../src/DefaultCommitManager.sol"; import {Engine} from "../../src/Engine.sol"; @@ -112,7 +113,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/DoublesTargetedAttack.sol b/test/mocks/DoublesTargetedAttack.sol new file mode 100644 index 0000000..45b48c2 --- /dev/null +++ b/test/mocks/DoublesTargetedAttack.sol @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity ^0.8.0; + +import "../../src/Structs.sol"; +import "../../src/Enums.sol"; +import "../../src/Constants.sol"; +import "../../src/Engine.sol"; +import "../../src/moves/IMoveSet.sol"; +import "../../src/types/ITypeCalculator.sol"; + +/** + * @title DoublesTargetedAttack + * @notice A mock attack for doubles battles that uses extraData for target slot selection + * @dev extraData is interpreted as the target slot index (0 or 1) on the opponent's side + */ +contract DoublesTargetedAttack is IMoveSet { + Engine public immutable ENGINE; + ITypeCalculator public immutable TYPE_CALCULATOR; + + uint32 private _basePower; + uint32 private _stamina; + uint32 private _accuracy; + uint32 private _priority; + Type private _moveType; + + struct Args { + Type TYPE; + uint32 BASE_POWER; + uint32 ACCURACY; + uint32 STAMINA_COST; + uint32 PRIORITY; + } + + constructor(Engine engine, ITypeCalculator typeCalc, Args memory args) { + ENGINE = engine; + TYPE_CALCULATOR = typeCalc; + _basePower = args.BASE_POWER; + _stamina = args.STAMINA_COST; + _accuracy = args.ACCURACY; + _priority = args.PRIORITY; + _moveType = args.TYPE; + } + + function move(bytes32 battleKey, uint256 attackerPlayerIndex, uint240 extraData, uint256 rng) external { + // Parse target slot from extraData (0 or 1) + uint256 targetSlot = uint256(extraData) & 0x01; + uint256 defenderPlayerIndex = (attackerPlayerIndex + 1) % 2; + + // Get the target mon index from the specified slot + uint256 defenderMonIndex = ENGINE.getActiveMonIndexForSlot(battleKey, defenderPlayerIndex, targetSlot); + + // Check accuracy + if (rng % 100 >= _accuracy) { + return; // Miss + } + + // Get attacker mon index (slot 0 for simplicity - in a real implementation would need slot info) + uint256 attackerMonIndex = ENGINE.getActiveMonIndexForSlot(battleKey, attackerPlayerIndex, 0); + + // Calculate damage using a simplified formula + // Get attacker's attack stat + int32 attackDelta = ENGINE.getMonStateForBattle(battleKey, attackerPlayerIndex, attackerMonIndex, MonStateIndexName.Attack); + uint32 baseAttack = ENGINE.getMonValueForBattle(battleKey, attackerPlayerIndex, attackerMonIndex, MonStateIndexName.Attack); + uint32 attack = uint32(int32(baseAttack) + attackDelta); + + // Get defender's defense stat + int32 defDelta = ENGINE.getMonStateForBattle(battleKey, defenderPlayerIndex, defenderMonIndex, MonStateIndexName.Defense); + uint32 baseDef = ENGINE.getMonValueForBattle(battleKey, defenderPlayerIndex, defenderMonIndex, MonStateIndexName.Defense); + uint32 defense = uint32(int32(baseDef) + defDelta); + + // Simple damage formula: (attack / defense) * basePower + uint32 damage = (_basePower * attack) / (defense > 0 ? defense : 1); + + // Apply type effectiveness + Type defType1 = Type(ENGINE.getMonValueForBattle(battleKey, defenderPlayerIndex, defenderMonIndex, MonStateIndexName.Type1)); + Type defType2 = Type(ENGINE.getMonValueForBattle(battleKey, defenderPlayerIndex, defenderMonIndex, MonStateIndexName.Type2)); + damage = TYPE_CALCULATOR.getTypeEffectiveness(_moveType, defType1, damage); + damage = TYPE_CALCULATOR.getTypeEffectiveness(_moveType, defType2, damage); + + // Deal damage to the targeted mon + if (damage > 0) { + ENGINE.dealDamage(defenderPlayerIndex, defenderMonIndex, int32(damage)); + } + } + + function isValidTarget(bytes32, uint240 extraData) external pure returns (bool) { + // extraData should be 0 or 1 for slot targeting + return (uint256(extraData) & 0x01) <= 1; + } + + function priority(bytes32, uint256) external view returns (uint32) { + return _priority; + } + + function stamina(bytes32, uint256, uint256) external view returns (uint32) { + return _stamina; + } + + function moveType(bytes32) external view returns (Type) { + return _moveType; + } + + function moveClass(bytes32) external pure returns (MoveClass) { + return MoveClass.Physical; + } + + function basePower(bytes32) external view returns (uint32) { + return _basePower; + } + + function accuracy(bytes32) external view returns (uint32) { + return _accuracy; + } + + function name() external pure returns (string memory) { + return "DoublesTargetedAttack"; + } + + function extraDataType() external pure returns (ExtraDataType) { + return ExtraDataType.None; // Custom targeting logic in this mock + } +}