diff --git a/drool/moves.csv b/drool/moves.csv index ce1d528f..55e5f945 100644 --- a/drool/moves.csv +++ b/drool/moves.csv @@ -44,8 +44,9 @@ Iron Wall,Aurox,0,3,100,0,Metal,Self,"Until Aurox switches out, regenerate 50% o Bull Rush,Aurox,140,2,100,0,Metal,Physical,Deals damage. Also deals 20% of max HP to self.,,none,0 Contagious Slumber,Xmon,0,2,100,0,Cosmic,Other,"Inflicts Sleep on self and opponent.",,none,0 Vital Siphon,Xmon,40,2,90,0,Cosmic,Special,"Deals damage, 50% chance to steal 1 stamina from opponent.",,none,0 -Somniphobia,Xmon,0,1,100,0,Cosmic,Other,"For the next 8 turns, any mon that rests will take 1/8th of max HP as damage.",,none,0 +Somniphobia,Xmon,0,1,100,0,Cosmic,Other,"For 4 turns, any active mon that gains stamina takes 1/8th of max HP per stack as damage. Stacks if used again.",,none,0 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.,,none,0 +Invoke Taboo,Xmon,0,1,100,-1,Cosmic,Other,"Brands the move the opponent just used. Until they switch out, if they use that move again they fall asleep.",,none,6 Bubble Bop,Ekineki,50,3,100,0,Liquid,Special,Hits twice. Each hit deals 50 base power.,,none,0 Sneak Attack,Ekineki,60,2,100,1,Liquid,Special,Hits any opponent mon (even non-active). Can only be used once per switch-in.,,opponent-mon,0 Nine Nine Nine,Ekineki,0,1,100,0,Math,Self,Sets crit rate to 90% on the next turn for all moves.,,none,0 diff --git a/script/SetupMons.s.sol b/script/SetupMons.s.sol index 3d79faa1..eb07a38c 100644 --- a/script/SetupMons.s.sol +++ b/script/SetupMons.s.sol @@ -62,6 +62,7 @@ import {PreemptiveShock} from "../src/mons/volthare/PreemptiveShock.sol"; import {RoundTrip} from "../src/mons/volthare/RoundTrip.sol"; import {ContagiousSlumber} from "../src/mons/xmon/ContagiousSlumber.sol"; import {Dreamcatcher} from "../src/mons/xmon/Dreamcatcher.sol"; +import {InvokeTaboo} from "../src/mons/xmon/InvokeTaboo.sol"; import {NightTerrors} from "../src/mons/xmon/NightTerrors.sol"; import {Somniphobia} from "../src/mons/xmon/Somniphobia.sol"; import {VitalSiphon} from "../src/mons/xmon/VitalSiphon.sol"; @@ -664,13 +665,13 @@ contract SetupMons is Script { } function deployXmon(GachaTeamRegistry registry) internal returns (DeployData[] memory) { - DeployData[] memory deployedContracts = new DeployData[](5); + DeployData[] memory deployedContracts = new DeployData[](6); // Cache commonly used addresses address sleepstatus = vm.envAddress("SLEEP_STATUS"); address typecalculator = vm.envAddress("TYPE_CALCULATOR"); - address[5] memory addrs; + address[6] memory addrs; { addrs[0] = address(new ContagiousSlumber(IEffect(sleepstatus))); @@ -689,8 +690,12 @@ contract SetupMons is Script { deployedContracts[3] = DeployData({name: "Night Terrors", contractAddress: addrs[3]}); } { - addrs[4] = address(new Dreamcatcher()); - deployedContracts[4] = DeployData({name: "Dreamcatcher", contractAddress: addrs[4]}); + addrs[4] = address(new InvokeTaboo(IEffect(sleepstatus))); + deployedContracts[4] = DeployData({name: "Invoke Taboo", contractAddress: addrs[4]}); + } + { + addrs[5] = address(new Dreamcatcher()); + deployedContracts[5] = DeployData({name: "Dreamcatcher", contractAddress: addrs[5]}); } _registerXmon(registry, addrs); @@ -698,7 +703,7 @@ contract SetupMons is Script { return deployedContracts; } - function _registerXmon(GachaTeamRegistry registry, address[5] memory addrs) internal { + function _registerXmon(GachaTeamRegistry registry, address[6] memory addrs) internal { MonStats memory stats = MonStats({ hp: 311, stamina: 5, @@ -710,13 +715,16 @@ contract SetupMons is Script { type1: Type.Cosmic, type2: Type.None }); - uint256[] memory moves = new uint256[](4); + // Lanes 0-3 are the level-0 battle moves; lane 4 (Invoke Taboo) is a higher-pool move that + // unlocks at level 6 (see moves.csv UnlockLevel and the MonExp by-lane gating). + uint256[] memory moves = new uint256[](5); moves[0] = uint256(uint160(addrs[0])); moves[1] = uint256(uint160(addrs[1])); moves[2] = uint256(uint160(addrs[2])); moves[3] = uint256(uint160(addrs[3])); + moves[4] = uint256(uint160(addrs[4])); uint256[] memory abilities = new uint256[](1); - abilities[0] = (uint256(1) << 248) | uint256(uint160(addrs[4])); + abilities[0] = (uint256(1) << 248) | uint256(uint160(addrs[5])); bytes32[] memory keys = new bytes32[](0); bytes32[] memory values = new bytes32[](0); registry.createMon(10, stats, moves, abilities, keys, values); diff --git a/src/mons/xmon/InvokeTaboo.sol b/src/mons/xmon/InvokeTaboo.sol new file mode 100644 index 00000000..e20cc96b --- /dev/null +++ b/src/mons/xmon/InvokeTaboo.sol @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: AGPL-3.0 + +pragma solidity ^0.8.0; + +import {ALWAYS_APPLIES_BIT, DEFAULT_PRIORITY, MOVE_INDEX_MASK, SWITCH_MOVE_INDEX} from "../../Constants.sol"; +import {ExtraDataType, MoveClass, Type} from "../../Enums.sol"; +import {MoveDecision, MoveMeta} from "../../Structs.sol"; + +import {IEngine} from "../../IEngine.sol"; +import {IMoveSet} from "../../moves/IMoveSet.sol"; +import {IEffect} from "../../effects/IEffect.sol"; +import {BasicEffect} from "../../effects/BasicEffect.sol"; + +contract InvokeTaboo is IMoveSet, BasicEffect { + IEffect immutable SLEEP_STATUS; + + constructor(IEffect _SLEEP_STATUS) { + SLEEP_STATUS = _SLEEP_STATUS; + } + + function name() public pure override(IMoveSet, BasicEffect) returns (string memory) { + return "Invoke Taboo"; + } + + function move( + IEngine engine, + bytes32 battleKey, + uint256 attackerPlayerIndex, + uint256, + uint256 defenderMonIndex, + uint16, + uint256 + ) external { + uint256 defenderPlayerIndex = (attackerPlayerIndex + 1) % 2; + + MoveDecision memory moveDecision = engine.getMoveDecisionForBattleState(battleKey, defenderPlayerIndex); + uint8 moveIndex = moveDecision.packedMoveIndex & MOVE_INDEX_MASK; + + // Only brand regular move slots, not a switch (125) or no-op (126). + if (moveIndex >= SWITCH_MOVE_INDEX) { + return; + } + + bytes32 tabooData = bytes32(uint256(moveIndex)); + (bool exists, uint256 effectIndex,) = + engine.getEffectData(battleKey, defenderPlayerIndex, defenderMonIndex, address(this)); + if (exists) { + engine.editEffect(defenderPlayerIndex, effectIndex, tabooData); + } else { + engine.addEffect(defenderPlayerIndex, defenderMonIndex, this, tabooData); + } + } + + function stamina(IEngine, bytes32, uint256, uint256) public pure returns (uint32) { + return 1; + } + + function priority(IEngine, bytes32, uint256) public pure returns (uint32) { + return DEFAULT_PRIORITY - 1; + } + + function moveType(IEngine, bytes32) public pure returns (Type) { + return Type.Cosmic; + } + + function moveClass(IEngine, bytes32) public pure returns (MoveClass) { + return MoveClass.Other; + } + + function extraDataType() public pure returns (ExtraDataType) { + return ExtraDataType.None; + } + + // Steps: OnMonSwitchOut (0x20), AfterMove (0x80), ALWAYS_APPLIES (0x8000) + function getStepsBitmap() external pure override returns (uint16) { + return ALWAYS_APPLIES_BIT | 0x00A0; + } + + function onAfterMove( + IEngine engine, + bytes32 battleKey, + uint256, + bytes32 extraData, + uint256 targetIndex, + uint256 monIndex, + uint256, + uint256 + ) external override returns (bytes32, bool) { + MoveDecision memory moveDecision = engine.getMoveDecisionForBattleState(battleKey, targetIndex); + uint8 moveIndex = moveDecision.packedMoveIndex & MOVE_INDEX_MASK; + uint8 tabooMoveIndex = uint8(uint256(extraData)); + + if (moveIndex == tabooMoveIndex) { + engine.addEffect(targetIndex, monIndex, SLEEP_STATUS, ""); + } + return (extraData, false); + } + + function onMonSwitchOut(IEngine, bytes32, uint256, bytes32 extraData, uint256, uint256, uint256, uint256) + external + pure + override + returns (bytes32, bool) + { + return (extraData, true); + } + + function getMeta(IEngine engine, bytes32 battleKey, uint256 attackerPlayerIndex, uint256 attackerMonIndex) + external + pure + returns (MoveMeta memory) + { + return MoveMeta({ + moveType: moveType(engine, battleKey), + moveClass: moveClass(engine, battleKey), + extraDataType: extraDataType(), + priority: priority(engine, battleKey, attackerPlayerIndex), + stamina: stamina(engine, battleKey, attackerPlayerIndex, attackerMonIndex), + basePower: 0 + }); + } +} diff --git a/src/mons/xmon/Somniphobia.sol b/src/mons/xmon/Somniphobia.sol index c7a9257d..e2540ec8 100644 --- a/src/mons/xmon/Somniphobia.sol +++ b/src/mons/xmon/Somniphobia.sol @@ -2,29 +2,47 @@ pragma solidity ^0.8.0; -import {NO_OP_MOVE_INDEX, DEFAULT_PRIORITY, MOVE_INDEX_MASK} from "../../Constants.sol"; -import {ExtraDataType, MoveClass, Type} from "../../Enums.sol"; -import { MoveDecision, MonStateIndexName, MoveMeta } from "../../Structs.sol"; +import {ALWAYS_APPLIES_BIT, DEFAULT_PRIORITY} from "../../Constants.sol"; +import {ExtraDataType, MoveClass, Type, MonStateIndexName} from "../../Enums.sol"; +import {MoveMeta} from "../../Structs.sol"; import {IEngine} from "../../IEngine.sol"; import {IMoveSet} from "../../moves/IMoveSet.sol"; import {BasicEffect} from "../../effects/BasicEffect.sol"; contract Somniphobia is IMoveSet, BasicEffect { - uint256 public constant DURATION = 8; + uint256 public constant DURATION = 4; int32 public constant DAMAGE_DENOM = 8; + // Global-coordinator data: [stack: bits 8-15 | remainingDuration: bits 0-7]. + // Per-mon-punisher data: this marker bit set (distinguishes the two roles, which share a contract). + uint256 internal constant PUNISHER_MARKER = 1 << 255; + function name() public pure override(IMoveSet, BasicEffect) returns (string memory) { return "Somniphobia"; } - function move(IEngine engine, bytes32 battleKey, uint256 attackerPlayerIndex, uint256, uint256, uint16, uint256) external { - // Add effect globally for 6 turns (only if it's not already in global effects) - (bool exists,,) = engine.getEffectData(battleKey, 2, 2, address(this)); + function move(IEngine engine, bytes32 battleKey, uint256 attackerPlayerIndex, uint256 attackerMonIndex, uint256 defenderMonIndex, uint16, uint256) external { + uint256 defenderPlayerIndex = (attackerPlayerIndex + 1) % 2; + + (bool exists, uint256 effectIndex, bytes32 data) = engine.getEffectData(battleKey, 2, 2, address(this)); if (exists) { - return; + // Bump the stack but keep the original countdown; the effect must fade before it resets. + uint256 stack = ((uint256(data) >> 8) & 0xFF) + 1; + engine.editEffect(2, effectIndex, bytes32((stack << 8) | (uint256(data) & 0xFF))); + } else { + engine.addEffect(2, attackerPlayerIndex, this, bytes32((uint256(1) << 8) | DURATION)); + } + + _applyPunisher(engine, battleKey, attackerPlayerIndex, attackerMonIndex); + _applyPunisher(engine, battleKey, defenderPlayerIndex, defenderMonIndex); + } + + function _applyPunisher(IEngine engine, bytes32 battleKey, uint256 playerIndex, uint256 monIndex) internal { + (bool exists,,) = engine.getEffectData(battleKey, playerIndex, monIndex, address(this)); + if (!exists) { + engine.addEffect(playerIndex, monIndex, this, bytes32(PUNISHER_MARKER)); } - engine.addEffect(2, attackerPlayerIndex, this, bytes32(DURATION)); } function stamina(IEngine, bytes32, uint256, uint256) public pure returns (uint32) { @@ -47,46 +65,76 @@ contract Somniphobia is IMoveSet, BasicEffect { return ExtraDataType.None; } - // Steps: RoundEnd, AfterMove + // Steps: RoundEnd (0x04), OnMonSwitchIn (0x10), OnMonSwitchOut (0x20), OnUpdateMonState (0x100), ALWAYS_APPLIES (0x8000) function getStepsBitmap() external pure override returns (uint16) { - return 0x8084; + return ALWAYS_APPLIES_BIT | 0x0134; } - function onAfterMove(IEngine engine, bytes32 battleKey, uint256, bytes32 extraData, uint256 targetIndex, uint256 monIndex, uint256, uint256) + function onUpdateMonState( + IEngine engine, + bytes32 battleKey, + uint256, + bytes32 extraData, + uint256 playerIndex, + uint256 monIndex, + uint256, + uint256, + MonStateIndexName stateVarIndex, + int32 valueToAdd + ) external override returns (bytes32, bool) { + if (stateVarIndex == MonStateIndexName.Stamina && valueToAdd > 0) { + (bool exists,, bytes32 data) = engine.getEffectData(battleKey, 2, 2, address(this)); + if (exists) { + int32 stack = int32(uint32((uint256(data) >> 8) & 0xFF)); + uint32 maxHp = engine.getMonValueForBattle(battleKey, playerIndex, monIndex, MonStateIndexName.Hp); + int32 damage = int32(uint32(maxHp)) / DAMAGE_DENOM * stack; + if (damage > 0) { + engine.dealDamage(playerIndex, monIndex, damage); + } + } + } + return (extraData, false); + } + + function onMonSwitchIn(IEngine engine, bytes32 battleKey, uint256, bytes32 extraData, uint256 targetIndex, uint256 monIndex, uint256, uint256) external override returns (bytes32, bool) { - MoveDecision memory moveDecision = engine.getMoveDecisionForBattleState(battleKey, targetIndex); - - // Unpack the move index from packedMoveIndex - uint8 moveIndex = moveDecision.packedMoveIndex & MOVE_INDEX_MASK; - - // If this player rested (NO_OP), deal damage - if (moveIndex == NO_OP_MOVE_INDEX) { - uint32 maxHp = engine.getMonValueForBattle(battleKey, targetIndex, monIndex, MonStateIndexName.Hp); - int32 damage = int32(uint32(maxHp)) / DAMAGE_DENOM; - - if (damage > 0) { - engine.dealDamage(targetIndex, monIndex, damage); - } + // Global coordinator only: apply the punisher to the mon that just switched in. + if (uint256(extraData) & PUNISHER_MARKER == 0) { + _applyPunisher(engine, battleKey, targetIndex, monIndex); } - return (extraData, false); } - function onRoundEnd(IEngine, bytes32, uint256, bytes32 extraData, uint256, uint256, uint256, uint256) + function onMonSwitchOut(IEngine, bytes32, uint256, bytes32 extraData, uint256, uint256, uint256, uint256) external pure override - returns (bytes32, bool removeAfterRun) + returns (bytes32, bool) { - uint256 turnsLeft = uint256(extraData); - if (turnsLeft == 1) { + // Punisher clears with its mon; the global coordinator persists. + return (extraData, uint256(extraData) & PUNISHER_MARKER != 0); + } + + function onRoundEnd(IEngine engine, bytes32 battleKey, uint256, bytes32 extraData, uint256, uint256, uint256, uint256) + external + view + override + returns (bytes32, bool) + { + if (uint256(extraData) & PUNISHER_MARKER != 0) { + // Punisher: drop self once the coordinator is gone. + (bool exists,,) = engine.getEffectData(battleKey, 2, 2, address(this)); + return (extraData, !exists); + } + // Global coordinator: count down, preserving the stack. + uint256 duration = uint256(extraData) & 0xFF; + if (duration <= 1) { return (extraData, true); - } else { - return (bytes32(turnsLeft - 1), false); } + return (bytes32((uint256(extraData) & 0xFF00) | (duration - 1)), false); } function getMeta(IEngine engine, bytes32 battleKey, uint256 attackerPlayerIndex, uint256 attackerMonIndex) diff --git a/test/mons/XmonTest.sol b/test/mons/XmonTest.sol index 79c61f17..83c9e149 100644 --- a/test/mons/XmonTest.sol +++ b/test/mons/XmonTest.sol @@ -32,15 +32,19 @@ import {TestTypeCalculator} from "../mocks/TestTypeCalculator.sol"; import {ContagiousSlumber} from "../../src/mons/xmon/ContagiousSlumber.sol"; import {VitalSiphon} from "../../src/mons/xmon/VitalSiphon.sol"; import {Somniphobia} from "../../src/mons/xmon/Somniphobia.sol"; +import {InvokeTaboo} from "../../src/mons/xmon/InvokeTaboo.sol"; import {Dreamcatcher} from "../../src/mons/xmon/Dreamcatcher.sol"; import {NightTerrors} from "../../src/mons/xmon/NightTerrors.sol"; /** - Contagious Slumber adds Sleep effect to both mons [x] - Vital Siphon drains stamina only when opponent has at least 1 stamina [x] - - Somniphobia correctly damages both mons if they choose to NO_OP [x] + - Somniphobia damages a mon on any stamina gain (round-end regen + resting) [x] + - Somniphobia does NOT damage a resting mon that gains no stamina (already full) [x] - Dreamcatcher heals on stamina gain (external StaminaRegen path) [x] - Dreamcatcher heals on inline stamina regen (INLINE_STAMINA_REGEN_RULESET) [x] + - Invoke Taboo brands the opponent's move; repeating it puts them to sleep [x] + - Invoke Taboo brand clears when the opponent switches out [x] - Night Terrors doesn't trigger when terror stacks > available stamina [ ] - Night Terrors effect clears on swap [ ] - Night Terrors damage differs when opponent is asleep vs awake [ ] @@ -196,67 +200,490 @@ contract XmonTest is Test, BattleHelper { assertEq(aliceStaminaDelta, 1 - 2 * int32(vitalSiphon.stamina(IEngine(address(0)), 0, 0, 0)), "Alice should have -3 stamina delta (after using the move)"); } - function test_somniphobiaDamagesMonsWhoRest() public { + // Stamina-burn filler: costs stamina, deals no damage, so a mon can drop below full and regen. + function _staminaBurn(uint32 cost) internal returns (StandardAttack) { + return attackFactory.createAttack( + ATTACK_PARAMS({ + BASE_POWER: 0, + STAMINA_COST: cost, + ACCURACY: 100, + PRIORITY: DEFAULT_PRIORITY, + MOVE_TYPE: Type.Cosmic, + EFFECT_ACCURACY: 0, + MOVE_CLASS: MoveClass.Physical, + CRIT_RATE: 0, + VOLATILITY: 0, + NAME: "Stamina Burn", + EFFECT: IEffect(address(0)) + }) + ); + } + + // Somniphobia triggers on any stamina gain (not just resting): the round-end regen tick deals + // damage, while a mon resting at full stamina gains nothing and is untouched. + function test_somniphobiaDamagesOnAnyStaminaGain() public { Somniphobia somniphobia = new Somniphobia(); + StaminaRegen staminaRegen = new StaminaRegen(); - uint256[] memory moves = new uint256[](1); + uint32 maxHp = uint32(somniphobia.DAMAGE_DENOM()) * 50; // 400 HP -> 50 damage per tick at 1 stack + int32 tick = -int32(maxHp) / somniphobia.DAMAGE_DENOM(); // -50 + + StandardAttack staminaBurn = _staminaBurn(2); + + uint256[] memory moves = new uint256[](2); + moves[0] = uint256(uint160(address(somniphobia))); + moves[1] = uint256(uint160(address(staminaBurn))); + + // Alice is faster so move order is deterministic. + Mon memory aliceMon = _createMon(); + aliceMon.moves = moves; + aliceMon.stats.hp = maxHp; + aliceMon.stats.stamina = 5; + aliceMon.stats.speed = 2; + + Mon memory bobMon = _createMon(); + bobMon.moves = moves; + bobMon.stats.hp = maxHp; + bobMon.stats.stamina = 5; + bobMon.stats.speed = 1; + + Mon[] memory aliceTeam = new Mon[](1); + aliceTeam[0] = aliceMon; + Mon[] memory bobTeam = new Mon[](1); + bobTeam[0] = bobMon; + + defaultRegistry.setTeam(ALICE, aliceTeam); + defaultRegistry.setTeam(BOB, bobTeam); + + DefaultValidator validator = new DefaultValidator( + IEngine(address(engine)), DefaultValidator.Args({MONS_PER_TEAM: 1, MOVES_PER_MON: 2, TIMEOUT_DURATION: 10}) + ); + + // Battle with the StaminaRegen global effect so resting / round-end actually regen stamina. + IEffect[] memory effects = new IEffect[](1); + effects[0] = staminaRegen; + DefaultRuleset ruleset = new DefaultRuleset(IEngine(address(engine)), effects); + + bytes32 battleKey = _startBattle( + validator, engine, mockOracle, defaultRegistry, matchmaker, new IEngineHook[](0), IRuleset(address(ruleset)), address(commitManager) + ); + + // Both players select their first mon + _commitRevealExecuteForAliceAndBob( + engine, commitManager, battleKey, SWITCH_MOVE_INDEX, SWITCH_MOVE_INDEX, uint16(0), uint16(0) + ); + + // Turn 1: Alice casts Somniphobia, Bob burns 2 stamina. Neither rests. At round-end + // StaminaRegen tops each mon back up by 1, and that gain triggers Somniphobia on both. + _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey, 0, 1, 0, 0); + + // The global coordinator exists and both active mons carry the per-mon effect. + (bool coordinatorExists,,) = engine.getEffectData(battleKey, 2, 2, address(somniphobia)); + (bool aliceHas,,) = engine.getEffectData(battleKey, 0, 0, address(somniphobia)); + (bool bobHas,,) = engine.getEffectData(battleKey, 1, 0, address(somniphobia)); + assertTrue(coordinatorExists, "Somniphobia coordinator should exist"); + assertTrue(aliceHas, "Alice should have Somniphobia effect"); + assertTrue(bobHas, "Bob should have Somniphobia effect"); + + // Round-end regen (a non-rest stamina gain) dealt one tick to each. + assertEq(engine.getMonStateForBattle(battleKey, 0, 0, MonStateIndexName.Hp), tick, "Alice tick from round-end regen"); + assertEq(engine.getMonStateForBattle(battleKey, 1, 0, MonStateIndexName.Hp), tick, "Bob tick from round-end regen"); + // Alice regen'd from -1 -> 0; Bob from -2 -> -1. + assertEq(engine.getMonStateForBattle(battleKey, 0, 0, MonStateIndexName.Stamina), 0, "Alice stamina back to full"); + assertEq(engine.getMonStateForBattle(battleKey, 1, 0, MonStateIndexName.Stamina), -1, "Bob stamina still -1"); + + // Turn 2: both rest. Alice is already at full stamina -> no gain -> NO damage. + // Bob is at -1 -> resting regens +1 -> gain -> one more tick. + _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey, NO_OP_MOVE_INDEX, NO_OP_MOVE_INDEX, 0, 0); + + assertEq( + engine.getMonStateForBattle(battleKey, 0, 0, MonStateIndexName.Hp), + tick, + "Alice took NO new damage: resting at full stamina is not a gain" + ); + assertEq( + engine.getMonStateForBattle(battleKey, 1, 0, MonStateIndexName.Hp), + tick * 2, + "Bob took another tick: resting regen'd stamina" + ); + } + + // Re-casting Somniphobia raises the stack, scaling the per-gain damage (1/8 max HP per stack). + function test_somniphobiaStacks() public { + Somniphobia somniphobia = new Somniphobia(); + StaminaRegen staminaRegen = new StaminaRegen(); + + uint32 maxHp = uint32(somniphobia.DAMAGE_DENOM()) * 100; // 800 HP -> 100 per stack + int32 stack1 = -int32(maxHp) / somniphobia.DAMAGE_DENOM(); // -100 + + StandardAttack staminaBurn = _staminaBurn(2); + uint256[] memory moves = new uint256[](2); moves[0] = uint256(uint160(address(somniphobia))); + moves[1] = uint256(uint160(address(staminaBurn))); + + Mon memory aliceMon = _createMon(); + aliceMon.moves = moves; + aliceMon.stats.hp = maxHp; + aliceMon.stats.stamina = 5; + aliceMon.stats.speed = 2; + + Mon memory bobMon = _createMon(); + bobMon.moves = moves; + bobMon.stats.hp = maxHp; + bobMon.stats.stamina = 5; + bobMon.stats.speed = 1; + + Mon[] memory aliceTeam = new Mon[](1); + aliceTeam[0] = aliceMon; + Mon[] memory bobTeam = new Mon[](1); + bobTeam[0] = bobMon; + defaultRegistry.setTeam(ALICE, aliceTeam); + defaultRegistry.setTeam(BOB, bobTeam); + + DefaultValidator validator = new DefaultValidator( + IEngine(address(engine)), DefaultValidator.Args({MONS_PER_TEAM: 1, MOVES_PER_MON: 2, TIMEOUT_DURATION: 10}) + ); + IEffect[] memory effects = new IEffect[](1); + effects[0] = staminaRegen; + DefaultRuleset ruleset = new DefaultRuleset(IEngine(address(engine)), effects); + bytes32 battleKey = _startBattle( + validator, engine, mockOracle, defaultRegistry, matchmaker, new IEngineHook[](0), IRuleset(address(ruleset)), address(commitManager) + ); + + _commitRevealExecuteForAliceAndBob( + engine, commitManager, battleKey, SWITCH_MOVE_INDEX, SWITCH_MOVE_INDEX, uint16(0), uint16(0) + ); + + // Turn 1: Alice casts Somniphobia (stack 1), Bob burns stamina. Round-end regen deals 1 stack. + _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey, 0, 1, 0, 0); + assertEq(engine.getMonStateForBattle(battleKey, 0, 0, MonStateIndexName.Hp), stack1, "Alice 1-stack tick"); + assertEq(engine.getMonStateForBattle(battleKey, 1, 0, MonStateIndexName.Hp), stack1, "Bob 1-stack tick"); + + // Turn 2: Alice casts again (stack 2), Bob burns stamina. Round-end regen now deals 2 stacks. + _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey, 0, 1, 0, 0); + + (,, bytes32 data) = engine.getEffectData(battleKey, 2, 2, address(somniphobia)); + assertEq((uint256(data) >> 8) & 0xFF, 2, "Coordinator should be at stack 2"); + assertEq(engine.getMonStateForBattle(battleKey, 0, 0, MonStateIndexName.Hp), stack1 * 3, "Alice: 1 stack + 2 stacks"); + assertEq(engine.getMonStateForBattle(battleKey, 1, 0, MonStateIndexName.Hp), stack1 * 3, "Bob: 1 stack + 2 stacks"); + } + + // Both players casting in the same battle share one global instance; the stack just accumulates. + function test_somniphobiaSingleInstanceWhenBothCast() public { + Somniphobia somniphobia = new Somniphobia(); + + uint256[] memory moves = new uint256[](2); + moves[0] = uint256(uint160(address(somniphobia))); + moves[1] = uint256(uint160(address(_staminaBurn(2)))); + + Mon memory aliceMon = _createMon(); + aliceMon.moves = moves; + aliceMon.stats.hp = 1000; + aliceMon.stats.stamina = 10; + aliceMon.stats.speed = 2; + + Mon memory bobMon = _createMon(); + bobMon.moves = moves; + bobMon.stats.hp = 1000; + bobMon.stats.stamina = 10; + bobMon.stats.speed = 1; + + Mon[] memory aliceTeam = new Mon[](1); + aliceTeam[0] = aliceMon; + Mon[] memory bobTeam = new Mon[](1); + bobTeam[0] = bobMon; + defaultRegistry.setTeam(ALICE, aliceTeam); + defaultRegistry.setTeam(BOB, bobTeam); + + 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)); + + _commitRevealExecuteForAliceAndBob( + engine, commitManager, battleKey, SWITCH_MOVE_INDEX, SWITCH_MOVE_INDEX, uint16(0), uint16(0) + ); + + // Both players cast Somniphobia on the same turn -> one coordinator, stack 2. + _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey, 0, 0, 0, 0); + + (bool exists,, bytes32 data) = engine.getEffectData(battleKey, 2, 2, address(somniphobia)); + assertTrue(exists, "Single global coordinator should exist"); + assertEq((uint256(data) >> 8) & 0xFF, 2, "Both casts accumulate into one stack-2 instance"); + } + + // A mon switched in while Somniphobia is active picks up the effect; the one switched out loses it. + function test_somniphobiaFollowsSwitchIns() public { + Somniphobia somniphobia = new Somniphobia(); + StaminaRegen staminaRegen = new StaminaRegen(); + + uint32 maxHp = uint32(somniphobia.DAMAGE_DENOM()) * 100; // 800 HP -> 100 per stack + int32 stack1 = -int32(maxHp) / somniphobia.DAMAGE_DENOM(); // -100 + + StandardAttack staminaBurn = _staminaBurn(2); + uint256[] memory moves = new uint256[](2); + moves[0] = uint256(uint160(address(somniphobia))); + moves[1] = uint256(uint160(address(staminaBurn))); Mon memory mon = _createMon(); mon.moves = moves; - // Set HP to be a multiple of DAMAGE_DENOM (16) for easy math - mon.stats.hp = uint32(somniphobia.DAMAGE_DENOM()) * 10; // 160 HP - Mon[] memory team = new Mon[](1); + mon.stats.hp = maxHp; + mon.stats.stamina = 5; + 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: 2, TIMEOUT_DURATION: 10}) + ); + IEffect[] memory effects = new IEffect[](1); + effects[0] = staminaRegen; + DefaultRuleset ruleset = new DefaultRuleset(IEngine(address(engine)), effects); + bytes32 battleKey = _startBattle( + validator, engine, mockOracle, defaultRegistry, matchmaker, new IEngineHook[](0), IRuleset(address(ruleset)), address(commitManager) + ); + + _commitRevealExecuteForAliceAndBob( + engine, commitManager, battleKey, SWITCH_MOVE_INDEX, SWITCH_MOVE_INDEX, uint16(0), uint16(0) + ); + + // Turn 1: Alice casts Somniphobia, Bob burns stamina -> Bob's mon 0 picks up the effect. + _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey, 0, 1, 0, 0); + (bool bob0Has,,) = engine.getEffectData(battleKey, 1, 0, address(somniphobia)); + assertTrue(bob0Has, "Bob mon 0 should have the effect"); + + // Turn 2: Bob switches to mon 1. Mon 0 loses the effect; mon 1 gains it on switch-in. + _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey, NO_OP_MOVE_INDEX, SWITCH_MOVE_INDEX, 0, uint16(1)); + (bool bob0Still,,) = engine.getEffectData(battleKey, 1, 0, address(somniphobia)); + (bool bob1Has,,) = engine.getEffectData(battleKey, 1, 1, address(somniphobia)); + (bool coordinatorAlive,,) = engine.getEffectData(battleKey, 2, 2, address(somniphobia)); + assertFalse(bob0Still, "Switched-out mon 0 should lose the effect"); + assertTrue(bob1Has, "Switched-in mon 1 should pick up the effect"); + assertTrue(coordinatorAlive, "Coordinator persists across switches"); + + // Turn 3: Bob's mon 1 burns stamina; the round-end regen gain damages it, proving coverage. + _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey, NO_OP_MOVE_INDEX, 1, 0, 0); + assertEq(engine.getMonStateForBattle(battleKey, 1, 1, MonStateIndexName.Hp), stack1, "Switched-in mon takes a tick"); + } + + // The effect (coordinator + per-mon copies) is gone after DURATION turns. + function test_somniphobiaExpiresAfterDuration() public { + Somniphobia somniphobia = new Somniphobia(); + + uint256[] memory moves = new uint256[](2); + moves[0] = uint256(uint160(address(somniphobia))); + moves[1] = uint256(uint160(address(_staminaBurn(2)))); + Mon memory mon = _createMon(); + mon.moves = moves; + mon.stats.hp = 1000; + mon.stats.stamina = 10; + 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}) + 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)); + + _commitRevealExecuteForAliceAndBob( + engine, commitManager, battleKey, SWITCH_MOVE_INDEX, SWITCH_MOVE_INDEX, uint16(0), uint16(0) ); + // Turn 1 casts it (DURATION = 4). It then counts down one per round end. + _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey, 0, NO_OP_MOVE_INDEX, 0, 0); + // Turns 2-3: still active. + _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey, NO_OP_MOVE_INDEX, NO_OP_MOVE_INDEX, 0, 0); + _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey, NO_OP_MOVE_INDEX, NO_OP_MOVE_INDEX, 0, 0); + (bool aliveAtTurn3,,) = engine.getEffectData(battleKey, 2, 2, address(somniphobia)); + assertTrue(aliveAtTurn3, "Coordinator still active before duration elapses"); + + // Turn 4: the 4th round end expires it; the per-mon copy self-clears the same round. + _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey, NO_OP_MOVE_INDEX, NO_OP_MOVE_INDEX, 0, 0); + (bool coordinatorGone,,) = engine.getEffectData(battleKey, 2, 2, address(somniphobia)); + (bool punisherGone,,) = engine.getEffectData(battleKey, 0, 0, address(somniphobia)); + assertFalse(coordinatorGone, "Coordinator should expire after DURATION turns"); + assertFalse(punisherGone, "Per-mon copy should clear once the coordinator is gone"); + } + + // Re-casting bumps the stack but does NOT refresh the timer: it still expires on the original + // schedule (4 rounds after the first cast), and only a fresh cast after that resets it. + function test_somniphobiaRecastDoesNotRefreshDuration() public { + Somniphobia somniphobia = new Somniphobia(); + + uint256[] memory moves = new uint256[](2); + moves[0] = uint256(uint160(address(somniphobia))); + moves[1] = uint256(uint160(address(_staminaBurn(2)))); + + Mon memory mon = _createMon(); + mon.moves = moves; + mon.stats.hp = 1000; + mon.stats.stamina = 10; + 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, uint16(0), uint16(0) ); - // Alice uses Somniphobia, Bob uses Somniphobia too - _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey, 0, 0, 0, 0); + // Turn 1: first cast (DURATION = 4). + _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey, 0, NO_OP_MOVE_INDEX, 0, 0); + // Turn 2: re-cast -> stack 2, but the timer keeps ticking from the first cast. + _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey, 0, NO_OP_MOVE_INDEX, 0, 0); + (,, bytes32 data) = engine.getEffectData(battleKey, 2, 2, address(somniphobia)); + assertEq((uint256(data) >> 8) & 0xFF, 2, "Re-cast should raise the stack to 2"); - // Verify that the global effect is applied - (EffectInstance[] memory globalEffects, ) = engine.getEffects(battleKey, 2, 2); - bool hasSomniphobia = false; - for (uint256 i = 0; i < globalEffects.length; i++) { - if (address(globalEffects[i].effect) == address(somniphobia)) { - hasSomniphobia = true; - break; - } - } - assertTrue(hasSomniphobia, "Somniphobia effect should be applied globally"); + // Turn 3: still ticking from the original cast. + _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey, NO_OP_MOVE_INDEX, NO_OP_MOVE_INDEX, 0, 0); + (bool aliveAtTurn3,,) = engine.getEffectData(battleKey, 2, 2, address(somniphobia)); + assertTrue(aliveAtTurn3, "Still active before the original duration elapses"); - // Both players rest (NO_OP) + // Turn 4: expires on the original schedule despite the turn-2 re-cast. _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey, NO_OP_MOVE_INDEX, NO_OP_MOVE_INDEX, 0, 0); + (bool gone,,) = engine.getEffectData(battleKey, 2, 2, address(somniphobia)); + assertFalse(gone, "Re-cast must not extend the duration; it expires on the original schedule"); + } + + /// @notice Invoke Taboo (-1 priority) reads the move the opponent used this turn and brands it. + /// If the opponent uses that same move again before switching out, they fall asleep. + function test_invokeTabooSleepsOnRepeatedMove() public { + SleepStatus sleepStatus = new SleepStatus(); + InvokeTaboo invokeTaboo = new InvokeTaboo(IEffect(address(sleepStatus))); + + // A cheap, low-power attack that Bob will repeat. + StandardAttack jab = attackFactory.createAttack( + ATTACK_PARAMS({ + BASE_POWER: 10, + STAMINA_COST: 1, + ACCURACY: 100, + PRIORITY: DEFAULT_PRIORITY, + MOVE_TYPE: Type.Cosmic, + EFFECT_ACCURACY: 0, + MOVE_CLASS: MoveClass.Physical, + CRIT_RATE: 0, + VOLATILITY: 0, + NAME: "Jab", + EFFECT: IEffect(address(0)) + }) + ); + + uint256[] memory moves = new uint256[](2); + moves[0] = uint256(uint160(address(invokeTaboo))); + moves[1] = uint256(uint160(address(jab))); - // Verify that both mons took 1/16 of max HP as damage (160 / 16 = 10) - int32 aliceHpDelta = engine.getMonStateForBattle(battleKey, 0, 0, MonStateIndexName.Hp); - int32 bobHpDelta = engine.getMonStateForBattle(battleKey, 1, 0, MonStateIndexName.Hp); + Mon memory mon = _createMon(); + mon.moves = moves; + mon.stats.hp = 1000; // high HP so jabs never KO + mon.stats.stamina = 10; + + 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)); + + _commitRevealExecuteForAliceAndBob( + engine, commitManager, battleKey, SWITCH_MOVE_INDEX, SWITCH_MOVE_INDEX, uint16(0), uint16(0) + ); + + // Turn 1: Alice uses Invoke Taboo (priority 2), Bob jabs (priority 3 -> moves first). + // Invoke Taboo resolves after Bob and brands Bob's jab (slot 1). + _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey, 0, 1, 0, 0); + + (bool bobBranded,, bytes32 brandData) = engine.getEffectData(battleKey, 1, 0, address(invokeTaboo)); + assertTrue(bobBranded, "Bob should be branded with the Invoke Taboo effect"); + // Regular move slots are stored +1 in the packed move index (slot 1 -> 2). The brand records + // that same packed form, and the trigger compares against it, so both stay consistent. + assertEq(uint256(brandData), 1 + uint256(MOVE_INDEX_OFFSET), "Branded move should be Bob's jab (slot 1)"); + + // Bob is not asleep yet (the branding turn does not trigger). + (bool asleepAfterBrand,,) = engine.getEffectData(battleKey, 1, 0, address(sleepStatus)); + assertFalse(asleepAfterBrand, "Bob should not be asleep on the branding turn"); + + // Turn 2: Bob repeats the tabooed jab. Alice rests. After Bob's move, the taboo triggers sleep. + _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey, NO_OP_MOVE_INDEX, 1, 0, 0); + + (bool asleepAfterRepeat,,) = engine.getEffectData(battleKey, 1, 0, address(sleepStatus)); + assertTrue(asleepAfterRepeat, "Bob should fall asleep after repeating the tabooed move"); + } + + /// @notice The Invoke Taboo brand is scoped to the branded mon and is cleared when it switches out. + function test_invokeTabooClearsOnSwitchOut() public { + SleepStatus sleepStatus = new SleepStatus(); + InvokeTaboo invokeTaboo = new InvokeTaboo(IEffect(address(sleepStatus))); + + StandardAttack jab = attackFactory.createAttack( + ATTACK_PARAMS({ + BASE_POWER: 10, + STAMINA_COST: 1, + ACCURACY: 100, + PRIORITY: DEFAULT_PRIORITY, + MOVE_TYPE: Type.Cosmic, + EFFECT_ACCURACY: 0, + MOVE_CLASS: MoveClass.Physical, + CRIT_RATE: 0, + VOLATILITY: 0, + NAME: "Jab", + EFFECT: IEffect(address(0)) + }) + ); + + uint256[] memory moves = new uint256[](2); + moves[0] = uint256(uint160(address(invokeTaboo))); + moves[1] = uint256(uint160(address(jab))); + + // Both sides field two mons (Bob needs a second mon to switch into). + Mon memory mon = _createMon(); + mon.moves = moves; + mon.stats.hp = 1000; + mon.stats.stamina = 10; + Mon[] memory team = new Mon[](2); + team[0] = mon; + team[1] = mon; + + defaultRegistry.setTeam(ALICE, team); + defaultRegistry.setTeam(BOB, team); + + // One validator governs both sides; size it for the larger (2-mon) team. + DefaultValidator validator = new DefaultValidator( + IEngine(address(engine)), DefaultValidator.Args({MONS_PER_TEAM: 2, MOVES_PER_MON: 2, TIMEOUT_DURATION: 10}) + ); + + bytes32 battleKey = _startBattle(validator, engine, mockOracle, defaultRegistry, matchmaker, address(commitManager)); + + // Alice sends mon 0, Bob sends mon 0 + _commitRevealExecuteForAliceAndBob( + engine, commitManager, battleKey, SWITCH_MOVE_INDEX, SWITCH_MOVE_INDEX, uint16(0), uint16(0) + ); - int32 expectedDamage = -10; // 160 / 16 = 10 - assertEq(aliceHpDelta, expectedDamage, "Alice should take 1/16 max HP damage for resting"); - assertEq(bobHpDelta, expectedDamage, "Bob should take 1/16 max HP damage for resting"); + // Turn 1: Alice uses Invoke Taboo, Bob jabs -> Bob mon 0 branded with slot 1. + _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey, 0, 1, 0, 0); - // Alice rests, Bob does nothing (but doesn't rest) - _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey, NO_OP_MOVE_INDEX, 0, 0, 0); + (bool branded,,) = engine.getEffectData(battleKey, 1, 0, address(invokeTaboo)); + assertTrue(branded, "Bob mon 0 should be branded before switching out"); - // Verify that only Alice took additional damage - aliceHpDelta = engine.getMonStateForBattle(battleKey, 0, 0, MonStateIndexName.Hp); - bobHpDelta = engine.getMonStateForBattle(battleKey, 1, 0, MonStateIndexName.Hp); + // Turn 2: Bob switches to mon 1, clearing the brand on mon 0. Alice rests. + _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey, NO_OP_MOVE_INDEX, SWITCH_MOVE_INDEX, 0, uint16(1)); - assertEq(aliceHpDelta, expectedDamage * 2, "Alice should take damage again for resting"); - assertEq(bobHpDelta, expectedDamage, "Bob should not take additional damage (didn't rest)"); + (bool stillBranded,,) = engine.getEffectData(battleKey, 1, 0, address(invokeTaboo)); + assertFalse(stillBranded, "Brand should be cleared after the branded mon switches out"); } function test_dreamcatcherHealsOnStaminaGain() public {