From b12370e13fe44cad88ec14f848ba589e583b157d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 24 Jun 2026 16:45:46 +0000 Subject: [PATCH 1/3] Somniphobia triggers on any stamina gain; add Xmon's Invoke Taboo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Somniphobia previously punished only the rest action by checking for a NO_OP move in a global AfterMove hook. It now punishes *any* stamina gain (resting, round-end stamina regen, stamina-steal moves, etc.) by listening at OnUpdateMonState — the same hook Dreamcatcher uses to heal on stamina gain. Because that hook only fires for local (per-mon) effects, Somniphobia is now registered on both active mons rather than as a single global effect, and is cleared on switch-out. Invoke Taboo is a new Xmon move (-1 priority) that resolves after the opponent acts, reads the move they just used, and brands it on their mon. Until they switch out, repeating the branded move puts them to sleep. Wired into the move catalog as Xmon's 5th (level-6) move and the deploy script. Tests: rewrote the Somniphobia test for the new stamina-gain semantics (round-end regen ticks; a full-stamina rest does nothing) and added Invoke Taboo coverage (brand + sleep on repeat, and brand clears on switch-out). Full suite: 487 passed, 0 failed. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01D3pJ3pS1brczwbbfRSYeoV --- drool/moves.csv | 3 +- script/SetupMons.s.sol | 22 ++- src/mons/xmon/InvokeTaboo.sol | 135 +++++++++++++++++++ src/mons/xmon/Somniphobia.sol | 82 ++++++++---- test/mons/XmonTest.sol | 245 +++++++++++++++++++++++++++++----- 5 files changed, 419 insertions(+), 68 deletions(-) create mode 100644 src/mons/xmon/InvokeTaboo.sol diff --git a/drool/moves.csv b/drool/moves.csv index ce1d528f..89bc41ef 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 the next 8 turns, any mon that gains stamina will take 1/8th of max HP as damage.",,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..8c8cc87f --- /dev/null +++ b/src/mons/xmon/InvokeTaboo.sol @@ -0,0 +1,135 @@ +// 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"; + +/// @notice Invoke Taboo declares the opponent's move forbidden. It resolves at -1 priority so it +/// goes after the opponent has already acted, then reads the move they used this turn and +/// brands it taboo. While the brand is on that mon (it is cleared when they switch out), the +/// next time they use the tabooed move they fall asleep. +/// @dev Implemented as an IMoveSet + BasicEffect hybrid. The effect lives on the *opponent's* mon; +/// its AfterMove hook checks whether the move that just resolved matches the branded move and, +/// if so, applies SleepStatus to that mon. +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; + + // Read the move the opponent used this turn. Invoke Taboo's -1 priority guarantees it + // resolves after the opponent, so their decision is already recorded. + MoveDecision memory moveDecision = engine.getMoveDecisionForBattleState(battleKey, defenderPlayerIndex); + uint8 moveIndex = moveDecision.packedMoveIndex & MOVE_INDEX_MASK; + + // Only brand actual moves (slots 0..3). Switching (125) or resting/no-op (126) are not + // tabooable — there is nothing meaningful to forbid. + if (moveIndex >= SWITCH_MOVE_INDEX) { + return; + } + + bytes32 tabooData = bytes32(uint256(moveIndex)); + (bool exists, uint256 effectIndex,) = + engine.getEffectData(battleKey, defenderPlayerIndex, defenderMonIndex, address(this)); + if (exists) { + // Re-brand with the most recent move. + 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; + } + + /// @notice After the branded mon moves, if it used the tabooed move, put it to sleep. + 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) + { + // Taboo lasts until the branded mon switches out. + 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..cc9425e7 100644 --- a/src/mons/xmon/Somniphobia.sol +++ b/src/mons/xmon/Somniphobia.sol @@ -2,14 +2,23 @@ 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"; +/// @notice Somniphobia punishes recovering stamina. When used, it places an effect on BOTH active +/// mons that lasts DURATION turns. While the effect is active, any time that mon gains +/// stamina from *any* source (resting, the round-end stamina regen, a stamina-steal move, +/// etc.) it immediately takes 1/DAMAGE_DENOM of its max HP as damage. +/// @dev Detecting "any stamina gain" is done via the OnUpdateMonState hook (the same hook +/// Dreamcatcher uses to heal on stamina gain). That hook only fires for *local* (per-mon) +/// effects — global effects never receive it — so Somniphobia is registered locally on each +/// active mon rather than as a single battlefield-wide global effect. It is cleared when the +/// mon switches out, scoping it to the mons that were active when it was invoked. contract Somniphobia is IMoveSet, BasicEffect { uint256 public constant DURATION = 8; int32 public constant DAMAGE_DENOM = 8; @@ -18,13 +27,21 @@ contract Somniphobia is IMoveSet, BasicEffect { 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; + _applyTo(engine, battleKey, attackerPlayerIndex, attackerMonIndex); + _applyTo(engine, battleKey, defenderPlayerIndex, defenderMonIndex); + } + + /// @dev Add (or refresh) the effect on a single mon. Re-invoking while it is already present + /// resets the remaining duration rather than stacking a second copy. + function _applyTo(IEngine engine, bytes32 battleKey, uint256 playerIndex, uint256 monIndex) internal { + (bool exists, uint256 effectIndex,) = engine.getEffectData(battleKey, playerIndex, monIndex, address(this)); if (exists) { - return; + engine.editEffect(playerIndex, effectIndex, bytes32(DURATION)); + } else { + engine.addEffect(playerIndex, monIndex, this, bytes32(DURATION)); } - engine.addEffect(2, attackerPlayerIndex, this, bytes32(DURATION)); } function stamina(IEngine, bytes32, uint256, uint256) public pure returns (uint32) { @@ -47,31 +64,34 @@ contract Somniphobia is IMoveSet, BasicEffect { return ExtraDataType.None; } - // Steps: RoundEnd, AfterMove + // Steps: RoundEnd (0x04), OnMonSwitchOut (0x20), OnUpdateMonState (0x100), ALWAYS_APPLIES (0x8000) function getStepsBitmap() external pure override returns (uint16) { - return 0x8084; + return ALWAYS_APPLIES_BIT | 0x0124; } - 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); - - // 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); + /// @notice Damage the mon whenever its stamina is increased. + function onUpdateMonState( + IEngine engine, + bytes32 battleKey, + uint256, + bytes32 extraData, + uint256 playerIndex, + uint256 monIndex, + uint256, + uint256, + MonStateIndexName stateVarIndex, + int32 valueToAdd + ) external override returns (bytes32, bool) { + // Only trigger on a stamina *gain*. The damage below routes back through updateMonState + // (as an Hp delta), which re-enters this hook — the stat guard makes that re-entry a no-op, + // so there is no recursion. + if (stateVarIndex == MonStateIndexName.Stamina && valueToAdd > 0) { + uint32 maxHp = engine.getMonValueForBattle(battleKey, playerIndex, monIndex, MonStateIndexName.Hp); int32 damage = int32(uint32(maxHp)) / DAMAGE_DENOM; - if (damage > 0) { - engine.dealDamage(targetIndex, monIndex, damage); + engine.dealDamage(playerIndex, monIndex, damage); } } - return (extraData, false); } @@ -89,6 +109,16 @@ contract Somniphobia is IMoveSet, BasicEffect { } } + function onMonSwitchOut(IEngine, bytes32, uint256, bytes32 extraData, uint256, uint256, uint256, uint256) + external + pure + override + returns (bytes32, bool) + { + // Clear when the mon leaves the field. + return (extraData, true); + } + function getMeta(IEngine engine, bytes32 battleKey, uint256 attackerPlayerIndex, uint256 attackerMonIndex) external pure diff --git a/test/mons/XmonTest.sol b/test/mons/XmonTest.sol index 79c61f17..e085b3d5 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,16 +200,143 @@ 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 { + /// @notice Somniphobia now triggers on ANY stamina gain (via OnUpdateMonState), not just the + /// rest action. This exercises two non-trivial cases in one battle: + /// * the end-of-turn StaminaRegen tick (a gain that is NOT a rest) deals damage; + /// * a resting mon that is already at full stamina gains nothing and takes no damage. + 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 + int32 tick = -int32(maxHp) / somniphobia.DAMAGE_DENOM(); // -50 + + // A move that just burns stamina (cost 2, no damage) so a mon can drop below full and regen. + StandardAttack staminaBurn = attackFactory.createAttack( + ATTACK_PARAMS({ + BASE_POWER: 0, + STAMINA_COST: 2, + 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)) + }) + ); + + 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 (applies to BOTH active mons, costs Alice 1 stamina), + // Bob burns 2 stamina. Neither rests. At round-end StaminaRegen tops each mon back up by 1, + // and that gain triggers Somniphobia on both: each takes one tick. + _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey, 0, 1, 0, 0); + + // Both should have the (local) Somniphobia effect. + (bool aliceHas,,) = engine.getEffectData(battleKey, 0, 0, address(somniphobia)); + (bool bobHas,,) = engine.getEffectData(battleKey, 1, 0, address(somniphobia)); + 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" + ); + } + + /// @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))); 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.stats.hp = 1000; // high HP so jabs never KO + mon.stats.stamina = 10; + Mon[] memory team = new Mon[](1); team[0] = mon; @@ -213,50 +344,96 @@ contract XmonTest is Test, BattleHelper { 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)); - // 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: 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); - // 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"); + (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)"); - // Both players rest (NO_OP) - _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey, NO_OP_MOVE_INDEX, NO_OP_MOVE_INDEX, 0, 0); + // 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))); - // 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); + 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 { From 88a8348a185495f2795489fbaa4842f0d2bfdefa Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 24 Jun 2026 17:08:09 +0000 Subject: [PATCH 2/3] Rework Somniphobia: global, stacking, 4-turn, switch-in coverage Per review feedback: - Somniphobia is now a battlefield-wide effect again. A global coordinator holds the stack level and a 4-turn duration; it applies a per-mon copy to any mon that switches in (either side) and the copy clears on switch-out. The per-mon copies do the "damage on any stamina gain" via OnUpdateMonState (which only fires for per-mon effects). Damage scales with stack: 1/8 max HP per stack. Re-casting raises the stack and refreshes duration. - Duration cut from 8 to 4 turns. - Removed the verbose comments from Somniphobia and the dev-note/prose comments from Invoke Taboo (moves.csv carries the descriptions). Tests: added stacking, switch-in coverage, and expiry cases alongside the any-stamina-gain test. Full suite: 490 passed, 0 failed. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01D3pJ3pS1brczwbbfRSYeoV --- drool/moves.csv | 2 +- src/mons/xmon/InvokeTaboo.sol | 15 +-- src/mons/xmon/Somniphobia.sol | 95 +++++++++------- test/mons/XmonTest.sol | 197 ++++++++++++++++++++++++++++++---- 4 files changed, 237 insertions(+), 72 deletions(-) diff --git a/drool/moves.csv b/drool/moves.csv index 89bc41ef..55e5f945 100644 --- a/drool/moves.csv +++ b/drool/moves.csv @@ -44,7 +44,7 @@ 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 gains stamina 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 diff --git a/src/mons/xmon/InvokeTaboo.sol b/src/mons/xmon/InvokeTaboo.sol index 8c8cc87f..e20cc96b 100644 --- a/src/mons/xmon/InvokeTaboo.sol +++ b/src/mons/xmon/InvokeTaboo.sol @@ -11,13 +11,6 @@ import {IMoveSet} from "../../moves/IMoveSet.sol"; import {IEffect} from "../../effects/IEffect.sol"; import {BasicEffect} from "../../effects/BasicEffect.sol"; -/// @notice Invoke Taboo declares the opponent's move forbidden. It resolves at -1 priority so it -/// goes after the opponent has already acted, then reads the move they used this turn and -/// brands it taboo. While the brand is on that mon (it is cleared when they switch out), the -/// next time they use the tabooed move they fall asleep. -/// @dev Implemented as an IMoveSet + BasicEffect hybrid. The effect lives on the *opponent's* mon; -/// its AfterMove hook checks whether the move that just resolved matches the branded move and, -/// if so, applies SleepStatus to that mon. contract InvokeTaboo is IMoveSet, BasicEffect { IEffect immutable SLEEP_STATUS; @@ -40,13 +33,10 @@ contract InvokeTaboo is IMoveSet, BasicEffect { ) external { uint256 defenderPlayerIndex = (attackerPlayerIndex + 1) % 2; - // Read the move the opponent used this turn. Invoke Taboo's -1 priority guarantees it - // resolves after the opponent, so their decision is already recorded. MoveDecision memory moveDecision = engine.getMoveDecisionForBattleState(battleKey, defenderPlayerIndex); uint8 moveIndex = moveDecision.packedMoveIndex & MOVE_INDEX_MASK; - // Only brand actual moves (slots 0..3). Switching (125) or resting/no-op (126) are not - // tabooable — there is nothing meaningful to forbid. + // Only brand regular move slots, not a switch (125) or no-op (126). if (moveIndex >= SWITCH_MOVE_INDEX) { return; } @@ -55,7 +45,6 @@ contract InvokeTaboo is IMoveSet, BasicEffect { (bool exists, uint256 effectIndex,) = engine.getEffectData(battleKey, defenderPlayerIndex, defenderMonIndex, address(this)); if (exists) { - // Re-brand with the most recent move. engine.editEffect(defenderPlayerIndex, effectIndex, tabooData); } else { engine.addEffect(defenderPlayerIndex, defenderMonIndex, this, tabooData); @@ -87,7 +76,6 @@ contract InvokeTaboo is IMoveSet, BasicEffect { return ALWAYS_APPLIES_BIT | 0x00A0; } - /// @notice After the branded mon moves, if it used the tabooed move, put it to sleep. function onAfterMove( IEngine engine, bytes32 battleKey, @@ -114,7 +102,6 @@ contract InvokeTaboo is IMoveSet, BasicEffect { override returns (bytes32, bool) { - // Taboo lasts until the branded mon switches out. return (extraData, true); } diff --git a/src/mons/xmon/Somniphobia.sol b/src/mons/xmon/Somniphobia.sol index cc9425e7..fc31812e 100644 --- a/src/mons/xmon/Somniphobia.sol +++ b/src/mons/xmon/Somniphobia.sol @@ -10,37 +10,37 @@ import {IEngine} from "../../IEngine.sol"; import {IMoveSet} from "../../moves/IMoveSet.sol"; import {BasicEffect} from "../../effects/BasicEffect.sol"; -/// @notice Somniphobia punishes recovering stamina. When used, it places an effect on BOTH active -/// mons that lasts DURATION turns. While the effect is active, any time that mon gains -/// stamina from *any* source (resting, the round-end stamina regen, a stamina-steal move, -/// etc.) it immediately takes 1/DAMAGE_DENOM of its max HP as damage. -/// @dev Detecting "any stamina gain" is done via the OnUpdateMonState hook (the same hook -/// Dreamcatcher uses to heal on stamina gain). That hook only fires for *local* (per-mon) -/// effects — global effects never receive it — so Somniphobia is registered locally on each -/// active mon rather than as a single battlefield-wide global effect. It is cleared when the -/// mon switches out, scoping it to the mons that were active when it was invoked. 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 attackerMonIndex, uint256 defenderMonIndex, uint16, uint256) external { uint256 defenderPlayerIndex = (attackerPlayerIndex + 1) % 2; - _applyTo(engine, battleKey, attackerPlayerIndex, attackerMonIndex); - _applyTo(engine, battleKey, defenderPlayerIndex, defenderMonIndex); - } - /// @dev Add (or refresh) the effect on a single mon. Re-invoking while it is already present - /// resets the remaining duration rather than stacking a second copy. - function _applyTo(IEngine engine, bytes32 battleKey, uint256 playerIndex, uint256 monIndex) internal { - (bool exists, uint256 effectIndex,) = engine.getEffectData(battleKey, playerIndex, monIndex, address(this)); + (bool exists, uint256 effectIndex, bytes32 data) = engine.getEffectData(battleKey, 2, 2, address(this)); if (exists) { - engine.editEffect(playerIndex, effectIndex, bytes32(DURATION)); + uint256 stack = ((uint256(data) >> 8) & 0xFF) + 1; + engine.editEffect(2, effectIndex, bytes32((stack << 8) | DURATION)); } else { - engine.addEffect(playerIndex, monIndex, this, bytes32(DURATION)); + 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)); } } @@ -64,12 +64,11 @@ contract Somniphobia is IMoveSet, BasicEffect { return ExtraDataType.None; } - // Steps: RoundEnd (0x04), OnMonSwitchOut (0x20), OnUpdateMonState (0x100), ALWAYS_APPLIES (0x8000) + // Steps: RoundEnd (0x04), OnMonSwitchIn (0x10), OnMonSwitchOut (0x20), OnUpdateMonState (0x100), ALWAYS_APPLIES (0x8000) function getStepsBitmap() external pure override returns (uint16) { - return ALWAYS_APPLIES_BIT | 0x0124; + return ALWAYS_APPLIES_BIT | 0x0134; } - /// @notice Damage the mon whenever its stamina is increased. function onUpdateMonState( IEngine engine, bytes32 battleKey, @@ -82,31 +81,30 @@ contract Somniphobia is IMoveSet, BasicEffect { MonStateIndexName stateVarIndex, int32 valueToAdd ) external override returns (bytes32, bool) { - // Only trigger on a stamina *gain*. The damage below routes back through updateMonState - // (as an Hp delta), which re-enters this hook — the stat guard makes that re-entry a no-op, - // so there is no recursion. if (stateVarIndex == MonStateIndexName.Stamina && valueToAdd > 0) { - uint32 maxHp = engine.getMonValueForBattle(battleKey, playerIndex, monIndex, MonStateIndexName.Hp); - int32 damage = int32(uint32(maxHp)) / DAMAGE_DENOM; - if (damage > 0) { - engine.dealDamage(playerIndex, monIndex, damage); + (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 onRoundEnd(IEngine, bytes32, uint256, bytes32 extraData, uint256, uint256, uint256, uint256) + function onMonSwitchIn(IEngine engine, bytes32 battleKey, uint256, bytes32 extraData, uint256 targetIndex, uint256 monIndex, uint256, uint256) external - pure override - returns (bytes32, bool removeAfterRun) + returns (bytes32, bool) { - uint256 turnsLeft = uint256(extraData); - if (turnsLeft == 1) { - return (extraData, true); - } else { - return (bytes32(turnsLeft - 1), false); + // 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 onMonSwitchOut(IEngine, bytes32, uint256, bytes32 extraData, uint256, uint256, uint256, uint256) @@ -115,8 +113,27 @@ contract Somniphobia is IMoveSet, BasicEffect { override returns (bytes32, bool) { - // Clear when the mon leaves the field. - return (extraData, true); + // 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); + } + 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 e085b3d5..e79f38c9 100644 --- a/test/mons/XmonTest.sol +++ b/test/mons/XmonTest.sol @@ -200,22 +200,12 @@ 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)"); } - /// @notice Somniphobia now triggers on ANY stamina gain (via OnUpdateMonState), not just the - /// rest action. This exercises two non-trivial cases in one battle: - /// * the end-of-turn StaminaRegen tick (a gain that is NOT a rest) deals damage; - /// * a resting mon that is already at full stamina gains nothing and takes no damage. - function test_somniphobiaDamagesOnAnyStaminaGain() public { - Somniphobia somniphobia = new Somniphobia(); - StaminaRegen staminaRegen = new StaminaRegen(); - - uint32 maxHp = uint32(somniphobia.DAMAGE_DENOM()) * 50; // 400 HP -> 50 damage per tick - int32 tick = -int32(maxHp) / somniphobia.DAMAGE_DENOM(); // -50 - - // A move that just burns stamina (cost 2, no damage) so a mon can drop below full and regen. - StandardAttack staminaBurn = attackFactory.createAttack( + // 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: 2, + STAMINA_COST: cost, ACCURACY: 100, PRIORITY: DEFAULT_PRIORITY, MOVE_TYPE: Type.Cosmic, @@ -227,6 +217,18 @@ contract XmonTest is Test, BattleHelper { 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(); + + 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))); @@ -271,14 +273,15 @@ contract XmonTest is Test, BattleHelper { engine, commitManager, battleKey, SWITCH_MOVE_INDEX, SWITCH_MOVE_INDEX, uint16(0), uint16(0) ); - // Turn 1: Alice casts Somniphobia (applies to BOTH active mons, costs Alice 1 stamina), - // Bob burns 2 stamina. Neither rests. At round-end StaminaRegen tops each mon back up by 1, - // and that gain triggers Somniphobia on both: each takes one tick. + // 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); - // Both should have the (local) Somniphobia effect. + // 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"); @@ -305,6 +308,164 @@ contract XmonTest is Test, BattleHelper { ); } + // 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"); + } + + // 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; + 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: 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"); + } + /// @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 { From e6c820c6b987e15cb8c622ed977f04c9d45ad766 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 24 Jun 2026 18:09:03 +0000 Subject: [PATCH 3/3] Somniphobia: re-cast bumps stack without refreshing the timer A re-cast now preserves the remaining duration instead of resetting it to DURATION, so the effect must fade completely before it can be reset. Single global instance is unchanged: both players casting accumulate into one coordinator's stack. Added test_somniphobiaRecastDoesNotRefreshDuration (expires on the original 4-round schedule despite a turn-2 re-cast) and test_somniphobiaSingleInstance WhenBothCast (both sides casting -> one stack-2 coordinator). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01D3pJ3pS1brczwbbfRSYeoV --- src/mons/xmon/Somniphobia.sol | 3 +- test/mons/XmonTest.sol | 89 +++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 1 deletion(-) diff --git a/src/mons/xmon/Somniphobia.sol b/src/mons/xmon/Somniphobia.sol index fc31812e..e2540ec8 100644 --- a/src/mons/xmon/Somniphobia.sol +++ b/src/mons/xmon/Somniphobia.sol @@ -27,8 +27,9 @@ contract Somniphobia is IMoveSet, BasicEffect { (bool exists, uint256 effectIndex, bytes32 data) = engine.getEffectData(battleKey, 2, 2, address(this)); if (exists) { + // 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) | DURATION)); + engine.editEffect(2, effectIndex, bytes32((stack << 8) | (uint256(data) & 0xFF))); } else { engine.addEffect(2, attackerPlayerIndex, this, bytes32((uint256(1) << 8) | DURATION)); } diff --git a/test/mons/XmonTest.sol b/test/mons/XmonTest.sol index e79f38c9..83c9e149 100644 --- a/test/mons/XmonTest.sol +++ b/test/mons/XmonTest.sol @@ -368,6 +368,50 @@ contract XmonTest is Test, BattleHelper { 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(); @@ -466,6 +510,51 @@ contract XmonTest is Test, BattleHelper { 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)); + + _commitRevealExecuteForAliceAndBob( + engine, commitManager, battleKey, SWITCH_MOVE_INDEX, SWITCH_MOVE_INDEX, uint16(0), uint16(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"); + + // 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"); + + // 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 {