Compare commits
6 Commits
cd796ecffb
...
d2cd6f0702
| Author | SHA1 | Date | |
|---|---|---|---|
| d2cd6f0702 | |||
| bfb8d0a30c | |||
| cfeb28e336 | |||
| 90c71bbeb0 | |||
| f3d874790a | |||
| cd8051f2c4 |
@@ -455,19 +455,39 @@ Règles modélisées :
|
|||||||
`spell_slots` sans clés dupliquées.
|
`spell_slots` sans clés dupliquées.
|
||||||
- Snapshot d'observation (contrat LLM, Phase 2) : avant chaque appel de
|
- Snapshot d'observation (contrat LLM, Phase 2) : avant chaque appel de
|
||||||
politique, le moteur construit une `Observation` sérialisable (`to_dict()` →
|
politique, le moteur construit une `Observation` sérialisable (`to_dict()` →
|
||||||
JSON) figée — champs par combattant (id, side, pos, occupied_cells,
|
JSON) figée — le snapshot est la SEULE source d'info de la LLM (aucune
|
||||||
hp/hp_max, active/dead, conditions noms+durées, resources_left,
|
référence `engine.*` exposée, collections copiées/figées). Une politique LLM
|
||||||
weapon_broken/weapon_stolen, grappled_with, moved_this_turn, power_attack),
|
se branche via `CombatEngine(..., llm_policy=...)` (`LLMPolicy(observation,
|
||||||
par paire combattant actif × cible (distance_ft, weapon_for, range_penalty,
|
state) -> tuple[Action, ...]`). Schéma enrichi (E1-E5) :
|
||||||
can_charge, can_step5_to_attack, has_line_of_effect, flanking) et couches
|
- Par combattant : id, side, pos, occupied_cells, hp/hp_max, active/dead,
|
||||||
terrain/élévation de la grille. Les API de décision exposées (items L1-L8)
|
conditions (noms + rounds_left), resources_left (+ `spell_slot_{level}`),
|
||||||
|
weapon_broken/weapon_stolen, grappled_with, moved_this_turn, power_attack,
|
||||||
|
name, level, size, speed_land_ft, speed_fly_ft, ac {total, touch,
|
||||||
|
flat_footed}, saves {fort, ref, will}, cmb, cmd, attacks (référentiel
|
||||||
|
complet : id, name, kind, attack_bonus, damage, damage_bonus, crit_range,
|
||||||
|
crit_mult, reach_ft, range_increment_ft, count, firearm — y compris les
|
||||||
|
armes broken/stolen), features (noms des capacités), spells (résumés
|
||||||
|
enrichis du registre : name, level, school, range_ft, save, dc, summary).
|
||||||
|
- Par paire combattant actif × cible : target_index, distance_ft, weapon_for,
|
||||||
|
range_penalty, can_charge, can_step5_to_attack, has_line_of_effect,
|
||||||
|
flanking, movement_cost_ft, target_threatens_me, enemies_threatening_me.
|
||||||
|
- Top-level : round, acting_combatant, combatants, pairs, terrain, elevation,
|
||||||
|
threatened (grille bool des cases menacées par les ennemis),
|
||||||
|
terrain_semantics (un dict par type de terrain : type, move_cost,
|
||||||
|
blocks_los, cover).
|
||||||
|
Les helpers publics du moteur (L5) sont désormais embarqués EN DONNÉES dans
|
||||||
|
le snapshot (`threatened`, `terrain_semantics`, `movement_cost_ft`,
|
||||||
|
`target_threatens_me`, `enemies_threatening_me`) — une `LLMPolicy` ne peut
|
||||||
|
pas appeler `engine.*`, elle lit leurs résultats. Mapping des clés
|
||||||
|
`resources_left` (vérifié par grep sur `spend_resource`/`resources_left[` :
|
||||||
|
`channel_energy`, `hexes`, `grit`, `arcane_pool`, `bardic_rounds`, plus
|
||||||
|
`spell_slot_{level}` ajouté par le snapshot). Limites documentées : pas de
|
||||||
|
grille `reachable_ft` budget=speed (délibérément rejetée —
|
||||||
|
`movement_cost_ft` par paire + `terrain_semantics` + `speed_land_ft`
|
||||||
|
suffisent), pas de couvert par paire (déductible via `has_line_of_effect` +
|
||||||
|
`terrain_semantics` + positions). Les API de décision exposées (items L1-L8)
|
||||||
s'y retrouvent : `direction`/`trick_condition`/`weapon_id` sur `Action`,
|
s'y retrouvent : `direction`/`trick_condition`/`weapon_id` sur `Action`,
|
||||||
`can_charge` → `ChargeResult`, zones menacées `threatened_squares`/
|
`can_charge` → `ChargeResult`, kinds `retrieve_weapon`/`repair_weapon`.
|
||||||
`enemies_threatening`, coûts `reachable_ft`/`movement_cost_ft`, kinds
|
|
||||||
`retrieve_weapon`/`repair_weapon`. Une politique LLM se branche via
|
|
||||||
`CombatEngine(..., llm_policy=...)` (`LLMPolicy(observation, state) ->
|
|
||||||
tuple[Action, ...]`) ; le snapshot est la SEULE source d'info de la LLM
|
|
||||||
(aucune référence `engine.*` exposée, collections copiées/figées).
|
|
||||||
|
|
||||||
Non modélisé : la couche `markers` (points nommés de la carte) est validée à
|
Non modélisé : la couche `markers` (points nommés de la carte) est validée à
|
||||||
la lecture mais non appliquée dans la résolution.
|
la lecture mais non appliquée dans la résolution.
|
||||||
@@ -555,7 +575,7 @@ la lecture mais non appliquée dans la résolution.
|
|||||||
La gate de validation complète (tests + lint + types) :
|
La gate de validation complète (tests + lint + types) :
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv run pytest -q # 664 tests
|
uv run pytest -q # 714 tests
|
||||||
uv run ruff check src tests
|
uv run ruff check src tests
|
||||||
uv run basedpyright src # mode strict
|
uv run basedpyright src # mode strict
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ from pathlib import Path
|
|||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from pf1e_simulator.conditions import CONDITIONS_BY_NAME, Condition
|
from pf1e_simulator.conditions import CONDITIONS_BY_NAME, Condition
|
||||||
|
from pf1e_simulator.dice import dice_to_str
|
||||||
from pf1e_simulator.effects import ActiveBuff, ActiveCondition, StatModifier, resolve_modifiers
|
from pf1e_simulator.effects import ActiveBuff, ActiveCondition, StatModifier, resolve_modifiers
|
||||||
from pf1e_simulator.loaders.combatant import CombatantLoadError, load_combatant
|
from pf1e_simulator.loaders.combatant import CombatantLoadError, load_combatant
|
||||||
from pf1e_simulator.models import PREDEFINED_SKILLS
|
from pf1e_simulator.models import PREDEFINED_SKILLS
|
||||||
@@ -106,7 +107,7 @@ if TYPE_CHECKING:
|
|||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from pf1e_simulator.grid import Grid
|
from pf1e_simulator.grid import Grid
|
||||||
from pf1e_simulator.map import Pos
|
from pf1e_simulator.map import Pos, TerrainType
|
||||||
from pf1e_simulator.models import AttackSpec, Combatant, DamageReduction
|
from pf1e_simulator.models import AttackSpec, Combatant, DamageReduction
|
||||||
from pf1e_simulator.rng import Rng
|
from pf1e_simulator.rng import Rng
|
||||||
from pf1e_simulator.spells import SpellSpec
|
from pf1e_simulator.spells import SpellSpec
|
||||||
@@ -182,6 +183,29 @@ def _save_label(result: SaveResult | None) -> str:
|
|||||||
return "SAVED" if result.success else "FAILED"
|
return "SAVED" if result.success else "FAILED"
|
||||||
|
|
||||||
|
|
||||||
|
def _spell_effect_summary(effect: SpellEffect) -> str:
|
||||||
|
"""Short serializable summary of one spell effect (never the description)."""
|
||||||
|
if isinstance(effect, DamageEffect):
|
||||||
|
parts = [dice_to_str(effect.formula), *effect.types]
|
||||||
|
if effect.half_on_save:
|
||||||
|
parts.append("half on save")
|
||||||
|
return " ".join(parts)
|
||||||
|
if isinstance(effect, ConditionEffect):
|
||||||
|
return f"{effect.condition_name} {effect.duration_rounds}r"
|
||||||
|
if isinstance(effect, HealEffect):
|
||||||
|
return f"heal {dice_to_str(effect.formula)}"
|
||||||
|
if isinstance(effect, BuffEffect):
|
||||||
|
return f"buff {effect.duration_rounds}r"
|
||||||
|
if isinstance(effect, AreaEffect):
|
||||||
|
return f"area {effect.radius_ft}ft"
|
||||||
|
return f"summon {effect.creature}"
|
||||||
|
|
||||||
|
|
||||||
|
def _spell_summary(spell: SpellSpec) -> str:
|
||||||
|
"""Short serializable summary of a spell's effects, joined with ', '."""
|
||||||
|
return ", ".join(_spell_effect_summary(e) for e in spell.effects)
|
||||||
|
|
||||||
|
|
||||||
_STEP_DELTAS: tuple[Pos, ...] = (
|
_STEP_DELTAS: tuple[Pos, ...] = (
|
||||||
(-1, -1),
|
(-1, -1),
|
||||||
(-1, 0),
|
(-1, 0),
|
||||||
@@ -357,7 +381,15 @@ class Observation:
|
|||||||
``pairs`` holds one dict per (acting combatant x each other combatant) with
|
``pairs`` holds one dict per (acting combatant x each other combatant) with
|
||||||
``target_index`` disambiguating the target. ``terrain`` and ``elevation``
|
``target_index`` disambiguating the target. ``terrain`` and ``elevation``
|
||||||
are height x width layers aligned on the same dimensions (``elevation`` is
|
are height x width layers aligned on the same dimensions (``elevation`` is
|
||||||
``()`` when the grid has no elevation layer).
|
``()`` when the grid has no elevation layer). ``threatened`` is a height x
|
||||||
|
width boolean grid whose cells are True when threatened by at least one
|
||||||
|
ENEMY of the acting combatant (active, opposite side, with a melee
|
||||||
|
weapon); the union is bounded to the grid — out-of-grid threatened cells
|
||||||
|
are dropped. ``terrain_semantics`` is one dict per distinct terrain type
|
||||||
|
on the map (sorted by type for determinism), carrying
|
||||||
|
``{type, move_cost, blocks_los, cover}`` — derived from
|
||||||
|
``grid.terrain_at((r, c))`` over the entire map (the Grid does not expose
|
||||||
|
``MapSpec.legend``).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
round: int
|
round: int
|
||||||
@@ -366,6 +398,8 @@ class Observation:
|
|||||||
pairs: tuple[dict[str, object], ...]
|
pairs: tuple[dict[str, object], ...]
|
||||||
terrain: tuple[tuple[str | None, ...], ...]
|
terrain: tuple[tuple[str | None, ...], ...]
|
||||||
elevation: tuple[tuple[int, ...], ...]
|
elevation: tuple[tuple[int, ...], ...]
|
||||||
|
threatened: tuple[tuple[bool, ...], ...] = ()
|
||||||
|
terrain_semantics: tuple[dict[str, object], ...] = ()
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, object]:
|
def to_dict(self) -> dict[str, object]:
|
||||||
"""Return a JSON-serializable dict with the pinned top-level keys."""
|
"""Return a JSON-serializable dict with the pinned top-level keys."""
|
||||||
@@ -376,6 +410,8 @@ class Observation:
|
|||||||
"pairs": self.pairs,
|
"pairs": self.pairs,
|
||||||
"terrain": self.terrain,
|
"terrain": self.terrain,
|
||||||
"elevation": self.elevation,
|
"elevation": self.elevation,
|
||||||
|
"threatened": self.threatened,
|
||||||
|
"terrain_semantics": self.terrain_semantics,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -563,6 +599,8 @@ class CombatEngine:
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
elevation = ()
|
elevation = ()
|
||||||
|
threatened = self._threat_grid(state)
|
||||||
|
terrain_semantics = self._terrain_semantics()
|
||||||
return Observation(
|
return Observation(
|
||||||
round=self._current_round,
|
round=self._current_round,
|
||||||
acting_combatant=acting_index,
|
acting_combatant=acting_index,
|
||||||
@@ -570,6 +608,56 @@ class CombatEngine:
|
|||||||
pairs=pairs,
|
pairs=pairs,
|
||||||
terrain=terrain,
|
terrain=terrain,
|
||||||
elevation=elevation,
|
elevation=elevation,
|
||||||
|
threatened=threatened,
|
||||||
|
terrain_semantics=terrain_semantics,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _threat_grid(self, state: CombatantState) -> tuple[tuple[bool, ...], ...]:
|
||||||
|
"""Build the height x width bool grid of cells threatened by enemies.
|
||||||
|
|
||||||
|
Union of ``threatened_squares(e)`` for every enemy ``e`` of ``state``
|
||||||
|
(active, opposite side, with a usable melee weapon). The union is
|
||||||
|
bounded to the grid: cells produced by the public helper that lie
|
||||||
|
outside the map are dropped (helper output is in 5-ft-square coords
|
||||||
|
and may overflow on small maps).
|
||||||
|
"""
|
||||||
|
enemies = (
|
||||||
|
s for s in self._states
|
||||||
|
if s is not state
|
||||||
|
and s.active
|
||||||
|
and s.side != state.side
|
||||||
|
and any(w.kind == "melee" for w in s.usable_attacks())
|
||||||
|
)
|
||||||
|
threatened: set[Pos] = set()
|
||||||
|
for enemy in enemies:
|
||||||
|
threatened |= self.threatened_squares(enemy)
|
||||||
|
return tuple(
|
||||||
|
tuple((r, c) in threatened for c in range(self._grid.width))
|
||||||
|
for r in range(self._grid.height)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _terrain_semantics(self) -> tuple[dict[str, object], ...]:
|
||||||
|
"""One dict per distinct ``TerrainType`` on the map (sorted by type).
|
||||||
|
|
||||||
|
Derived by deduplicating ``grid.terrain_at`` over the entire map: the
|
||||||
|
Grid stores resolved ``TerrainType`` objects in ``_cells`` but does
|
||||||
|
not expose ``MapSpec.legend`` directly, so iterating the cells is
|
||||||
|
the safest source. The Grid is guaranteed to hold at least one
|
||||||
|
cell (constructor enforces non-empty ``cells``).
|
||||||
|
"""
|
||||||
|
seen: dict[str, TerrainType] = {}
|
||||||
|
for r in range(self._grid.height):
|
||||||
|
for c in range(self._grid.width):
|
||||||
|
cell = self._grid.terrain_at((r, c))
|
||||||
|
seen.setdefault(cell.type, cell)
|
||||||
|
return tuple(
|
||||||
|
{
|
||||||
|
"type": cell.type,
|
||||||
|
"move_cost": cell.move_cost,
|
||||||
|
"blocks_los": cell.blocks_los,
|
||||||
|
"cover": cell.cover,
|
||||||
|
}
|
||||||
|
for cell in (seen[name] for name in sorted(seen))
|
||||||
)
|
)
|
||||||
|
|
||||||
def _combatant_snapshot(self, state: CombatantState) -> dict[str, object]:
|
def _combatant_snapshot(self, state: CombatantState) -> dict[str, object]:
|
||||||
@@ -596,6 +684,50 @@ class CombatEngine:
|
|||||||
"grappled_with": state.grappled_with,
|
"grappled_with": state.grappled_with,
|
||||||
"moved_this_turn": state.moved_this_turn,
|
"moved_this_turn": state.moved_this_turn,
|
||||||
"power_attack": state.power_attack,
|
"power_attack": state.power_attack,
|
||||||
|
"name": state.combatant.name,
|
||||||
|
"level": state.combatant.level,
|
||||||
|
"size": state.combatant.size,
|
||||||
|
"speed_land_ft": state.combatant.speed_land_ft,
|
||||||
|
"speed_fly_ft": state.combatant.speed_fly_ft,
|
||||||
|
"ac": {
|
||||||
|
"total": state.combatant.ac.total,
|
||||||
|
"touch": state.combatant.ac.touch,
|
||||||
|
"flat_footed": state.combatant.ac.flat_footed,
|
||||||
|
},
|
||||||
|
"saves": {
|
||||||
|
"fort": state.combatant.saves.fort,
|
||||||
|
"ref": state.combatant.saves.ref,
|
||||||
|
"will": state.combatant.saves.will,
|
||||||
|
},
|
||||||
|
"cmb": self._maneuver_cmb(state),
|
||||||
|
"cmd": self._maneuver_cmd(state),
|
||||||
|
"attacks": tuple(
|
||||||
|
{
|
||||||
|
"id": attack.id,
|
||||||
|
"name": attack.name,
|
||||||
|
"kind": attack.kind,
|
||||||
|
"attack_bonus": attack.attack_bonus,
|
||||||
|
"damage": tuple(
|
||||||
|
{
|
||||||
|
"formula": dice_to_str(component.formula),
|
||||||
|
"types": tuple(component.types),
|
||||||
|
}
|
||||||
|
for component in attack.damage
|
||||||
|
),
|
||||||
|
"damage_bonus": attack.damage_bonus,
|
||||||
|
"crit_range": attack.crit_range,
|
||||||
|
"crit_mult": attack.crit_mult,
|
||||||
|
"reach_ft": attack.reach_ft,
|
||||||
|
"range_increment_ft": attack.range_increment_ft,
|
||||||
|
"count": attack.count,
|
||||||
|
"firearm": attack.firearm,
|
||||||
|
}
|
||||||
|
for attack in state.combatant.attacks
|
||||||
|
),
|
||||||
|
"features": tuple(sorted(f.name for f in state.combatant.features)),
|
||||||
|
"spells": tuple(
|
||||||
|
self._spell_snapshot(state, name) for name in state.combatant.spells
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
def _pair_snapshot(
|
def _pair_snapshot(
|
||||||
@@ -610,6 +742,16 @@ class CombatEngine:
|
|||||||
)
|
)
|
||||||
charge = self.can_charge(state, target)
|
charge = self.can_charge(state, target)
|
||||||
can_charge = charge is not None and charge.weapon is not None
|
can_charge = charge is not None and charge.weapon is not None
|
||||||
|
occupied = state.occupied_cells()
|
||||||
|
target_threatens_me = bool(
|
||||||
|
target.active
|
||||||
|
and any(w.kind == "melee" for w in target.usable_attacks())
|
||||||
|
and self.threatened_squares(target) & occupied
|
||||||
|
)
|
||||||
|
threatening_enemies: set[str] = set()
|
||||||
|
for cell in occupied:
|
||||||
|
for enemy in self.enemies_threatening(state, cell):
|
||||||
|
threatening_enemies.add(enemy.combatant.id)
|
||||||
return {
|
return {
|
||||||
"target_index": self._states.index(target),
|
"target_index": self._states.index(target),
|
||||||
"distance_ft": distance_ft,
|
"distance_ft": distance_ft,
|
||||||
@@ -621,6 +763,9 @@ class CombatEngine:
|
|||||||
self._grid, state.occupied_cells(), target.occupied_cells()
|
self._grid, state.occupied_cells(), target.occupied_cells()
|
||||||
),
|
),
|
||||||
"flanking": self._flanking_bonus(state, target) > 0,
|
"flanking": self._flanking_bonus(state, target) > 0,
|
||||||
|
"movement_cost_ft": self.movement_cost_ft(state, target),
|
||||||
|
"target_threatens_me": target_threatens_me,
|
||||||
|
"enemies_threatening_me": len(threatening_enemies),
|
||||||
}
|
}
|
||||||
|
|
||||||
def _take_turn(self, state: CombatantState) -> bool:
|
def _take_turn(self, state: CombatantState) -> bool:
|
||||||
@@ -2520,6 +2665,36 @@ class CombatEngine:
|
|||||||
return
|
return
|
||||||
self._charge(state, target, result.weapon)
|
self._charge(state, target, result.weapon)
|
||||||
|
|
||||||
|
def _spell_dc(self, caster: CombatantState, spell: SpellSpec) -> int:
|
||||||
|
"""Spell save DC: 10 + spell level + casting ability modifier."""
|
||||||
|
ability_name = caster.combatant.casting_ability.lower()
|
||||||
|
ability_score = getattr(caster.combatant.abilities, f"{ability_name}_score")
|
||||||
|
ability_mod = (ability_score - 10) // 2
|
||||||
|
return 10 + spell.level + ability_mod
|
||||||
|
|
||||||
|
def _spell_snapshot(self, state: CombatantState, spell_name: str) -> dict[str, object]:
|
||||||
|
"""One per-spell dict for the Observation, enriched from the registry."""
|
||||||
|
spell = self._spell_registry.get(spell_name)
|
||||||
|
if spell is None:
|
||||||
|
return {
|
||||||
|
"name": spell_name,
|
||||||
|
"level": None,
|
||||||
|
"school": "",
|
||||||
|
"range_ft": 0,
|
||||||
|
"save": "none",
|
||||||
|
"dc": None,
|
||||||
|
"summary": "",
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"name": spell.name,
|
||||||
|
"level": spell.level,
|
||||||
|
"school": spell.school,
|
||||||
|
"range_ft": spell_range_ft(spell, state.combatant.level),
|
||||||
|
"save": spell.save,
|
||||||
|
"dc": self._spell_dc(state, spell),
|
||||||
|
"summary": _spell_summary(spell),
|
||||||
|
}
|
||||||
|
|
||||||
def _cast_spell(
|
def _cast_spell(
|
||||||
self, caster: CombatantState, target: CombatantState | None, spell_name: str
|
self, caster: CombatantState, target: CombatantState | None, spell_name: str
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -2552,11 +2727,7 @@ class CombatEngine:
|
|||||||
if self._check_casting_aoo(caster, spell.level):
|
if self._check_casting_aoo(caster, spell.level):
|
||||||
return
|
return
|
||||||
caster.spend_spell_slot(spell.level)
|
caster.spend_spell_slot(spell.level)
|
||||||
# DC = 10 + spell level + caster's casting ability modifier
|
dc = self._spell_dc(caster, spell)
|
||||||
ability_name = caster.combatant.casting_ability.lower()
|
|
||||||
ability_score = getattr(caster.combatant.abilities, f"{ability_name}_score")
|
|
||||||
ability_mod = (ability_score - 10) // 2
|
|
||||||
dc = 10 + spell.level + ability_mod
|
|
||||||
save_result = (
|
save_result = (
|
||||||
self.resolve_save(
|
self.resolve_save(
|
||||||
target, spell.save, dc, save_descriptors=spell_save_descriptors(spell)
|
target, spell.save, dc, save_descriptors=spell_save_descriptors(spell)
|
||||||
|
|||||||
@@ -122,3 +122,20 @@ def parse_dice(text: str) -> DiceExpr:
|
|||||||
raise DiceParseError(text) from None
|
raise DiceParseError(text) from None
|
||||||
|
|
||||||
return DiceExpr.flat(n)
|
return DiceExpr.flat(n)
|
||||||
|
|
||||||
|
|
||||||
|
def dice_to_str(expr: DiceExpr) -> str:
|
||||||
|
"""Serialize a DiceExpr into canonical dice notation.
|
||||||
|
|
||||||
|
Inverse of `parse_dice`: flat expressions (count=0) render as the bare
|
||||||
|
integer, dice expressions render as "NdS" with a signed bonus appended
|
||||||
|
only when non-zero.
|
||||||
|
"""
|
||||||
|
if expr.count == 0:
|
||||||
|
return str(expr.bonus)
|
||||||
|
base = f"{expr.count}d{expr.sides}"
|
||||||
|
if expr.bonus > 0:
|
||||||
|
return f"{base}+{expr.bonus}"
|
||||||
|
if expr.bonus < 0:
|
||||||
|
return f"{base}{expr.bonus}"
|
||||||
|
return base
|
||||||
|
|||||||
+67
-1
@@ -6,7 +6,7 @@ from collections import Counter
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from pf1e_simulator.dice import DiceExpr, DiceParseError, parse_dice
|
from pf1e_simulator.dice import DiceExpr, DiceParseError, dice_to_str, parse_dice
|
||||||
from pf1e_simulator.rng import ScriptedRng
|
from pf1e_simulator.rng import ScriptedRng
|
||||||
|
|
||||||
# ── Parsing: accepted forms ──────────────────────────────────────────────────
|
# ── Parsing: accepted forms ──────────────────────────────────────────────────
|
||||||
@@ -194,3 +194,69 @@ class TestDiceExprFlat:
|
|||||||
rng = ScriptedRng([])
|
rng = ScriptedRng([])
|
||||||
expr = DiceExpr.flat(5)
|
expr = DiceExpr.flat(5)
|
||||||
assert expr.roll(rng) == 5
|
assert expr.roll(rng) == 5
|
||||||
|
|
||||||
|
|
||||||
|
# ── dice_to_str: canonical string representation ────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestDiceToStr:
|
||||||
|
"""Given: a DiceExpr
|
||||||
|
When: dice_to_str is called
|
||||||
|
Then: returns the canonical string inverse of parse_dice."""
|
||||||
|
|
||||||
|
def test_2d6_plus_3(self) -> None:
|
||||||
|
assert dice_to_str(DiceExpr(count=2, sides=6, bonus=3)) == "2d6+3"
|
||||||
|
|
||||||
|
def test_1d4_no_bonus(self) -> None:
|
||||||
|
assert dice_to_str(DiceExpr(count=1, sides=4, bonus=0)) == "1d4"
|
||||||
|
|
||||||
|
def test_flat_positive(self) -> None:
|
||||||
|
assert dice_to_str(DiceExpr(count=0, sides=0, bonus=7)) == "7"
|
||||||
|
|
||||||
|
def test_flat_negative(self) -> None:
|
||||||
|
assert dice_to_str(DiceExpr(count=0, sides=0, bonus=-5)) == "-5"
|
||||||
|
|
||||||
|
def test_flat_zero(self) -> None:
|
||||||
|
assert dice_to_str(DiceExpr(count=0, sides=0, bonus=0)) == "0"
|
||||||
|
|
||||||
|
def test_negative_bonus(self) -> None:
|
||||||
|
assert dice_to_str(DiceExpr(count=2, sides=6, bonus=-1)) == "2d6-1"
|
||||||
|
|
||||||
|
def test_d20_no_bonus(self) -> None:
|
||||||
|
assert dice_to_str(DiceExpr(count=1, sides=20, bonus=0)) == "1d20"
|
||||||
|
|
||||||
|
def test_large_dice(self) -> None:
|
||||||
|
assert dice_to_str(DiceExpr(count=10, sides=100, bonus=50)) == "10d100+50"
|
||||||
|
|
||||||
|
|
||||||
|
class TestDiceToStrRoundTrip:
|
||||||
|
"""Given: a valid dice string
|
||||||
|
When: parse_dice → dice_to_str → parse_dice
|
||||||
|
Then: the final DiceExpr equals the first."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"text",
|
||||||
|
[
|
||||||
|
"2d6+3",
|
||||||
|
"2d6-1",
|
||||||
|
"1d4",
|
||||||
|
"d20",
|
||||||
|
"7",
|
||||||
|
"-5",
|
||||||
|
"0",
|
||||||
|
"10d100+50",
|
||||||
|
"1d6",
|
||||||
|
"4d8-2",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_round_trip_equivalence(self, text: str) -> None:
|
||||||
|
first = parse_dice(text)
|
||||||
|
serialized = dice_to_str(first)
|
||||||
|
second = parse_dice(serialized)
|
||||||
|
assert first == second
|
||||||
|
|
||||||
|
def test_round_trip_any_flat(self) -> None:
|
||||||
|
for n in (-100, -1, 0, 1, 42, 999):
|
||||||
|
expr = parse_dice(str(n))
|
||||||
|
assert dice_to_str(expr) == str(n)
|
||||||
|
assert parse_dice(dice_to_str(expr)) == expr
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user