From 9c6f5e277e16cdd0068a0852e0237233c1bc7da8 Mon Sep 17 00:00:00 2001 From: ctan Date: Fri, 11 Sep 2026 13:02:31 +0200 Subject: [PATCH] feat(cli): render --transcript output as a table --- README.md | 25 +++- src/pf1e_simulator/cli.py | 82 ++++++++++- tests/test_cli.py | 8 +- tests/test_conformance_transcript_table.py | 155 +++++++++++++++++++++ 4 files changed, 266 insertions(+), 4 deletions(-) create mode 100644 tests/test_conformance_transcript_table.py diff --git a/README.md b/README.md index b1c39e3..9910ac7 100644 --- a/README.md +++ b/README.md @@ -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é. diff --git a/src/pf1e_simulator/cli.py b/src/pf1e_simulator/cli.py index 959f114..90738aa 100644 --- a/src/pf1e_simulator/cli.py +++ b/src/pf1e_simulator/cli.py @@ -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 diff --git a/tests/test_cli.py b/tests/test_cli.py index 06a0edb..3236ee3 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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") diff --git a/tests/test_conformance_transcript_table.py b/tests/test_conformance_transcript_table.py new file mode 100644 index 0000000..c2dab1f --- /dev/null +++ b/tests/test_conformance_transcript_table.py @@ -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")