fix(rules): give Tiny-or-smaller creatures zero reach and square sharing
This commit is contained in:
@@ -657,8 +657,9 @@ Règles modélisées :
|
|||||||
inchangée. `default_policy` ne choisit pas d'arme (disponible via une
|
inchangée. `default_policy` ne choisit pas d'arme (disponible via une
|
||||||
politique personnalisée).
|
politique personnalisée).
|
||||||
- Mêlée (conformance R1, CRB « Big and Little Creatures in Combat ») :
|
- 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
|
l'**allonge naturelle** du gabarit (Tiny/Diminutive/Fine 0 ft, Small/Medium
|
||||||
15 ft, Gargantuan 20 ft, Colossal 30 ft) est un **disque** : la créature
|
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
|
frappe et menace toutes les cases jusqu'à son allonge, l'adjacence
|
||||||
comprise — une créature Large dont l'attaque naturelle a `reach_ft` 10
|
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
|
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
|
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
|
Small/Medium et 10 ft pour un porteur Large. Une arme ordinaire sans
|
||||||
allonge menace les cases adjacentes (disque de 5 ft).
|
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
|
Mouvement : un déplacement parcourt jusqu'à la
|
||||||
vitesse du combattant en cases le long du plus court chemin réel (champ de
|
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 à
|
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 =
|
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
|
minimum entre cellules, LoS/couvert = meilleure paire) ; le comportement 1×1
|
||||||
reste bit-identique.
|
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
|
- 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
|
`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,
|
du modificateur de taille PF1e aux attaques/CA (Small +1, Medium 0, Large −1,
|
||||||
@@ -1228,9 +1257,11 @@ Règles modélisées :
|
|||||||
L1 (SRD « Combat Maneuvers ») : « Creatures that are size Tiny or smaller
|
L1 (SRD « Combat Maneuvers ») : « Creatures that are size Tiny or smaller
|
||||||
use their Dexterity modifier in place of their Strength modifier to
|
use their Dexterity modifier in place of their Strength modifier to
|
||||||
determine their CMB » — Tiny/Diminutive/Fine calculent donc leur CMB avec
|
determine their CMB » — Tiny/Diminutive/Fine calculent donc leur CMB avec
|
||||||
le modificateur de Dextérité à la place de celui de Force ; lacune
|
le modificateur de Dextérité à la place de celui de Force. Leur allonge
|
||||||
documentée : l'allonge nulle et la co-occupation des créatures Tiny et
|
naturelle nulle (aucune case menacée, attaque dans leur propre case) et la
|
||||||
plus petites ne sont pas modélisées (abstraction à la grille de 5 ft).
|
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
|
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
|
les bonus de CA des huit types autorisés (« A creature can also add any
|
||||||
circumstance, deflection, dodge, insight, luck, morale, profane, and
|
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,
|
- Par combattant : id, side, pos, occupied_cells, hp/hp_max, active/dead,
|
||||||
dying/stabilized, conditions (noms + rounds_left), resources_left (+ `spell_slot_{level}`),
|
dying/stabilized, conditions (noms + rounds_left), resources_left (+ `spell_slot_{level}`),
|
||||||
weapon_broken/weapon_stolen, grappled_with, moved_this_turn, power_attack,
|
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),
|
dr {amount, bypass} ou None (C1), creature_type (C3),
|
||||||
bab (E1), initiative_mod (E2) et initiative_total (U2 : total du jet
|
bab (E1), initiative_mod (E2) et initiative_total (U2 : total du jet
|
||||||
d'initiative courant = d20 + mod, 0 si pas encore de clé — invocation
|
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
|
spell automatically fails ») : non planifiables dans un tour, ils sont
|
||||||
jetés du budget (`downgraded:cast_time`) plutôt qu'approximés, faute de
|
jetés du budget (`downgraded:cast_time`) plutôt qu'approximés, faute de
|
||||||
données d'objectif pour ces sorts rituels.
|
données d'objectif pour ces sorts rituels.
|
||||||
- **Gabarits Tiny/Diminutive/Fine abstraits à la grille de 5 ft** : leur
|
- **Co-occupation au-delà de Tiny vs plus grand** : la règle Tiny-or-smaller
|
||||||
allonge nulle et la co-occupation de leurs cases (plusieurs créatures
|
est modélisée (S4c) — allonge naturelle 0, aucune case menacée, attaque
|
||||||
minuscules sur une même case) ne sont pas modélisées ; seule la substitution
|
possible dans sa propre case, et partage de case avec une créature
|
||||||
Dextérité/Force du CMB (conformance L1) tient compte de leur taille.
|
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) :
|
Limites de sorts (conformance D6-D9, documentées plutôt que bricolées) :
|
||||||
|
|
||||||
|
|||||||
@@ -306,8 +306,15 @@ def _size_rank(size: str) -> int:
|
|||||||
return _SIZE_ORDER.index("medium")
|
return _SIZE_ORDER.index("medium")
|
||||||
|
|
||||||
# PF1e natural reach for tall creatures (simplification: no long/quadruped
|
# 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] = {
|
_SIZE_NATURAL_REACH_FT: dict[str, int] = {
|
||||||
|
"Fine": 0,
|
||||||
|
"Diminutive": 0,
|
||||||
|
"Tiny": 0,
|
||||||
"Small": 5,
|
"Small": 5,
|
||||||
"Medium": 5,
|
"Medium": 5,
|
||||||
"Large": 10,
|
"Large": 10,
|
||||||
@@ -316,6 +323,28 @@ _SIZE_NATURAL_REACH_FT: dict[str, int] = {
|
|||||||
"Colossal": 30,
|
"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).
|
# Number of grid cells per side of a creature's footprint (square footprint).
|
||||||
# Unknown sizes fall back to 1 (the historical single-cell behavior).
|
# Unknown sizes fall back to 1 (the historical single-cell behavior).
|
||||||
SIZE_CELLS: dict[str, int] = {
|
SIZE_CELLS: dict[str, int] = {
|
||||||
@@ -1378,6 +1407,7 @@ class CombatEngine:
|
|||||||
"name": state.combatant.name,
|
"name": state.combatant.name,
|
||||||
"level": state.combatant.level,
|
"level": state.combatant.level,
|
||||||
"size": state.combatant.size,
|
"size": state.combatant.size,
|
||||||
|
"natural_reach_ft": _SIZE_NATURAL_REACH_FT.get(state.combatant.size, 5),
|
||||||
"aware": state.combatant.aware,
|
"aware": state.combatant.aware,
|
||||||
"creature_type": state.combatant.creature_type,
|
"creature_type": state.combatant.creature_type,
|
||||||
"bab": state.combatant.bab,
|
"bab": state.combatant.bab,
|
||||||
@@ -2279,25 +2309,44 @@ class CombatEngine:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _blocked(self, *exclude: CombatantState) -> frozenset[Pos]:
|
def _blocked(self, *exclude: CombatantState) -> frozenset[Pos]:
|
||||||
"""Union of occupied cells of all active states except those in ``exclude``."""
|
"""Occupied cells that block the mover (the first excluded state).
|
||||||
return frozenset(
|
|
||||||
cell
|
Union of the active creatures' cells except those in ``exclude``. PF1e
|
||||||
for s in self._states
|
co-occupancy (SRD "Big and Little Creatures in Combat") lets a
|
||||||
if s.active and all(s is not e for e in exclude)
|
Tiny-or-smaller creature share a square with a strictly larger one, so
|
||||||
for cell in s.occupied_cells()
|
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]:
|
def _blocked_hostile(self, mover: CombatantState) -> frozenset[Pos]:
|
||||||
"""Occupied cells of active creatures hostile to ``mover``.
|
"""Occupied cells of active creatures hostile to ``mover``.
|
||||||
|
|
||||||
Same-side allies are excluded: PF1e (SRD Movement, Position and
|
Same-side allies are excluded: PF1e (SRD Movement, Position and
|
||||||
Distance) — "You can move through a square occupied by a friendly
|
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(
|
return frozenset(
|
||||||
cell
|
cell
|
||||||
for s in self._states
|
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()
|
for cell in s.occupied_cells()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -403,3 +403,119 @@ def test_no_aoo_from_adjacent_to_reach_weapon() -> None:
|
|||||||
"round 1 dest: wait",
|
"round 1 dest: wait",
|
||||||
"battle over: draw after 1 rounds",
|
"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
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user