From 26494497c0356f68eed2189a09608d5a40528c07 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Feb 2026 02:23:54 +0000 Subject: [PATCH 1/7] Add ekineki mon folder with Savior Complex ability and 3 moves - SaviorComplex ability: on switch-in, boosts sp atk by 15/25/30% based on KO'd teammates (1/2/3+), triggers once per game - DualFlow: hits twice at 50bp each, 3 stamina, Liquid/Special - SneakAttack: targets any opponent mon (including bench), 60bp, 2 stamina, Liquid/Special, once per switch-in - NineNineNine (999): sets crit rate to 90% for next turn, Math/Self, 2 stamina. Other moves check this via shared EkinekiLib global KV - EkinekiLib: shared library for global KV key management across ekineki contracts (999 crit boost, sneak attack tracking, savior complex once-per-game flag) - 8 tests covering all moves and ability edge cases https://claude.ai/code/session_011WmJgts8AesNNrDaccz2fn --- src/mons/ekineki/DualFlow.sol | 72 +++ src/mons/ekineki/EkinekiLib.sol | 66 +++ src/mons/ekineki/NineNineNine.sol | 53 +++ src/mons/ekineki/SaviorComplex.sol | 71 +++ src/mons/ekineki/SneakAttack.sol | 109 +++++ test/mons/EkinekiTest.sol | 691 +++++++++++++++++++++++++++++ 6 files changed, 1062 insertions(+) create mode 100644 src/mons/ekineki/DualFlow.sol create mode 100644 src/mons/ekineki/EkinekiLib.sol create mode 100644 src/mons/ekineki/NineNineNine.sol create mode 100644 src/mons/ekineki/SaviorComplex.sol create mode 100644 src/mons/ekineki/SneakAttack.sol create mode 100644 test/mons/EkinekiTest.sol diff --git a/src/mons/ekineki/DualFlow.sol b/src/mons/ekineki/DualFlow.sol new file mode 100644 index 00000000..2729d6c7 --- /dev/null +++ b/src/mons/ekineki/DualFlow.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 {EkinekiLib} from "./EkinekiLib.sol"; + +contract DualFlow is StandardAttack { + constructor(IEngine _ENGINE, ITypeCalculator _TYPE_CALCULATOR) + StandardAttack( + address(msg.sender), + _ENGINE, + _TYPE_CALCULATOR, + ATTACK_PARAMS({ + NAME: "Dual Flow", + 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 = EkinekiLib._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/EkinekiLib.sol b/src/mons/ekineki/EkinekiLib.sol new file mode 100644 index 00000000..32188b09 --- /dev/null +++ b/src/mons/ekineki/EkinekiLib.sol @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: AGPL-3.0 + +pragma solidity ^0.8.0; + +import "../../Constants.sol"; + +import {IEngine} from "../../IEngine.sol"; + +library EkinekiLib { + uint32 constant NINE_NINE_NINE_CRIT_RATE = 90; + + // --- 999 crit rate boost --- + + function _getNineNineNineKey(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, _getNineNineNineKey(playerIndex)); + uint256 currentTurn = engine.getTurnIdForBattleState(battleKey); + if (boostTurn > 0 && uint256(boostTurn) == currentTurn) { + return NINE_NINE_NINE_CRIT_RATE; + } + return DEFAULT_CRIT_RATE; + } + + // --- Sneak Attack once-per-switch-in tracking --- + + function _getSneakAttackKey(uint256 playerIndex) internal pure returns (bytes32) { + return keccak256(abi.encode(playerIndex, "SNEAK_ATTACK")); + } + + function _getSneakAttackUsed(IEngine engine, bytes32 battleKey, uint256 playerIndex) + internal + view + returns (uint192) + { + return engine.getGlobalKV(battleKey, _getSneakAttackKey(playerIndex)); + } + + function _setSneakAttackUsed(IEngine engine, uint256 playerIndex, uint192 value) internal { + engine.setGlobalKV(_getSneakAttackKey(playerIndex), value); + } + + // --- Savior Complex once-per-game tracking --- + + function _getSaviorComplexKey(uint256 playerIndex) internal pure returns (bytes32) { + return keccak256(abi.encode(playerIndex, "SAVIOR_COMPLEX")); + } + + function _getSaviorComplexTriggered(IEngine engine, bytes32 battleKey, uint256 playerIndex) + internal + view + returns (bool) + { + return engine.getGlobalKV(battleKey, _getSaviorComplexKey(playerIndex)) == 1; + } + + function _setSaviorComplexTriggered(IEngine engine, uint256 playerIndex) internal { + engine.setGlobalKV(_getSaviorComplexKey(playerIndex), 1); + } +} diff --git a/src/mons/ekineki/NineNineNine.sol b/src/mons/ekineki/NineNineNine.sol new file mode 100644 index 00000000..a3a5e3f9 --- /dev/null +++ b/src/mons/ekineki/NineNineNine.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 {EkinekiLib} from "./EkinekiLib.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 = EkinekiLib._getNineNineNineKey(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/SaviorComplex.sol b/src/mons/ekineki/SaviorComplex.sol new file mode 100644 index 00000000..8dda0126 --- /dev/null +++ b/src/mons/ekineki/SaviorComplex.sol @@ -0,0 +1,71 @@ +// 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"; +import {EkinekiLib} from "./EkinekiLib.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 activateOnSwitch(bytes32 battleKey, uint256 playerIndex, uint256 monIndex) external { + // Reset sneak attack usage on every switch-in + EkinekiLib._setSneakAttackUsed(ENGINE, playerIndex, 0); + + // Check if already triggered this game + if (EkinekiLib._getSaviorComplexTriggered(ENGINE, battleKey, playerIndex)) { + return; + } + + // Count KO'd mons on the player's team + uint256 teamSize = ENGINE.getTeamSize(battleKey, playerIndex); + uint256 koCount = 0; + for (uint256 i = 0; i < teamSize; i++) { + if (ENGINE.getMonStateForBattle(battleKey, playerIndex, i, MonStateIndexName.IsKnockedOut) == 1) { + koCount++; + } + } + + if (koCount == 0) return; + + // 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 sp atk boost + StatBoostToApply[] memory statBoosts = new StatBoostToApply[](1); + statBoosts[0] = StatBoostToApply({ + stat: MonStateIndexName.SpecialAttack, + boostPercent: boostPercent, + boostType: StatBoostType.Multiply + }); + STAT_BOOSTS.addStatBoosts(playerIndex, monIndex, statBoosts, StatBoostFlag.Perm); + + // Mark as triggered (once per game) + EkinekiLib._setSaviorComplexTriggered(ENGINE, playerIndex); + } +} diff --git a/src/mons/ekineki/SneakAttack.sol b/src/mons/ekineki/SneakAttack.sol new file mode 100644 index 00000000..81fe72d4 --- /dev/null +++ b/src/mons/ekineki/SneakAttack.sol @@ -0,0 +1,109 @@ +// 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 {EkinekiLib} from "./EkinekiLib.sol"; + +contract SneakAttack is IMoveSet { + 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() external pure returns (string memory) { + return "Sneak Attack"; + } + + function move(bytes32 battleKey, uint256 attackerPlayerIndex, uint240 extraData, uint256 rng) external { + // Check if already used this switch-in + if (EkinekiLib._getSneakAttackUsed(ENGINE, battleKey, attackerPlayerIndex) == 1) { + return; + } + + uint256 defenderPlayerIndex = (attackerPlayerIndex + 1) % 2; + uint256 targetMonIndex = uint256(extraData); + + // Get effective crit rate (checks 999 buff) + uint32 effectiveCritRate = EkinekiLib._getEffectiveCritRate(ENGINE, battleKey, attackerPlayerIndex); + + // Build DamageCalcContext manually to target any opponent mon (not just active) + uint256 attackerMonIndex = ENGINE.getActiveMonIndexForBattleState(battleKey)[attackerPlayerIndex]; + 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 this switch-in + EkinekiLib._setSneakAttackUsed(ENGINE, attackerPlayerIndex, 1); + } + + 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.None; + } +} diff --git a/test/mons/EkinekiTest.sol b/test/mons/EkinekiTest.sol new file mode 100644 index 00000000..ac2650f7 --- /dev/null +++ b/test/mons/EkinekiTest.sol @@ -0,0 +1,691 @@ +// 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 {DualFlow} from "../../src/mons/ekineki/DualFlow.sol"; +import {NineNineNine} from "../../src/mons/ekineki/NineNineNine.sol"; +import {SaviorComplex} from "../../src/mons/ekineki/SaviorComplex.sol"; +import {SneakAttack} from "../../src/mons/ekineki/SneakAttack.sol"; + +/** + * Tests: + * - DualFlow 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-in via SaviorComplex ability [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] + */ +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_dualFlowHitsTwice() public { + uint32 maxHp = 200; + + DualFlow dualFlow = new DualFlow(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 DualFlow + IMoveSet[] memory dualFlowMoves = new IMoveSet[](1); + dualFlowMoves[0] = dualFlow; + Mon memory dualFlowMon = _createMon(); + dualFlowMon.moves = dualFlowMoves; + dualFlowMon.stats.hp = maxHp; + dualFlowMon.stats.specialAttack = 100; + dualFlowMon.stats.specialDefense = 100; + Mon[] memory aliceTeam = new Mon[](1); + aliceTeam[0] = dualFlowMon; + + // 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 DualFlow, 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 Dual Flow"); + + // 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); + + // DualFlow should deal more damage than a single hit of same base power + // (since DualFlow has volatility and two hits, we check it dealt strictly more) + assertTrue( + bobHpDelta < aliceSingleHitDamage, + "Dual Flow (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 (resets sneak attack via SaviorComplex) + _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); + assertEq( + spAtkDeltaSecondSwitch, + spAtkDeltaFirstSwitch, + "Savior Complex should not trigger again (once per game)" + ); + } + + 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"); + } +} From 03df8b30fb0e7ee0922e52038a035632544f0364 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Feb 2026 02:37:13 +0000 Subject: [PATCH 2/7] Refactor: replace EkinekiLib with NineNineNineLib, make boost temporary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename EkinekiLib → NineNineNineLib (only 999 crit rate logic) - Inline sneak attack KV tracking in SneakAttack and SaviorComplex - Inline savior complex once-per-game KV tracking in SaviorComplex - Change SaviorComplex sp atk boost from Perm to Temp (cleared on switch out) - Update test assertion for temp boost behavior https://claude.ai/code/session_011WmJgts8AesNNrDaccz2fn --- src/mons/ekineki/DualFlow.sol | 4 +- src/mons/ekineki/EkinekiLib.sol | 66 ---------------------------- src/mons/ekineki/NineNineNine.sol | 4 +- src/mons/ekineki/NineNineNineLib.sol | 28 ++++++++++++ src/mons/ekineki/SaviorComplex.sol | 19 +++++--- src/mons/ekineki/SneakAttack.sol | 12 +++-- test/mons/EkinekiTest.sol | 6 ++- 7 files changed, 57 insertions(+), 82 deletions(-) delete mode 100644 src/mons/ekineki/EkinekiLib.sol create mode 100644 src/mons/ekineki/NineNineNineLib.sol diff --git a/src/mons/ekineki/DualFlow.sol b/src/mons/ekineki/DualFlow.sol index 2729d6c7..9c68c6cb 100644 --- a/src/mons/ekineki/DualFlow.sol +++ b/src/mons/ekineki/DualFlow.sol @@ -11,7 +11,7 @@ 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 {EkinekiLib} from "./EkinekiLib.sol"; +import {NineNineNineLib} from "./NineNineNineLib.sol"; contract DualFlow is StandardAttack { constructor(IEngine _ENGINE, ITypeCalculator _TYPE_CALCULATOR) @@ -36,7 +36,7 @@ contract DualFlow is StandardAttack { {} function move(bytes32 battleKey, uint256 attackerPlayerIndex, uint240, uint256 rng) public override { - uint32 effectiveCritRate = EkinekiLib._getEffectiveCritRate(ENGINE, battleKey, attackerPlayerIndex); + uint32 effectiveCritRate = NineNineNineLib._getEffectiveCritRate(ENGINE, battleKey, attackerPlayerIndex); // First hit AttackCalculator._calculateDamage( diff --git a/src/mons/ekineki/EkinekiLib.sol b/src/mons/ekineki/EkinekiLib.sol deleted file mode 100644 index 32188b09..00000000 --- a/src/mons/ekineki/EkinekiLib.sol +++ /dev/null @@ -1,66 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0 - -pragma solidity ^0.8.0; - -import "../../Constants.sol"; - -import {IEngine} from "../../IEngine.sol"; - -library EkinekiLib { - uint32 constant NINE_NINE_NINE_CRIT_RATE = 90; - - // --- 999 crit rate boost --- - - function _getNineNineNineKey(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, _getNineNineNineKey(playerIndex)); - uint256 currentTurn = engine.getTurnIdForBattleState(battleKey); - if (boostTurn > 0 && uint256(boostTurn) == currentTurn) { - return NINE_NINE_NINE_CRIT_RATE; - } - return DEFAULT_CRIT_RATE; - } - - // --- Sneak Attack once-per-switch-in tracking --- - - function _getSneakAttackKey(uint256 playerIndex) internal pure returns (bytes32) { - return keccak256(abi.encode(playerIndex, "SNEAK_ATTACK")); - } - - function _getSneakAttackUsed(IEngine engine, bytes32 battleKey, uint256 playerIndex) - internal - view - returns (uint192) - { - return engine.getGlobalKV(battleKey, _getSneakAttackKey(playerIndex)); - } - - function _setSneakAttackUsed(IEngine engine, uint256 playerIndex, uint192 value) internal { - engine.setGlobalKV(_getSneakAttackKey(playerIndex), value); - } - - // --- Savior Complex once-per-game tracking --- - - function _getSaviorComplexKey(uint256 playerIndex) internal pure returns (bytes32) { - return keccak256(abi.encode(playerIndex, "SAVIOR_COMPLEX")); - } - - function _getSaviorComplexTriggered(IEngine engine, bytes32 battleKey, uint256 playerIndex) - internal - view - returns (bool) - { - return engine.getGlobalKV(battleKey, _getSaviorComplexKey(playerIndex)) == 1; - } - - function _setSaviorComplexTriggered(IEngine engine, uint256 playerIndex) internal { - engine.setGlobalKV(_getSaviorComplexKey(playerIndex), 1); - } -} diff --git a/src/mons/ekineki/NineNineNine.sol b/src/mons/ekineki/NineNineNine.sol index a3a5e3f9..a30980ea 100644 --- a/src/mons/ekineki/NineNineNine.sol +++ b/src/mons/ekineki/NineNineNine.sol @@ -7,7 +7,7 @@ import "../../Enums.sol"; import {IEngine} from "../../IEngine.sol"; import {IMoveSet} from "../../moves/IMoveSet.sol"; -import {EkinekiLib} from "./EkinekiLib.sol"; +import {NineNineNineLib} from "./NineNineNineLib.sol"; contract NineNineNine is IMoveSet { IEngine immutable ENGINE; @@ -23,7 +23,7 @@ contract NineNineNine is IMoveSet { function move(bytes32 battleKey, uint256 attackerPlayerIndex, uint240, uint256) external { // Set crit boost for the next turn uint256 currentTurn = ENGINE.getTurnIdForBattleState(battleKey); - bytes32 key = EkinekiLib._getNineNineNineKey(attackerPlayerIndex); + bytes32 key = NineNineNineLib._getKey(attackerPlayerIndex); ENGINE.setGlobalKV(key, uint192(currentTurn + 1)); } diff --git a/src/mons/ekineki/NineNineNineLib.sol b/src/mons/ekineki/NineNineNineLib.sol new file mode 100644 index 00000000..fdd8f3ea --- /dev/null +++ b/src/mons/ekineki/NineNineNineLib.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/SaviorComplex.sol b/src/mons/ekineki/SaviorComplex.sol index 8dda0126..1e284484 100644 --- a/src/mons/ekineki/SaviorComplex.sol +++ b/src/mons/ekineki/SaviorComplex.sol @@ -7,7 +7,6 @@ import {StatBoostToApply} from "../../Structs.sol"; import {IEngine} from "../../IEngine.sol"; import {IAbility} from "../../abilities/IAbility.sol"; import {StatBoosts} from "../../effects/StatBoosts.sol"; -import {EkinekiLib} from "./EkinekiLib.sol"; contract SaviorComplex is IAbility { uint8 public constant STAGE_1_BOOST = 15; // 1 KO'd @@ -26,12 +25,20 @@ contract SaviorComplex is IAbility { return "Savior Complex"; } + function _getSneakAttackKey(uint256 playerIndex) internal pure returns (bytes32) { + return keccak256(abi.encode(playerIndex, "SNEAK_ATTACK")); + } + + function _getSaviorComplexKey(uint256 playerIndex) internal pure returns (bytes32) { + return keccak256(abi.encode(playerIndex, "SAVIOR_COMPLEX")); + } + function activateOnSwitch(bytes32 battleKey, uint256 playerIndex, uint256 monIndex) external { // Reset sneak attack usage on every switch-in - EkinekiLib._setSneakAttackUsed(ENGINE, playerIndex, 0); + ENGINE.setGlobalKV(_getSneakAttackKey(playerIndex), 0); // Check if already triggered this game - if (EkinekiLib._getSaviorComplexTriggered(ENGINE, battleKey, playerIndex)) { + if (ENGINE.getGlobalKV(battleKey, _getSaviorComplexKey(playerIndex)) == 1) { return; } @@ -56,16 +63,16 @@ contract SaviorComplex is IAbility { boostPercent = STAGE_1_BOOST; } - // Apply sp atk 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.Perm); + STAT_BOOSTS.addStatBoosts(playerIndex, monIndex, statBoosts, StatBoostFlag.Temp); // Mark as triggered (once per game) - EkinekiLib._setSaviorComplexTriggered(ENGINE, playerIndex); + ENGINE.setGlobalKV(_getSaviorComplexKey(playerIndex), 1); } } diff --git a/src/mons/ekineki/SneakAttack.sol b/src/mons/ekineki/SneakAttack.sol index 81fe72d4..86c62a14 100644 --- a/src/mons/ekineki/SneakAttack.sol +++ b/src/mons/ekineki/SneakAttack.sol @@ -10,7 +10,7 @@ import {IEngine} from "../../IEngine.sol"; import {ITypeCalculator} from "../../types/ITypeCalculator.sol"; import {AttackCalculator} from "../../moves/AttackCalculator.sol"; import {IMoveSet} from "../../moves/IMoveSet.sol"; -import {EkinekiLib} from "./EkinekiLib.sol"; +import {NineNineNineLib} from "./NineNineNineLib.sol"; contract SneakAttack is IMoveSet { uint32 public constant BASE_POWER = 60; @@ -28,9 +28,13 @@ contract SneakAttack is IMoveSet { return "Sneak Attack"; } + function _getSneakAttackKey(uint256 playerIndex) internal pure returns (bytes32) { + return keccak256(abi.encode(playerIndex, "SNEAK_ATTACK")); + } + function move(bytes32 battleKey, uint256 attackerPlayerIndex, uint240 extraData, uint256 rng) external { // Check if already used this switch-in - if (EkinekiLib._getSneakAttackUsed(ENGINE, battleKey, attackerPlayerIndex) == 1) { + if (ENGINE.getGlobalKV(battleKey, _getSneakAttackKey(attackerPlayerIndex)) == 1) { return; } @@ -38,7 +42,7 @@ contract SneakAttack is IMoveSet { uint256 targetMonIndex = uint256(extraData); // Get effective crit rate (checks 999 buff) - uint32 effectiveCritRate = EkinekiLib._getEffectiveCritRate(ENGINE, battleKey, attackerPlayerIndex); + uint32 effectiveCritRate = NineNineNineLib._getEffectiveCritRate(ENGINE, battleKey, attackerPlayerIndex); // Build DamageCalcContext manually to target any opponent mon (not just active) uint256 attackerMonIndex = ENGINE.getActiveMonIndexForBattleState(battleKey)[attackerPlayerIndex]; @@ -80,7 +84,7 @@ contract SneakAttack is IMoveSet { } // Mark as used this switch-in - EkinekiLib._setSneakAttackUsed(ENGINE, attackerPlayerIndex, 1); + ENGINE.setGlobalKV(_getSneakAttackKey(attackerPlayerIndex), 1); } function stamina(bytes32, uint256, uint256) external pure returns (uint32) { diff --git a/test/mons/EkinekiTest.sol b/test/mons/EkinekiTest.sol index ac2650f7..affe8e1d 100644 --- a/test/mons/EkinekiTest.sol +++ b/test/mons/EkinekiTest.sol @@ -585,10 +585,12 @@ contract EkinekiTest is Test, BattleHelper { 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, - spAtkDeltaFirstSwitch, - "Savior Complex should not trigger again (once per game)" + 0, + "Savior Complex should not trigger again (once per game), temp boost cleared on switch out" ); } From f15ff918ad58c8b33ca276ed7e49458f19a007be Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Feb 2026 02:48:14 +0000 Subject: [PATCH 3/7] Decouple SneakAttack from SaviorComplex by self-managing reset via global effect SneakAttack now implements BasicEffect and registers itself as a global effect on first use. Its onMonSwitchIn hook resets the once-per-switch-in flag, removing the parasitic dependency on SaviorComplex's activateOnSwitch. https://claude.ai/code/session_011WmJgts8AesNNrDaccz2fn --- src/mons/ekineki/SaviorComplex.sol | 7 ------- src/mons/ekineki/SneakAttack.sol | 33 ++++++++++++++++++++++++++++-- 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/src/mons/ekineki/SaviorComplex.sol b/src/mons/ekineki/SaviorComplex.sol index 1e284484..45bc84bc 100644 --- a/src/mons/ekineki/SaviorComplex.sol +++ b/src/mons/ekineki/SaviorComplex.sol @@ -25,18 +25,11 @@ contract SaviorComplex is IAbility { return "Savior Complex"; } - function _getSneakAttackKey(uint256 playerIndex) internal pure returns (bytes32) { - return keccak256(abi.encode(playerIndex, "SNEAK_ATTACK")); - } - function _getSaviorComplexKey(uint256 playerIndex) internal pure returns (bytes32) { return keccak256(abi.encode(playerIndex, "SAVIOR_COMPLEX")); } function activateOnSwitch(bytes32 battleKey, uint256 playerIndex, uint256 monIndex) external { - // Reset sneak attack usage on every switch-in - ENGINE.setGlobalKV(_getSneakAttackKey(playerIndex), 0); - // Check if already triggered this game if (ENGINE.getGlobalKV(battleKey, _getSaviorComplexKey(playerIndex)) == 1) { return; diff --git a/src/mons/ekineki/SneakAttack.sol b/src/mons/ekineki/SneakAttack.sol index 86c62a14..c56b2828 100644 --- a/src/mons/ekineki/SneakAttack.sol +++ b/src/mons/ekineki/SneakAttack.sol @@ -10,9 +10,11 @@ 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 "./NineNineNineLib.sol"; -contract SneakAttack is IMoveSet { +contract SneakAttack is IMoveSet, BasicEffect { uint32 public constant BASE_POWER = 60; uint32 public constant STAMINA_COST = 2; @@ -24,7 +26,7 @@ contract SneakAttack is IMoveSet { TYPE_CALCULATOR = _TYPE_CALCULATOR; } - function name() external pure returns (string memory) { + function name() public pure override(IMoveSet, BasicEffect) returns (string memory) { return "Sneak Attack"; } @@ -32,6 +34,16 @@ contract SneakAttack is IMoveSet { return keccak256(abi.encode(playerIndex, "SNEAK_ATTACK")); } + function _ensureGlobalEffect(bytes32 battleKey) internal { + (EffectInstance[] memory effects,) = ENGINE.getEffects(battleKey, 2, 0); + for (uint256 i = 0; i < effects.length; i++) { + if (address(effects[i].effect) == address(this)) { + return; + } + } + ENGINE.addEffect(2, 0, IEffect(address(this)), bytes32(0)); + } + function move(bytes32 battleKey, uint256 attackerPlayerIndex, uint240 extraData, uint256 rng) external { // Check if already used this switch-in if (ENGINE.getGlobalKV(battleKey, _getSneakAttackKey(attackerPlayerIndex)) == 1) { @@ -85,6 +97,9 @@ contract SneakAttack is IMoveSet { // Mark as used this switch-in ENGINE.setGlobalKV(_getSneakAttackKey(attackerPlayerIndex), 1); + + // Register global effect to reset flag on future switch-ins + _ensureGlobalEffect(battleKey); } function stamina(bytes32, uint256, uint256) external pure returns (uint32) { @@ -110,4 +125,18 @@ contract SneakAttack is IMoveSet { function extraDataType() external pure returns (ExtraDataType) { return ExtraDataType.None; } + + // IEffect implementation — global effect that resets sneak attack on switch-in + function shouldRunAtStep(EffectStep step) external pure override returns (bool) { + return step == EffectStep.OnMonSwitchIn; + } + + function onMonSwitchIn(uint256, bytes32 extraData, uint256 playerIndex, uint256) + external + override + returns (bytes32 updatedExtraData, bool removeAfterRun) + { + ENGINE.setGlobalKV(_getSneakAttackKey(playerIndex), 0); + return (extraData, false); + } } From 06c4b1b8843309b20c073b3ebab9096e1c2443fe Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Feb 2026 05:19:08 +0000 Subject: [PATCH 4/7] SneakAttack: use local effect for once-per-switch-in tracking, rename 999 files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SneakAttack now adds itself as a local effect on the attacker's mon. Effect presence = already used, removed on switch-out via onMonSwitchOut. No global KV needed — cleaner than global effect and scoped to ekineki only. Also rename NineNineNine.sol -> 999.sol and NineNineNineLib.sol -> 999Lib.sol. https://claude.ai/code/session_011WmJgts8AesNNrDaccz2fn --- .../ekineki/{NineNineNine.sol => 999.sol} | 2 +- .../{NineNineNineLib.sol => 999Lib.sol} | 0 src/mons/ekineki/DualFlow.sol | 2 +- src/mons/ekineki/SneakAttack.sol | 38 ++++++------------- test/mons/EkinekiTest.sol | 6 +-- 5 files changed, 17 insertions(+), 31 deletions(-) rename src/mons/ekineki/{NineNineNine.sol => 999.sol} (96%) rename src/mons/ekineki/{NineNineNineLib.sol => 999Lib.sol} (100%) diff --git a/src/mons/ekineki/NineNineNine.sol b/src/mons/ekineki/999.sol similarity index 96% rename from src/mons/ekineki/NineNineNine.sol rename to src/mons/ekineki/999.sol index a30980ea..8d58ef12 100644 --- a/src/mons/ekineki/NineNineNine.sol +++ b/src/mons/ekineki/999.sol @@ -7,7 +7,7 @@ import "../../Enums.sol"; import {IEngine} from "../../IEngine.sol"; import {IMoveSet} from "../../moves/IMoveSet.sol"; -import {NineNineNineLib} from "./NineNineNineLib.sol"; +import {NineNineNineLib} from "./999Lib.sol"; contract NineNineNine is IMoveSet { IEngine immutable ENGINE; diff --git a/src/mons/ekineki/NineNineNineLib.sol b/src/mons/ekineki/999Lib.sol similarity index 100% rename from src/mons/ekineki/NineNineNineLib.sol rename to src/mons/ekineki/999Lib.sol diff --git a/src/mons/ekineki/DualFlow.sol b/src/mons/ekineki/DualFlow.sol index 9c68c6cb..39e2dd3a 100644 --- a/src/mons/ekineki/DualFlow.sol +++ b/src/mons/ekineki/DualFlow.sol @@ -11,7 +11,7 @@ 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 "./NineNineNineLib.sol"; +import {NineNineNineLib} from "./999Lib.sol"; contract DualFlow is StandardAttack { constructor(IEngine _ENGINE, ITypeCalculator _TYPE_CALCULATOR) diff --git a/src/mons/ekineki/SneakAttack.sol b/src/mons/ekineki/SneakAttack.sol index c56b2828..2252020f 100644 --- a/src/mons/ekineki/SneakAttack.sol +++ b/src/mons/ekineki/SneakAttack.sol @@ -12,7 +12,7 @@ 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 "./NineNineNineLib.sol"; +import {NineNineNineLib} from "./999Lib.sol"; contract SneakAttack is IMoveSet, BasicEffect { uint32 public constant BASE_POWER = 60; @@ -30,25 +30,15 @@ contract SneakAttack is IMoveSet, BasicEffect { return "Sneak Attack"; } - function _getSneakAttackKey(uint256 playerIndex) internal pure returns (bytes32) { - return keccak256(abi.encode(playerIndex, "SNEAK_ATTACK")); - } - - function _ensureGlobalEffect(bytes32 battleKey) internal { - (EffectInstance[] memory effects,) = ENGINE.getEffects(battleKey, 2, 0); + 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; } } - ENGINE.addEffect(2, 0, IEffect(address(this)), bytes32(0)); - } - - function move(bytes32 battleKey, uint256 attackerPlayerIndex, uint240 extraData, uint256 rng) external { - // Check if already used this switch-in - if (ENGINE.getGlobalKV(battleKey, _getSneakAttackKey(attackerPlayerIndex)) == 1) { - return; - } uint256 defenderPlayerIndex = (attackerPlayerIndex + 1) % 2; uint256 targetMonIndex = uint256(extraData); @@ -57,7 +47,6 @@ contract SneakAttack is IMoveSet, BasicEffect { uint32 effectiveCritRate = NineNineNineLib._getEffectiveCritRate(ENGINE, battleKey, attackerPlayerIndex); // Build DamageCalcContext manually to target any opponent mon (not just active) - uint256 attackerMonIndex = ENGINE.getActiveMonIndexForBattleState(battleKey)[attackerPlayerIndex]; MonStats memory attackerStats = ENGINE.getMonStatsForBattle(battleKey, attackerPlayerIndex, attackerMonIndex); MonStats memory defenderStats = ENGINE.getMonStatsForBattle(battleKey, defenderPlayerIndex, targetMonIndex); @@ -95,11 +84,8 @@ contract SneakAttack is IMoveSet, BasicEffect { ENGINE.emitEngineEvent(eventType, ""); } - // Mark as used this switch-in - ENGINE.setGlobalKV(_getSneakAttackKey(attackerPlayerIndex), 1); - - // Register global effect to reset flag on future switch-ins - _ensureGlobalEffect(battleKey); + // 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) { @@ -126,17 +112,17 @@ contract SneakAttack is IMoveSet, BasicEffect { return ExtraDataType.None; } - // IEffect implementation — global effect that resets sneak attack on switch-in + // IEffect implementation — local effect that cleans up on switch-out function shouldRunAtStep(EffectStep step) external pure override returns (bool) { - return step == EffectStep.OnMonSwitchIn; + return step == EffectStep.OnMonSwitchOut; } - function onMonSwitchIn(uint256, bytes32 extraData, uint256 playerIndex, uint256) + function onMonSwitchOut(uint256, bytes32, uint256, uint256) external + pure override returns (bytes32 updatedExtraData, bool removeAfterRun) { - ENGINE.setGlobalKV(_getSneakAttackKey(playerIndex), 0); - return (extraData, false); + return (bytes32(0), true); } } diff --git a/test/mons/EkinekiTest.sol b/test/mons/EkinekiTest.sol index affe8e1d..e36f5192 100644 --- a/test/mons/EkinekiTest.sol +++ b/test/mons/EkinekiTest.sol @@ -27,7 +27,7 @@ import {TestTypeCalculator} from "../mocks/TestTypeCalculator.sol"; // Ekineki contracts import {DualFlow} from "../../src/mons/ekineki/DualFlow.sol"; -import {NineNineNine} from "../../src/mons/ekineki/NineNineNine.sol"; +import {NineNineNine} from "../../src/mons/ekineki/999.sol"; import {SaviorComplex} from "../../src/mons/ekineki/SaviorComplex.sol"; import {SneakAttack} from "../../src/mons/ekineki/SneakAttack.sol"; @@ -36,7 +36,7 @@ import {SneakAttack} from "../../src/mons/ekineki/SneakAttack.sol"; * - DualFlow 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-in via SaviorComplex ability [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] @@ -300,7 +300,7 @@ contract EkinekiTest is Test, BattleHelper { int32 damageAfterFirst = engine.getMonStateForBattle(battleKey, 1, 1, MonStateIndexName.Hp); assertTrue(damageAfterFirst < 0, "First sneak attack should deal damage"); - // Alice switches to mon 1 (resets sneak attack via SaviorComplex) + // 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 ); From 6c7a3fb8a1a1a97dc0c3292b013529be6b453c5f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Feb 2026 05:53:17 +0000 Subject: [PATCH 5/7] Add KO bitmap getter, rename Bubble Bop, add Overflow, update CSVs - Expose getKOBitmap in IEngine/Engine; SaviorComplex uses bitmap popcount instead of iterating all mons - Rename DualFlow -> BubbleBop (file and contract) - Add Overflow move (Math type, 90bp, 3 stamina, 999 crit rate dependency) - Add all ekineki moves and ability to drool/ CSV files https://claude.ai/code/session_011WmJgts8AesNNrDaccz2fn --- drool/abilities.csv | 1 + drool/moves.csv | 6 +- snapshots/EngineGasTest.json | 28 +++---- snapshots/MatchmakerTest.json | 6 +- src/Engine.sol | 4 + src/IEngine.sol | 1 + .../ekineki/{DualFlow.sol => BubbleBop.sol} | 4 +- src/mons/ekineki/Overflow.sol | 54 +++++++++++++ src/mons/ekineki/SaviorComplex.sol | 13 ++-- test/mons/EkinekiTest.sol | 76 ++++++++++++++----- 10 files changed, 147 insertions(+), 46 deletions(-) rename src/mons/ekineki/{DualFlow.sol => BubbleBop.sol} (96%) create mode 100644 src/mons/ekineki/Overflow.sol 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..3f8b8236 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.,,No +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/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/mons/ekineki/DualFlow.sol b/src/mons/ekineki/BubbleBop.sol similarity index 96% rename from src/mons/ekineki/DualFlow.sol rename to src/mons/ekineki/BubbleBop.sol index 39e2dd3a..af8cd65d 100644 --- a/src/mons/ekineki/DualFlow.sol +++ b/src/mons/ekineki/BubbleBop.sol @@ -13,14 +13,14 @@ import {StandardAttack} from "../../moves/StandardAttack.sol"; import {ATTACK_PARAMS} from "../../moves/StandardAttackStructs.sol"; import {NineNineNineLib} from "./999Lib.sol"; -contract DualFlow is StandardAttack { +contract BubbleBop is StandardAttack { constructor(IEngine _ENGINE, ITypeCalculator _TYPE_CALCULATOR) StandardAttack( address(msg.sender), _ENGINE, _TYPE_CALCULATOR, ATTACK_PARAMS({ - NAME: "Dual Flow", + NAME: "Bubble Bop", BASE_POWER: 50, STAMINA_COST: 3, ACCURACY: 100, 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 index 45bc84bc..6a5181c9 100644 --- a/src/mons/ekineki/SaviorComplex.sol +++ b/src/mons/ekineki/SaviorComplex.sol @@ -35,17 +35,14 @@ contract SaviorComplex is IAbility { return; } - // Count KO'd mons on the player's team - uint256 teamSize = ENGINE.getTeamSize(battleKey, playerIndex); + // Count KO'd mons via bitmap popcount + uint256 koBitmap = ENGINE.getKOBitmap(battleKey, playerIndex); + if (koBitmap == 0) return; uint256 koCount = 0; - for (uint256 i = 0; i < teamSize; i++) { - if (ENGINE.getMonStateForBattle(battleKey, playerIndex, i, MonStateIndexName.IsKnockedOut) == 1) { - koCount++; - } + for (uint256 bits = koBitmap; bits != 0; bits >>= 1) { + koCount += bits & 1; } - if (koCount == 0) return; - // Determine boost based on stage uint8 boostPercent; if (koCount >= 3) { diff --git a/test/mons/EkinekiTest.sol b/test/mons/EkinekiTest.sol index e36f5192..8c1f2053 100644 --- a/test/mons/EkinekiTest.sol +++ b/test/mons/EkinekiTest.sol @@ -26,14 +26,15 @@ import {TestTeamRegistry} from "../mocks/TestTeamRegistry.sol"; import {TestTypeCalculator} from "../mocks/TestTypeCalculator.sol"; // Ekineki contracts -import {DualFlow} from "../../src/mons/ekineki/DualFlow.sol"; +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: - * - DualFlow hits twice, dealing damage with each hit [x] + * - 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] @@ -41,6 +42,7 @@ import {SneakAttack} from "../../src/mons/ekineki/SneakAttack.sol"; * - 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; @@ -63,10 +65,10 @@ contract EkinekiTest is Test, BattleHelper { attackFactory = new StandardAttackFactory(IEngine(address(engine)), ITypeCalculator(address(typeCalc))); } - function test_dualFlowHitsTwice() public { + function test_bubbleBopHitsTwice() public { uint32 maxHp = 200; - DualFlow dualFlow = new DualFlow(IEngine(address(engine)), ITypeCalculator(address(typeCalc))); + 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( @@ -85,16 +87,16 @@ contract EkinekiTest is Test, BattleHelper { }) ); - // Set up team with DualFlow - IMoveSet[] memory dualFlowMoves = new IMoveSet[](1); - dualFlowMoves[0] = dualFlow; - Mon memory dualFlowMon = _createMon(); - dualFlowMon.moves = dualFlowMoves; - dualFlowMon.stats.hp = maxHp; - dualFlowMon.stats.specialAttack = 100; - dualFlowMon.stats.specialDefense = 100; + // 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] = dualFlowMon; + aliceTeam[0] = bubbleBopMon; // Set up team with single hit for Bob (so bob takes damage, not deals it) IMoveSet[] memory singleMoves = new IMoveSet[](1); @@ -123,12 +125,12 @@ contract EkinekiTest is Test, BattleHelper { engine, commitManager, battleKey, SWITCH_MOVE_INDEX, SWITCH_MOVE_INDEX, uint240(0), uint240(0) ); - // Alice uses DualFlow, Bob does nothing + // 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 Dual Flow"); + 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(); @@ -153,11 +155,11 @@ contract EkinekiTest is Test, BattleHelper { _commitRevealExecuteForAliceAndBob(engine2, commitManager2, battleKey2, NO_OP_MOVE_INDEX, 0, 0, 0); int32 aliceSingleHitDamage = engine2.getMonStateForBattle(battleKey2, 0, 0, MonStateIndexName.Hp); - // DualFlow should deal more damage than a single hit of same base power - // (since DualFlow has volatility and two hits, we check it dealt strictly more) + // 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, - "Dual Flow (two hits) should deal more damage than a single hit of same base power" + "Bubble Bop (two hits) should deal more damage than a single hit of same base power" ); } @@ -690,4 +692,42 @@ contract EkinekiTest is Test, BattleHelper { 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"); + } } From 73ce78fe0b36ebd5fec07e3f41f7c68b9734e5ff Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Feb 2026 06:05:36 +0000 Subject: [PATCH 6/7] Add OpponentTeamIndex ExtraDataType for SneakAttack CPU support SneakAttack now declares ExtraDataType.OpponentTeamIndex so the CPU knows to provide a random opponent mon index. Added the new enum variant and handling in CPU.sol. https://claude.ai/code/session_011WmJgts8AesNNrDaccz2fn --- drool/moves.csv | 2 +- src/Enums.sol | 3 ++- src/cpu/CPU.sol | 7 +++++++ src/mons/ekineki/SneakAttack.sol | 2 +- 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/drool/moves.csv b/drool/moves.csv index 3f8b8236..abdedb32 100644 --- a/drool/moves.csv +++ b/drool/moves.csv @@ -44,6 +44,6 @@ Vital Siphon,Xmon,40,2,90,0,Cosmic,Special,"Deals damage, 50% chance to steal 1 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 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.,,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/src/Enums.sol b/src/Enums.sol index 4dbc1843..1101e6a0 100644 --- a/src/Enums.sol +++ b/src/Enums.sol @@ -75,5 +75,6 @@ enum StatBoostFlag { enum ExtraDataType { None, - SelfTeamIndex + SelfTeamIndex, + OpponentTeamIndex } diff --git a/src/cpu/CPU.sol b/src/cpu/CPU.sol index 42197edc..6465957e 100644 --- a/src/cpu/CPU.sol +++ b/src/cpu/CPU.sol @@ -109,6 +109,13 @@ 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.OpponentTeamIndex) { + uint256 opponentIndex = (playerIndex + 1) % 2; + uint256 opponentTeamSize = ENGINE.getTeamSize(battleKey, opponentIndex); + uint256 randomIndex = + RNG.getRNG(keccak256(abi.encode(nonce++, battleKey, block.timestamp))) % opponentTeamSize; + extraDataToUse = uint240(randomIndex); + validMoveExtraData[validMoveCount] = extraDataToUse; } if (validator.validatePlayerMove(battleKey, i, playerIndex, extraDataToUse)) { validMoveIndices[validMoveCount++] = uint8(i); diff --git a/src/mons/ekineki/SneakAttack.sol b/src/mons/ekineki/SneakAttack.sol index 2252020f..ce7b25ce 100644 --- a/src/mons/ekineki/SneakAttack.sol +++ b/src/mons/ekineki/SneakAttack.sol @@ -109,7 +109,7 @@ contract SneakAttack is IMoveSet, BasicEffect { } function extraDataType() external pure returns (ExtraDataType) { - return ExtraDataType.None; + return ExtraDataType.OpponentTeamIndex; } // IEffect implementation — local effect that cleans up on switch-out From 9e436e4483f0db1c9a89051ba405d4f56c3f43d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Feb 2026 06:30:09 +0000 Subject: [PATCH 7/7] Rename to OpponentNonKOTeamIndex, filter out KO'd mons in CPU CPU now uses getKOBitmap to only pick non-KO'd opponent mons when selecting a target for OpponentNonKOTeamIndex moves like SneakAttack. https://claude.ai/code/session_011WmJgts8AesNNrDaccz2fn --- src/Enums.sol | 2 +- src/cpu/CPU.sol | 17 ++++++++++++++--- src/mons/ekineki/SneakAttack.sol | 2 +- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/Enums.sol b/src/Enums.sol index 1101e6a0..b5754f69 100644 --- a/src/Enums.sol +++ b/src/Enums.sol @@ -76,5 +76,5 @@ enum StatBoostFlag { enum ExtraDataType { None, SelfTeamIndex, - OpponentTeamIndex + OpponentNonKOTeamIndex } diff --git a/src/cpu/CPU.sol b/src/cpu/CPU.sol index 6465957e..77bd2b8f 100644 --- a/src/cpu/CPU.sol +++ b/src/cpu/CPU.sol @@ -109,12 +109,23 @@ 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.OpponentTeamIndex) { + } 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))) % opponentTeamSize; - extraDataToUse = uint240(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)) { diff --git a/src/mons/ekineki/SneakAttack.sol b/src/mons/ekineki/SneakAttack.sol index ce7b25ce..6fd8ea74 100644 --- a/src/mons/ekineki/SneakAttack.sol +++ b/src/mons/ekineki/SneakAttack.sol @@ -109,7 +109,7 @@ contract SneakAttack is IMoveSet, BasicEffect { } function extraDataType() external pure returns (ExtraDataType) { - return ExtraDataType.OpponentTeamIndex; + return ExtraDataType.OpponentNonKOTeamIndex; } // IEffect implementation — local effect that cleans up on switch-out