Rename monster.py → combatant.py with _KNOWN_FEATS lookup table
that resolves feat name strings to AbilitySpec constants (Power Attack,
Iron Will, Toughness, Weapon Focus, etc.). Unknown feats produce
minimal AbilitySpec (data preserved for future implementation).
Migrate all 8 PC JSONs (data/pcs/) with complete feat/spell lists
extracted from Foundry sheets — including traits, racial abilities,
and all spells. Monsters (goblin, orc) unchanged (no feats/spells).
Update all imports, add backward-compatible aliases (load_monster,
MonsterLoadError), update CLI to use load_combatant.
Gate: 377 tests, ruff clean, basedpyright clean.
Power Attack is the first active feat: a free-action toggle declared at
turn start. When state.power_attack is True and the combatant has the
POWER_ATTACK feature, melee attacks apply -X to the attack roll and
+2X to damage, where X = BAB//4 + 1 (min 1). Ranged attacks are
unaffected. The flag is cleared at the start of each turn via
_take_turn, like other per-turn state.
- abilities.py: POWER_ATTACK constant (AbilitySpec, no passive effects)
- combat.py: _has_feat helper, _power_attack_amt, resolve_attack
applies penalty to attack total + crit confirm, bonus to damage
- tests/test_power_attack.py: 14 tests (amount at BAB 1/4/8/12, flag
false, feat missing, attack penalty, damage bonus, ranged unaffected,
flag cleared on turn)
- pyproject.toml: add SLF001 + RUF059 to test per-file-ignores (white-box
testing accesses private helpers, partial tuple unpacking)
- README.md: document Power Attack in abilities.py section, 307 tests
- New Action kind 'full_attack': manufactured weapons (count=1) gain BAB
iteratives at +6/+11/+16 (-5/-10/-15 from attack bonus, capped at 4);
natural weapons (count>1) delegate to _attack (no iteratives).
- Extracted _resolve_swing from _attack: one swing, log, stats, target-down
check. Both _attack and _full_attack use it. AttackResult gains base_bonus
field so the transcript shows the actual bonus used per iterative.
- default_policy now returns full_attack instead of attack when in range.
Non-breaking for BAB < 6 (single swing, identical transcript).
- Refactored round_no to self._current_round instance variable, removing it
from 6 method signatures and fixing PLR0913 on _resolve_swing.
- 4 new tests: BAB+6 two iteratives, BAB+11 three iteratives, target drops
mid-full-attack, BAB<6 single swing. README and docstring updated; test
count 202 -> 206.
- _flanking_bonus checks for an active same-side ally with a melee weapon
at the position diametrically opposite the attacker through the target.
- +2 applies to melee attack rolls and crit-confirm rolls; ranged attacks
ignore flanking; allies without melee weapons don't threaten.
- AttackResult gains flank_bonus field; transcript shows '+2(flank)' when
active.
- 6 new tests: opposite ally grants +2, no ally = no bonus, ranged ignores
flanking, diagonal flanking, downed ally doesn't flank, enemy on opposite
side doesn't flank.
- README and module docstring updated; test count 196 -> 202.
- default_policy returns (move, attack) when target out of reach: full-speed
move along the Dijkstra path (up to speed cells, 5-10-5 diagonals, stops
adjacent to nearest enemy), then attack if a weapon is usable; returns
(attack,) when already in range.
- _move logs the full path in one line (start)->(step1)->...->(end); silent
on empty path. _execute is silent on invalid attacks/moves (no spurious
wait log); run() adds a wait line only when a combatant's turn produced
no transcript line.
- Extract _take_turn helper from run() to keep cyclomatic complexity <= 10.
- Module docstring updated for activated economy.
- 4 pinned transcripts regenerated (move-then-attack shifts combat pacing):
test_default_policy_moves_toward_enemy, test_scripted_2v2_transcript
(monsters win in R2 instead of R3 — orc-2 moves + attacks same turn),
test_ranged_no_line_of_effect_moves_around_wall (archer rounds wall and
shoots in 1 turn), test_ranged_weapon_unusable_beyond_maximum_range
(archer moves 6 cells then attacks at -14 penalty).
- README: demo numbers refreshed (1v2: 49.6%->22.0%, 11.4->5.2 rounds;
3v2: 97.8%->91.4%, 6.9->6.0 rounds), rules and non-modeled sections
updated for the activated economy.
A weapon with count>1 ('2x Talons') now resolves one independent attack
roll per swing in the same attack action (label '#N'), aggregating stats
per swing. Remaining swings are lost if the target drops mid-routine.
BAB iterative attacks remain out of Phase 0 scope.
All 8 sheets from fiches_personnages/ converted to the strict Combatant
JSON schema (data/monsters convention) so the CLI can run PCs directly:
--side players data/pcs/esha.json
- id derived from the file stem; source field points at the original sheet
- oni: placeholder attacks without encoded damage ('une Pioche', 'Weapon')
left out, documented in notes (pick stats ambiguous light/heavy in the
rules KB; no identification possible for 'Weapon')
- README: PC section now documents data/pcs usage
- tests: parametrized load check + golden spots for the 8 PC files
Adds the S6 layer on top of the deterministic combat engine:
- metrics.py: closed-form binomial statistics (win rate, standard error,
3-sigma confidence band) with clamped degenerate proportions.
- runner.py: EncounterSpec/Side definitions, per-run SeededRng streams,
global duplicate-id disambiguation, attrition averages per combatant.
- cli.py: pf1e-sim entry point producing a French balance report with
win rates, 3-sigma bands, draws, rounds, and attrition.
- Movement fix: greedy straight-line heuristic could oscillate at walls;
_step_toward now follows the true shortest path via an unbounded
Dijkstra cost field from the target (Grid.reachable budget=None).
- Regression tests: melee unit routes around a wall and engages; grid
unbounded-budget coverage.