feat(phase2): adversary and robustness cartography (P4)

This commit is contained in:
2026-09-25 21:34:11 +02:00
parent 016d6e1459
commit cd69312ff4
7 changed files with 1692 additions and 2 deletions
+24 -1
View File
@@ -35,6 +35,7 @@ class SideSpec:
zone: str # deployment zone letter, e.g. "A"
combatant_files: tuple[Path, ...] # monster/PJ JSON files, loaded via load_combatant
hp_fraction: float = 1.0 # initial HP as a fraction of hp_max, in (0, 1]
aware: bool | None = None # SRD "Surprise" override; None keeps Combatant.aware (True)
@dataclass(frozen=True)
@@ -82,6 +83,19 @@ def _apply_hp_fraction(combatant: Combatant, fraction: float) -> Combatant:
)
def _apply_aware(combatant: Combatant, *, aware: bool | None) -> Combatant:
"""Apply a side's SRD "Surprise" awareness override via model_copy.
None keeps the loaded combatant unchanged (Combatant.aware defaults to
True, models.py:416-420). SRD "Surprise": only some combatants aware
means a surprise round precedes round 1; unaware combatants do not act
in it.
"""
if aware is None:
return combatant
return combatant.model_copy(update={"aware": aware})
def build_encounter(scenario: ScenarioSpec) -> EncounterSpec:
"""Compile a ScenarioSpec into a runner EncounterSpec (deterministic).
@@ -89,6 +103,12 @@ def build_encounter(scenario: ScenarioSpec) -> EncounterSpec:
file with load_combatant, then apply hp_fraction to hp_max via
pydantic model_copy(update={"hp_max": max(1, round(hp_max * fraction))})
(same model_copy pattern as runner.build_states).
- When a side's aware is not None, it is applied to that side's
combatants via model_copy(update={"aware": ...}) at the same point
where hp_fraction is applied (SRD "Surprise" round semantics: only
some aware -> surprise round; Combatant.aware defaults to True, so
aware=None keeps the loaded default and is bit-identical to the
behaviour without the field).
- Duplicate combatant ids are NOT disambiguated here: build_states
(called later by run_encounter) handles that. Just list each loaded
combatant once per occurrence in combatant_files.
@@ -126,7 +146,10 @@ def build_encounter(scenario: ScenarioSpec) -> EncounterSpec:
Side(
name=side.name,
combatants=tuple(
_apply_hp_fraction(load_combatant(path), side.hp_fraction)
_apply_aware(
_apply_hp_fraction(load_combatant(path), side.hp_fraction),
aware=side.aware,
)
for path in side.combatant_files
),
zone=side.zone,