feat(combat): implement overrun, dirty trick, steal, reposition and drag maneuvers
This commit is contained in:
@@ -377,14 +377,20 @@ Règles modélisées :
|
|||||||
`denies_dex_to_ac` (à plat ventre, paralysé…) fait perdre le bonus de Dextérité à
|
`denies_dex_to_ac` (à plat ventre, paralysé…) fait perdre le bonus de Dextérité à
|
||||||
la CA (CA flat-footed). À plat ventre : −4 CA et −4 aux attaques à distance.
|
la CA (CA flat-footed). À plat ventre : −4 CA et −4 aux attaques à distance.
|
||||||
Agrippé : −4 Dextérité, −2 attaque/CA/Réflexes, sans déplacement ni AoO.
|
Agrippé : −4 Dextérité, −2 attaque/CA/Réflexes, sans déplacement ni AoO.
|
||||||
- Manœuvres de combat : trip, disarm, bull_rush, sunder, grapple et escape sont
|
- Manœuvres de combat : trip, disarm, bull_rush, sunder, grapple, escape,
|
||||||
modélisées, chacune comme une tentative unique (1d20 + CMB contre CMD) :
|
overrun, dirty trick, steal, reposition et drag sont modélisées, chacune
|
||||||
|
comme une tentative unique (1d20 + CMB contre CMD) :
|
||||||
trip → `prone` ; disarm → `disarmed` (−4 attaque) ; bull_rush → pousse la cible ;
|
trip → `prone` ; disarm → `disarmed` (−4 attaque) ; bull_rush → pousse la cible ;
|
||||||
grapple → `grappled`/`pinned` ; escape → **une** sauvegarde (bonus statique de la
|
grapple → `grappled`/`pinned` ; escape → **une** sauvegarde (bonus statique de la
|
||||||
compétence `escape_artist` ou CMB, + un d20) contre CMD — une seule tentative,
|
compétence `escape_artist` ou CMB, + un d20) contre CMD — une seule tentative,
|
||||||
sans boucle multi-tour. Un sunder endommage l'**arme**, pas les PV : `str_mod + 2`
|
sans boucle multi-tour. Un sunder endommage l'**arme**, pas les PV : `str_mod + 2`
|
||||||
de dégâts à l'arme, qui se brise à 10 d'usure cumulée (`_WEAPON_DURABILITY`) →
|
de dégâts à l'arme, qui se brise à 10 d'usure cumulée (`_WEAPON_DURABILITY`) →
|
||||||
`disarmed` ; une arme brisée est exclue de `usable_attacks()`. `default_policy` ne
|
`disarmed` ; une arme brisée est exclue de `usable_attacks()`. overrun →
|
||||||
|
`prone` 1 round + passage au travers si la case au-delà est libre ; dirty trick →
|
||||||
|
`dazzled` 1 round ; steal → l'arme de mêlée de la cible est volée (exclue de
|
||||||
|
`usable_attacks()`) ; reposition → déplacement de 5 ft de la cible vers le
|
||||||
|
premier voisin libre ; drag → traction de 5 ft de la cible, l'attaquant
|
||||||
|
occupant sa case d'origine. `default_policy` ne
|
||||||
choisit pas ces manœuvres (utilisables via `Action(kind="maneuver")` ou une
|
choisit pas ces manœuvres (utilisables via `Action(kind="maneuver")` ou une
|
||||||
politique personnalisée).
|
politique personnalisée).
|
||||||
- Combat Reflexes : 1 AoO par combattant par round par défaut ; avec le don Combat
|
- Combat Reflexes : 1 AoO par combattant par round par défaut ; avec le don Combat
|
||||||
@@ -420,9 +426,11 @@ dans la résolution) :
|
|||||||
appliquées au déplacement, à la portée ou à la ligne d'effet).
|
appliquées au déplacement, à la portée ou à la ligne d'effet).
|
||||||
- Tailles Large+ (2×2), allonge > 5 ft (la mêlée reste limitée à une case) et
|
- Tailles Large+ (2×2), allonge > 5 ft (la mêlée reste limitée à une case) et
|
||||||
actions immédiates hors-tour (consommeraient le prochain swift).
|
actions immédiates hors-tour (consommeraient le prochain swift).
|
||||||
- Parmi les manœuvres, seules trip / disarm / bull_rush / sunder / grapple / escape
|
- Manœuvres : dirty trick applique toujours `dazzled` (le choix
|
||||||
sont câblées ; les autres (overrun, dirty trick, steal, reposition, drag) ne le
|
aveuglé/assourdi/entravé/malade n'est pas modélisé) ; reposition et drag
|
||||||
sont pas. Une arme brisée ne peut pas être réparée en cours de combat.
|
utilisent une direction déterministe (premier voisin libre / au-delà de la
|
||||||
|
cible) ; un objet volé n'est pas récupérable en cours de combat. Une arme
|
||||||
|
brisée ne peut pas être réparée en cours de combat.
|
||||||
- La distribution des dégâts par type sur un sort n'envoie que des dégâts simples
|
- La distribution des dégâts par type sur un sort n'envoie que des dégâts simples
|
||||||
(sans plage « half on save » pour les effets ciblés).
|
(sans plage « half on save » pour les effets ciblés).
|
||||||
|
|
||||||
@@ -522,8 +530,8 @@ uv run basedpyright src # mode strict
|
|||||||
résolus, `abilities.py` + chargeur) et capacités de classe (channel energy,
|
résolus, `abilities.py` + chargeur) et capacités de classe (channel energy,
|
||||||
hex, deeds, inspire courage / spell combat) sont en place et documentés dans
|
hex, deeds, inspire courage / spell combat) sont en place et documentés dans
|
||||||
« Règles implémentées ». Restent hors du socle (voir « Non modélisé ») : les
|
« Règles implémentées ». Restent hors du socle (voir « Non modélisé ») : les
|
||||||
hauteurs/élévations, tailles Large+, allonges > 5 ft, actions immédiates
|
hauteurs/élévations, tailles Large+, allonges > 5 ft et actions immédiates
|
||||||
hors-tour, et le reste des manœuvres.
|
hors-tour.
|
||||||
- **Phase 2** — couche tactique LLM : stratégies en langage naturel traduites
|
- **Phase 2** — couche tactique LLM : stratégies en langage naturel traduites
|
||||||
en politiques, balayage de matrices de positionnement. (Non implémentée.)
|
en politiques, balayage de matrices de positionnement. (Non implémentée.)
|
||||||
- **Phase 3** — rapporteur LLM local : agrégation des statistiques et
|
- **Phase 3** — rapporteur LLM local : agrégation des statistiques et
|
||||||
|
|||||||
@@ -138,6 +138,11 @@ _SIZE_MODIFIERS: dict[str, int] = {
|
|||||||
"medium": 0, "large": 1, "huge": 2, "gargantuan": 4, "colossal": 8,
|
"medium": 0, "large": 1, "huge": 2, "gargantuan": 4, "colossal": 8,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Row-major scan order of the 8 adjacent cells, used by reposition.
|
||||||
|
_NEIGHBOR_OFFSETS: tuple[tuple[int, int], ...] = (
|
||||||
|
(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _save_label(result: SaveResult | None) -> str:
|
def _save_label(result: SaveResult | None) -> str:
|
||||||
if result is None:
|
if result is None:
|
||||||
@@ -216,9 +221,15 @@ class CombatantState:
|
|||||||
# sunder damage per weapon id, and the set of weapon ids rendered unusable.
|
# sunder damage per weapon id, and the set of weapon ids rendered unusable.
|
||||||
weapon_damage: dict[str, int] = field(default_factory=dict)
|
weapon_damage: dict[str, int] = field(default_factory=dict)
|
||||||
weapon_broken: set[str] = field(default_factory=set)
|
weapon_broken: set[str] = field(default_factory=set)
|
||||||
|
# Runtime weapon theft (not in the frozen data model): weapon ids taken by
|
||||||
|
# steal maneuvers.
|
||||||
|
weapon_stolen: set[str] = field(default_factory=set)
|
||||||
|
|
||||||
def usable_attacks(self) -> list[AttackSpec]:
|
def usable_attacks(self) -> list[AttackSpec]:
|
||||||
return [w for w in self.combatant.attacks if w.id not in self.weapon_broken]
|
return [
|
||||||
|
w for w in self.combatant.attacks
|
||||||
|
if w.id not in self.weapon_broken and w.id not in self.weapon_stolen
|
||||||
|
]
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
if not self.spell_slots_left:
|
if not self.spell_slots_left:
|
||||||
@@ -300,7 +311,8 @@ class Action:
|
|||||||
]
|
]
|
||||||
target_id: str | None = None
|
target_id: str | None = None
|
||||||
spell_name: str | None = None
|
spell_name: str | None = None
|
||||||
maneuver: str | None = None # "trip", "disarm", "bull_rush", "sunder", "grapple", "escape"
|
maneuver: str | None = None # "trip", "disarm", "bull_rush", "sunder",
|
||||||
|
# "grapple", "escape", "overrun", "dirty_trick", "steal", "reposition", "drag"
|
||||||
hex_name: str | None = None # "evil_eye", "slumber", "cackle"
|
hex_name: str | None = None # "evil_eye", "slumber", "cackle"
|
||||||
deed_name: str | None = None # "up_close_and_deadly", "quick_clear"
|
deed_name: str | None = None # "up_close_and_deadly", "quick_clear"
|
||||||
class_ability: str | None = None # "deed"|"spell_combat"|"inspire_courage"|"hex"|"channel"
|
class_ability: str | None = None # "deed"|"spell_combat"|"inspire_courage"|"hex"|"channel"
|
||||||
@@ -1287,7 +1299,8 @@ class CombatEngine:
|
|||||||
def _execute_maneuver(
|
def _execute_maneuver(
|
||||||
self, state: CombatantState, target: CombatantState | None, maneuver: str | None
|
self, state: CombatantState, target: CombatantState | None, maneuver: str | None
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Execute a combat maneuver (trip, disarm, bull_rush, sunder)."""
|
"""Execute a combat maneuver (trip, disarm, bull_rush, sunder, grapple,
|
||||||
|
escape, overrun, dirty_trick, steal, reposition, drag)."""
|
||||||
if target is None or not target.active:
|
if target is None or not target.active:
|
||||||
return
|
return
|
||||||
if self.has_condition_flag(state, "cant_attack"):
|
if self.has_condition_flag(state, "cant_attack"):
|
||||||
@@ -1296,7 +1309,10 @@ class CombatEngine:
|
|||||||
if maneuver == "escape":
|
if maneuver == "escape":
|
||||||
self._maneuver_escape(state, target)
|
self._maneuver_escape(state, target)
|
||||||
return
|
return
|
||||||
if maneuver not in ("trip", "disarm", "bull_rush", "sunder", "grapple"):
|
if maneuver not in (
|
||||||
|
"trip", "disarm", "bull_rush", "sunder", "grapple",
|
||||||
|
"overrun", "dirty_trick", "steal", "reposition", "drag",
|
||||||
|
):
|
||||||
return
|
return
|
||||||
|
|
||||||
attacker_cmb = self._maneuver_cmb(state)
|
attacker_cmb = self._maneuver_cmb(state)
|
||||||
@@ -1314,6 +1330,11 @@ class CombatEngine:
|
|||||||
"bull_rush": lambda: self._maneuver_bull_rush(state, target, m),
|
"bull_rush": lambda: self._maneuver_bull_rush(state, target, m),
|
||||||
"sunder": lambda: self._maneuver_sunder(state, target, m),
|
"sunder": lambda: self._maneuver_sunder(state, target, m),
|
||||||
"grapple": lambda: self._maneuver_grapple(state, target, m),
|
"grapple": lambda: self._maneuver_grapple(state, target, m),
|
||||||
|
"overrun": lambda: self._maneuver_overrun(state, target, m),
|
||||||
|
"dirty_trick": lambda: self._maneuver_dirty_trick(state, target, m),
|
||||||
|
"steal": lambda: self._maneuver_steal(state, target, m),
|
||||||
|
"reposition": lambda: self._maneuver_reposition(state, target, m),
|
||||||
|
"drag": lambda: self._maneuver_drag(state, target, m),
|
||||||
}
|
}
|
||||||
handlers[maneuver]()
|
handlers[maneuver]()
|
||||||
|
|
||||||
@@ -1453,6 +1474,118 @@ class CombatEngine:
|
|||||||
f"CMD {m.target_cmd} -> FAILED"
|
f"CMD {m.target_cmd} -> FAILED"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _maneuver_overrun(
|
||||||
|
self, state: CombatantState, target: CombatantState, m: _ManeuverRoll
|
||||||
|
) -> None:
|
||||||
|
if not m.success:
|
||||||
|
self._maneuver_fail(state, target, "overrun", m)
|
||||||
|
return
|
||||||
|
target.active_conditions.append(
|
||||||
|
ActiveCondition(
|
||||||
|
condition=CONDITIONS_BY_NAME["prone"],
|
||||||
|
rounds_left=1,
|
||||||
|
source="maneuver: overrun",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
beyond = self._beyond_cell(state.pos, target.pos)
|
||||||
|
if self._cell_free(beyond, state):
|
||||||
|
state.pos = beyond
|
||||||
|
outcome = "SUCCESS (prone, moved through)"
|
||||||
|
else:
|
||||||
|
outcome = "SUCCESS (prone)"
|
||||||
|
self._log(
|
||||||
|
f"round {self._current_round} {state.combatant.id}: overrun vs "
|
||||||
|
f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} "
|
||||||
|
f"CMD {m.target_cmd} -> {outcome}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _maneuver_dirty_trick(
|
||||||
|
self, state: CombatantState, target: CombatantState, m: _ManeuverRoll
|
||||||
|
) -> None:
|
||||||
|
if not m.success:
|
||||||
|
self._maneuver_fail(state, target, "dirty_trick", m)
|
||||||
|
return
|
||||||
|
target.active_conditions.append(
|
||||||
|
ActiveCondition(
|
||||||
|
condition=CONDITIONS_BY_NAME["dazzled"],
|
||||||
|
rounds_left=1,
|
||||||
|
source="maneuver: dirty trick",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self._log(
|
||||||
|
f"round {self._current_round} {state.combatant.id}: dirty trick vs "
|
||||||
|
f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} "
|
||||||
|
f"CMD {m.target_cmd} -> SUCCESS (dazzled)"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _maneuver_steal(
|
||||||
|
self, state: CombatantState, target: CombatantState, m: _ManeuverRoll
|
||||||
|
) -> None:
|
||||||
|
if not m.success:
|
||||||
|
self._maneuver_fail(state, target, "steal", m)
|
||||||
|
return
|
||||||
|
weapon = next((w for w in target.usable_attacks() if w.kind == "melee"), None)
|
||||||
|
if weapon is None:
|
||||||
|
outcome = "SUCCESS (nothing to steal)"
|
||||||
|
else:
|
||||||
|
target.weapon_stolen.add(weapon.id)
|
||||||
|
outcome = f"SUCCESS (stole {weapon.id})"
|
||||||
|
self._log(
|
||||||
|
f"round {self._current_round} {state.combatant.id}: steal vs "
|
||||||
|
f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} "
|
||||||
|
f"CMD {m.target_cmd} -> {outcome}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _maneuver_reposition(
|
||||||
|
self, state: CombatantState, target: CombatantState, m: _ManeuverRoll
|
||||||
|
) -> None:
|
||||||
|
if not m.success:
|
||||||
|
self._maneuver_fail(state, target, "reposition", m)
|
||||||
|
return
|
||||||
|
new_pos: Pos | None = None
|
||||||
|
for dr, dc in _NEIGHBOR_OFFSETS:
|
||||||
|
pos = (target.pos[0] + dr, target.pos[1] + dc)
|
||||||
|
if pos == state.pos:
|
||||||
|
continue
|
||||||
|
if not (0 <= pos[0] < self._grid.height and 0 <= pos[1] < self._grid.width):
|
||||||
|
continue
|
||||||
|
if self._cell_free(pos, target):
|
||||||
|
new_pos = pos
|
||||||
|
break
|
||||||
|
if new_pos is not None:
|
||||||
|
target.pos = new_pos
|
||||||
|
outcome = f"SUCCESS (moved to {new_pos})"
|
||||||
|
else:
|
||||||
|
outcome = "SUCCESS (no space)"
|
||||||
|
self._log(
|
||||||
|
f"round {self._current_round} {state.combatant.id}: reposition vs "
|
||||||
|
f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} "
|
||||||
|
f"CMD {m.target_cmd} -> {outcome}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _maneuver_drag(
|
||||||
|
self, state: CombatantState, target: CombatantState, m: _ManeuverRoll
|
||||||
|
) -> None:
|
||||||
|
if not m.success:
|
||||||
|
self._maneuver_fail(state, target, "drag", m)
|
||||||
|
return
|
||||||
|
ar, ac = state.pos
|
||||||
|
tr, tc = target.pos
|
||||||
|
adjacent = max(abs(tr - ar), abs(tc - ac)) == 1
|
||||||
|
beyond = self._beyond_cell(state.pos, target.pos)
|
||||||
|
if adjacent and self._cell_free(beyond, target):
|
||||||
|
target_old = target.pos
|
||||||
|
target.pos = beyond
|
||||||
|
state.pos = target_old
|
||||||
|
outcome = "SUCCESS (dragged)"
|
||||||
|
else:
|
||||||
|
outcome = "SUCCESS (no space)"
|
||||||
|
self._log(
|
||||||
|
f"round {self._current_round} {state.combatant.id}: drag vs "
|
||||||
|
f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} "
|
||||||
|
f"CMD {m.target_cmd} -> {outcome}"
|
||||||
|
)
|
||||||
|
|
||||||
def _maneuver_escape(
|
def _maneuver_escape(
|
||||||
self, state: CombatantState, target: CombatantState
|
self, state: CombatantState, target: CombatantState
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -2481,6 +2614,20 @@ class CombatEngine:
|
|||||||
return False
|
return False
|
||||||
target.pos = new_pos
|
target.pos = new_pos
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def _beyond_cell(self, attacker_pos: Pos, target_pos: Pos) -> Pos:
|
||||||
|
"""The 1-cell position on the far side of the target from the attacker."""
|
||||||
|
ar, ac = attacker_pos
|
||||||
|
tr, tc = target_pos
|
||||||
|
dr = 1 if tr - ar > 0 else (-1 if tr - ar < 0 else 0)
|
||||||
|
dc = 1 if tc - ac > 0 else (-1 if tc - ac < 0 else 0)
|
||||||
|
return (tr + dr, tc + dc)
|
||||||
|
|
||||||
|
def _cell_free(self, pos: Pos, mover: CombatantState) -> bool:
|
||||||
|
"""True if pos is in bounds and not occupied by any active state but mover."""
|
||||||
|
if not (0 <= pos[0] < self._grid.height and 0 <= pos[1] < self._grid.width):
|
||||||
|
return False
|
||||||
|
return not any(s.pos == pos for s in self._states if s.active and s is not mover)
|
||||||
def _find_heal_spell(engine: CombatEngine, spells_known: list[str]) -> str | None:
|
def _find_heal_spell(engine: CombatEngine, spells_known: list[str]) -> str | None:
|
||||||
"""Find a heal spell in known spells."""
|
"""Find a heal spell in known spells."""
|
||||||
for name in spells_known:
|
for name in spells_known:
|
||||||
|
|||||||
@@ -116,3 +116,133 @@ def test_escape_from_grapple_consumes_single_d20() -> None:
|
|||||||
assert not engine._rng._queue
|
assert not engine._rng._queue
|
||||||
assert a.grappled_with is None
|
assert a.grappled_with is None
|
||||||
assert not any(c.condition.name == "grappled" for c in a.active_conditions)
|
assert not any(c.condition.name == "grappled" for c in a.active_conditions)
|
||||||
|
|
||||||
|
|
||||||
|
def test_overrun_knocks_prone_and_moves_through() -> None:
|
||||||
|
a_spec = make_combatant("a", attack_bonus=2)
|
||||||
|
b_spec = make_combatant("b")
|
||||||
|
a = make_state(a_spec, "players", (1, 1))
|
||||||
|
b = make_state(b_spec, "monsters", (1, 2))
|
||||||
|
engine = make_engine([12], [a, b])
|
||||||
|
engine._execute_maneuver(a, b, "overrun")
|
||||||
|
prone = next(c for c in b.active_conditions if c.condition.name == "prone")
|
||||||
|
assert prone.rounds_left == 1
|
||||||
|
assert prone.source == "maneuver: overrun"
|
||||||
|
assert a.pos == (1, 3)
|
||||||
|
|
||||||
|
|
||||||
|
def test_overrun_prone_but_no_space() -> None:
|
||||||
|
a_spec = make_combatant("a", attack_bonus=2)
|
||||||
|
b_spec = make_combatant("b")
|
||||||
|
c_spec = make_combatant("c")
|
||||||
|
a = make_state(a_spec, "players", (1, 1))
|
||||||
|
b = make_state(b_spec, "monsters", (1, 2))
|
||||||
|
c = make_state(c_spec, "monsters", (1, 3))
|
||||||
|
engine = make_engine([12], [a, b, c])
|
||||||
|
engine._execute_maneuver(a, b, "overrun")
|
||||||
|
assert any(cond.condition.name == "prone" for cond in b.active_conditions)
|
||||||
|
assert a.pos == (1, 1)
|
||||||
|
assert "SUCCESS (prone)" in engine._transcript[-1]
|
||||||
|
assert "moved through" not in engine._transcript[-1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_dirty_trick_dazzles() -> None:
|
||||||
|
a_spec = make_combatant("a", attack_bonus=2)
|
||||||
|
b_spec = make_combatant("b")
|
||||||
|
a = make_state(a_spec, "players", (1, 1))
|
||||||
|
b = make_state(b_spec, "monsters", (1, 2))
|
||||||
|
engine = make_engine([12], [a, b])
|
||||||
|
engine._execute_maneuver(a, b, "dirty_trick")
|
||||||
|
dazzled = next(c for c in b.active_conditions if c.condition.name == "dazzled")
|
||||||
|
assert dazzled.rounds_left == 1
|
||||||
|
assert dazzled.source == "maneuver: dirty trick"
|
||||||
|
|
||||||
|
|
||||||
|
def test_dirty_trick_fails_on_natural_1() -> None:
|
||||||
|
a_spec = make_combatant("a", attack_bonus=2)
|
||||||
|
b_spec = make_combatant("b")
|
||||||
|
a = make_state(a_spec, "players", (1, 1))
|
||||||
|
b = make_state(b_spec, "monsters", (1, 2))
|
||||||
|
engine = make_engine([1], [a, b])
|
||||||
|
engine._execute_maneuver(a, b, "dirty_trick")
|
||||||
|
assert not any(c.condition.name == "dazzled" for c in b.active_conditions)
|
||||||
|
assert not any(c.condition.name == "prone" for c in b.active_conditions)
|
||||||
|
assert engine._transcript[-1].endswith("-> FAILED")
|
||||||
|
|
||||||
|
|
||||||
|
def test_steal_takes_melee_weapon() -> None:
|
||||||
|
a_spec = make_combatant("a", attack_bonus=2)
|
||||||
|
b_spec = make_combatant("b")
|
||||||
|
a = make_state(a_spec, "players", (1, 1))
|
||||||
|
b = make_state(b_spec, "monsters", (1, 2))
|
||||||
|
hp_before = b.hp
|
||||||
|
engine = make_engine([12], [a, b])
|
||||||
|
engine._execute_maneuver(a, b, "steal")
|
||||||
|
assert "b-w" in b.weapon_stolen
|
||||||
|
assert engine.weapon_for(b, a) is None
|
||||||
|
assert b.hp == hp_before
|
||||||
|
|
||||||
|
|
||||||
|
def test_steal_nothing_to_steal() -> None:
|
||||||
|
a_spec = make_combatant("a", attack_bonus=2)
|
||||||
|
b_spec = make_combatant("b").model_copy(update={"attacks": []})
|
||||||
|
a = make_state(a_spec, "players", (1, 1))
|
||||||
|
b = make_state(b_spec, "monsters", (1, 2))
|
||||||
|
engine = make_engine([12], [a, b])
|
||||||
|
engine._execute_maneuver(a, b, "steal")
|
||||||
|
assert not b.weapon_stolen
|
||||||
|
assert "nothing to steal" in engine._transcript[-1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_reposition_moves_to_first_free_neighbor() -> None:
|
||||||
|
a_spec = make_combatant("a", attack_bonus=2)
|
||||||
|
b_spec = make_combatant("b")
|
||||||
|
c_spec = make_combatant("c")
|
||||||
|
a = make_state(a_spec, "players", (1, 1))
|
||||||
|
b = make_state(b_spec, "monsters", (1, 2))
|
||||||
|
c = make_state(c_spec, "monsters", (1, 3))
|
||||||
|
engine = make_engine([12], [a, b, c])
|
||||||
|
engine._execute_maneuver(a, b, "reposition")
|
||||||
|
assert b.pos == (0, 1)
|
||||||
|
assert c.pos == (1, 3)
|
||||||
|
|
||||||
|
|
||||||
|
def test_reposition_no_space() -> None:
|
||||||
|
a_spec = make_combatant("a", attack_bonus=2)
|
||||||
|
b_spec = make_combatant("b")
|
||||||
|
blocker_positions = [(0, 0), (0, 1), (0, 2), (1, 0), (1, 2), (2, 0), (2, 2)]
|
||||||
|
blockers = [
|
||||||
|
make_state(make_combatant(f"c{idx}"), "monsters", pos)
|
||||||
|
for idx, pos in enumerate(blocker_positions)
|
||||||
|
]
|
||||||
|
a = make_state(a_spec, "players", (2, 1))
|
||||||
|
b = make_state(b_spec, "monsters", (1, 1))
|
||||||
|
engine = make_engine([12], [a, b, *blockers])
|
||||||
|
engine._execute_maneuver(a, b, "reposition")
|
||||||
|
assert b.pos == (1, 1)
|
||||||
|
assert "no space" in engine._transcript[-1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_drag_moves_pair_together() -> None:
|
||||||
|
a_spec = make_combatant("a", attack_bonus=2)
|
||||||
|
b_spec = make_combatant("b")
|
||||||
|
a = make_state(a_spec, "players", (1, 1))
|
||||||
|
b = make_state(b_spec, "monsters", (1, 2))
|
||||||
|
engine = make_engine([12], [a, b])
|
||||||
|
engine._execute_maneuver(a, b, "drag")
|
||||||
|
assert b.pos == (1, 3)
|
||||||
|
assert a.pos == (1, 2)
|
||||||
|
|
||||||
|
|
||||||
|
def test_drag_no_space() -> None:
|
||||||
|
a_spec = make_combatant("a", attack_bonus=2)
|
||||||
|
b_spec = make_combatant("b")
|
||||||
|
c_spec = make_combatant("c")
|
||||||
|
a = make_state(a_spec, "players", (1, 1))
|
||||||
|
b = make_state(b_spec, "monsters", (1, 2))
|
||||||
|
c = make_state(c_spec, "monsters", (1, 3))
|
||||||
|
engine = make_engine([12], [a, b, c])
|
||||||
|
engine._execute_maneuver(a, b, "drag")
|
||||||
|
assert b.pos == (1, 2)
|
||||||
|
assert a.pos == (1, 1)
|
||||||
|
assert "no space" in engine._transcript[-1]
|
||||||
|
|||||||
Reference in New Issue
Block a user