diff --git a/README.md b/README.md index a110712..32f7309 100644 --- a/README.md +++ b/README.md @@ -393,12 +393,15 @@ Règles modélisées : `(attack, attack)` → `(attack,)` ; `(charge, attack)` → `(charge,)` ; `(attack, full_attack, move)` → `(attack, move)` ; `(move, attack, move)` → `(move, attack)` ; `(full_attack, deed, inspire_courage)` → `(full_attack, deed)`. - Classification (exhaustive sur le `Literal` des kinds, verrouillée par - `tests/test_conformance_action_economy.py`) : **standard** = `attack`, - `maneuver`, `channel_energy`, `hex`, `delay`, `ready`, - `total_defense`, `aid_another` ; `cast_spell` occupe le compartiment que - lui donne son **temps d'incantation** (champ `cast_time` de `SpellSpec`, - voir « Temps d'incantation » plus bas ; standard par défaut) ; + Classification (exhaustive sur le `Literal` des kinds, verrouillée par + `tests/test_conformance_action_economy.py`) : **standard** = `attack`, + `maneuver`, `channel_energy`, `hex`, `delay`, `ready`, + `total_defense`, `aid_another` ; `cast_spell` occupe le compartiment que + lui donne son **temps d'incantation** (champ `cast_time` de `SpellSpec`, + voir « Temps d'incantation » plus bas ; standard par défaut) ; exception + par action (conformance D5 — APG « Cackle ») : `hex` avec + `hex_name="cackle"` occupe le compartiment **libre** (résolu par + `_effective_slot`, le kind `hex` restant standard pour evil_eye/slumber) ; **mouvement** = `move`, `stand_up`, `retrieve_weapon` (sortir une arme est une action de mouvement), `repair_weapon` ; **swift** = `swift`, `deed`, @@ -1102,8 +1105,14 @@ Règles modélisées : égal au niveau de la sorcière** (Volonté annule ; simplification documentée : la fin RAW du sortilège quand la cible subit des dégâts n'est pas modélisée, le moteur n'a pas de crochet de réveil) ; - `cackle` prolonge d'un round les sortilèges déjà - actifs, sur une liste instantanée pour ne pas s'étendre à l'infini. Les sorts à + `cackle` (APG, conformance D5) est une **action libre sans coût de + ressource** qui prolonge d'un round tous les sortilèges evil-eye (et + fortune/misfortune — non implémentés, seule extension modélisée : les + buffs evil_eye) portés par **toutes les créatures à 30 ft** de la + sorcière, sur une liste instantanée pour ne pas s'étendre à l'infini. + Simplification documentée : le slot « hex » reste standard pour + evil_eye/slumber ; les autres hexes à prolonger ne sont pas implémentés. + Les sorts à zone (AoE) utilisent le DD d'incantation du jeteur et chaque créature de la zone effectue la sauvegarde listée par le sort (Volonté / Réflexes / Constitution — le sort primaire réutilise son jet déjà effectué) ; un succès diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 6eb4e36..f1bb7ff 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -4733,7 +4733,14 @@ class CombatEngine: def _execute_hex( self, state: CombatantState, target: CombatantState | None, hex_name: str | None ) -> None: - """Execute a witch hex (evil_eye, slumber, cackle).""" + """Execute a witch hex (evil_eye, slumber, cackle). + + APG Cackle is a free action with no hex resource cost, so it is + dispatched before the resource/target gate; evil_eye and slumber are + standard actions that each spend one use.""" + if hex_name == "cackle": + self._hex_cackle(state) + return if not state.spend_resource("hexes", 1): self._log( f"round {self._current_round} {state.combatant.id}: hex failed (no resources)" @@ -4741,7 +4748,7 @@ class CombatEngine: return if target is None or not target.active: return - if hex_name not in ("evil_eye", "slumber", "cackle"): + if hex_name not in ("evil_eye", "slumber"): self._log( f"round {self._current_round} {state.combatant.id}: unknown hex {hex_name}" ) @@ -4751,10 +4758,8 @@ class CombatEngine: if hex_name == "evil_eye": self._hex_evil_eye(state, target, hex_dc) - elif hex_name == "slumber": + else: self._hex_slumber(state, target, hex_dc) - elif hex_name == "cackle": - self._hex_cackle(state, target) _EVIL_EYE_ROUNDS = 3 _EVIL_EYE_SAVED_ROUNDS = 1 @@ -4807,29 +4812,42 @@ class CombatEngine: f"{target.combatant.id} -> asleep ({rounds}r)" ) - def _hex_cackle(self, state: CombatantState, target: CombatantState) -> None: - """Cackle: prolong one hex by 1 round (extends all hex conditions).""" - extended = False - for ac in list(target.active_conditions): - if ac.source.startswith("hex:"): - target.active_conditions.remove(ac) - target.active_conditions.append( - ActiveCondition( - condition=ac.condition, - rounds_left=ac.rounds_left + 1, - source=ac.source, - ) + _CACKLE_RANGE_FT = 30 + + def _hex_cackle(self, state: CombatantState) -> None: + """Cackle (APG): a free action that extends the duration of every + evil eye, fortune or misfortune hex on all creatures within 30 ft by + 1 round. Fortune/misfortune are not implemented, so the extension + covers evil_eye buffs; each buff list is rebuilt in a single pass so + an extended hex is never re-extended.""" + extended = 0 + for other in self._states: + if not other.active: + continue + if self.creature_distance(state, other) * _SQUARE_FT > self._CACKLE_RANGE_FT: + continue + other.active_buffs[:] = [ + ActiveBuff( + modifiers=buff.modifiers, + rounds_left=buff.rounds_left + 1, + source=buff.source, ) - extended = True + if buff.source.startswith("hex: evil_eye") + else buff + for buff in other.active_buffs + ] + extended += sum( + 1 for buff in other.active_buffs if buff.source.startswith("hex: evil_eye") + ) if extended: self._log( f"round {self._current_round} {state.combatant.id}: cackle extends " - f"hex on {target.combatant.id}" + f"{extended} evil eye hex(es)" ) else: self._log( f"round {self._current_round} {state.combatant.id}: cackle - no hex " - f"to extend on {target.combatant.id}" + f"to extend" ) def _execute_deed( @@ -6607,9 +6625,12 @@ def _cast_time_slot(cast_time: CastTime) -> _ActionEconomySlot: def _effective_slot(action: Action, cast_time: CastTimeResolver) -> _ActionEconomySlot: - """The budget slot an action consumes, resolving cast_spell by cast time.""" + """The budget slot an action consumes, resolving cast_spell by cast time + and the cackle hex by its APG free-action cost.""" if action.kind == "cast_spell": return _cast_time_slot(cast_time(action)) + if action.kind == "hex" and action.hex_name == "cackle": + return "free" return _action_economy_slot(action.kind) diff --git a/tests/test_cackle.py b/tests/test_cackle.py index e5b7549..bcd81a2 100644 --- a/tests/test_cackle.py +++ b/tests/test_cackle.py @@ -1,19 +1,24 @@ -"""Tests for the cackle hex: bound the extension of hex conditions (Task 9). +"""Tests for the cackle hex (APG): free action, no resource cost, extends +every evil-eye hex on all creatures within 30 ft by exactly 1 round. -Cackle must extend exactly one +1 round per pre-existing hex condition, in a -single pass, without re-extending the replacements it appends. The pre-fix code -mutated ``target.active_conditions`` (remove/append) while iterating it, which -grew the list unboundedly and looped forever. +The pre-fix code spent a hex use, acted on a single target, and extended +every ``hex:``-sourced condition (including slumber, which RAW cackle does +not touch). Cackle now rebuilds each in-range creature's buff list in a +single pass, so an extended hex is never re-extended and the call always +terminates (the pre-Task-9 code mutated the list while iterating it). """ from __future__ import annotations -from typing import TYPE_CHECKING - -from pf1e_simulator.combat import CombatantState, CombatEngine -from pf1e_simulator.conditions import CONDITIONS_BY_NAME +from pf1e_simulator.combat import ( + Action, + CombatantState, + CombatEngine, + _default_cast_time, + _effective_slot, +) from pf1e_simulator.dice import parse_dice -from pf1e_simulator.effects import ActiveCondition +from pf1e_simulator.effects import ActiveBuff, StatModifier from pf1e_simulator.grid import Grid from pf1e_simulator.map import MapSpec, TerrainType from pf1e_simulator.models import ( @@ -26,11 +31,9 @@ from pf1e_simulator.models import ( ) from pf1e_simulator.rng import ScriptedRng -if TYPE_CHECKING: - from collections.abc import Collection - -_HEX_SOURCES = ("hex: evil_eye by caster", "hex: evil_eye by caster-2") -_ROUNDS_BEFORE = (2, 1) +_HEX_SOURCE = "hex: evil_eye by caster" +_OTHER_SOURCE = "hex: evil_eye by caster-2" +_UNRELATED_SOURCE = "spell: bull's strength" def _make_witch(cid: str = "caster") -> Combatant: @@ -90,7 +93,7 @@ def _make_target(cid: str = "target") -> Combatant: ) -def _make_engine() -> tuple[CombatEngine, CombatantState, CombatantState]: +def _make_engine() -> tuple[CombatEngine, CombatantState, CombatantState, CombatantState]: c = CombatantState( combatant=_make_witch(), side="players", pos=(0, 0), hp=20, resources_left={"hexes": 3}, @@ -98,42 +101,70 @@ def _make_engine() -> tuple[CombatEngine, CombatantState, CombatantState]: t = CombatantState( combatant=_make_target(), side="monsters", pos=(3, 0), hp=30, ) + far = CombatantState( + combatant=_make_target("far"), side="monsters", pos=(10, 0), hp=30, + ) legend = {".": TerrainType(type="floor", move_cost=1)} - spec = MapSpec(name="test", terrain=tuple(["." * 8] * 8), legend=legend) - engine = CombatEngine(ScriptedRng([]), Grid.from_spec(spec), [c, t]) - return engine, c, t + spec = MapSpec(name="test", terrain=tuple(["." * 12] * 12), legend=legend) + engine = CombatEngine(ScriptedRng([]), Grid.from_spec(spec), [c, t, far]) + return engine, c, t, far -def _seed_hex_conditions(target: CombatantState) -> dict[str, int]: - target.active_conditions = [ - ActiveCondition( - condition=CONDITIONS_BY_NAME["shaken"], - rounds_left=rounds, - source=source, - ) - for source, rounds in zip(_HEX_SOURCES, _ROUNDS_BEFORE, strict=True) - ] - return dict(zip(_HEX_SOURCES, _ROUNDS_BEFORE, strict=True)) +def _evil_eye(rounds_left: int, source: str = _HEX_SOURCE) -> ActiveBuff: + return ActiveBuff( + modifiers=(StatModifier(target="attack", value=-2),), + rounds_left=rounds_left, + source=source, + ) -def _rounds_by_source(conditions: Collection[ActiveCondition]) -> dict[str, int]: - return {ac.source: ac.rounds_left for ac in conditions} +def _rounds_by_source(buffs: list[ActiveBuff]) -> dict[str, int]: + return {b.source: b.rounds_left for b in buffs} -class TestCackleExtendsHexOnce: - """Given: a target carrying 2 pre-existing hex conditions (2 Evil Eyes, - rounds_left 2 and 1) +class TestCackleExtendsEvilEyeOnce: + """Given: a target carrying 2 pre-existing evil-eye buffs (2 Evil Eyes, + rounds_left 2 and 1) and an unrelated buff, plus a creature beyond 30 ft + carrying its own evil eye When: the witch cackles - Then: EXACTLY 2 extensions of +1 round happen — each hex's rounds_left - rises by 1, the total hex-condition count stays 2 (no growth), and the - call terminates (the pre-fix loop never did).""" + Then: EXACTLY 2 extensions of +1 round happen in range — each evil eye's + rounds_left rises by 1, the unrelated buff is untouched, the out-of-range + creature is not extended, the buff count stays stable (no growth), and + the call terminates.""" - def test_exactly_two_hex_extensions_of_one_round(self) -> None: - engine, c, t = _make_engine() - expected = _seed_hex_conditions(t) + def test_exactly_two_evil_eye_extensions_of_one_round(self) -> None: + engine, c, t, far = _make_engine() + t.active_buffs = [_evil_eye(2), _evil_eye(1, _OTHER_SOURCE), + ActiveBuff(modifiers=(), rounds_left=5, source=_UNRELATED_SOURCE)] + far.active_buffs = [_evil_eye(3, "hex: evil_eye by other-witch")] - engine._hex_cackle(c, t) + engine._hex_cackle(c) - after = _rounds_by_source(t.active_conditions) - assert len(after) == 2 - assert after == {source: rounds + 1 for source, rounds in expected.items()} + after = _rounds_by_source(t.active_buffs) + assert len(after) == 3 + assert after[_HEX_SOURCE] == 3 + assert after[_OTHER_SOURCE] == 2 + assert after[_UNRELATED_SOURCE] == 5 + assert _rounds_by_source(far.active_buffs)["hex: evil_eye by other-witch"] == 3 + + +class TestCackleIsFreeAndCostless: + """Given: the cackle hex action + When: budgeted and executed + Then: it occupies the FREE slot (not the standard one), does not spend a + hex use, and works without a resolved target.""" + + def test_cackle_slot_is_free_but_evil_eye_stays_standard(self) -> None: + cackle = Action(kind="hex", hex_name="cackle") + evil_eye = Action(kind="hex", target_id="target", hex_name="evil_eye") + assert _effective_slot(cackle, _default_cast_time) == "free" + assert _effective_slot(evil_eye, _default_cast_time) == "standard" + + def test_cackle_spends_no_hex_resource(self) -> None: + engine, c, t, _far = _make_engine() + t.active_buffs = [_evil_eye(1)] + + engine._execute(c, Action(kind="hex", hex_name="cackle")) + + assert c.resources_left["hexes"] == 3 + assert _rounds_by_source(t.active_buffs)[_HEX_SOURCE] == 2