fix(rules): give Tiny-or-smaller creatures zero reach and square sharing

This commit is contained in:
2026-09-13 15:35:23 +02:00
parent 7fb7ec55dd
commit b579cb4934
4 changed files with 322 additions and 21 deletions
+48 -11
View File
@@ -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) :
+59 -10
View File
@@ -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()
)
+116
View File
@@ -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
+99
View File
@@ -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