feat(combat): deterministic engine with attacks, crits, DR, hp states, initiative, rounds, basic policies
This commit is contained in:
@@ -0,0 +1,333 @@
|
|||||||
|
"""Deterministic combat engine: attacks, crits, DR, hp states, initiative, rounds.
|
||||||
|
|
||||||
|
Phase 0 documented deviations from PF1e (conventions):
|
||||||
|
- One action per turn: move OR attack (no move+attack, no AoO, no flanking).
|
||||||
|
- Natural 1 always misses; natural 20 always hits and threatens a crit.
|
||||||
|
- A confirmed crit multiplies the total damage (dice + flat bonus) by crit_mult.
|
||||||
|
- DR applies once, after crit multiplication; any bypassing type defeats DR.
|
||||||
|
- Death when hp < min(-10, -CON); hp <= 0 cannot act.
|
||||||
|
- Initiative ties: higher initiative_mod first, then list order (no re-roll).
|
||||||
|
- Movement: greedy single step toward the nearest enemy minimizing
|
||||||
|
accumulated cost + remaining grid distance; ties keep delta order.
|
||||||
|
- Ranged attacks ignore cover and range penalties in Phase 0.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
from dataclasses import asdict, dataclass
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pf1e_simulator.grid import Grid
|
||||||
|
from pf1e_simulator.map import Pos
|
||||||
|
from pf1e_simulator.models import AttackSpec, Combatant, DamageReduction
|
||||||
|
from pf1e_simulator.rng import Rng
|
||||||
|
|
||||||
|
_SQUARE_FT = 5 # Phase 0 maps use 5-ft squares
|
||||||
|
_DEATH_FLOOR = -10 # PF1e: dead when hp < -10 or -CON, whichever is lower
|
||||||
|
_NATURAL_ONE = 1 # PF1e: natural 1 always misses
|
||||||
|
_NATURAL_TWENTY = 20 # PF1e: natural 20 always hits and threatens
|
||||||
|
|
||||||
|
_STEP_DELTAS: tuple[Pos, ...] = (
|
||||||
|
(-1, -1),
|
||||||
|
(-1, 0),
|
||||||
|
(-1, 1),
|
||||||
|
(0, -1),
|
||||||
|
(0, 1),
|
||||||
|
(1, -1),
|
||||||
|
(1, 0),
|
||||||
|
(1, 1),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CombatantStats:
|
||||||
|
"""Aggregated outcomes for one combatant over a battle."""
|
||||||
|
|
||||||
|
hits: int = 0
|
||||||
|
crits: int = 0
|
||||||
|
damage_dealt: int = 0
|
||||||
|
damage_taken: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CombatantState:
|
||||||
|
"""Mutable runtime state of one combatant on the grid."""
|
||||||
|
|
||||||
|
combatant: Combatant
|
||||||
|
side: str
|
||||||
|
pos: Pos
|
||||||
|
hp: int
|
||||||
|
|
||||||
|
@property
|
||||||
|
def active(self) -> bool:
|
||||||
|
return self.hp > 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dead(self) -> bool:
|
||||||
|
return self.hp < self.death_threshold
|
||||||
|
|
||||||
|
@property
|
||||||
|
def death_threshold(self) -> int:
|
||||||
|
return min(_DEATH_FLOOR, -self.combatant.abilities.con_score)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AttackResult:
|
||||||
|
"""Outcome of one attack roll, including the raw rolls for the log."""
|
||||||
|
|
||||||
|
hit: bool
|
||||||
|
crit: bool
|
||||||
|
damage: int
|
||||||
|
roll: int
|
||||||
|
total: int
|
||||||
|
ac: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Action:
|
||||||
|
"""What a policy wants a combatant to do this turn."""
|
||||||
|
|
||||||
|
kind: Literal["attack", "move", "wait"]
|
||||||
|
target_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CombatResult:
|
||||||
|
"""Outcome of a full battle: winner, rounds, log, and per-combatant stats."""
|
||||||
|
|
||||||
|
winner: str | None
|
||||||
|
rounds: int
|
||||||
|
transcript: tuple[str, ...]
|
||||||
|
stats: dict[str, CombatantStats]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _LiveStats:
|
||||||
|
hits: int = 0
|
||||||
|
crits: int = 0
|
||||||
|
damage_dealt: int = 0
|
||||||
|
damage_taken: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class CombatEngine:
|
||||||
|
"""Runs one battle to completion on a grid with a single RNG stream."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
rng: Rng,
|
||||||
|
grid: Grid,
|
||||||
|
states: list[CombatantState],
|
||||||
|
*,
|
||||||
|
round_cap: int = 100,
|
||||||
|
policy: Policy | None = None,
|
||||||
|
) -> None:
|
||||||
|
self._rng = rng
|
||||||
|
self._grid = grid
|
||||||
|
self._states = states
|
||||||
|
self._round_cap = round_cap
|
||||||
|
self._policy = policy if policy is not None else default_policy
|
||||||
|
self._transcript: list[str] = []
|
||||||
|
self._stats: dict[str, _LiveStats] = {s.combatant.id: _LiveStats() for s in states}
|
||||||
|
|
||||||
|
def run(self) -> CombatResult:
|
||||||
|
"""Run the battle and return the final result and transcript."""
|
||||||
|
order = self._roll_initiative()
|
||||||
|
round_no = 1
|
||||||
|
while round_no <= self._round_cap:
|
||||||
|
for state in order:
|
||||||
|
if not state.active:
|
||||||
|
continue
|
||||||
|
action = self._policy(self, state)
|
||||||
|
self._execute(state, action, round_no)
|
||||||
|
if self._winner_side() is not None:
|
||||||
|
break
|
||||||
|
if self._winner_side() is not None:
|
||||||
|
break
|
||||||
|
round_no += 1
|
||||||
|
winner = self._winner_side()
|
||||||
|
if winner is not None:
|
||||||
|
self._log(f"battle over: {winner} win in {round_no} rounds")
|
||||||
|
else:
|
||||||
|
self._log(f"battle over: draw after {self._round_cap} rounds")
|
||||||
|
stats = {cid: CombatantStats(**asdict(live)) for cid, live in self._stats.items()}
|
||||||
|
return CombatResult(
|
||||||
|
winner=winner,
|
||||||
|
rounds=round_no if winner is not None else self._round_cap,
|
||||||
|
transcript=tuple(self._transcript),
|
||||||
|
stats=stats,
|
||||||
|
)
|
||||||
|
|
||||||
|
def nearest_enemy(self, state: CombatantState) -> CombatantState | None:
|
||||||
|
"""Closest active enemy by grid distance; ties keep list order."""
|
||||||
|
enemies = [s for s in self._states if s.side != state.side and s.active]
|
||||||
|
if not enemies:
|
||||||
|
return None
|
||||||
|
return min(
|
||||||
|
enemies,
|
||||||
|
key=lambda s: (self._grid.distance(state.pos, s.pos), self._states.index(s)),
|
||||||
|
)
|
||||||
|
|
||||||
|
def weapon_for(self, attacker: CombatantState, target: CombatantState) -> AttackSpec | None:
|
||||||
|
"""First weapon of the attacker usable against the target at this range."""
|
||||||
|
dist_ft = self._grid.distance(attacker.pos, target.pos) * _SQUARE_FT
|
||||||
|
for weapon in attacker.combatant.attacks:
|
||||||
|
if weapon.kind in ("melee", "touch") and dist_ft <= weapon.reach_ft:
|
||||||
|
return weapon
|
||||||
|
if (
|
||||||
|
weapon.kind == "ranged"
|
||||||
|
and weapon.range_increment_ft is not None
|
||||||
|
and dist_ft <= weapon.range_increment_ft
|
||||||
|
):
|
||||||
|
return weapon
|
||||||
|
return None
|
||||||
|
|
||||||
|
def resolve_attack(
|
||||||
|
self, target: CombatantState, weapon: AttackSpec
|
||||||
|
) -> AttackResult:
|
||||||
|
"""Roll one attack (with crit confirm and damage) and apply it."""
|
||||||
|
roll = self._rng.d20()
|
||||||
|
total = roll + weapon.attack_bonus
|
||||||
|
ac = target.combatant.ac.total
|
||||||
|
hit = roll == _NATURAL_TWENTY or (roll != _NATURAL_ONE and total >= ac)
|
||||||
|
crit = False
|
||||||
|
damage = 0
|
||||||
|
if hit:
|
||||||
|
if roll != _NATURAL_ONE and roll >= weapon.crit_range:
|
||||||
|
confirm = self._rng.d20()
|
||||||
|
crit = confirm != _NATURAL_ONE and confirm + weapon.attack_bonus >= ac
|
||||||
|
damage = sum(c.formula.roll(self._rng) for c in weapon.damage) + weapon.damage_bonus
|
||||||
|
if crit:
|
||||||
|
damage *= weapon.crit_mult
|
||||||
|
damage = self._apply_dr(damage, weapon, target)
|
||||||
|
target.hp -= damage
|
||||||
|
return AttackResult(hit=hit, crit=crit, damage=damage, roll=roll, total=total, ac=ac)
|
||||||
|
|
||||||
|
def _apply_dr(self, damage: int, weapon: AttackSpec, target: CombatantState) -> int:
|
||||||
|
dr: DamageReduction | None = target.combatant.dr
|
||||||
|
if dr is None:
|
||||||
|
return damage
|
||||||
|
types = {t for component in weapon.damage for t in component.types}
|
||||||
|
if types & dr.bypass:
|
||||||
|
return damage
|
||||||
|
return max(0, damage - dr.amount)
|
||||||
|
|
||||||
|
def _roll_initiative(self) -> list[CombatantState]:
|
||||||
|
rolls: dict[str, tuple[int, int]] = {}
|
||||||
|
for state in self._states:
|
||||||
|
roll = self._rng.d20()
|
||||||
|
rolls[state.combatant.id] = (roll, roll + state.combatant.initiative_mod)
|
||||||
|
order = sorted(
|
||||||
|
self._states,
|
||||||
|
key=lambda s: (
|
||||||
|
rolls[s.combatant.id][1],
|
||||||
|
s.combatant.initiative_mod,
|
||||||
|
self._states.index(s),
|
||||||
|
),
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
for state in order:
|
||||||
|
roll, total = rolls[state.combatant.id]
|
||||||
|
self._log(
|
||||||
|
f"initiative: {state.combatant.id} "
|
||||||
|
f"d20={roll}+{state.combatant.initiative_mod}={total}"
|
||||||
|
)
|
||||||
|
return order
|
||||||
|
|
||||||
|
def _winner_side(self) -> str | None:
|
||||||
|
active_sides = {s.side for s in self._states if s.active}
|
||||||
|
if len(active_sides) == 1:
|
||||||
|
return active_sides.pop()
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _execute(self, state: CombatantState, action: Action, round_no: int) -> None:
|
||||||
|
target = next((s for s in self._states if s.combatant.id == action.target_id), None)
|
||||||
|
if action.kind == "attack":
|
||||||
|
if target is None or not target.active:
|
||||||
|
self._log(f"round {round_no} {state.combatant.id}: wait")
|
||||||
|
return
|
||||||
|
weapon = self.weapon_for(state, target)
|
||||||
|
if weapon is None:
|
||||||
|
self._log(f"round {round_no} {state.combatant.id}: wait")
|
||||||
|
return
|
||||||
|
self._attack(state, target, weapon, round_no)
|
||||||
|
elif action.kind == "move":
|
||||||
|
if target is None:
|
||||||
|
self._log(f"round {round_no} {state.combatant.id}: wait")
|
||||||
|
return
|
||||||
|
self._move(state, target, round_no)
|
||||||
|
else:
|
||||||
|
self._log(f"round {round_no} {state.combatant.id}: wait")
|
||||||
|
|
||||||
|
def _attack(
|
||||||
|
self, attacker: CombatantState, target: CombatantState, weapon: AttackSpec, round_no: int
|
||||||
|
) -> None:
|
||||||
|
hp_before = target.hp
|
||||||
|
result = self.resolve_attack(target, weapon)
|
||||||
|
live = self._stats[attacker.combatant.id]
|
||||||
|
live.hits += int(result.hit)
|
||||||
|
live.crits += int(result.crit)
|
||||||
|
live.damage_dealt += result.damage
|
||||||
|
self._stats[target.combatant.id].damage_taken += result.damage
|
||||||
|
outcome = "CRIT" if result.crit else "HIT" if result.hit else "MISS"
|
||||||
|
line = (
|
||||||
|
f"round {round_no} {attacker.combatant.id}: {weapon.name} vs {target.combatant.id} "
|
||||||
|
f"d20={result.roll}+{weapon.attack_bonus}={result.total} AC {result.ac} -> {outcome}"
|
||||||
|
)
|
||||||
|
if result.hit:
|
||||||
|
line += f" {result.damage} damage ({hp_before}->{target.hp})"
|
||||||
|
self._log(line)
|
||||||
|
if target.dead:
|
||||||
|
self._log(f"round {round_no} {attacker.combatant.id}: {target.combatant.id} dead")
|
||||||
|
elif not target.active:
|
||||||
|
self._log(f"round {round_no} {attacker.combatant.id}: {target.combatant.id} down")
|
||||||
|
|
||||||
|
def _move(self, state: CombatantState, target: CombatantState, round_no: int) -> None:
|
||||||
|
step = self._step_toward(state, target)
|
||||||
|
if step is None:
|
||||||
|
self._log(f"round {round_no} {state.combatant.id}: wait")
|
||||||
|
return
|
||||||
|
self._log(
|
||||||
|
f"round {round_no} {state.combatant.id}: move "
|
||||||
|
f"({state.pos[0]},{state.pos[1]})->({step[0]},{step[1]})"
|
||||||
|
)
|
||||||
|
state.pos = step
|
||||||
|
|
||||||
|
def _step_toward(self, state: CombatantState, target: CombatantState) -> Pos | None:
|
||||||
|
speed_cells = state.combatant.speed_land_ft // _SQUARE_FT
|
||||||
|
if speed_cells <= 0:
|
||||||
|
return None
|
||||||
|
blocked = frozenset(s.pos for s in self._states if s is not state and s.active)
|
||||||
|
costs = self._grid.reachable(state.pos, speed_cells, blocked)
|
||||||
|
best: tuple[int, Pos] | None = None
|
||||||
|
row, col = state.pos
|
||||||
|
for d_row, d_col in _STEP_DELTAS:
|
||||||
|
nxt = (row + d_row, col + d_col)
|
||||||
|
cost = costs.get(nxt)
|
||||||
|
if cost is None:
|
||||||
|
continue
|
||||||
|
score = cost + self._grid.distance(nxt, target.pos)
|
||||||
|
if best is None or score < best[0]:
|
||||||
|
best = (score, nxt)
|
||||||
|
if best is None:
|
||||||
|
return None
|
||||||
|
return best[1]
|
||||||
|
|
||||||
|
def _log(self, line: str) -> None:
|
||||||
|
self._transcript.append(line)
|
||||||
|
|
||||||
|
|
||||||
|
Policy = Callable[[CombatEngine, CombatantState], Action]
|
||||||
|
|
||||||
|
|
||||||
|
def default_policy(engine: CombatEngine, state: CombatantState) -> Action:
|
||||||
|
"""Attack the nearest active enemy when a weapon is in range, else approach."""
|
||||||
|
target = engine.nearest_enemy(state)
|
||||||
|
if target is None:
|
||||||
|
return Action(kind="wait")
|
||||||
|
if engine.weapon_for(state, target) is not None:
|
||||||
|
return Action(kind="attack", target_id=target.combatant.id)
|
||||||
|
return Action(kind="move", target_id=target.combatant.id)
|
||||||
@@ -0,0 +1,396 @@
|
|||||||
|
"""Tests for the combat engine: attacks, crits, DR, hp, initiative, rounds.
|
||||||
|
|
||||||
|
The scripted transcripts pin the exact deterministic behavior of the engine
|
||||||
|
end to end: dice order, log lines, winner, and per-combatant stats.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pf1e_simulator.combat import (
|
||||||
|
CombatantState,
|
||||||
|
CombatantStats,
|
||||||
|
CombatEngine,
|
||||||
|
CombatResult,
|
||||||
|
)
|
||||||
|
from pf1e_simulator.dice import parse_dice
|
||||||
|
from pf1e_simulator.grid import Grid
|
||||||
|
from pf1e_simulator.map import MapSpec, TerrainType
|
||||||
|
from pf1e_simulator.models import (
|
||||||
|
AbilityScores,
|
||||||
|
ACProfile,
|
||||||
|
AttackSpec,
|
||||||
|
Combatant,
|
||||||
|
DamageComponent,
|
||||||
|
DamageReduction,
|
||||||
|
Saves,
|
||||||
|
)
|
||||||
|
from pf1e_simulator.rng import ScriptedRng, SeededRng
|
||||||
|
|
||||||
|
|
||||||
|
def make_combatant(
|
||||||
|
cid: str,
|
||||||
|
*,
|
||||||
|
hp: int = 6,
|
||||||
|
ac: int = 13,
|
||||||
|
attack_bonus: int = 2,
|
||||||
|
damage: str = "1d4",
|
||||||
|
damage_bonus: int = 0,
|
||||||
|
crit_range: int = 20,
|
||||||
|
crit_mult: int = 2,
|
||||||
|
initiative_mod: int = 0,
|
||||||
|
con: int = 12,
|
||||||
|
speed: int = 30,
|
||||||
|
weapon_name: str = "short sword",
|
||||||
|
dr: DamageReduction | None = None,
|
||||||
|
) -> Combatant:
|
||||||
|
attack = AttackSpec(
|
||||||
|
id=f"{cid}-w",
|
||||||
|
name=weapon_name,
|
||||||
|
kind="melee",
|
||||||
|
attack_bonus=attack_bonus,
|
||||||
|
damage=[DamageComponent(formula=parse_dice(damage), types=["slashing"])],
|
||||||
|
damage_bonus=damage_bonus,
|
||||||
|
crit_range=crit_range,
|
||||||
|
crit_mult=crit_mult,
|
||||||
|
)
|
||||||
|
return Combatant(
|
||||||
|
id=cid,
|
||||||
|
name=cid,
|
||||||
|
level=1,
|
||||||
|
size="Medium",
|
||||||
|
abilities=AbilityScores(
|
||||||
|
str_score=10,
|
||||||
|
dex_score=10,
|
||||||
|
con_score=con,
|
||||||
|
int_score=10,
|
||||||
|
wis_score=10,
|
||||||
|
cha_score=10,
|
||||||
|
),
|
||||||
|
hp_max=hp,
|
||||||
|
ac=ACProfile(total=ac, touch=ac, flat_footed=ac),
|
||||||
|
bab=attack_bonus,
|
||||||
|
initiative_mod=initiative_mod,
|
||||||
|
speed_land_ft=speed,
|
||||||
|
saves=Saves(fort=0, ref=0, will=0),
|
||||||
|
attacks=[attack],
|
||||||
|
dr=dr,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def make_state(combatant: Combatant, side: str, pos: tuple[int, int]) -> CombatantState:
|
||||||
|
return CombatantState(combatant=combatant, side=side, pos=pos, hp=combatant.hp_max)
|
||||||
|
|
||||||
|
|
||||||
|
def make_grid() -> Grid:
|
||||||
|
legend = {".": TerrainType(type="floor", move_cost=1)}
|
||||||
|
spec = MapSpec(name="test", terrain=tuple(["." * 8] * 8), legend=legend)
|
||||||
|
return Grid.from_spec(spec)
|
||||||
|
|
||||||
|
|
||||||
|
def make_engine(
|
||||||
|
queue: list[int], states: list[CombatantState], *, round_cap: int = 100
|
||||||
|
) -> CombatEngine:
|
||||||
|
return CombatEngine(ScriptedRng(queue), make_grid(), states, round_cap=round_cap)
|
||||||
|
|
||||||
|
|
||||||
|
def test_attack_hit_deals_damage() -> 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, 3], [a, b])
|
||||||
|
result = engine.resolve_attack(b, a_spec.attacks[0])
|
||||||
|
assert result.hit is True
|
||||||
|
assert result.crit is False
|
||||||
|
assert result.damage == 3
|
||||||
|
assert b.hp == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_attack_miss_leaves_target_untouched() -> None:
|
||||||
|
a_spec = make_combatant("a")
|
||||||
|
b_spec = make_combatant("b")
|
||||||
|
a = make_state(a_spec, "players", (1, 1))
|
||||||
|
b = make_state(b_spec, "monsters", (1, 2))
|
||||||
|
engine = make_engine([9], [a, b])
|
||||||
|
result = engine.resolve_attack(b, a_spec.attacks[0])
|
||||||
|
assert result.hit is False
|
||||||
|
assert result.damage == 0
|
||||||
|
assert b.hp == 6
|
||||||
|
|
||||||
|
|
||||||
|
def test_natural_1_always_misses() -> None:
|
||||||
|
a_spec = make_combatant("a", attack_bonus=20)
|
||||||
|
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])
|
||||||
|
result = engine.resolve_attack(b, a_spec.attacks[0])
|
||||||
|
assert result.hit is False
|
||||||
|
assert b.hp == 6
|
||||||
|
|
||||||
|
|
||||||
|
def test_natural_20_threatens_and_crits() -> 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([20, 12, 4], [a, b])
|
||||||
|
result = engine.resolve_attack(b, a_spec.attacks[0])
|
||||||
|
assert result.crit is True
|
||||||
|
assert result.damage == 8
|
||||||
|
assert b.hp == -2
|
||||||
|
|
||||||
|
|
||||||
|
def test_crit_range_19_threatens_on_19() -> None:
|
||||||
|
a_spec = make_combatant("a", attack_bonus=2, crit_range=19)
|
||||||
|
b_spec = make_combatant("b")
|
||||||
|
a = make_state(a_spec, "players", (1, 1))
|
||||||
|
b = make_state(b_spec, "monsters", (1, 2))
|
||||||
|
engine = make_engine([19, 11, 2], [a, b])
|
||||||
|
result = engine.resolve_attack(b, a_spec.attacks[0])
|
||||||
|
assert result.crit is True
|
||||||
|
assert result.damage == 4
|
||||||
|
assert b.hp == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_confirm_fail_is_normal_hit() -> None:
|
||||||
|
a_spec = make_combatant("a", attack_bonus=2, crit_range=19)
|
||||||
|
b_spec = make_combatant("b")
|
||||||
|
a = make_state(a_spec, "players", (1, 1))
|
||||||
|
b = make_state(b_spec, "monsters", (1, 2))
|
||||||
|
engine = make_engine([19, 8, 2], [a, b])
|
||||||
|
result = engine.resolve_attack(b, a_spec.attacks[0])
|
||||||
|
assert result.hit is True
|
||||||
|
assert result.crit is False
|
||||||
|
assert result.damage == 2
|
||||||
|
assert b.hp == 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_threat_outside_crit_range() -> None:
|
||||||
|
a_spec = make_combatant("a", attack_bonus=2, crit_range=19)
|
||||||
|
b_spec = make_combatant("b")
|
||||||
|
a = make_state(a_spec, "players", (1, 1))
|
||||||
|
b = make_state(b_spec, "monsters", (1, 2))
|
||||||
|
# Only 2 rolls consumed: no confirm roll happens outside the threat range.
|
||||||
|
engine = make_engine([18, 3], [a, b])
|
||||||
|
result = engine.resolve_attack(b, a_spec.attacks[0])
|
||||||
|
assert result.crit is False
|
||||||
|
assert result.damage == 3
|
||||||
|
assert b.hp == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_dr_reduces_damage() -> None:
|
||||||
|
a_spec = make_combatant("a", attack_bonus=2)
|
||||||
|
b_spec = make_combatant("b", dr=DamageReduction(amount=5))
|
||||||
|
a = make_state(a_spec, "players", (1, 1))
|
||||||
|
b = make_state(b_spec, "monsters", (1, 2))
|
||||||
|
engine = make_engine([12, 7], [a, b])
|
||||||
|
result = engine.resolve_attack(b, a_spec.attacks[0])
|
||||||
|
assert result.damage == 2
|
||||||
|
assert b.hp == 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_dr_floors_damage_at_zero() -> None:
|
||||||
|
a_spec = make_combatant("a", attack_bonus=2)
|
||||||
|
b_spec = make_combatant("b", dr=DamageReduction(amount=5))
|
||||||
|
a = make_state(a_spec, "players", (1, 1))
|
||||||
|
b = make_state(b_spec, "monsters", (1, 2))
|
||||||
|
engine = make_engine([12, 3], [a, b])
|
||||||
|
result = engine.resolve_attack(b, a_spec.attacks[0])
|
||||||
|
assert result.damage == 0
|
||||||
|
assert b.hp == 6
|
||||||
|
|
||||||
|
|
||||||
|
def test_dr_bypass_ignores_reduction() -> None:
|
||||||
|
a_spec = make_combatant("a", attack_bonus=2)
|
||||||
|
b_spec = make_combatant("b", dr=DamageReduction(amount=5, bypass=frozenset({"slashing"})))
|
||||||
|
a = make_state(a_spec, "players", (1, 1))
|
||||||
|
b = make_state(b_spec, "monsters", (1, 2))
|
||||||
|
engine = make_engine([12, 7], [a, b])
|
||||||
|
result = engine.resolve_attack(b, a_spec.attacks[0])
|
||||||
|
assert result.damage == 7
|
||||||
|
assert b.hp == -1
|
||||||
|
|
||||||
|
|
||||||
|
def test_dr_applies_after_crit_multiplier() -> None:
|
||||||
|
a_spec = make_combatant("a", attack_bonus=2)
|
||||||
|
b_spec = make_combatant("b", dr=DamageReduction(amount=5))
|
||||||
|
a = make_state(a_spec, "players", (1, 1))
|
||||||
|
b = make_state(b_spec, "monsters", (1, 2))
|
||||||
|
engine = make_engine([20, 11, 6], [a, b])
|
||||||
|
result = engine.resolve_attack(b, a_spec.attacks[0])
|
||||||
|
assert result.crit is True
|
||||||
|
assert result.damage == 7
|
||||||
|
assert b.hp == -1
|
||||||
|
|
||||||
|
|
||||||
|
def test_hp_states_active_down_dead() -> None:
|
||||||
|
c = make_combatant("c", con=12)
|
||||||
|
st = make_state(c, "players", (0, 0))
|
||||||
|
st.hp = 1
|
||||||
|
assert st.active is True
|
||||||
|
assert st.dead is False
|
||||||
|
st.hp = 0
|
||||||
|
assert st.active is False
|
||||||
|
assert st.dead is False
|
||||||
|
st.hp = -11
|
||||||
|
assert st.active is False
|
||||||
|
assert st.dead is False
|
||||||
|
st.hp = -12
|
||||||
|
assert st.active is False
|
||||||
|
assert st.dead is False # dead requires hp < -12, not <=
|
||||||
|
st.hp = -13
|
||||||
|
assert st.active is False
|
||||||
|
assert st.dead is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_scripted_2v2_transcript() -> None:
|
||||||
|
gob_1 = make_combatant(
|
||||||
|
"gob-1", hp=6, ac=16, attack_bonus=2, damage="1d4", crit_range=19, initiative_mod=6
|
||||||
|
)
|
||||||
|
gob_2 = make_combatant(
|
||||||
|
"gob-2", hp=6, ac=16, attack_bonus=2, damage="1d4", crit_range=19, initiative_mod=6
|
||||||
|
)
|
||||||
|
orc_1 = make_combatant(
|
||||||
|
"orc-1", hp=6, ac=13, attack_bonus=5, damage="2d4", damage_bonus=4, crit_range=18,
|
||||||
|
weapon_name="falchion",
|
||||||
|
)
|
||||||
|
orc_2 = make_combatant(
|
||||||
|
"orc-2", hp=6, ac=13, attack_bonus=5, damage="2d4", damage_bonus=4, crit_range=18,
|
||||||
|
weapon_name="falchion",
|
||||||
|
)
|
||||||
|
states = [
|
||||||
|
make_state(gob_1, "players", (2, 2)),
|
||||||
|
make_state(gob_2, "players", (2, 5)),
|
||||||
|
make_state(orc_1, "monsters", (3, 3)),
|
||||||
|
make_state(orc_2, "monsters", (3, 4)),
|
||||||
|
]
|
||||||
|
queue = [
|
||||||
|
15, 5, 14, 7, # initiative: gob-1, gob-2, orc-1, orc-2
|
||||||
|
13, 3, # R1 gob-1 hits orc-1 for 3
|
||||||
|
10, # R1 orc-1 misses gob-1
|
||||||
|
14, 1, # R1 gob-2 hits orc-2 for 1
|
||||||
|
17, 2, 3, # R1 orc-2 hits gob-2 for 2+3+4=9
|
||||||
|
19, 11, 2, # R2 gob-1 crits orc-1: 19 threatens, 11 confirms, 2*2=4
|
||||||
|
7, # R3 gob-1 misses orc-2
|
||||||
|
15, 1, 4, # R3 orc-2 hits gob-1 for 1+4+4=9
|
||||||
|
]
|
||||||
|
engine = make_engine(queue, states)
|
||||||
|
result = engine.run()
|
||||||
|
assert result.winner == "monsters"
|
||||||
|
assert result.rounds == 3
|
||||||
|
assert result.transcript == (
|
||||||
|
"initiative: gob-1 d20=15+6=21",
|
||||||
|
"initiative: orc-1 d20=14+0=14",
|
||||||
|
"initiative: gob-2 d20=5+6=11",
|
||||||
|
"initiative: orc-2 d20=7+0=7",
|
||||||
|
"round 1 gob-1: short sword vs orc-1 d20=13+2=15 AC 13 -> HIT 3 damage (6->3)",
|
||||||
|
"round 1 orc-1: falchion vs gob-1 d20=10+5=15 AC 16 -> MISS",
|
||||||
|
"round 1 gob-2: short sword vs orc-2 d20=14+2=16 AC 13 -> HIT 1 damage (6->5)",
|
||||||
|
"round 1 orc-2: falchion vs gob-2 d20=17+5=22 AC 16 -> HIT 9 damage (6->-3)",
|
||||||
|
"round 1 orc-2: gob-2 down",
|
||||||
|
"round 2 gob-1: short sword vs orc-1 d20=19+2=21 AC 13 -> CRIT 4 damage (3->-1)",
|
||||||
|
"round 2 gob-1: orc-1 down",
|
||||||
|
"round 2 orc-2: move (3,4)->(2,3)",
|
||||||
|
"round 3 gob-1: short sword vs orc-2 d20=7+2=9 AC 13 -> MISS",
|
||||||
|
"round 3 orc-2: falchion vs gob-1 d20=15+5=20 AC 16 -> HIT 9 damage (6->-3)",
|
||||||
|
"round 3 orc-2: gob-1 down",
|
||||||
|
"battle over: monsters win in 3 rounds",
|
||||||
|
)
|
||||||
|
assert result.stats["gob-1"] == CombatantStats(hits=2, crits=1, damage_dealt=7, damage_taken=9)
|
||||||
|
assert result.stats["gob-2"] == CombatantStats(hits=1, crits=0, damage_dealt=1, damage_taken=9)
|
||||||
|
assert result.stats["orc-1"] == CombatantStats(hits=0, crits=0, damage_dealt=0, damage_taken=7)
|
||||||
|
assert result.stats["orc-2"] == CombatantStats(hits=2, crits=0, damage_dealt=18, damage_taken=1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_same_seed_replay_is_identical() -> None:
|
||||||
|
def run_battle() -> CombatResult:
|
||||||
|
gob = make_combatant(
|
||||||
|
"gob", hp=6, ac=16, attack_bonus=2, damage="1d4", crit_range=19, initiative_mod=6
|
||||||
|
)
|
||||||
|
orc = make_combatant(
|
||||||
|
"orc", hp=6, ac=13, attack_bonus=5, damage="2d4", damage_bonus=4, crit_range=18
|
||||||
|
)
|
||||||
|
states = [make_state(gob, "players", (1, 1)), make_state(orc, "monsters", (1, 2))]
|
||||||
|
return CombatEngine(SeededRng(42), make_grid(), states).run()
|
||||||
|
|
||||||
|
first = run_battle()
|
||||||
|
second = run_battle()
|
||||||
|
assert first.transcript == second.transcript
|
||||||
|
assert first.stats == second.stats
|
||||||
|
assert first.winner == second.winner
|
||||||
|
assert first.rounds == second.rounds
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_policy_moves_toward_enemy() -> None:
|
||||||
|
gob = make_combatant("gob", speed=30)
|
||||||
|
orc = make_combatant("orc", speed=0)
|
||||||
|
states = [make_state(gob, "players", (1, 1)), make_state(orc, "monsters", (1, 5))]
|
||||||
|
engine = make_engine([10, 9], states, round_cap=2)
|
||||||
|
result = engine.run()
|
||||||
|
assert result.transcript == (
|
||||||
|
"initiative: gob d20=10+0=10",
|
||||||
|
"initiative: orc d20=9+0=9",
|
||||||
|
"round 1 gob: move (1,1)->(0,2)",
|
||||||
|
"round 1 orc: wait",
|
||||||
|
"round 2 gob: move (0,2)->(0,3)",
|
||||||
|
"round 2 orc: wait",
|
||||||
|
"battle over: draw after 2 rounds",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_wait_when_enemy_out_of_reach_and_speed_zero() -> None:
|
||||||
|
gob = make_combatant("gob", speed=0)
|
||||||
|
orc = make_combatant("orc", speed=0)
|
||||||
|
states = [make_state(gob, "players", (1, 1)), make_state(orc, "monsters", (1, 3))]
|
||||||
|
engine = make_engine([10, 9], states, round_cap=1)
|
||||||
|
result = engine.run()
|
||||||
|
assert result.transcript == (
|
||||||
|
"initiative: gob d20=10+0=10",
|
||||||
|
"initiative: orc d20=9+0=9",
|
||||||
|
"round 1 gob: wait",
|
||||||
|
"round 1 orc: wait",
|
||||||
|
"battle over: draw after 1 rounds",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_nearest_enemy_targeting_and_full_rounds() -> None:
|
||||||
|
gob = make_combatant("gob", hp=6, ac=13, attack_bonus=2, damage="1d4")
|
||||||
|
orc_1 = make_combatant("orc-1", hp=6, ac=13, attack_bonus=2, damage="1d4")
|
||||||
|
orc_2 = make_combatant("orc-2", hp=6, ac=13, attack_bonus=2, damage="1d4")
|
||||||
|
states = [
|
||||||
|
make_state(gob, "players", (1, 1)),
|
||||||
|
make_state(orc_1, "monsters", (1, 2)),
|
||||||
|
make_state(orc_2, "monsters", (2, 1)),
|
||||||
|
]
|
||||||
|
queue = [
|
||||||
|
15, 10, 5, # initiative: gob, orc-1, orc-2
|
||||||
|
12, 4, # R1 gob hits orc-1 for 4
|
||||||
|
7, # R1 orc-1 misses
|
||||||
|
8, # R1 orc-2 misses
|
||||||
|
12, 6, # R2 gob downs orc-1
|
||||||
|
9, # R2 orc-2 misses
|
||||||
|
11, 3, # R3 gob hits orc-2 for 3
|
||||||
|
12, 4, # R3 orc-2 hits gob for 4
|
||||||
|
12, 6, # R4 gob downs orc-2
|
||||||
|
]
|
||||||
|
engine = make_engine(queue, states)
|
||||||
|
result = engine.run()
|
||||||
|
assert result.winner == "players"
|
||||||
|
assert result.rounds == 4
|
||||||
|
assert result.transcript == (
|
||||||
|
"initiative: gob d20=15+0=15",
|
||||||
|
"initiative: orc-1 d20=10+0=10",
|
||||||
|
"initiative: orc-2 d20=5+0=5",
|
||||||
|
"round 1 gob: short sword vs orc-1 d20=12+2=14 AC 13 -> HIT 4 damage (6->2)",
|
||||||
|
"round 1 orc-1: short sword vs gob d20=7+2=9 AC 13 -> MISS",
|
||||||
|
"round 1 orc-2: short sword vs gob d20=8+2=10 AC 13 -> MISS",
|
||||||
|
"round 2 gob: short sword vs orc-1 d20=12+2=14 AC 13 -> HIT 6 damage (2->-4)",
|
||||||
|
"round 2 gob: orc-1 down",
|
||||||
|
"round 2 orc-2: short sword vs gob d20=9+2=11 AC 13 -> MISS",
|
||||||
|
"round 3 gob: short sword vs orc-2 d20=11+2=13 AC 13 -> HIT 3 damage (6->3)",
|
||||||
|
"round 3 orc-2: short sword vs gob d20=12+2=14 AC 13 -> HIT 4 damage (6->2)",
|
||||||
|
"round 4 gob: short sword vs orc-2 d20=12+2=14 AC 13 -> HIT 6 damage (3->-3)",
|
||||||
|
"round 4 gob: orc-2 down",
|
||||||
|
"battle over: players win in 4 rounds",
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user