diff --git a/drool/abilities.csv b/drool/abilities.csv index 433229ed..b3d010e0 100644 --- a/drool/abilities.csv +++ b/drool/abilities.csv @@ -10,3 +10,4 @@ Preemptive Shock,Volthare,"When Volthare swaps in, they deal a small amount of d Interweaving,Inutia,"When Inutia swaps in, the opposing mon's ATK decreases 10%. When Inutia swaps out, the opposing mon's SpATK decreases 10%." Up Only,Aurox,"Whenever Aurox takes damage, they gain a persistent 10% ATK boost." Dreamcatcher,Xmon,"Whenever Xmon gains stamina, heal 6.6% of max HP." +Savior Complex,Ekineki,"On switch-in, gain 15/25/30% SpATK boost based on KO'd mons (1/2/3+). Triggers once per game. Boost is temporary (cleared on switch-out)." diff --git a/drool/moves.csv b/drool/moves.csv index ab4d1593..abdedb32 100644 --- a/drool/moves.csv +++ b/drool/moves.csv @@ -42,4 +42,8 @@ Bull Rush,Aurox,120,2,100,0,Metal,Physical,Deals damage. Also deals 20% of max H Contagious Slumber,Xmon,0,2,100,0,Cosmic,Other,"Inflicts Sleep on self and opponent. When asleep, you are forced to rest.",,No Vital Siphon,Xmon,40,2,90,0,Cosmic,Special,"Deals damage, 50% chance to steal 1 stamina from opponent.",,No Somniphobia,Xmon,0,1,100,0,Cosmic,Other,"For the next 6 turns, any mon that rests will take 1/16th of max HP as damage.",,No -Night Terrors,Xmon,0,0,100,0,Cosmic,Special,Gain a Terror stack. Deals damage and costs stamina at end of turn for each Terror stack. Deals extra damage if opponent is alseep.,,No \ No newline at end of file +Night Terrors,Xmon,0,0,100,0,Cosmic,Special,Gain a Terror stack. Deals damage and costs stamina at end of turn for each Terror stack. Deals extra damage if opponent is alseep.,,No +Bubble Bop,Ekineki,50,3,100,0,Liquid,Special,Hits twice. Each hit deals 50 base power.,,No +Sneak Attack,Ekineki,60,2,100,0,Liquid,Special,Hits any opponent mon (even non-active). Can only be used once per switch-in.,,Yes +999,Ekineki,0,2,100,0,Math,Self,Sets crit rate to 90% on the next turn for all moves.,,No +Overflow,Ekineki,90,3,100,0,Math,Special,Deals damage.,,No \ No newline at end of file diff --git a/snapshots/EngineGasTest.json b/snapshots/EngineGasTest.json index 5b75c438..3cb61c2d 100644 --- a/snapshots/EngineGasTest.json +++ b/snapshots/EngineGasTest.json @@ -1,17 +1,17 @@ { - "B1_Execute": "959518", - "B1_Setup": "817602", - "B2_Execute": "739644", - "B2_Setup": "279034", - "Battle1_Execute": "486807", - "Battle1_Setup": "794019", - "Battle2_Execute": "401430", - "Battle2_Setup": "235683", - "FirstBattle": "3455942", + "B1_Execute": "961278", + "B1_Setup": "817690", + "B2_Execute": "741382", + "B2_Setup": "279144", + "Battle1_Execute": "487401", + "Battle1_Setup": "794107", + "Battle2_Execute": "402024", + "Battle2_Setup": "235771", + "FirstBattle": "3463862", "Intermediary stuff": "47036", - "SecondBattle": "3545468", - "Setup 1": "1673954", - "Setup 2": "296769", - "Setup 3": "339643", - "ThirdBattle": "2866229" + "SecondBattle": "3554466", + "Setup 1": "1674042", + "Setup 2": "296857", + "Setup 3": "339731", + "ThirdBattle": "2874149" } \ No newline at end of file diff --git a/snapshots/MatchmakerTest.json b/snapshots/MatchmakerTest.json index b8be7a97..9554ed1a 100644 --- a/snapshots/MatchmakerTest.json +++ b/snapshots/MatchmakerTest.json @@ -1,5 +1,5 @@ { - "Accept1": "312093", - "Accept2": "33991", - "Propose1": "197148" + "Accept1": "312115", + "Accept2": "34013", + "Propose1": "197170" } \ No newline at end of file diff --git a/src/Engine.sol b/src/Engine.sol index 91d45fdb..17f0e5da 100644 --- a/src/Engine.sol +++ b/src/Engine.sol @@ -1743,6 +1743,10 @@ contract Engine is IEngine, MappingAllocator { return battleConfig[_getStorageKey(battleKey)].startTimestamp; } + function getKOBitmap(bytes32 battleKey, uint256 playerIndex) external view returns (uint256) { + return _getKOBitmap(battleConfig[_getStorageKey(battleKey)], playerIndex); + } + function getPrevPlayerSwitchForTurnFlagForBattleState(bytes32 battleKey) external view returns (uint256) { return battleData[battleKey].prevPlayerSwitchForTurnFlag; } diff --git a/src/Enums.sol b/src/Enums.sol index 4dbc1843..b5754f69 100644 --- a/src/Enums.sol +++ b/src/Enums.sol @@ -75,5 +75,6 @@ enum StatBoostFlag { enum ExtraDataType { None, - SelfTeamIndex + SelfTeamIndex, + OpponentNonKOTeamIndex } diff --git a/src/IEngine.sol b/src/IEngine.sol index 1f8c8eeb..4c579dc4 100644 --- a/src/IEngine.sol +++ b/src/IEngine.sol @@ -77,6 +77,7 @@ interface IEngine { returns (EffectInstance[] memory, uint256[] memory); function getWinner(bytes32 battleKey) external view returns (address); function getStartTimestamp(bytes32 battleKey) external view returns (uint256); + function getKOBitmap(bytes32 battleKey, uint256 playerIndex) external view returns (uint256); function getPrevPlayerSwitchForTurnFlagForBattleState(bytes32 battleKey) external view returns (uint256); function getBattleContext(bytes32 battleKey) external view returns (BattleContext memory); function getCommitContext(bytes32 battleKey) external view returns (CommitContext memory); diff --git a/src/cpu/CPU.sol b/src/cpu/CPU.sol index 42197edc..77bd2b8f 100644 --- a/src/cpu/CPU.sol +++ b/src/cpu/CPU.sol @@ -109,6 +109,24 @@ abstract contract CPU is CPUMoveManager, ICPU, ICPURNG, IMatchmaker { RNG.getRNG(keccak256(abi.encode(nonce++, battleKey, block.timestamp))) % validSwitchCount; extraDataToUse = uint240(validSwitchIndices[randomIndex]); validMoveExtraData[validMoveCount] = extraDataToUse; + } else if (move.extraDataType() == ExtraDataType.OpponentNonKOTeamIndex) { + uint256 opponentIndex = (playerIndex + 1) % 2; + uint256 opponentTeamSize = ENGINE.getTeamSize(battleKey, opponentIndex); + uint256 koBitmap = ENGINE.getKOBitmap(battleKey, opponentIndex); + uint256[] memory validTargets = new uint256[](opponentTeamSize); + uint256 validTargetCount; + for (uint256 j = 0; j < opponentTeamSize; j++) { + if ((koBitmap & (1 << j)) == 0) { + validTargets[validTargetCount++] = j; + } + } + if (validTargetCount == 0) { + continue; + } + uint256 randomIndex = + RNG.getRNG(keccak256(abi.encode(nonce++, battleKey, block.timestamp))) % validTargetCount; + extraDataToUse = uint240(validTargets[randomIndex]); + validMoveExtraData[validMoveCount] = extraDataToUse; } if (validator.validatePlayerMove(battleKey, i, playerIndex, extraDataToUse)) { validMoveIndices[validMoveCount++] = uint8(i); diff --git a/src/mons/ekineki/999.sol b/src/mons/ekineki/999.sol new file mode 100644 index 00000000..8d58ef12 --- /dev/null +++ b/src/mons/ekineki/999.sol @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: AGPL-3.0 + +pragma solidity ^0.8.0; + +import "../../Constants.sol"; +import "../../Enums.sol"; + +import {IEngine} from "../../IEngine.sol"; +import {IMoveSet} from "../../moves/IMoveSet.sol"; +import {NineNineNineLib} from "./999Lib.sol"; + +contract NineNineNine is IMoveSet { + IEngine immutable ENGINE; + + constructor(IEngine _ENGINE) { + ENGINE = _ENGINE; + } + + function name() external pure returns (string memory) { + return "999"; + } + + function move(bytes32 battleKey, uint256 attackerPlayerIndex, uint240, uint256) external { + // Set crit boost for the next turn + uint256 currentTurn = ENGINE.getTurnIdForBattleState(battleKey); + bytes32 key = NineNineNineLib._getKey(attackerPlayerIndex); + ENGINE.setGlobalKV(key, uint192(currentTurn + 1)); + } + + function stamina(bytes32, uint256, uint256) external pure returns (uint32) { + return 2; + } + + function priority(bytes32, uint256) external pure returns (uint32) { + return DEFAULT_PRIORITY; + } + + function moveType(bytes32) external pure returns (Type) { + return Type.Math; + } + + function moveClass(bytes32) external pure returns (MoveClass) { + return MoveClass.Self; + } + + function isValidTarget(bytes32, uint240) external pure returns (bool) { + return true; + } + + function extraDataType() external pure returns (ExtraDataType) { + return ExtraDataType.None; + } +} diff --git a/src/mons/ekineki/999Lib.sol b/src/mons/ekineki/999Lib.sol new file mode 100644 index 00000000..fdd8f3ea --- /dev/null +++ b/src/mons/ekineki/999Lib.sol @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: AGPL-3.0 + +pragma solidity ^0.8.0; + +import "../../Constants.sol"; + +import {IEngine} from "../../IEngine.sol"; + +library NineNineNineLib { + uint32 constant NINE_NINE_NINE_CRIT_RATE = 90; + + function _getKey(uint256 playerIndex) internal pure returns (bytes32) { + return keccak256(abi.encode(playerIndex, "NINE_NINE_NINE")); + } + + function _getEffectiveCritRate(IEngine engine, bytes32 battleKey, uint256 playerIndex) + internal + view + returns (uint32) + { + uint192 boostTurn = engine.getGlobalKV(battleKey, _getKey(playerIndex)); + uint256 currentTurn = engine.getTurnIdForBattleState(battleKey); + if (boostTurn > 0 && uint256(boostTurn) == currentTurn) { + return NINE_NINE_NINE_CRIT_RATE; + } + return DEFAULT_CRIT_RATE; + } +} diff --git a/src/mons/ekineki/BubbleBop.sol b/src/mons/ekineki/BubbleBop.sol new file mode 100644 index 00000000..af8cd65d --- /dev/null +++ b/src/mons/ekineki/BubbleBop.sol @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: AGPL-3.0 + +pragma solidity ^0.8.0; + +import "../../Constants.sol"; +import "../../Enums.sol"; + +import {IEngine} from "../../IEngine.sol"; +import {IEffect} from "../../effects/IEffect.sol"; +import {ITypeCalculator} from "../../types/ITypeCalculator.sol"; +import {AttackCalculator} from "../../moves/AttackCalculator.sol"; +import {StandardAttack} from "../../moves/StandardAttack.sol"; +import {ATTACK_PARAMS} from "../../moves/StandardAttackStructs.sol"; +import {NineNineNineLib} from "./999Lib.sol"; + +contract BubbleBop is StandardAttack { + constructor(IEngine _ENGINE, ITypeCalculator _TYPE_CALCULATOR) + StandardAttack( + address(msg.sender), + _ENGINE, + _TYPE_CALCULATOR, + ATTACK_PARAMS({ + NAME: "Bubble Bop", + BASE_POWER: 50, + STAMINA_COST: 3, + ACCURACY: 100, + MOVE_TYPE: Type.Liquid, + MOVE_CLASS: MoveClass.Special, + PRIORITY: DEFAULT_PRIORITY, + CRIT_RATE: DEFAULT_CRIT_RATE, + VOLATILITY: DEFAULT_VOL, + EFFECT_ACCURACY: 0, + EFFECT: IEffect(address(0)) + }) + ) + {} + + function move(bytes32 battleKey, uint256 attackerPlayerIndex, uint240, uint256 rng) public override { + uint32 effectiveCritRate = NineNineNineLib._getEffectiveCritRate(ENGINE, battleKey, attackerPlayerIndex); + + // First hit + AttackCalculator._calculateDamage( + ENGINE, + TYPE_CALCULATOR, + battleKey, + attackerPlayerIndex, + basePower(battleKey), + accuracy(battleKey), + volatility(battleKey), + moveType(battleKey), + moveClass(battleKey), + rng, + effectiveCritRate + ); + + // Second hit with different RNG + uint256 rng2 = uint256(keccak256(abi.encode(rng, "SECOND_HIT"))); + AttackCalculator._calculateDamage( + ENGINE, + TYPE_CALCULATOR, + battleKey, + attackerPlayerIndex, + basePower(battleKey), + accuracy(battleKey), + volatility(battleKey), + moveType(battleKey), + moveClass(battleKey), + rng2, + effectiveCritRate + ); + } +} diff --git a/src/mons/ekineki/Overflow.sol b/src/mons/ekineki/Overflow.sol new file mode 100644 index 00000000..2b9374d1 --- /dev/null +++ b/src/mons/ekineki/Overflow.sol @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: AGPL-3.0 + +pragma solidity ^0.8.0; + +import "../../Constants.sol"; +import "../../Enums.sol"; + +import {IEngine} from "../../IEngine.sol"; +import {IEffect} from "../../effects/IEffect.sol"; +import {ITypeCalculator} from "../../types/ITypeCalculator.sol"; +import {AttackCalculator} from "../../moves/AttackCalculator.sol"; +import {StandardAttack} from "../../moves/StandardAttack.sol"; +import {ATTACK_PARAMS} from "../../moves/StandardAttackStructs.sol"; +import {NineNineNineLib} from "./999Lib.sol"; + +contract Overflow is StandardAttack { + constructor(IEngine _ENGINE, ITypeCalculator _TYPE_CALCULATOR) + StandardAttack( + address(msg.sender), + _ENGINE, + _TYPE_CALCULATOR, + ATTACK_PARAMS({ + NAME: "Overflow", + BASE_POWER: 90, + STAMINA_COST: 3, + ACCURACY: 100, + MOVE_TYPE: Type.Math, + MOVE_CLASS: MoveClass.Special, + PRIORITY: DEFAULT_PRIORITY, + CRIT_RATE: DEFAULT_CRIT_RATE, + VOLATILITY: DEFAULT_VOL, + EFFECT_ACCURACY: 0, + EFFECT: IEffect(address(0)) + }) + ) + {} + + function move(bytes32 battleKey, uint256 attackerPlayerIndex, uint240, uint256 rng) public override { + uint32 effectiveCritRate = NineNineNineLib._getEffectiveCritRate(ENGINE, battleKey, attackerPlayerIndex); + AttackCalculator._calculateDamage( + ENGINE, + TYPE_CALCULATOR, + battleKey, + attackerPlayerIndex, + basePower(battleKey), + accuracy(battleKey), + volatility(battleKey), + moveType(battleKey), + moveClass(battleKey), + rng, + effectiveCritRate + ); + } +} diff --git a/src/mons/ekineki/SaviorComplex.sol b/src/mons/ekineki/SaviorComplex.sol new file mode 100644 index 00000000..6a5181c9 --- /dev/null +++ b/src/mons/ekineki/SaviorComplex.sol @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: AGPL-3.0 + +pragma solidity ^0.8.0; + +import {MonStateIndexName, StatBoostFlag, StatBoostType} from "../../Enums.sol"; +import {StatBoostToApply} from "../../Structs.sol"; +import {IEngine} from "../../IEngine.sol"; +import {IAbility} from "../../abilities/IAbility.sol"; +import {StatBoosts} from "../../effects/StatBoosts.sol"; + +contract SaviorComplex is IAbility { + uint8 public constant STAGE_1_BOOST = 15; // 1 KO'd + uint8 public constant STAGE_2_BOOST = 25; // 2 KO'd + uint8 public constant STAGE_3_BOOST = 30; // 3+ KO'd + + IEngine immutable ENGINE; + StatBoosts immutable STAT_BOOSTS; + + constructor(IEngine _ENGINE, StatBoosts _STAT_BOOSTS) { + ENGINE = _ENGINE; + STAT_BOOSTS = _STAT_BOOSTS; + } + + function name() external pure returns (string memory) { + return "Savior Complex"; + } + + function _getSaviorComplexKey(uint256 playerIndex) internal pure returns (bytes32) { + return keccak256(abi.encode(playerIndex, "SAVIOR_COMPLEX")); + } + + function activateOnSwitch(bytes32 battleKey, uint256 playerIndex, uint256 monIndex) external { + // Check if already triggered this game + if (ENGINE.getGlobalKV(battleKey, _getSaviorComplexKey(playerIndex)) == 1) { + return; + } + + // Count KO'd mons via bitmap popcount + uint256 koBitmap = ENGINE.getKOBitmap(battleKey, playerIndex); + if (koBitmap == 0) return; + uint256 koCount = 0; + for (uint256 bits = koBitmap; bits != 0; bits >>= 1) { + koCount += bits & 1; + } + + // Determine boost based on stage + uint8 boostPercent; + if (koCount >= 3) { + boostPercent = STAGE_3_BOOST; + } else if (koCount >= 2) { + boostPercent = STAGE_2_BOOST; + } else { + boostPercent = STAGE_1_BOOST; + } + + // Apply temporary sp atk boost (cleared on switch out) + StatBoostToApply[] memory statBoosts = new StatBoostToApply[](1); + statBoosts[0] = StatBoostToApply({ + stat: MonStateIndexName.SpecialAttack, + boostPercent: boostPercent, + boostType: StatBoostType.Multiply + }); + STAT_BOOSTS.addStatBoosts(playerIndex, monIndex, statBoosts, StatBoostFlag.Temp); + + // Mark as triggered (once per game) + ENGINE.setGlobalKV(_getSaviorComplexKey(playerIndex), 1); + } +} diff --git a/src/mons/ekineki/SneakAttack.sol b/src/mons/ekineki/SneakAttack.sol new file mode 100644 index 00000000..6fd8ea74 --- /dev/null +++ b/src/mons/ekineki/SneakAttack.sol @@ -0,0 +1,128 @@ +// 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 {ITypeCalculator} from "../../types/ITypeCalculator.sol"; +import {AttackCalculator} from "../../moves/AttackCalculator.sol"; +import {IMoveSet} from "../../moves/IMoveSet.sol"; +import {BasicEffect} from "../../effects/BasicEffect.sol"; +import {IEffect} from "../../effects/IEffect.sol"; +import {NineNineNineLib} from "./999Lib.sol"; + +contract SneakAttack is IMoveSet, BasicEffect { + uint32 public constant BASE_POWER = 60; + uint32 public constant STAMINA_COST = 2; + + IEngine immutable ENGINE; + ITypeCalculator immutable TYPE_CALCULATOR; + + constructor(IEngine _ENGINE, ITypeCalculator _TYPE_CALCULATOR) { + ENGINE = _ENGINE; + TYPE_CALCULATOR = _TYPE_CALCULATOR; + } + + function name() public pure override(IMoveSet, BasicEffect) returns (string memory) { + return "Sneak Attack"; + } + + function move(bytes32 battleKey, uint256 attackerPlayerIndex, uint240 extraData, uint256 rng) external { + // Check if already used this switch-in (effect present = already used) + uint256 attackerMonIndex = ENGINE.getActiveMonIndexForBattleState(battleKey)[attackerPlayerIndex]; + (EffectInstance[] memory effects,) = ENGINE.getEffects(battleKey, attackerPlayerIndex, attackerMonIndex); + for (uint256 i = 0; i < effects.length; i++) { + if (address(effects[i].effect) == address(this)) { + return; + } + } + + uint256 defenderPlayerIndex = (attackerPlayerIndex + 1) % 2; + uint256 targetMonIndex = uint256(extraData); + + // Get effective crit rate (checks 999 buff) + uint32 effectiveCritRate = NineNineNineLib._getEffectiveCritRate(ENGINE, battleKey, attackerPlayerIndex); + + // Build DamageCalcContext manually to target any opponent mon (not just active) + MonStats memory attackerStats = ENGINE.getMonStatsForBattle(battleKey, attackerPlayerIndex, attackerMonIndex); + MonStats memory defenderStats = ENGINE.getMonStatsForBattle(battleKey, defenderPlayerIndex, targetMonIndex); + + DamageCalcContext memory ctx = DamageCalcContext({ + attackerMonIndex: uint8(attackerMonIndex), + defenderMonIndex: uint8(targetMonIndex), + attackerAttack: attackerStats.attack, + attackerAttackDelta: ENGINE.getMonStateForBattle( + battleKey, attackerPlayerIndex, attackerMonIndex, MonStateIndexName.Attack + ), + attackerSpAtk: attackerStats.specialAttack, + attackerSpAtkDelta: ENGINE.getMonStateForBattle( + battleKey, attackerPlayerIndex, attackerMonIndex, MonStateIndexName.SpecialAttack + ), + defenderDef: defenderStats.defense, + defenderDefDelta: ENGINE.getMonStateForBattle( + battleKey, defenderPlayerIndex, targetMonIndex, MonStateIndexName.Defense + ), + defenderSpDef: defenderStats.specialDefense, + defenderSpDefDelta: ENGINE.getMonStateForBattle( + battleKey, defenderPlayerIndex, targetMonIndex, MonStateIndexName.SpecialDefense + ), + defenderType1: defenderStats.type1, + defenderType2: defenderStats.type2 + }); + + (int32 damage, bytes32 eventType) = AttackCalculator._calculateDamageFromContext( + TYPE_CALCULATOR, ctx, BASE_POWER, DEFAULT_ACCURACY, DEFAULT_VOL, Type.Liquid, MoveClass.Special, rng, effectiveCritRate + ); + + if (damage != 0) { + ENGINE.dealDamage(defenderPlayerIndex, targetMonIndex, damage); + } + if (eventType != bytes32(0)) { + ENGINE.emitEngineEvent(eventType, ""); + } + + // Mark as used by adding local effect on the attacker's mon + ENGINE.addEffect(attackerPlayerIndex, attackerMonIndex, IEffect(address(this)), bytes32(0)); + } + + function stamina(bytes32, uint256, uint256) external pure returns (uint32) { + return STAMINA_COST; + } + + function priority(bytes32, uint256) external pure returns (uint32) { + return DEFAULT_PRIORITY; + } + + function moveType(bytes32) external pure returns (Type) { + return Type.Liquid; + } + + function moveClass(bytes32) external pure returns (MoveClass) { + return MoveClass.Special; + } + + function isValidTarget(bytes32, uint240) external pure returns (bool) { + return true; + } + + function extraDataType() external pure returns (ExtraDataType) { + return ExtraDataType.OpponentNonKOTeamIndex; + } + + // IEffect implementation — local effect that cleans up on switch-out + function shouldRunAtStep(EffectStep step) external pure override returns (bool) { + return step == EffectStep.OnMonSwitchOut; + } + + function onMonSwitchOut(uint256, bytes32, uint256, uint256) + external + pure + override + returns (bytes32 updatedExtraData, bool removeAfterRun) + { + return (bytes32(0), true); + } +} diff --git a/test/mons/EkinekiTest.sol b/test/mons/EkinekiTest.sol new file mode 100644 index 00000000..8c1f2053 --- /dev/null +++ b/test/mons/EkinekiTest.sol @@ -0,0 +1,733 @@ +// SPDX-License-Identifier: AGPL-3.0 + +pragma solidity ^0.8.0; + +import {Test} from "forge-std/Test.sol"; + +import "../../src/Constants.sol"; +import "../../src/Structs.sol"; + +import {DefaultCommitManager} from "../../src/commit-manager/DefaultCommitManager.sol"; +import {DefaultValidator} from "../../src/DefaultValidator.sol"; +import {Engine} from "../../src/Engine.sol"; +import {MonStateIndexName, MoveClass, Type} from "../../src/Enums.sol"; +import {IEngine} from "../../src/IEngine.sol"; +import {IEffect} from "../../src/effects/IEffect.sol"; +import {StatBoosts} from "../../src/effects/StatBoosts.sol"; +import {DefaultMatchmaker} from "../../src/matchmaker/DefaultMatchmaker.sol"; +import {IMoveSet} from "../../src/moves/IMoveSet.sol"; +import {StandardAttack} from "../../src/moves/StandardAttack.sol"; +import {StandardAttackFactory} from "../../src/moves/StandardAttackFactory.sol"; +import {ATTACK_PARAMS} from "../../src/moves/StandardAttackStructs.sol"; +import {ITypeCalculator} from "../../src/types/ITypeCalculator.sol"; +import {BattleHelper} from "../abstract/BattleHelper.sol"; +import {MockRandomnessOracle} from "../mocks/MockRandomnessOracle.sol"; +import {TestTeamRegistry} from "../mocks/TestTeamRegistry.sol"; +import {TestTypeCalculator} from "../mocks/TestTypeCalculator.sol"; + +// Ekineki contracts +import {BubbleBop} from "../../src/mons/ekineki/BubbleBop.sol"; +import {NineNineNine} from "../../src/mons/ekineki/999.sol"; +import {Overflow} from "../../src/mons/ekineki/Overflow.sol"; +import {SaviorComplex} from "../../src/mons/ekineki/SaviorComplex.sol"; +import {SneakAttack} from "../../src/mons/ekineki/SneakAttack.sol"; + +/** + * Tests: + * - Bubble Bop hits twice, dealing damage with each hit [x] + * - SneakAttack hits a non-active opponent mon [x] + * - SneakAttack can only be used once per switch-in [x] + * - SneakAttack resets on switch (local effect removed on switch-out) [x] + * - 999 boosts crit rate to 90% on the next turn [x] + * - SaviorComplex boosts sp atk based on KO'd mons [x] + * - SaviorComplex only triggers once per game [x] + * - SaviorComplex does not trigger with 0 KOs (can trigger later) [x] + * - Overflow deals damage [x] + */ +contract EkinekiTest is Test, BattleHelper { + Engine engine; + DefaultCommitManager commitManager; + TestTypeCalculator typeCalc; + MockRandomnessOracle mockOracle; + TestTeamRegistry defaultRegistry; + StatBoosts statBoosts; + DefaultMatchmaker matchmaker; + StandardAttackFactory attackFactory; + + function setUp() public { + typeCalc = new TestTypeCalculator(); + mockOracle = new MockRandomnessOracle(); + defaultRegistry = new TestTeamRegistry(); + engine = new Engine(); + commitManager = new DefaultCommitManager(IEngine(address(engine))); + statBoosts = new StatBoosts(IEngine(address(engine))); + matchmaker = new DefaultMatchmaker(engine); + attackFactory = new StandardAttackFactory(IEngine(address(engine)), ITypeCalculator(address(typeCalc))); + } + + function test_bubbleBopHitsTwice() public { + uint32 maxHp = 200; + + BubbleBop bubbleBop = new BubbleBop(IEngine(address(engine)), ITypeCalculator(address(typeCalc))); + + // Create a single-hit reference attack with same params (0 vol, 0 crit for predictable damage) + StandardAttack singleHit = attackFactory.createAttack( + ATTACK_PARAMS({ + BASE_POWER: 50, + STAMINA_COST: 3, + ACCURACY: 100, + PRIORITY: DEFAULT_PRIORITY, + MOVE_TYPE: Type.Liquid, + MOVE_CLASS: MoveClass.Special, + CRIT_RATE: 0, + VOLATILITY: 0, + NAME: "Single Hit", + EFFECT_ACCURACY: 0, + EFFECT: IEffect(address(0)) + }) + ); + + // Set up team with BubbleBop + IMoveSet[] memory bubbleBopMoves = new IMoveSet[](1); + bubbleBopMoves[0] = bubbleBop; + Mon memory bubbleBopMon = _createMon(); + bubbleBopMon.moves = bubbleBopMoves; + bubbleBopMon.stats.hp = maxHp; + bubbleBopMon.stats.specialAttack = 100; + bubbleBopMon.stats.specialDefense = 100; + Mon[] memory aliceTeam = new Mon[](1); + aliceTeam[0] = bubbleBopMon; + + // Set up team with single hit for Bob (so bob takes damage, not deals it) + IMoveSet[] memory singleMoves = new IMoveSet[](1); + singleMoves[0] = singleHit; + Mon memory singleMon = _createMon(); + singleMon.moves = singleMoves; + singleMon.stats.hp = maxHp; + singleMon.stats.specialAttack = 100; + singleMon.stats.specialDefense = 100; + Mon[] memory bobTeam = new Mon[](1); + bobTeam[0] = singleMon; + + defaultRegistry.setTeam(ALICE, aliceTeam); + defaultRegistry.setTeam(BOB, bobTeam); + + DefaultValidator validator = new DefaultValidator( + IEngine(address(engine)), + DefaultValidator.Args({MONS_PER_TEAM: 1, MOVES_PER_MON: 1, TIMEOUT_DURATION: 10}) + ); + + bytes32 battleKey = + _startBattle(validator, engine, mockOracle, defaultRegistry, matchmaker, address(commitManager)); + + // Both players select their first mon + _commitRevealExecuteForAliceAndBob( + engine, commitManager, battleKey, SWITCH_MOVE_INDEX, SWITCH_MOVE_INDEX, uint240(0), uint240(0) + ); + + // Alice uses Bubble Bop, Bob does nothing + _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey, 0, NO_OP_MOVE_INDEX, 0, 0); + + // Verify Bob took damage (dual hit should deal damage) + int32 bobHpDelta = engine.getMonStateForBattle(battleKey, 1, 0, MonStateIndexName.Hp); + assertTrue(bobHpDelta < 0, "Bob should have taken damage from Bubble Bop"); + + // Now do a fresh battle with single hit to compare + Engine engine2 = new Engine(); + DefaultCommitManager commitManager2 = new DefaultCommitManager(IEngine(address(engine2))); + DefaultMatchmaker matchmaker2 = new DefaultMatchmaker(engine2); + TestTeamRegistry registry2 = new TestTeamRegistry(); + registry2.setTeam(ALICE, aliceTeam); + registry2.setTeam(BOB, bobTeam); + + DefaultValidator validator2 = new DefaultValidator( + IEngine(address(engine2)), + DefaultValidator.Args({MONS_PER_TEAM: 1, MOVES_PER_MON: 1, TIMEOUT_DURATION: 10}) + ); + + bytes32 battleKey2 = + _startBattle(validator2, engine2, mockOracle, registry2, matchmaker2, address(commitManager2)); + _commitRevealExecuteForAliceAndBob( + engine2, commitManager2, battleKey2, SWITCH_MOVE_INDEX, SWITCH_MOVE_INDEX, uint240(0), uint240(0) + ); + + // Bob uses single hit on Alice + _commitRevealExecuteForAliceAndBob(engine2, commitManager2, battleKey2, NO_OP_MOVE_INDEX, 0, 0, 0); + int32 aliceSingleHitDamage = engine2.getMonStateForBattle(battleKey2, 0, 0, MonStateIndexName.Hp); + + // Bubble Bop should deal more damage than a single hit of same base power + // (since Bubble Bop has volatility and two hits, we check it dealt strictly more) + assertTrue( + bobHpDelta < aliceSingleHitDamage, + "Bubble Bop (two hits) should deal more damage than a single hit of same base power" + ); + } + + function test_sneakAttackHitsNonActiveMon() public { + uint32 maxHp = 100; + + SneakAttack sneakAttack = new SneakAttack(IEngine(address(engine)), ITypeCalculator(address(typeCalc))); + SaviorComplex saviorComplex = new SaviorComplex(IEngine(address(engine)), statBoosts); + + IMoveSet[] memory moves = new IMoveSet[](1); + moves[0] = sneakAttack; + + Mon memory mon = _createMon(); + mon.moves = moves; + mon.ability = saviorComplex; + mon.stats.hp = maxHp; + mon.stats.specialAttack = 100; + mon.stats.specialDefense = 100; + Mon[] memory team = new Mon[](2); + team[0] = mon; + team[1] = mon; + + defaultRegistry.setTeam(ALICE, team); + defaultRegistry.setTeam(BOB, team); + + DefaultValidator validator = new DefaultValidator( + IEngine(address(engine)), + DefaultValidator.Args({MONS_PER_TEAM: 2, MOVES_PER_MON: 1, TIMEOUT_DURATION: 10}) + ); + + bytes32 battleKey = + _startBattle(validator, engine, mockOracle, defaultRegistry, matchmaker, address(commitManager)); + + // Both players select their first mon (index 0) + _commitRevealExecuteForAliceAndBob( + engine, commitManager, battleKey, SWITCH_MOVE_INDEX, SWITCH_MOVE_INDEX, uint240(0), uint240(0) + ); + + // Alice uses SneakAttack targeting Bob's non-active mon (index 1) + _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey, 0, NO_OP_MOVE_INDEX, uint240(1), 0); + + // Verify Bob's mon at index 1 (non-active) took damage + int32 bobMon1HpDelta = engine.getMonStateForBattle(battleKey, 1, 1, MonStateIndexName.Hp); + assertTrue(bobMon1HpDelta < 0, "Bob's non-active mon should have taken damage from Sneak Attack"); + + // Verify Bob's active mon (index 0) was unaffected + int32 bobMon0HpDelta = engine.getMonStateForBattle(battleKey, 1, 0, MonStateIndexName.Hp); + assertEq(bobMon0HpDelta, 0, "Bob's active mon should be unaffected"); + } + + function test_sneakAttackOncePerSwitchIn() public { + uint32 maxHp = 100; + + SneakAttack sneakAttack = new SneakAttack(IEngine(address(engine)), ITypeCalculator(address(typeCalc))); + SaviorComplex saviorComplex = new SaviorComplex(IEngine(address(engine)), statBoosts); + + IMoveSet[] memory moves = new IMoveSet[](1); + moves[0] = sneakAttack; + + Mon memory mon = _createMon(); + mon.moves = moves; + mon.ability = saviorComplex; + mon.stats.hp = maxHp; + mon.stats.specialAttack = 100; + mon.stats.specialDefense = 100; + Mon[] memory team = new Mon[](2); + team[0] = mon; + team[1] = mon; + + defaultRegistry.setTeam(ALICE, team); + defaultRegistry.setTeam(BOB, team); + + DefaultValidator validator = new DefaultValidator( + IEngine(address(engine)), + DefaultValidator.Args({MONS_PER_TEAM: 2, MOVES_PER_MON: 1, TIMEOUT_DURATION: 10}) + ); + + bytes32 battleKey = + _startBattle(validator, engine, mockOracle, defaultRegistry, matchmaker, address(commitManager)); + + // Both players select their first mon + _commitRevealExecuteForAliceAndBob( + engine, commitManager, battleKey, SWITCH_MOVE_INDEX, SWITCH_MOVE_INDEX, uint240(0), uint240(0) + ); + + // Alice uses SneakAttack targeting Bob's non-active mon (index 1) - first use + _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey, 0, NO_OP_MOVE_INDEX, uint240(1), 0); + + int32 bobMon1DamageAfterFirst = engine.getMonStateForBattle(battleKey, 1, 1, MonStateIndexName.Hp); + assertTrue(bobMon1DamageAfterFirst < 0, "First sneak attack should deal damage"); + + // Alice uses SneakAttack again - should do nothing (already used this switch-in) + _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey, 0, NO_OP_MOVE_INDEX, uint240(1), 0); + + int32 bobMon1DamageAfterSecond = engine.getMonStateForBattle(battleKey, 1, 1, MonStateIndexName.Hp); + assertEq( + bobMon1DamageAfterSecond, + bobMon1DamageAfterFirst, + "Second sneak attack should not deal additional damage" + ); + } + + function test_sneakAttackResetsOnSwitchIn() public { + uint32 maxHp = 200; + + SneakAttack sneakAttack = new SneakAttack(IEngine(address(engine)), ITypeCalculator(address(typeCalc))); + SaviorComplex saviorComplex = new SaviorComplex(IEngine(address(engine)), statBoosts); + + IMoveSet[] memory moves = new IMoveSet[](1); + moves[0] = sneakAttack; + + Mon memory mon = _createMon(); + mon.moves = moves; + mon.ability = saviorComplex; + mon.stats.hp = maxHp; + mon.stats.specialAttack = 100; + mon.stats.specialDefense = 100; + Mon[] memory team = new Mon[](2); + team[0] = mon; + team[1] = mon; + + defaultRegistry.setTeam(ALICE, team); + defaultRegistry.setTeam(BOB, team); + + DefaultValidator validator = new DefaultValidator( + IEngine(address(engine)), + DefaultValidator.Args({MONS_PER_TEAM: 2, MOVES_PER_MON: 1, TIMEOUT_DURATION: 10}) + ); + + bytes32 battleKey = + _startBattle(validator, engine, mockOracle, defaultRegistry, matchmaker, address(commitManager)); + + // Both players select mon 0 + _commitRevealExecuteForAliceAndBob( + engine, commitManager, battleKey, SWITCH_MOVE_INDEX, SWITCH_MOVE_INDEX, uint240(0), uint240(0) + ); + + // Alice uses SneakAttack - first use works + _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey, 0, NO_OP_MOVE_INDEX, uint240(1), 0); + int32 damageAfterFirst = engine.getMonStateForBattle(battleKey, 1, 1, MonStateIndexName.Hp); + assertTrue(damageAfterFirst < 0, "First sneak attack should deal damage"); + + // Alice switches to mon 1 (sneak attack effect removed on switch-out) + _commitRevealExecuteForAliceAndBob( + engine, commitManager, battleKey, SWITCH_MOVE_INDEX, NO_OP_MOVE_INDEX, uint240(1), 0 + ); + + // Alice (now mon 1) uses SneakAttack again - should work (reset by switch) + _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey, 0, NO_OP_MOVE_INDEX, uint240(1), 0); + int32 damageAfterReset = engine.getMonStateForBattle(battleKey, 1, 1, MonStateIndexName.Hp); + assertTrue(damageAfterReset < damageAfterFirst, "Sneak attack should work again after switching"); + } + + function test_nineNineNineBoostsCritRate() public { + uint32 maxHp = 200; + + NineNineNine nineNineNine = new NineNineNine(IEngine(address(engine))); + SaviorComplex saviorComplex = new SaviorComplex(IEngine(address(engine)), statBoosts); + + // Create a predictable attack (0 vol, 0 default crit) to isolate crit boost + StandardAttack testAttack = attackFactory.createAttack( + ATTACK_PARAMS({ + BASE_POWER: 50, + STAMINA_COST: 1, + ACCURACY: 100, + PRIORITY: DEFAULT_PRIORITY, + MOVE_TYPE: Type.Liquid, + MOVE_CLASS: MoveClass.Special, + CRIT_RATE: 0, + VOLATILITY: 0, + NAME: "Test Attack", + EFFECT_ACCURACY: 0, + EFFECT: IEffect(address(0)) + }) + ); + + // Team with 999 + test attack + IMoveSet[] memory moves = new IMoveSet[](2); + moves[0] = nineNineNine; + moves[1] = testAttack; + + Mon memory mon = _createMon(); + mon.moves = moves; + mon.ability = saviorComplex; + mon.stats.hp = maxHp; + mon.stats.specialAttack = 100; + mon.stats.specialDefense = 100; + Mon[] memory team = new Mon[](1); + team[0] = mon; + + defaultRegistry.setTeam(ALICE, team); + defaultRegistry.setTeam(BOB, team); + + DefaultValidator validator = new DefaultValidator( + IEngine(address(engine)), + DefaultValidator.Args({MONS_PER_TEAM: 1, MOVES_PER_MON: 2, TIMEOUT_DURATION: 10}) + ); + + bytes32 battleKey = + _startBattle(validator, engine, mockOracle, defaultRegistry, matchmaker, address(commitManager)); + + // Both players select their first mon + _commitRevealExecuteForAliceAndBob( + engine, commitManager, battleKey, SWITCH_MOVE_INDEX, SWITCH_MOVE_INDEX, uint240(0), uint240(0) + ); + + // Bob uses test attack without 999 (baseline) + _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey, NO_OP_MOVE_INDEX, 1, 0, 0); + int32 aliceDamageWithoutCrit = engine.getMonStateForBattle(battleKey, 0, 0, MonStateIndexName.Hp); + + // Expected: 50 base * 100 spAtk / 100 spDef = 50 damage (no crit, no vol) + assertEq(aliceDamageWithoutCrit, -50, "Baseline damage without crit should be 50"); + + // Alice uses 999, Bob does nothing + _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey, 0, NO_OP_MOVE_INDEX, 0, 0); + + // Verify the 999 KV is set for the next turn + bytes32 nineKey = keccak256(abi.encode(uint256(0), "NINE_NINE_NINE")); + uint192 storedTurn = engine.getGlobalKV(battleKey, nineKey); + uint256 currentTurn = engine.getTurnIdForBattleState(battleKey); + assertEq(uint256(storedTurn), currentTurn, "999 should be set for the current turn (which is now the 'next' turn)"); + } + + function test_saviorComplexBoostsOnKO() public { + uint32 maxHp = 100; + + SaviorComplex saviorComplex = new SaviorComplex(IEngine(address(engine)), statBoosts); + + // Create a strong attack that will KO in one hit + StandardAttack koAttack = attackFactory.createAttack( + ATTACK_PARAMS({ + BASE_POWER: maxHp, + STAMINA_COST: 1, + ACCURACY: 100, + PRIORITY: DEFAULT_PRIORITY, + MOVE_TYPE: Type.Fire, + MOVE_CLASS: MoveClass.Physical, + CRIT_RATE: 0, + VOLATILITY: 0, + NAME: "KO Attack", + EFFECT_ACCURACY: 0, + EFFECT: IEffect(address(0)) + }) + ); + + IMoveSet[] memory moves = new IMoveSet[](1); + moves[0] = koAttack; + + // Alice's team: 3 mons, one with savior complex + Mon memory aliceMon = _createMon(); + aliceMon.moves = moves; + aliceMon.stats.hp = maxHp; + aliceMon.stats.attack = 100; + aliceMon.stats.defense = 100; + aliceMon.stats.specialAttack = 100; + aliceMon.stats.specialDefense = 100; + + Mon memory aliceMonWithAbility = _createMon(); + aliceMonWithAbility.moves = moves; + aliceMonWithAbility.ability = saviorComplex; + aliceMonWithAbility.stats.hp = maxHp; + aliceMonWithAbility.stats.attack = 100; + aliceMonWithAbility.stats.defense = 100; + aliceMonWithAbility.stats.specialAttack = 100; + aliceMonWithAbility.stats.specialDefense = 100; + + Mon[] memory aliceTeam = new Mon[](3); + aliceTeam[0] = aliceMon; + aliceTeam[1] = aliceMon; + aliceTeam[2] = aliceMonWithAbility; + + // Bob's team: faster mon to get first hit + Mon memory bobMon = _createMon(); + bobMon.moves = moves; + bobMon.stats.hp = maxHp; + bobMon.stats.attack = 100; + bobMon.stats.defense = 100; + bobMon.stats.specialAttack = 100; + bobMon.stats.specialDefense = 100; + bobMon.stats.speed = 2; // Faster + Mon[] memory bobTeam = new Mon[](3); + bobTeam[0] = bobMon; + bobTeam[1] = bobMon; + bobTeam[2] = bobMon; + + defaultRegistry.setTeam(ALICE, aliceTeam); + defaultRegistry.setTeam(BOB, bobTeam); + + DefaultValidator validator = new DefaultValidator( + IEngine(address(engine)), + DefaultValidator.Args({MONS_PER_TEAM: 3, MOVES_PER_MON: 1, TIMEOUT_DURATION: 10}) + ); + + bytes32 battleKey = + _startBattle(validator, engine, mockOracle, defaultRegistry, matchmaker, address(commitManager)); + + // Both select mon 0 + _commitRevealExecuteForAliceAndBob( + engine, commitManager, battleKey, SWITCH_MOVE_INDEX, SWITCH_MOVE_INDEX, uint240(0), uint240(0) + ); + + // Bob attacks Alice's mon 0 (KO), Alice does nothing + _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey, NO_OP_MOVE_INDEX, 0, 0, 0); + + // Verify Alice's mon 0 is KO'd + assertEq( + engine.getMonStateForBattle(battleKey, 0, 0, MonStateIndexName.IsKnockedOut), + 1, + "Alice's mon 0 should be KO'd" + ); + + // Alice forced switch to mon 2 (the one with savior complex) + // After KO, playerSwitchForTurnFlag = 0 (Alice must switch, no commit needed) + vm.startPrank(ALICE); + commitManager.revealMove(battleKey, SWITCH_MOVE_INDEX, "", uint240(2), true); + + // Verify that Alice's mon 2 got a sp atk boost (STAGE_1_BOOST = 15% of 100 = 15) + int32 spAtkDelta = engine.getMonStateForBattle(battleKey, 0, 2, MonStateIndexName.SpecialAttack); + assertEq( + spAtkDelta, + int32(int8(saviorComplex.STAGE_1_BOOST())) * 100 / 100, + "Alice's mon should have sp atk boost from Savior Complex with 1 KO" + ); + } + + function test_saviorComplexTriggersOncePerGame() public { + uint32 maxHp = 100; + + SaviorComplex saviorComplex = new SaviorComplex(IEngine(address(engine)), statBoosts); + + StandardAttack koAttack = attackFactory.createAttack( + ATTACK_PARAMS({ + BASE_POWER: maxHp, + STAMINA_COST: 1, + ACCURACY: 100, + PRIORITY: DEFAULT_PRIORITY, + MOVE_TYPE: Type.Fire, + MOVE_CLASS: MoveClass.Physical, + CRIT_RATE: 0, + VOLATILITY: 0, + NAME: "KO Attack", + EFFECT_ACCURACY: 0, + EFFECT: IEffect(address(0)) + }) + ); + + IMoveSet[] memory moves = new IMoveSet[](1); + moves[0] = koAttack; + + Mon memory aliceMon = _createMon(); + aliceMon.moves = moves; + aliceMon.stats.hp = maxHp; + aliceMon.stats.attack = 100; + aliceMon.stats.defense = 100; + aliceMon.stats.specialAttack = 100; + aliceMon.stats.specialDefense = 100; + + Mon memory aliceMonWithAbility = _createMon(); + aliceMonWithAbility.moves = moves; + aliceMonWithAbility.ability = saviorComplex; + aliceMonWithAbility.stats.hp = maxHp; + aliceMonWithAbility.stats.attack = 100; + aliceMonWithAbility.stats.defense = 100; + aliceMonWithAbility.stats.specialAttack = 100; + aliceMonWithAbility.stats.specialDefense = 100; + + Mon[] memory aliceTeam = new Mon[](3); + aliceTeam[0] = aliceMon; + aliceTeam[1] = aliceMonWithAbility; + aliceTeam[2] = aliceMon; + + Mon memory bobMon = _createMon(); + bobMon.moves = moves; + bobMon.stats.hp = maxHp; + bobMon.stats.attack = 100; + bobMon.stats.defense = 100; + bobMon.stats.specialAttack = 100; + bobMon.stats.specialDefense = 100; + bobMon.stats.speed = 2; + Mon[] memory bobTeam = new Mon[](3); + bobTeam[0] = bobMon; + bobTeam[1] = bobMon; + bobTeam[2] = bobMon; + + defaultRegistry.setTeam(ALICE, aliceTeam); + defaultRegistry.setTeam(BOB, bobTeam); + + DefaultValidator validator = new DefaultValidator( + IEngine(address(engine)), + DefaultValidator.Args({MONS_PER_TEAM: 3, MOVES_PER_MON: 1, TIMEOUT_DURATION: 10}) + ); + + bytes32 battleKey = + _startBattle(validator, engine, mockOracle, defaultRegistry, matchmaker, address(commitManager)); + + // Both select mon 0 + _commitRevealExecuteForAliceAndBob( + engine, commitManager, battleKey, SWITCH_MOVE_INDEX, SWITCH_MOVE_INDEX, uint240(0), uint240(0) + ); + + // Bob KOs Alice's mon 0 + _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey, NO_OP_MOVE_INDEX, 0, 0, 0); + + // Alice forced switch to mon 1 (savior complex triggers with 1 KO) + vm.startPrank(ALICE); + commitManager.revealMove(battleKey, SWITCH_MOVE_INDEX, "", uint240(1), true); + + int32 spAtkDeltaFirstSwitch = engine.getMonStateForBattle(battleKey, 0, 1, MonStateIndexName.SpecialAttack); + assertEq(spAtkDeltaFirstSwitch, 15, "Should get 15 sp atk boost from 1 KO"); + + // Alice switches to mon 2, Bob KOs Alice's mon 2 in the same turn (Bob is faster but switch has higher priority) + _commitRevealExecuteForAliceAndBob( + engine, commitManager, battleKey, SWITCH_MOVE_INDEX, 0, uint240(2), 0 + ); + + // Verify Alice's mon 2 is KO'd + assertEq( + engine.getMonStateForBattle(battleKey, 0, 2, MonStateIndexName.IsKnockedOut), + 1, + "Alice's mon 2 should be KO'd" + ); + + // Alice forced switch back to mon 1 (savior complex should NOT trigger again) + vm.startPrank(ALICE); + commitManager.revealMove(battleKey, SWITCH_MOVE_INDEX, "", uint240(1), true); + + int32 spAtkDeltaSecondSwitch = engine.getMonStateForBattle(battleKey, 0, 1, MonStateIndexName.SpecialAttack); + // Boost is temp so it was cleared when mon 1 switched out, and savior complex + // should not re-apply it (once per game), so delta should be 0 + assertEq( + spAtkDeltaSecondSwitch, + 0, + "Savior Complex should not trigger again (once per game), temp boost cleared on switch out" + ); + } + + function test_saviorComplexNoBoostWithZeroKOs() public { + uint32 maxHp = 100; + + SaviorComplex saviorComplex = new SaviorComplex(IEngine(address(engine)), statBoosts); + + StandardAttack koAttack = attackFactory.createAttack( + ATTACK_PARAMS({ + BASE_POWER: maxHp, + STAMINA_COST: 1, + ACCURACY: 100, + PRIORITY: DEFAULT_PRIORITY, + MOVE_TYPE: Type.Fire, + MOVE_CLASS: MoveClass.Physical, + CRIT_RATE: 0, + VOLATILITY: 0, + NAME: "KO Attack", + EFFECT_ACCURACY: 0, + EFFECT: IEffect(address(0)) + }) + ); + + IMoveSet[] memory moves = new IMoveSet[](1); + moves[0] = koAttack; + + Mon memory monWithAbility = _createMon(); + monWithAbility.moves = moves; + monWithAbility.ability = saviorComplex; + monWithAbility.stats.hp = maxHp; + monWithAbility.stats.attack = 100; + monWithAbility.stats.defense = 100; + monWithAbility.stats.specialAttack = 100; + monWithAbility.stats.specialDefense = 100; + + Mon memory normalMon = _createMon(); + normalMon.moves = moves; + normalMon.stats.hp = maxHp; + normalMon.stats.attack = 100; + normalMon.stats.defense = 100; + normalMon.stats.specialAttack = 100; + normalMon.stats.specialDefense = 100; + + Mon[] memory aliceTeam = new Mon[](2); + aliceTeam[0] = monWithAbility; + aliceTeam[1] = normalMon; + + Mon memory bobMon = _createMon(); + bobMon.moves = moves; + bobMon.stats.hp = maxHp; + bobMon.stats.attack = 100; + bobMon.stats.defense = 100; + bobMon.stats.specialAttack = 100; + bobMon.stats.specialDefense = 100; + bobMon.stats.speed = 2; + Mon[] memory bobTeam = new Mon[](2); + bobTeam[0] = bobMon; + bobTeam[1] = bobMon; + + defaultRegistry.setTeam(ALICE, aliceTeam); + defaultRegistry.setTeam(BOB, bobTeam); + + DefaultValidator validator = new DefaultValidator( + IEngine(address(engine)), + DefaultValidator.Args({MONS_PER_TEAM: 2, MOVES_PER_MON: 1, TIMEOUT_DURATION: 10}) + ); + + bytes32 battleKey = + _startBattle(validator, engine, mockOracle, defaultRegistry, matchmaker, address(commitManager)); + + // Alice selects mon 0 (with savior complex) - no KO'd mons + _commitRevealExecuteForAliceAndBob( + engine, commitManager, battleKey, SWITCH_MOVE_INDEX, SWITCH_MOVE_INDEX, uint240(0), uint240(0) + ); + + // Verify no boost was applied (0 KOs) + int32 spAtkDelta = engine.getMonStateForBattle(battleKey, 0, 0, MonStateIndexName.SpecialAttack); + assertEq(spAtkDelta, 0, "No sp atk boost should be applied with 0 KOs"); + + // Now Bob KOs Alice's mon 0 + _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey, NO_OP_MOVE_INDEX, 0, 0, 0); + assertEq( + engine.getMonStateForBattle(battleKey, 0, 0, MonStateIndexName.IsKnockedOut), + 1, + "Alice's mon 0 should be KO'd" + ); + + // Alice forced switch to mon 1 + vm.startPrank(ALICE); + commitManager.revealMove(battleKey, SWITCH_MOVE_INDEX, "", uint240(1), true); + + // Mon 1 has no ability, so no savior complex trigger + // But the savior complex on mon 0 should NOT have been consumed (it didn't trigger) + // Verify by checking global KV is still 0 + bytes32 scKey = keccak256(abi.encode(uint256(0), "SAVIOR_COMPLEX")); + uint192 scTriggered = engine.getGlobalKV(battleKey, scKey); + assertEq(scTriggered, 0, "Savior Complex should not have been consumed with 0 KOs"); + } + + function test_overflowDealsDamage() public { + uint32 maxHp = 200; + + Overflow overflow = new Overflow(IEngine(address(engine)), ITypeCalculator(address(typeCalc))); + + IMoveSet[] memory moves = new IMoveSet[](1); + moves[0] = overflow; + + Mon memory mon = _createMon(); + mon.moves = moves; + mon.stats.hp = maxHp; + mon.stats.specialAttack = 100; + mon.stats.specialDefense = 100; + Mon[] memory team = new Mon[](1); + team[0] = mon; + + defaultRegistry.setTeam(ALICE, team); + defaultRegistry.setTeam(BOB, team); + + DefaultValidator validator = new DefaultValidator( + IEngine(address(engine)), + DefaultValidator.Args({MONS_PER_TEAM: 1, MOVES_PER_MON: 1, TIMEOUT_DURATION: 10}) + ); + + bytes32 battleKey = + _startBattle(validator, engine, mockOracle, defaultRegistry, matchmaker, address(commitManager)); + + _commitRevealExecuteForAliceAndBob( + engine, commitManager, battleKey, SWITCH_MOVE_INDEX, SWITCH_MOVE_INDEX, uint240(0), uint240(0) + ); + + // Alice uses Overflow, Bob does nothing + _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey, 0, NO_OP_MOVE_INDEX, 0, 0); + + int32 bobHpDelta = engine.getMonStateForBattle(battleKey, 1, 0, MonStateIndexName.Hp); + assertTrue(bobHpDelta < 0, "Bob should have taken damage from Overflow"); + } +}