diff --git a/README.md b/README.md index 3112f67..0ae99fd 100644 --- a/README.md +++ b/README.md @@ -657,8 +657,9 @@ Règles modélisées : inchangée. `default_policy` ne choisit pas d'arme (disponible via une politique personnalisée). - Mêlée (conformance R1, CRB « Big and Little Creatures in Combat ») : - l'**allonge naturelle** du gabarit (Small/Medium 5 ft, Large 10 ft, Huge - 15 ft, Gargantuan 20 ft, Colossal 30 ft) est un **disque** : la créature + l'**allonge naturelle** du gabarit (Tiny/Diminutive/Fine 0 ft, Small/Medium + 5 ft, Large 10 ft, Huge 15 ft, Gargantuan 20 ft, Colossal 30 ft) est un + **disque** : la créature frappe et menace toutes les cases jusqu'à son allonge, l'adjacence comprise — une créature Large dont l'attaque naturelle a `reach_ft` 10 menace donc aussi les cases adjacentes (le `reach_ft` d'une attaque @@ -669,6 +670,19 @@ Règles modélisées : allonge naturelle`, la dead zone vaut donc 5 ft pour un porteur Small/Medium et 10 ft pour un porteur Large. Une arme ordinaire sans allonge menace les cases adjacentes (disque de 5 ft). + **S4c — allonge nulle des créatures Tiny et plus petites** (CRB « Big and + Little Creatures in Combat » : « Tiny, Diminutive, and Fine Creatures [...] + They must enter an opponent's square to attack in melee ») : l'allonge + naturelle de Tiny/Diminutive/Fine vaut **0 ft** — une telle créature ne + **menace aucune case** (aucune AoO) et ne peut pas frapper une case + adjacente ; son attaque de mêlée n'est utilisable qu'à **distance 0**, la + case qu'elle partage avec sa cible (l'abstraction « entrer dans la case de + l'adversaire »). L'allonge effective reste `max(allonge naturelle, + reach_ft de l'arme)` : une attaque naturelle dont le JSON déclare + explicitement `reach_ft` (ierlieth : morsure `reach_ft: 5`) conserve cette + allonge — le `reach_ft` d'une attaque naturelle EST son allonge naturelle, + une créature Tiny peut donc être encodée avec une allonge non nulle si sa + fiche l'exige. Mouvement : un déplacement parcourt jusqu'à la vitesse du combattant en cases le long du plus court chemin réel (champ de coût Dijkstra depuis la cible, diagonales 5-10-5), en s'arrêtant adjacent à @@ -711,6 +725,21 @@ Règles modélisées : manœuvres et invocation sont généralisés aux cellules occupées (distance = minimum entre cellules, LoS/couvert = meilleure paire) ; le comportement 1×1 reste bit-identique. + **S4c — co-occupation des créatures Tiny et plus petites** (CRB « Big and + Little Creatures in Combat » : « Very small creatures take up less than 1 + square of space. This means that more than one such creature can fit into a + single square ») : une créature Tiny/Diminutive/Fine peut **traverser et + finir son déplacement** dans une case occupée par une créature plus grande, + et une créature plus grande peut traverser et finir dans une case occupée + par une créature Tiny ou plus petite — la case n'est pas « bloquée » pour + ce couple de tailles (`_can_cooccupy` : tailles différentes ET la plus + petite est Tiny ou moins). Deux créatures de même taille (ou Small et plus + grande) ne partagent jamais une case : le blocage reste total pour elles. + La co-occupation s'applique au mouvement (`_blocked`/`_blocked_hostile` + sont conscients du déplaceur), à la destination explicite (`_cells_free`) + et à la charge ; deux créatures partageant une case sont à **distance 0** + l'une de l'autre (ligne d'effet directe, attaque de mêlée possible — c'est + la voie d'attaque des créatures à allonge nulle, voir « Mêlée »). - Taille et attaques/CA : les valeurs `ac` (total/touch/flat_footed) et `attack_bonus` des JSON de créatures sont des valeurs FINALES, déjà majorées du modificateur de taille PF1e aux attaques/CA (Small +1, Medium 0, Large −1, @@ -1227,10 +1256,12 @@ Règles modélisées : pénalisé (un test de compétence n'est pas un jet de manœuvre). Conformance L1 (SRD « Combat Maneuvers ») : « Creatures that are size Tiny or smaller use their Dexterity modifier in place of their Strength modifier to - determine their CMB » — Tiny/Diminutive/Fine calculent donc leur CMB avec - le modificateur de Dextérité à la place de celui de Force ; lacune - documentée : l'allonge nulle et la co-occupation des créatures Tiny et - plus petites ne sont pas modélisées (abstraction à la grille de 5 ft). + determine their CMB » — Tiny/Diminutive/Fine calculent donc leur CMB avec + le modificateur de Dextérité à la place de celui de Force. Leur allonge + naturelle nulle (aucune case menacée, attaque dans leur propre case) et la + co-occupation Tiny-or-smaller avec une créature strictement plus grande + sont modélisées (conformance S4c) ; une attaque naturelle dotée d'un + `reach_ft` explicite (ex. morsure d'ierlieth) conserve cette allonge. Conformance L2 (SRD « Combat Maneuver Defense ») : le CMD calculé ajoute les bonus de CA des huit types autorisés (« A creature can also add any circumstance, deflection, dodge, insight, luck, morale, profane, and @@ -1391,7 +1422,10 @@ Règles modélisées : - Par combattant : id, side, pos, occupied_cells, hp/hp_max, active/dead, dying/stabilized, conditions (noms + rounds_left), resources_left (+ `spell_slot_{level}`), weapon_broken/weapon_stolen, grappled_with, moved_this_turn, power_attack, - name, level, size, aware (SRD « Surprise » : agit au round de surprise), + name, level, size, natural_reach_ft (S4c : allonge naturelle du gabarit + en pieds — 0 pour Tiny/Diminutive/Fine, 5 pour Small/Medium, 10 pour + Large… ; l'allonge effective d'une attaque reste `max(allonge naturelle, + reach_ft de l'arme)`), aware (SRD « Surprise » : agit au round de surprise), dr {amount, bypass} ou None (C1), creature_type (C3), bab (E1), initiative_mod (E2) et initiative_total (U2 : total du jet d'initiative courant = d20 + mod, 0 si pas encore de clé — invocation @@ -1514,10 +1548,13 @@ plutôt que bricolées) : spell automatically fails ») : non planifiables dans un tour, ils sont jetés du budget (`downgraded:cast_time`) plutôt qu'approximés, faute de données d'objectif pour ces sorts rituels. -- **Gabarits Tiny/Diminutive/Fine abstraits à la grille de 5 ft** : leur - allonge nulle et la co-occupation de leurs cases (plusieurs créatures - minuscules sur une même case) ne sont pas modélisées ; seule la substitution - Dextérité/Force du CMB (conformance L1) tient compte de leur taille. +- **Co-occupation au-delà de Tiny vs plus grand** : la règle Tiny-or-smaller + est modélisée (S4c) — allonge naturelle 0, aucune case menacée, attaque + possible dans sa propre case, et partage de case avec une créature + strictement plus grande (déplacement à travers et fin de mouvement dessus) ; + en revanche deux créatures de même taille (Small et plus) ne partagent + jamais une case, et la co-occupation de plusieurs Tiny sur une même case + n'est pas modélisée. Limites de sorts (conformance D6-D9, documentées plutôt que bricolées) : diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 7972e04..4c92ce9 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -306,8 +306,15 @@ def _size_rank(size: str) -> int: return _SIZE_ORDER.index("medium") # PF1e natural reach for tall creatures (simplification: no long/quadruped -# distinction). Unknown sizes fall back to 5 ft (the historical behavior). +# distinction). Tiny, Diminutive and Fine have a natural reach of 0 ft (SRD +# "Big and Little Creatures in Combat"): "Creatures that take up less than 1 +# square of space typically have a natural reach of 0 feet, meaning they can't +# reach into adjacent squares." Unknown sizes fall back to 5 ft (the historical +# behavior). _SIZE_NATURAL_REACH_FT: dict[str, int] = { + "Fine": 0, + "Diminutive": 0, + "Tiny": 0, "Small": 5, "Medium": 5, "Large": 10, @@ -316,6 +323,28 @@ _SIZE_NATURAL_REACH_FT: dict[str, int] = { "Colossal": 30, } +# PF1e co-occupancy (SRD "Big and Little Creatures in Combat"): "Very small +# creatures take up less than 1 square of space. This means that more than one +# such creature can fit into a single square" and "They must enter an +# opponent's square to attack in melee" — a Tiny-or-smaller creature shares a +# square with a strictly larger one (and the larger one may move through and +# end in the smaller's square). Small and larger creatures occupy a full +# square and never share. The rank ceiling for "takes up less than 1 square". +_TINY_OR_SMALLER_MAX_RANK: int = _size_rank("Tiny") + + +def _can_cooccupy(size_a: str, size_b: str) -> bool: + """True when two sizes may share a single grid square (PF1e co-occupancy). + + Only a Tiny-or-smaller creature ("less than 1 square") and a STRICTLY + larger creature share; two same-size creatures never do. + """ + rank_a = _size_rank(size_a) + rank_b = _size_rank(size_b) + if rank_a == rank_b: + return False + return min(rank_a, rank_b) <= _TINY_OR_SMALLER_MAX_RANK + # Number of grid cells per side of a creature's footprint (square footprint). # Unknown sizes fall back to 1 (the historical single-cell behavior). SIZE_CELLS: dict[str, int] = { @@ -1378,6 +1407,7 @@ class CombatEngine: "name": state.combatant.name, "level": state.combatant.level, "size": state.combatant.size, + "natural_reach_ft": _SIZE_NATURAL_REACH_FT.get(state.combatant.size, 5), "aware": state.combatant.aware, "creature_type": state.combatant.creature_type, "bab": state.combatant.bab, @@ -2279,25 +2309,44 @@ class CombatEngine: ) def _blocked(self, *exclude: CombatantState) -> frozenset[Pos]: - """Union of occupied cells of all active states except those in ``exclude``.""" - return frozenset( - cell - for s in self._states - if s.active and all(s is not e for e in exclude) - for cell in s.occupied_cells() - ) + """Occupied cells that block the mover (the first excluded state). + + Union of the active creatures' cells except those in ``exclude``. PF1e + co-occupancy (SRD "Big and Little Creatures in Combat") lets a + Tiny-or-smaller creature share a square with a strictly larger one, so + a co-occupiable creature's cells do NOT block the mover (it may move + through and end on them); two same-size creatures (or two Small-or- + larger) never share, so their cells still block. + """ + mover = exclude[0] if exclude else None + blocked: set[Pos] = set() + for s in self._states: + if not s.active or any(s is e for e in exclude): + continue + if mover is not None and _can_cooccupy( + mover.combatant.size, s.combatant.size + ): + continue + blocked |= set(s.occupied_cells()) + return frozenset(blocked) def _blocked_hostile(self, mover: CombatantState) -> frozenset[Pos]: """Occupied cells of active creatures hostile to ``mover``. Same-side allies are excluded: PF1e (SRD Movement, Position and Distance) — "You can move through a square occupied by a friendly - character, unless you are charging." + character, unless you are charging." Co-occupiable creatures (a + Tiny-or-smaller mover and a strictly larger occupant, or the reverse) + are excluded too — the mover may pass through and end on their squares + (SRD "Big and Little Creatures in Combat"). """ return frozenset( cell for s in self._states - if s.active and s is not mover and s.side != mover.side + if s.active + and s is not mover + and s.side != mover.side + and not _can_cooccupy(mover.combatant.size, s.combatant.size) for cell in s.occupied_cells() ) diff --git a/tests/test_reach.py b/tests/test_reach.py index 00681ba..1ac64a3 100644 --- a/tests/test_reach.py +++ b/tests/test_reach.py @@ -403,3 +403,119 @@ def test_no_aoo_from_adjacent_to_reach_weapon() -> None: "round 1 dest: wait", "battle over: draw after 1 rounds", ) + + +# ── S4c: Tiny/Diminutive/Fine natural reach 0 and co-occupancy ─────────────── + + +def test_tiny_reach_zero_threatens_nothing() -> None: + """S4c: a Tiny creature's natural reach is 0 — it threatens no squares + (no AoO) and cannot strike adjacent squares.""" + tiny = make_combatant("tiny", size="Tiny", reach_ft=0) + state = make_state(tiny, "players", (3, 3)) + engine = make_engine([state]) + assert engine.threatened_squares(state) == frozenset() + assert (3, 2) not in engine.threatened_squares(state) + + +def test_tiny_reach_zero_attacks_own_cell_only() -> None: + """S4c: a 0-reach Tiny can attack a creature in its OWN cell (distance 0) + but not an adjacent one (distance 1).""" + tiny = make_combatant("tiny", size="Tiny", reach_ft=0) + target = make_combatant("target", hp=20, ac=12) + tiny_state = make_state(tiny, "players", (3, 3)) + # Same cell: distance 0 -> the 0-reach attack is usable. + foe_same = make_state(target, "monsters", (3, 3)) + engine_same = make_engine([tiny_state, foe_same]) + assert engine_same.weapon_for(tiny_state, foe_same) is not None + # Adjacent cell: distance 1 (5 ft) -> out of the 0-reach zone. + foe_adj = make_state(target, "monsters", (3, 4)) + engine_adj = make_engine([tiny_state, foe_adj]) + assert engine_adj.weapon_for(tiny_state, foe_adj) is None + + +def _tiny_attack_policy(engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]: + if state.combatant.id == "tiny": + return (Action(kind="attack", target_id="target"),) + return (Action(kind="wait"),) + + +def test_tiny_reach_zero_hits_creature_in_own_cell() -> None: + """S4c: a 0-reach Tiny strikes a creature sharing its cell (the 'entered + the square' abstraction) — the attack resolves at distance 0.""" + tiny = make_combatant("tiny", size="Tiny", reach_ft=0) + target = make_combatant("target", hp=20, ac=12) + tiny_state = make_state(tiny, "players", (3, 3)) + target_state = make_state(target, "monsters", (3, 3)) + engine = CombatEngine( + ScriptedRng([20, 1, 15, 3]), + make_grid(), [tiny_state, target_state], + round_cap=1, policy=_tiny_attack_policy, + ) + result = engine.run() + assert any( + "short sword vs target" in line and "-> HIT" in line + for line in result.transcript + ), result.transcript + assert target_state.hp == 17 # 20 - 1d6(3) + + +def _tiny_move_policy(engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]: + if state.combatant.id == "tiny": + return (Action(kind="move", destination=(3, 3)),) + return (Action(kind="wait"),) + + +def test_tiny_ends_move_in_medium_cell() -> None: + """S4c: a Tiny creature may END its move in a cell occupied by a larger + creature (co-occupancy).""" + tiny = make_combatant("tiny", size="Tiny", reach_ft=0) + medium = make_combatant("medium", hp=20, ac=15, speed=0) + tiny_state = make_state(tiny, "players", (3, 2)) + medium_state = make_state(medium, "monsters", (3, 3)) + engine = CombatEngine( + ScriptedRng([20, 1, 5]), + make_grid(), [tiny_state, medium_state], + round_cap=1, policy=_tiny_move_policy, + ) + result = engine.run() + assert any("move -> (3,3)" in line for line in result.transcript), result.transcript + assert tiny_state.pos == (3, 3) + + +def _medium_move_policy(engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]: + if state.combatant.id == "a": + return (Action(kind="move", destination=(3, 3)),) + return (Action(kind="wait"),) + + +def test_two_medium_cannot_share_cell() -> None: + """S4c: two Medium creatures still cannot share a cell — the move onto + the occupied cell is rejected.""" + a = make_combatant("a", speed=30) + b = make_combatant("b", hp=20, ac=15, speed=0) + a_state = make_state(a, "players", (3, 2)) + b_state = make_state(b, "monsters", (3, 3)) + engine = CombatEngine( + ScriptedRng([20, 1]), + make_grid(), [a_state, b_state], + round_cap=1, policy=_medium_move_policy, + ) + result = engine.run() + assert any( + "move rejected (unreachable_destination)" in line for line in result.transcript + ), result.transcript + assert a_state.pos == (3, 2) + + +def test_observation_exposes_natural_reach_ft() -> None: + """S4c: the combatant snapshot exposes the size's natural reach.""" + tiny = make_combatant("tiny", size="Tiny", reach_ft=0) + medium = make_combatant("medium") + tiny_state = make_state(tiny, "players", (3, 3)) + medium_state = make_state(medium, "monsters", (3, 5)) + engine = make_engine([tiny_state, medium_state]) + obs = engine._build_observation(tiny_state).to_dict() + combatants = {c["id"]: c for c in obs["combatants"]} + assert combatants["tiny"]["natural_reach_ft"] == 0 + assert combatants["medium"]["natural_reach_ft"] == 5 diff --git a/tests/test_tiny_cooccupancy.py b/tests/test_tiny_cooccupancy.py new file mode 100644 index 0000000..908b149 --- /dev/null +++ b/tests/test_tiny_cooccupancy.py @@ -0,0 +1,99 @@ +"""Tests for Tiny-or-smaller reach zero and square sharing (PF1e S4c). + +SRD "Big and Little Creatures in Combat": Tiny, Diminutive and Fine creatures +"take up less than 1 square of space", have "a natural reach of 0 feet", do not +threaten the squares around them, and "must enter an opponent's square to +attack" — so a Tiny-or-smaller creature and a strictly larger one may share a +square (move through and end on it), while two creatures of the same (Small+) +size never share. +""" + +from __future__ import annotations + +from pf1e_simulator.combat import _effective_reach_ft +from tests.test_creature_sizes import make_combatant, make_engine, make_state + + +class TestTinyReachZero: + """A Tiny creature whose melee attack has reach_ft 0 reaches only its cell.""" + + def test_threatens_no_squares(self) -> None: + tiny = make_combatant("tiny", size="Tiny", reach_ft=0) + foe = make_combatant("foe", size="Medium") + s_tiny = make_state(tiny, "a", (3, 3)) + s_foe = make_state(foe, "b", (3, 4)) + eng = make_engine([s_tiny, s_foe]) + assert eng.threatened_squares(s_tiny) == frozenset() + + def test_attacks_a_creature_in_its_own_cell(self) -> None: + tiny = make_combatant("tiny", size="Tiny", reach_ft=0) + foe = make_combatant("foe", size="Medium") + s_tiny = make_state(tiny, "a", (3, 3)) + s_foe = make_state(foe, "b", (3, 3)) # sharing the square + eng = make_engine([s_tiny, s_foe]) + assert eng.weapon_for(s_tiny, s_foe) is not None + + def test_cannot_reach_an_adjacent_creature(self) -> None: + tiny = make_combatant("tiny", size="Tiny", reach_ft=0) + foe = make_combatant("foe", size="Medium") + s_tiny = make_state(tiny, "a", (3, 3)) + s_foe = make_state(foe, "b", (3, 4)) + eng = make_engine([s_tiny, s_foe]) + assert eng.weapon_for(s_tiny, s_foe) is None + + def test_authored_reach_is_kept(self) -> None: + # A natural attack authored with reach_ft 5 (e.g. ierlieth's bite) + # overrides the size default: the creature reaches and threatens 5 ft. + tiny = make_combatant("tiny", size="Tiny", reach_ft=5) + foe = make_combatant("foe", size="Medium") + s_tiny = make_state(tiny, "a", (3, 3)) + s_foe = make_state(foe, "b", (3, 4)) + eng = make_engine([s_tiny, s_foe]) + assert _effective_reach_ft(s_tiny, tiny.attacks[0]) == 5 + assert (3, 4) in eng.threatened_squares(s_tiny) + assert eng.weapon_for(s_tiny, s_foe) is not None + + +class TestCoOccupancy: + """A Tiny-or-smaller creature shares a square with a strictly larger one.""" + + def test_tiny_may_end_on_a_larger_creature_cell(self) -> None: + tiny = make_combatant("tiny", size="Tiny", reach_ft=0) + foe = make_combatant("foe", size="Medium") + s_tiny = make_state(tiny, "a", (3, 3)) + s_foe = make_state(foe, "b", (3, 4)) + eng = make_engine([s_tiny, s_foe]) + assert eng._destination_reachable(s_tiny, (3, 4)) is True + + def test_larger_may_end_on_a_tiny_cell(self) -> None: + tiny = make_combatant("tiny", size="Tiny", reach_ft=0) + foe = make_combatant("foe", size="Medium") + s_tiny = make_state(tiny, "a", (3, 4)) + s_foe = make_state(foe, "b", (3, 3)) + eng = make_engine([s_tiny, s_foe]) + assert eng._destination_reachable(s_foe, (3, 4)) is True + + def test_two_medium_never_share(self) -> None: + m1 = make_combatant("m1", size="Medium") + m2 = make_combatant("m2", size="Medium") + s1 = make_state(m1, "a", (3, 3)) + s2 = make_state(m2, "b", (3, 4)) + eng = make_engine([s1, s2]) + assert eng._destination_reachable(s1, (3, 4)) is False + assert eng._cells_free(s2.occupied_cells(), s1) is False + + def test_tiny_may_cross_a_larger_hostile_cell(self) -> None: + tiny = make_combatant("tiny", size="Tiny", reach_ft=0) + foe = make_combatant("foe", size="Medium") + s_tiny = make_state(tiny, "a", (3, 3)) + s_foe = make_state(foe, "b", (3, 4)) + eng = make_engine([s_tiny, s_foe]) + assert eng._cells_walkable(s_foe.occupied_cells(), s_tiny) is True + + def test_medium_cannot_cross_a_medium_hostile_cell(self) -> None: + m1 = make_combatant("m1", size="Medium") + m2 = make_combatant("m2", size="Medium") + s1 = make_state(m1, "a", (3, 3)) + s2 = make_state(m2, "b", (3, 4)) + eng = make_engine([s1, s2]) + assert eng._cells_walkable(s2.occupied_cells(), s1) is False