Add the Italian playtest edition (7.2.0, D53)

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>
This commit is contained in:
2026-09-27 19:06:09 +02:00
parent fea6792f25
commit 84673c573d
11 changed files with 1567 additions and 3 deletions
+274
View File
@@ -0,0 +1,274 @@
"""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)}")
+67
View File
@@ -0,0 +1,67 @@
# Testo delle carte Nebbia del Dojo, edizione italiana. Le chiavi sono i nomi delle carte
# nel motore (dojo.rules.Card); le copie vengono dal mazzo del motore (DEFAULT_DECK).
["Kiai"]
tipo = "Tecnica"
nome = "Kiai"
testo = "Gioca dopo i dadi di un Kumite: +2 al tuo totale."
["Uke"]
tipo = "Tecnica"
nome = "Uke (Parata)"
testo = "Gioca dopo i dadi di un Kumite. Se perdi, non torni alla Partenza (le tacche cambiano comunque). Da difensore resti sulla tua casella; da attaccante torni alla casella da cui venivi e il turno finisce."
["Kaeshi"]
tipo = "Tecnica"
nome = "Kaeshi (Contrattacco)"
testo = "Gioca dopo i dadi di un Kumite: ritira il tuo dado."
["Tsugi-ashi"]
tipo = "Movimento"
nome = "Tsugi-ashi"
testo = "Tira 2d6 e fai un altro movimento completo."
["Tunnel Segreto"]
tipo = "Movimento"
nome = "Tunnel Segreto"
testo = "Teletrasportati su un Tunnel a tua scelta. Uscita occupata: Kumite, tu sei l'attaccante."
["Irimi"]
tipo = "Movimento"
nome = "Irimi (Scambio)"
testo = "Scambia il posto con un avversario a tua scelta fuori da una zona sicura. Nessun Kumite, nessun effetto di casella."
["Sfida"]
tipo = "Interazione"
nome = "Sfida"
testo = "Combatti un Kumite contro un avversario a tua scelta fuori da una zona sicura. Tu sei l'attaccante."
["Il Sensei Osserva"]
tipo = "Evento"
nome = "Il Sensei Osserva"
testo = "Il giocatore (o i giocatori) più in basso sulla scala guadagna +2."
["Il Peso della Cintura"]
tipo = "Evento"
nome = "Il Peso della Cintura"
testo = "Il giocatore (o i giocatori) più in alto sulla scala perde 1."
["Allenamento Intensivo"]
tipo = "Evento"
nome = "Allenamento Intensivo"
testo = "+1 tacca."
["Colpo Proibito"]
tipo = "Evento"
nome = "Colpo Proibito"
testo = "−1 tacca."
["Passo Falso"]
tipo = "Evento"
nome = "Passo Falso"
testo = "Torna alla casella da cui è partito questo movimento."
["Meditazione"]
tipo = "Evento"
nome = "Meditazione"
testo = "Salti il prossimo turno. Fino alla fine del turno saltato non puoi essere affrontato né preso di mira."
File diff suppressed because one or more lines are too long
+47
View File
@@ -0,0 +1,47 @@
## Riepilogo
**Scopo:** sali da Bianca a Marrone (15), entra nel Dojo Centrale e supera l'Esame Finale.
### Il turno
1. Tira **2d6** e muovi **esattamente** quei passi: in orizzontale o verticale, mai
attraverso un muro, mai due volte sulla stessa casella nello stesso movimento.
2. **Entri su un avversario → Kumite** (sei l'attaccante). Vinci: lui torna alla Partenza,
tu continui. Perdi: torni alla Partenza e il turno finisce.
3. **Ogni Fatica in cui entri: −1 tacca**, anche se non ti fermi.
4. Fine movimento: applica la casella su cui ti fermi.
**Primo giro:** niente Kumite. Gli avversari si attraversano; se ti fermeresti su uno di
loro, fai un passo in più.
### Kumite
- Entrambi tirano **1d6**. La cintura più alta aggiunge **+1 per cintura di differenza**
(massimo +2).
- Visti i dadi: l'attaccante può giocare una Tecnica, poi il difensore.
- Totale più alto vince. **Pareggio: vince l'attaccante** (Senshu).
| Risultato | Vincitore | Sconfitto |
|---|---|---|
| Cinture uguali | +3 | −3, Partenza |
| Vince la più bassa | +3 | −3, Partenza |
| Vince la più alta | niente | Partenza |
### Caselle
| Casella | Effetto |
|---|---|
| Allenamento | +1 tacca |
| Fatica | −1 tacca per ogni Fatica attraversata o raggiunta camminando |
| Nebbia | pesca una carta (Tecniche coperte, massimo una) |
| Tunnel | puoi spostarti al Tunnel con lo stesso numero (uscita occupata: Kumite) |
| Passo Falso | torna alla casella di partenza del movimento |
| Sfida | Kumite obbligato contro un avversario a scelta |
### Esame Finale
- Solo da **Marrone (15+)**. Entri nel Dojo con almeno 1 passo rimasto; il movimento finisce.
- Difende l'avversario **più alto sulla scala**, e combatte come **Cintura Nera** (+1).
- Vinci: **Cintura Nera, hai vinto**. Perdi: −3 e torni alla Partenza.
**Stanze Sicure:** niente Kumite, niente effetti. La Partenza è sempre la tua stanza.
+245
View File
@@ -0,0 +1,245 @@
@page {
size: A4;
margin: 10mm;
}
:root {
--ink: #1c1917;
--muted: #57534e;
--line: #d6d3d1;
--paper: #ffffff;
--desk: #e7e5e4;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--desk);
color: var(--ink);
font: 10.5pt/1.4 Georgia, "Times New Roman", serif;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
.page {
width: 210mm;
min-height: 297mm;
margin: 8mm auto;
padding: 10mm;
background: var(--paper);
box-shadow: 0 1px 4px rgb(0 0 0 / 0.2);
}
h1 {
margin: 0;
font-size: 24pt;
}
h2 {
margin: 1.2em 0 0.4em;
font-size: 15pt;
border-bottom: 1px solid var(--line);
}
h3 {
margin: 1em 0 0.3em;
font-size: 12pt;
}
.edition {
margin: 0 0 4mm;
color: var(--muted);
font-style: italic;
}
table {
border-collapse: collapse;
margin: 2mm 0 3mm;
width: 100%;
}
th,
td {
border: 1px solid var(--line);
padding: 1mm 2mm;
text-align: left;
vertical-align: top;
}
th {
background: #f5f5f4;
}
ol,
ul {
margin: 1mm 0 2mm;
padding-left: 6mm;
}
.cover {
font-size: 10pt;
}
.cover h2 {
margin-top: 3mm;
}
.board {
display: block;
margin: 0 auto;
font-family: Helvetica, Arial, sans-serif;
}
.legend {
display: flex;
flex-wrap: wrap;
gap: 2mm 5mm;
margin: 4mm 0 2mm;
font-family: Helvetica, Arial, sans-serif;
font-size: 9pt;
}
.key {
display: inline-flex;
align-items: center;
gap: 1.5mm;
}
.swatch {
display: inline-block;
width: 7mm;
height: 7mm;
border: 1px solid #a8a29e;
font-weight: 700;
line-height: 7mm;
text-align: center;
}
.tile-name {
margin: 0 0 4mm;
color: var(--muted);
font-size: 9pt;
}
.track {
width: auto;
margin: 6mm auto;
font-family: Helvetica, Arial, sans-serif;
}
.track th {
width: 32mm;
font-size: 13pt;
vertical-align: middle;
}
.track td {
width: 32mm;
height: 30mm;
border: 2px solid var(--ink);
font-size: 20pt;
font-weight: 700;
text-align: center;
vertical-align: middle;
}
.track .black th,
.track .black td {
background: var(--ink);
color: var(--paper);
font-size: 12pt;
}
.cards {
display: grid;
grid-template-columns: repeat(3, 63mm);
grid-auto-rows: 88mm;
gap: 0;
align-content: start;
justify-content: center;
padding-top: 8mm;
}
.card {
display: flex;
flex-direction: column;
padding: 4mm;
border: 0.3mm dashed #78716c;
font-family: Helvetica, Arial, sans-serif;
}
.card .kind {
margin: 0;
color: var(--muted);
font-size: 8pt;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.card h3 {
margin: 1mm 0 3mm;
font-size: 13pt;
}
.card .text {
flex: 1;
margin: 0;
font-size: 10pt;
}
.card .note {
margin: 0 0 2mm;
font-size: 8pt;
font-style: italic;
}
.card .deck {
margin: 0;
padding-top: 1.5mm;
border-top: 1px solid var(--line);
color: var(--muted);
font-size: 7.5pt;
text-align: center;
}
.card.tecnica {
background: #eef2ff;
}
.card.movimento {
background: #ecfeff;
}
.card.interazione {
background: #fff7ed;
}
.card.evento {
background: #f7fee7;
}
@media print {
body {
background: none;
}
.page {
width: auto;
min-height: 0;
margin: 0;
padding: 0;
box-shadow: none;
break-after: page;
}
.page:last-child {
break-after: auto;
}
.cards {
padding-top: 4mm;
}
}