feat(cli): render --transcript output as a table
This commit is contained in:
@@ -93,9 +93,32 @@ usage: pf1e-sim [-h] --map MAP [--side NAME [FILE ...]] [--runs RUNS]
|
|||||||
| `--runs RUNS` | Nombre de combats simulés | `1000` |
|
| `--runs RUNS` | Nombre de combats simulés | `1000` |
|
||||||
| `--seed SEED` | Graine du générateur aléatoire | `1` |
|
| `--seed SEED` | Graine du générateur aléatoire | `1` |
|
||||||
| `--round-cap ROUND_CAP` | Plafond de rounds par combat (au-delà, combat nul) | `100` |
|
| `--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é |
|
| `--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 :
|
Codes de sortie :
|
||||||
|
|
||||||
- `0` — simulation terminée, rapport imprimé.
|
- `0` — simulation terminée, rapport imprimé.
|
||||||
|
|||||||
@@ -10,10 +10,13 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import sys
|
import sys
|
||||||
|
import unicodedata
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
from pf1e_simulator.runner import EncounterReport, EncounterSpec
|
from pf1e_simulator.runner import EncounterReport, EncounterSpec
|
||||||
|
|
||||||
from pf1e_simulator.loaders import CombatantLoadError, SheetLoadError, load_combatant
|
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)."""
|
"""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:
|
def _build_spec(args: argparse.Namespace) -> EncounterSpec:
|
||||||
map_spec = load_map(args.map)
|
map_spec = load_map(args.map)
|
||||||
provided = [tuple(raw) for raw in args.side]
|
provided = [tuple(raw) for raw in args.side]
|
||||||
@@ -103,7 +181,7 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--transcript",
|
"--transcript",
|
||||||
action="store_true",
|
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(
|
parser.add_argument(
|
||||||
"--simple-monster-death",
|
"--simple-monster-death",
|
||||||
@@ -139,7 +217,7 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
return 2
|
return 2
|
||||||
if args.transcript and report.transcript is not None:
|
if args.transcript and report.transcript is not None:
|
||||||
print("=== Déroulé du premier combat ===")
|
print("=== Déroulé du premier combat ===")
|
||||||
print("\n".join(report.transcript))
|
print(render_transcript_table(report.transcript))
|
||||||
print()
|
print()
|
||||||
print(format_report(report, spec))
|
print(format_report(report, spec))
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
+7
-1
@@ -80,8 +80,14 @@ def test_cli_transcript_prints_first_battle_log(capsys: pytest.CaptureFixture[st
|
|||||||
out = capsys.readouterr().out
|
out = capsys.readouterr().out
|
||||||
assert rc == 0
|
assert rc == 0
|
||||||
assert "Déroulé du premier combat" in out
|
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 "initiative:" in out
|
||||||
assert "round 1" in out
|
assert "goblin" in out
|
||||||
# The balance report must still be printed after the transcript.
|
# The balance report must still be printed after the transcript.
|
||||||
assert "Rapport d'équilibrage" in out
|
assert "Rapport d'équilibrage" in out
|
||||||
assert out.index("Déroulé du premier combat") < out.index("Rapport d'équilibrage")
|
assert out.index("Déroulé du premier combat") < out.index("Rapport d'équilibrage")
|
||||||
|
|||||||
@@ -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")
|
||||||
Reference in New Issue
Block a user