84673c573d
RULES.it.md translates RULES.md v0.9. edition/build.py renders a printable A4 HTML (edition/it/playtest.html): quick reference, rules, belt track, board overview and four board tiles drawn from the board model (Tunnel pairs numbered, Dojo entrances marked), and the 22 Nebbia cards. Adds markdown as a dev dependency. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
275 lines
11 KiB
Python
275 lines
11 KiB
Python
"""Build the printable Italian playtest edition: `uv run python edition/build.py`.
|
|
|
|
Writes `edition/it/playtest.html` (A4, print from the browser) from `RULES.it.md`, the
|
|
quick reference `edition/it/riepilogo.md`, the card texts `edition/it/carte.toml`, the
|
|
board (`board/quadrant.txt`, `board/legend.toml`) and the engine's deck. Never edit the HTML
|
|
by hand.
|
|
"""
|
|
|
|
import html
|
|
import tomllib
|
|
from collections.abc import Iterator, Mapping
|
|
from pathlib import Path
|
|
|
|
import markdown
|
|
|
|
from dojo.board import BOARD_SIZE, QUADRANT_SIZE, Board, Pos, Square, load_board
|
|
from dojo.grid import Grid
|
|
from dojo.rules import BELTS, DEFAULT_RULES
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
EDITION = ROOT / "edition" / "it"
|
|
OUTPUT = EDITION / "playtest.html"
|
|
TITLE = "Il Labirinto del Dojo"
|
|
EDITION_LABEL = "Bozza per playtest v0.9"
|
|
|
|
TILE_CELL_MM = 23.0
|
|
OVERVIEW_CELL_MM = 11.5
|
|
CARDS_PER_PAGE = 9
|
|
BELT_NAMES = ("Bianca", "Gialla", "Arancione", "Verde", "Blu", "Marrone")
|
|
BELT_COLOURS = ("#ffffff", "#fde047", "#fb923c", "#4ade80", "#60a5fa", "#a16207")
|
|
TILE_NAMES = {
|
|
(0, 0): "in alto a sinistra",
|
|
(0, 1): "in alto a destra",
|
|
(1, 1): "in basso a destra",
|
|
(1, 0): "in basso a sinistra",
|
|
}
|
|
|
|
# Square code -> (fill, name, symbol). Tunnels are added per pair, see tunnel_labels().
|
|
SQUARE_STYLE: Mapping[str, tuple[str, str, str]] = {
|
|
Square.SAFE: ("#dbeafe", "", ""),
|
|
Square.DOJO: ("#44403c", "", ""),
|
|
Square.TATAMI: ("#fbf7ee", "", ""),
|
|
Square.ALLENAMENTO: ("#bbf7d0", "Allenamento", "+1"),
|
|
Square.FATICA: ("#fecaca", "Fatica", "\u22121"),
|
|
Square.NEBBIA: ("#ddd6fe", "Nebbia", "?"),
|
|
Square.PASSO_FALSO: ("#fed7aa", "Passo Falso", "↩"),
|
|
Square.SFIDA: ("#fde68a", "Sfida", "VS"),
|
|
}
|
|
TUNNEL_FILL = "#bae6fd"
|
|
ENTRANCE_COLOUR = "#eab308"
|
|
|
|
|
|
def tunnel_labels(board: Board) -> dict[Pos, int]:
|
|
"""A number per Tunnel pair, unique on the whole board (the source repeats its digits)."""
|
|
labels: dict[Pos, int] = {}
|
|
for square in sorted(board.tunnels):
|
|
if square not in labels:
|
|
number = len(set(labels.values())) + 1
|
|
labels[square] = labels[board.tunnels[square]] = number
|
|
return labels
|
|
|
|
|
|
def board_svg(board: Board, rows: range, cols: range, cell: float) -> str:
|
|
grid = board.to_grid()
|
|
tunnels = tunnel_labels(board)
|
|
width, height = len(cols) * cell, len(rows) * cell
|
|
parts = [
|
|
f'<svg class="board" xmlns="http://www.w3.org/2000/svg" width="{width}mm" '
|
|
f'height="{height}mm" viewBox="0 0 {width} {height}">'
|
|
]
|
|
for r in rows:
|
|
for c in cols:
|
|
x, y = (c - cols.start) * cell, (r - rows.start) * cell
|
|
parts.append(_cell_svg(board.code(Pos(r, c)), tunnels.get(Pos(r, c)), x, y, cell))
|
|
parts.extend(_label_svg(board, rows, cols, cell))
|
|
parts.extend(_walls_svg(grid, rows, cols, cell))
|
|
parts.extend(_entrances_svg(board, rows, cols, cell))
|
|
parts.append("</svg>")
|
|
return "".join(parts)
|
|
|
|
|
|
def _cell_svg(code: str, tunnel: int | None, x: float, y: float, cell: float) -> str:
|
|
if tunnel is not None:
|
|
fill, name, symbol = TUNNEL_FILL, "Tunnel", str(tunnel)
|
|
else:
|
|
fill, name, symbol = SQUARE_STYLE[code]
|
|
parts = [
|
|
f'<rect x="{x}" y="{y}" width="{cell}" height="{cell}" fill="{fill}" '
|
|
f'stroke="#a8a29e" stroke-width="{cell * 0.01}"/>'
|
|
]
|
|
if symbol:
|
|
parts.append(
|
|
f'<text x="{x + cell / 2}" y="{y + cell * 0.62}" font-size="{cell * 0.38}" '
|
|
f'text-anchor="middle" font-weight="700">{html.escape(symbol)}</text>'
|
|
)
|
|
if name:
|
|
parts.append(
|
|
f'<text x="{x + cell / 2}" y="{y + cell * 0.9}" font-size="{cell * 0.14}" '
|
|
f'text-anchor="middle">{html.escape(name)}</text>'
|
|
)
|
|
return "".join(parts)
|
|
|
|
|
|
def _label_svg(board: Board, rows: range, cols: range, cell: float) -> Iterator[str]:
|
|
""" "Stanza Sicura" and "Dojo" written across their rooms, where the room lies in view."""
|
|
for code, text, colour in (
|
|
(Square.SAFE, "Stanza Sicura", "#1e3a8a"),
|
|
(Square.DOJO, "DOJO", "#fafaf9"),
|
|
):
|
|
squares = [p for p in board.cells_with(code) if p.row in rows and p.col in cols]
|
|
for group in _rooms(squares):
|
|
row = sum(p.row for p in group) / len(group) - rows.start + 0.5
|
|
col = sum(p.col for p in group) / len(group) - cols.start + 0.5
|
|
size = cell * (0.3 if code is Square.DOJO else 0.2)
|
|
yield (
|
|
f'<text x="{col * cell}" y="{row * cell + size / 3}" font-size="{size}" '
|
|
f'text-anchor="middle" font-weight="700" fill="{colour}">{text}</text>'
|
|
)
|
|
|
|
|
|
def _rooms(squares: list[Pos]) -> list[list[Pos]]:
|
|
"""Orthogonally connected groups of ``squares``."""
|
|
left, groups = set(squares), []
|
|
while left:
|
|
stack, group = [left.pop()], []
|
|
while stack:
|
|
p = stack.pop()
|
|
group.append(p)
|
|
for q in (
|
|
Pos(p.row + 1, p.col),
|
|
Pos(p.row - 1, p.col),
|
|
Pos(p.row, p.col + 1),
|
|
Pos(p.row, p.col - 1),
|
|
):
|
|
if q in left:
|
|
left.remove(q)
|
|
stack.append(q)
|
|
groups.append(group)
|
|
return groups
|
|
|
|
|
|
def _walls_svg(grid: Grid, rows: range, cols: range, cell: float) -> Iterator[str]:
|
|
stroke = cell * 0.09
|
|
style = f'stroke="#1c1917" stroke-width="{stroke}" stroke-linecap="square"'
|
|
for r in range(rows.start, rows.stop + 1):
|
|
for c in cols:
|
|
if grid.h_walls[r][c]:
|
|
x, y = (c - cols.start) * cell, (r - rows.start) * cell
|
|
yield f'<line x1="{x}" y1="{y}" x2="{x + cell}" y2="{y}" {style}/>'
|
|
for r in rows:
|
|
for c in range(cols.start, cols.stop + 1):
|
|
if grid.v_walls[r][c]:
|
|
x, y = (c - cols.start) * cell, (r - rows.start) * cell
|
|
yield f'<line x1="{x}" y1="{y}" x2="{x}" y2="{y + cell}" {style}/>'
|
|
|
|
|
|
def _entrances_svg(board: Board, rows: range, cols: range, cell: float) -> Iterator[str]:
|
|
"""A gold bar on every open edge into the Dojo, so the entrances stand out."""
|
|
style = f'stroke="{ENTRANCE_COLOUR}" stroke-width="{cell * 0.12}"'
|
|
for square in sorted(board.dojo):
|
|
if square.row not in rows or square.col not in cols:
|
|
continue
|
|
for outside in board.neighbours(square):
|
|
if outside in board.dojo:
|
|
continue
|
|
x = (max(square.col, outside.col) - cols.start) * cell
|
|
y = (max(square.row, outside.row) - rows.start) * cell
|
|
if square.row == outside.row:
|
|
x1, y1, x2, y2 = x, y + cell * 0.2, x, y + cell * 0.8
|
|
else:
|
|
x1, y1, x2, y2 = x + cell * 0.2, y, x + cell * 0.8, y
|
|
yield f'<line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" {style}/>'
|
|
|
|
|
|
def legend_html() -> str:
|
|
items = [
|
|
(fill, name, symbol)
|
|
for code, (fill, name, symbol) in SQUARE_STYLE.items()
|
|
if code not in (Square.SAFE, Square.DOJO, Square.TATAMI)
|
|
]
|
|
items.append((TUNNEL_FILL, "Tunnel (stesso numero)", "3"))
|
|
items.append((ENTRANCE_COLOUR, "Ingresso del Dojo", ""))
|
|
cells = "".join(
|
|
f'<span class="key"><span class="swatch" style="background:{fill}">'
|
|
f"{html.escape(symbol)}</span>{html.escape(name)}</span>"
|
|
for fill, name, symbol in items
|
|
)
|
|
return f'<p class="legend">{cells}</p>'
|
|
|
|
|
|
def track_html() -> str:
|
|
per_belt = DEFAULT_RULES.stripes_per_belt
|
|
rows = []
|
|
for belt in range(len(BELTS)):
|
|
cells = "".join(
|
|
f'<td style="background:{BELT_COLOURS[belt]}">{belt * per_belt + stripe}</td>'
|
|
for stripe in range(per_belt)
|
|
)
|
|
rows.append(f"<tr><th>{BELT_NAMES[belt]}</th>{cells}</tr>")
|
|
rows.append(
|
|
'<tr class="black"><th>Nera</th><td colspan="3">Esame Finale superato: vittoria</td></tr>'
|
|
)
|
|
return (
|
|
'<section class="page"><h2>Scala delle cinture</h2>'
|
|
"<p>Un segnalino per giocatore. Una tacca = una casella; una cintura = una riga. "
|
|
"Dal 15 (Marrone) il Dojo Centrale è aperto.</p>"
|
|
f'<table class="track">{"".join(reversed(rows))}</table></section>'
|
|
)
|
|
|
|
|
|
def cards_html(texts: Mapping[str, Mapping[str, str]]) -> str:
|
|
missing = {card.value for card, _ in DEFAULT_RULES.deck} ^ set(texts)
|
|
if missing:
|
|
raise SystemExit(f"carte.toml and the engine's deck disagree on: {sorted(missing)}")
|
|
cards = [texts[card.value] for card, copies in DEFAULT_RULES.deck for _ in range(copies)]
|
|
pages = []
|
|
for start in range(0, len(cards), CARDS_PER_PAGE):
|
|
faces = "".join(_card_html(card) for card in cards[start : start + CARDS_PER_PAGE])
|
|
pages.append(f'<section class="page cards">{faces}</section>')
|
|
return "".join(pages)
|
|
|
|
|
|
def _card_html(card: Mapping[str, str]) -> str:
|
|
kind = card["tipo"]
|
|
note = '<p class="note">Tienila coperta. Al massimo una.</p>' if kind == "Tecnica" else ""
|
|
return (
|
|
f'<div class="card {html.escape(kind.lower())}"><p class="kind">{html.escape(kind)}</p>'
|
|
f'<h3>{html.escape(card["nome"])}</h3><p class="text">{html.escape(card["testo"])}</p>'
|
|
f'{note}<p class="deck">Nebbia del Dojo</p></div>'
|
|
)
|
|
|
|
|
|
def board_pages(board: Board) -> str:
|
|
whole = range(BOARD_SIZE)
|
|
pages = [
|
|
'<section class="page"><h2>Tabellone intero</h2>'
|
|
f"{board_svg(board, whole, whole, OVERVIEW_CELL_MM)}{legend_html()}"
|
|
"<p>Muri: linee spesse. Barre gialle: ingressi del Dojo (solo per chi è Marrone). "
|
|
"I Tunnel con lo stesso numero sono collegati.</p></section>"
|
|
]
|
|
for (qr, qc), name in TILE_NAMES.items():
|
|
rows = range(qr * QUADRANT_SIZE, (qr + 1) * QUADRANT_SIZE)
|
|
cols = range(qc * QUADRANT_SIZE, (qc + 1) * QUADRANT_SIZE)
|
|
pages.append(
|
|
f'<section class="page tile"><p class="tile-name">Tabellone da gioco, foglio '
|
|
f"{len(pages)} di 4: {name}. Ritaglia lungo il bordo esterno e accosta i fogli."
|
|
f"</p>{board_svg(board, rows, cols, TILE_CELL_MM)}</section>"
|
|
)
|
|
return "".join(pages)
|
|
|
|
|
|
def rules_html(path: Path) -> str:
|
|
return markdown.markdown(path.read_text(encoding="utf-8"), extensions=["tables", "toc"])
|
|
|
|
|
|
def build() -> str:
|
|
board = load_board(ROOT / "board" / "quadrant.txt", ROOT / "board" / "legend.toml")
|
|
with (EDITION / "carte.toml").open("rb") as handle:
|
|
texts = tomllib.load(handle)
|
|
style = (EDITION / "style.css").read_text(encoding="utf-8")
|
|
return (
|
|
'<!doctype html><html lang="it"><head><meta charset="utf-8">'
|
|
f"<title>{TITLE}</title><style>{style}</style></head><body>"
|
|
f'<section class="page cover"><h1>{TITLE}</h1><p class="edition">{EDITION_LABEL}</p>'
|
|
f"{rules_html(EDITION / 'riepilogo.md')}</section>"
|
|
f'<section class="page rules">{rules_html(ROOT / "RULES.it.md")}</section>'
|
|
f"{track_html()}{board_pages(board)}{cards_html(texts)}"
|
|
"</body></html>"
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
OUTPUT.write_text(build(), encoding="utf-8")
|
|
print(f"wrote {OUTPUT.relative_to(ROOT)}")
|