feat(cli): render --transcript output as a table

This commit is contained in:
2026-09-11 13:02:31 +02:00
parent d6c8f88f77
commit 9c6f5e277e
4 changed files with 266 additions and 4 deletions
+24 -1
View File
@@ -93,9 +93,32 @@ usage: pf1e-sim [-h] --map MAP [--side NAME [FILE ...]] [--runs RUNS]
| `--runs RUNS` | Nombre de combats simulés | `1000` |
| `--seed SEED` | Graine du générateur aléatoire | `1` |
| `--round-cap ROUND_CAP` | Plafond de rounds par combat (au-delà, combat nul) | `100` |
| `--transcript` | Imprimer le déroulé du premier combat avant le rapport | désactivé |
| `--transcript` | Imprimer le déroulé du premier combat **en tableau** avant le rapport (voir ci-dessous) | désactivé |
| `--simple-monster-death` | Mort simplifiée des monstres : `hp ≤ 0` au lieu de `hp ≤ -CON` (les PJ ne sont jamais affectés) | désactivé |
Affichage du transcript (`--transcript`) : les lignes du moteur sont présentées
dans un tableau à trois colonnes alignées — `Round`, `Combattant`, `Message` —
précédé d'un en-tête et d'une ligne de séparation. Les largeurs de colonnes
sont calculées sur le contenu affiché et l'alignement tient compte de la
largeur d'affichage réelle des caractères (accents, CJK). Les lignes du
transcript qui ne suivent pas le format `round N id: message` (jets
d'initiative, fin de combat) restent affichées telles quelles, en ligne brute
intercalée à leur position d'origine — aucune entrée n'est jamais perdue ni
tronquée. Exemple :
```
=== Déroulé du premier combat ===
Round Combattant Message
----- ---------- ---------------------------------------------------------
initiative: orc d20=19+0=19
1 orc move (1,15)->(2,14)->(3,13)->(3,12)->(3,11)->(3,10)
1 goblin short bow vs orc d20=9+4=13 AC 17 -> MISS
battle over: players win in 4 rounds
```
Les données du transcript (contenu et ordre des lignes du moteur) ne sont pas
modifiées par cette présentation.
Codes de sortie :
- `0` — simulation terminée, rapport imprimé.
+80 -2
View File
@@ -10,10 +10,13 @@ from __future__ import annotations
import argparse
import sys
import unicodedata
from pathlib import Path
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Sequence
from pf1e_simulator.runner import EncounterReport, EncounterSpec
from pf1e_simulator.loaders import CombatantLoadError, SheetLoadError, load_combatant
@@ -26,6 +29,81 @@ class CliError(Exception):
"""User-facing CLI error (bad arguments or unusable files)."""
_TRANSCRIPT_COLUMNS = ("Round", "Combattant", "Message")
_TRANSCRIPT_GUTTER = " "
def _display_width(text: str) -> int:
"""Terminal columns used by ``text``: wide CJK counts 2, combining marks 0."""
width = 0
for char in text:
if unicodedata.combining(char):
continue
width += 2 if unicodedata.east_asian_width(char) in ("W", "F") else 1
return width
def _pad_cell(text: str, width: int, *, right: bool = False) -> str:
"""Pad ``text`` with spaces to ``width`` display columns."""
filler = " " * (width - _display_width(text))
return filler + text if right else text + filler
def _parse_transcript_line(line: str) -> tuple[str, str, str] | None:
"""Split a ``round N id: message`` engine line into (round, id, message).
Returns None for lines that do not follow the pattern (initiative rolls,
battle-over notices); the renderer keeps them as raw fallback rows.
"""
if not line.startswith("round "):
return None
rest = line.removeprefix("round ")
space = rest.find(" ")
if space <= 0 or not rest[:space].isdigit():
return None
remainder = rest[space + 1 :]
colon = remainder.find(": ")
if colon <= 0 or " " in remainder[:colon]:
return None
return rest[:space], remainder[:colon], remainder[colon + 2 :]
def render_transcript_table(transcript: Sequence[str]) -> str:
"""Render transcript lines as an aligned three-column table (no truncation).
Entries shaped ``round N id: message`` fill the Round / Combattant /
Message columns (computed widths, display-width-aware padding so accents
and CJK stay aligned); any other entry is emitted verbatim as a raw
fallback row so no transcript line is ever dropped.
"""
rows: list[tuple[str, str, str] | None] = [_parse_transcript_line(line) for line in transcript]
parsed = [row for row in rows if row is not None]
widths = [
max([_display_width(header)] + [_display_width(row[index]) for row in parsed])
for index, header in enumerate(_TRANSCRIPT_COLUMNS)
]
lines = [
_TRANSCRIPT_GUTTER.join(
_pad_cell(header, width)
for header, width in zip(_TRANSCRIPT_COLUMNS, widths, strict=True)
),
_TRANSCRIPT_GUTTER.join("-" * width for width in widths),
]
for row, line in zip(rows, transcript, strict=True):
if row is None:
lines.append(line)
continue
cells = [
_pad_cell(row[0], widths[0], right=True),
*(
_pad_cell(cell, width)
for cell, width in zip(row[1:], widths[1:], strict=True)
),
]
lines.append(_TRANSCRIPT_GUTTER.join(cells))
return "\n".join(lines)
def _build_spec(args: argparse.Namespace) -> EncounterSpec:
map_spec = load_map(args.map)
provided = [tuple(raw) for raw in args.side]
@@ -103,7 +181,7 @@ def main(argv: list[str] | None = None) -> int:
parser.add_argument(
"--transcript",
action="store_true",
help="print the turn-by-turn log of the first battle before the report",
help="print the turn-by-turn log of the first battle as a table before the report",
)
parser.add_argument(
"--simple-monster-death",
@@ -139,7 +217,7 @@ def main(argv: list[str] | None = None) -> int:
return 2
if args.transcript and report.transcript is not None:
print("=== Déroulé du premier combat ===")
print("\n".join(report.transcript))
print(render_transcript_table(report.transcript))
print()
print(format_report(report, spec))
return 0
+7 -1
View File
@@ -80,8 +80,14 @@ def test_cli_transcript_prints_first_battle_log(capsys: pytest.CaptureFixture[st
out = capsys.readouterr().out
assert rc == 0
assert "Déroulé du premier combat" in out
# X6: the transcript is rendered as a table (header + separator); engine
# lines outside the "round N id: message" shape (initiative rolls) stay
# visible as raw fallback rows.
assert "Round" in out
assert "Combattant" in out
assert "Message" in out
assert "initiative:" in out
assert "round 1" in out
assert "goblin" in out
# The balance report must still be printed after the transcript.
assert "Rapport d'équilibrage" in out
assert out.index("Déroulé du premier combat") < out.index("Rapport d'équilibrage")
+155
View File
@@ -0,0 +1,155 @@
"""Conformance X6: the CLI renders the --transcript output as an aligned table.
The engine transcript is a list of strings, mostly shaped
``round {n} {combatant_id}: {message}`` (see ``CombatEngine._log``), with a few
non-conforming lines (initiative rolls, battle-over notices). The CLI must
present these entries as a three-column table (Round / Combattant / Message)
with a header row, a separator line and display-width-aware padding, while
non-conforming lines fall back to their raw text — never dropped. The
transcript DATA (content and order) is untouched: this is presentation only.
"""
from __future__ import annotations
import unicodedata
from pathlib import Path
from typing import TYPE_CHECKING
from pf1e_simulator.cli import main, render_transcript_table
if TYPE_CHECKING:
from collections.abc import Sequence
import pytest
ROOT = Path(__file__).resolve().parents[1]
MAP = ROOT / "data" / "maps" / "sample_arena.yaml"
GOBLIN = ROOT / "data" / "monsters" / "goblin.json"
ORC = ROOT / "data" / "monsters" / "orc.json"
_SAMPLE = [
"initiative: goblin d20=17+6=23",
"round 1 goblin: short sword vs orc -> HIT (4 dmg)",
"round 1 orc: falchion vs goblin -> MISS",
"round 2 goblin: confused: hits self for 3 (hp 6->3)",
"battle over: goblin win in 2 rounds",
]
def _display_width(text: str) -> int:
"""Terminal columns used by text (wide CJK counts 2, combining marks 0)."""
width = 0
for char in text:
if unicodedata.combining(char):
continue
width += 2 if unicodedata.east_asian_width(char) in ("W", "F") else 1
return width
def _table_lines(transcript: Sequence[str]) -> list[str]:
return render_transcript_table(transcript).splitlines()
def _parsed_rows(lines: list[str]) -> list[str]:
"""Data rows built from parsed entries (the round column pads them left)."""
return [line for line in lines[2:] if line.startswith(" ")]
def test_render_headers_and_separator() -> None:
lines = _table_lines(_SAMPLE)
assert lines[0].split() == ["Round", "Combattant", "Message"]
assert set(lines[1]) == {"-", " "}
# One dash run per column, columns separated by two spaces.
assert len(lines[1].split(" ")) == 3
# Header, separator, then one row per transcript entry.
assert len(lines) == len(_SAMPLE) + 2
def test_render_exact_small_table() -> None:
out = render_transcript_table(["round 3 goblin-2: move (1,2)"])
assert out == (
"Round Combattant Message \n"
"----- ---------- ----------\n"
" 3 goblin-2 move (1,2)"
)
def test_render_round_column_right_aligned() -> None:
lines = _table_lines(["round 1 a: x", "round 10 b: y"])
# Header "Round" is 5 wide, so round numbers are right-aligned inside it.
assert lines[2].startswith(" 1 ")
assert lines[3].startswith(" 10 ")
def test_render_alignment_uniform_display_width() -> None:
lines = _table_lines(_SAMPLE)
widths = {_display_width(line) for line in [lines[0], lines[1], *_parsed_rows(lines)]}
assert len(widths) == 1
def test_render_non_ascii_alignment_accented_and_cjk() -> None:
transcript = [
"round 1 éowyn: attaque l'orc → 6 dégâts",
"round 1 oni: 攻撃 vs orc -> HIT",
"round 2 goblin: wait",
]
lines = _table_lines(transcript)
widths = {_display_width(line) for line in lines}
assert len(widths) == 1
def test_render_raw_fallback_preserved_verbatim() -> None:
lines = _table_lines(_SAMPLE)
assert "initiative: goblin d20=17+6=23" in lines
assert "battle over: goblin win in 2 rounds" in lines
# Fallback rows keep the original order between parsed rows.
assert lines.index("initiative: goblin d20=17+6=23") == 2
assert lines[-1] == "battle over: goblin win in 2 rounds"
def test_render_empty_transcript_is_header_only() -> None:
lines = _table_lines([])
assert lines[0].split() == ["Round", "Combattant", "Message"]
assert set(lines[1]) == {"-", " "}
assert len(lines) == 2
def test_render_long_transcript_stays_aligned() -> None:
transcript = [f"round {n} combatant-{n}: message number {n}" for n in range(1, 301)]
lines = _table_lines(transcript)
widths = {_display_width(line) for line in lines}
assert len(widths) == 1
assert len(lines) == 302
def test_cli_transcript_prints_table(capsys: pytest.CaptureFixture[str]) -> None:
rc = main(
[
"--map",
str(MAP),
"--side",
"players",
str(GOBLIN),
"--side",
"monsters",
str(ORC),
"--runs",
"5",
"--seed",
"1",
"--transcript",
]
)
out = capsys.readouterr().out
assert rc == 0
assert "Déroulé du premier combat" in out
# Table markers: header row and separator line.
assert "Round" in out
assert "Combattant" in out
assert "Message" in out
assert any(set(line) <= {"-", " "} and "-" in line for line in out.splitlines())
# Non-conforming engine lines still show up (raw fallback).
assert "initiative:" in out
# The balance report must still follow the transcript table.
assert "Rapport d'équilibrage" in out
assert out.index("Déroulé du premier combat") < out.index("Rapport d'équilibrage")