Compare commits

...

10 Commits

Author SHA1 Message Date
eisterman 84673c573d 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>
2026-09-27 19:06:09 +02:00
eisterman fea6792f25 Record the final 6.8 run with the fixed bots; Phase 6 complete
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 18:37:10 +02:00
eisterman 79cbbccbb2 Fix the bots' corner bias; settle seat order and RULES v0.9 (6.2, 6.8)
Bots now pick their start square and break path ties in their own
corner's frame; the absolute tie-break made corners 1 and 2 win 25-28%.
With that fixed, seat order is within target at 4 players, and a +1
stripe first-player bonus (new switch) gains only ~1 point, so no
compensation. RULES.md v0.9 lists the tested alternatives; the plan has
the 6.8 consolidation run and the seat-order study.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 18:27:06 +02:00
eisterman 52d8af620b Add failed-Exam loss switch; keep -3 and accept the length (6.11, D50)
RulesConfig.exam_failure_loss (bots aware) with the sim axis --exam-loss.
Losing 1 or 0 stripes saves 3-5 minutes but weakens comebacks, so the
rules keep -3. Default games are unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 17:51:13 +02:00
eisterman 542ac1b15f Park the chance square and close 6.4 (D49); add 6.11 length tuning
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 17:39:59 +02:00
eisterman f67514b57a Record 6.7 player counts; 6+ players start on Yellow (D48)
RULES.md: 6+ players start on 3, and any count may start on 3 for a
shorter game. Balancing simulations keep start 0.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 17:35:18 +02:00
eisterman 23ae7a2572 Add Kiai-decided metrics; keep Kiai at +2 (6.6, D47)
Report how often a Tecnica is played and how often Kiai decides a
Kumite or an Exam fight. The tie rule moves to RulesConfig.attacker_wins,
shared by the engine and the metric; games are unchanged. RULES.md drops
the Kiai +1 and catch-up variants from the rules under test.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 17:27:37 +02:00
eisterman f0e112590a Record 6.3 style dynamics results
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 17:23:28 +02:00
eisterman 0ca905eb71 Speed up the simulator 5-7x with identical games (6.10)
Precompute board adjacency and square sets, look up opponents once per
move, and cache walks that exclude only the start square. Every game is
unchanged for the same seed, checked with agents/scripts/fingerprint_games.py.
Plan: skip catch-up 6.9 (D44), stop running the random lineup (D45).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 17:02:53 +02:00
eisterman bbc95b6722 Rules v0.8: Fatica on crossing, no Kumite in round 1, board draft 2
- Fatica costs a stripe for every Fatica walked onto; a route whose Fatica
  drops a Brown player below 15 no longer enters the Dojo
- No Kumite in the first round: opponents are treated like meditating pawns
- Board draft 2: two Fatica tolls per quadrant, Allenamento moved to the ring
- Casual bot, --summary-csv, per-seat breakdown and move-options metric
- Out of contention measured at 3 belts
- Plan: Phase 6 baselines, decisions D34-D43

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 15:59:45 +02:00
32 changed files with 2755 additions and 131 deletions
+19 -1
View File
@@ -6,6 +6,7 @@ balance it.
| Document | What it is | | Document | What it is |
|---|---| |---|---|
| [`RULES.md`](RULES.md) | The rulebook (English working draft). | | [`RULES.md`](RULES.md) | The rulebook (English working draft). |
| [`RULES.it.md`](RULES.it.md) | Italian rulebook (playtest edition). |
| [`board/FORMAT.md`](board/FORMAT.md) | How the board is written as text. | | [`board/FORMAT.md`](board/FORMAT.md) | How the board is written as text. |
| [`agents/plans/`](agents/plans/) | Active work plan: decisions, phases, open questions. | | [`agents/plans/`](agents/plans/) | Active work plan: decisions, phases, open questions. |
@@ -36,20 +37,37 @@ uv run dojo board validate --quadrant other.txt --legend other.toml # check an
Plays many seeded games with bots and prints metrics per scenario. Every combination of the Plays many seeded games with bots and prints metrics per scenario. Every combination of the
listed values is a scenario; game `i` of each scenario uses seed `--seed + i`, and the lineup listed values is a scenario; game `i` of each scenario uses seed `--seed + i`, and the lineup
rotates one seat per game. Bots: `random`, `balanced`, `farmer`, `aggressive`. rotates one seat per game. Bots: `random`, `casual`, `balanced`, `farmer`, `aggressive`
(`casual` is `balanced` comparing only 6 random moves of those offered, like a person).
```sh ```sh
uv run dojo sim # 4 players, current rules, 200 games uv run dojo sim # 4 players, current rules, 200 games
uv run dojo sim --games 1000 --csv games.csv # more games; one CSV row per game uv run dojo sim --games 1000 --csv games.csv # more games; one CSV row per game
uv run dojo sim --lineup casual balanced --summary-csv s.csv # one CSV row per scenario
uv run dojo sim --exam c none a ac # Final Exam options in force (rules: c), combinable uv run dojo sim --exam c none a ac # Final Exam options in force (rules: c), combinable
uv run dojo sim --players 3 4 6 8 --start 0 3 # player counts x start position uv run dojo sim --players 3 4 6 8 --start 0 3 # player counts x start position
uv run dojo sim --kiai 2 1 # Kiai +2 vs +1 uv run dojo sim --kiai 2 1 # Kiai +2 vs +1
uv run dojo sim --exam-loss 3 1 0 # stripes a failed Exam challenger loses (rules: 3)
uv run dojo sim --lineup farmer aggressive,farmer # several lineups (comma-separated seats) uv run dojo sim --lineup farmer aggressive,farmer # several lineups (comma-separated seats)
uv run dojo sim --seed 42 --jobs 1 # other dice; single process uv run dojo sim --seed 42 --jobs 1 # other dice; single process
``` ```
Minutes are estimated as 45 s per turn plus 30 s per Kumite. Minutes are estimated as 45 s per turn plus 30 s per Kumite.
## Italian playtest edition
`edition/build.py` builds `edition/it/playtest.html`: quick reference, full rules, belt track,
the board (one A4 overview and four A4 tiles to cut and join), and the 22 Nebbia cards to cut
out. Open it in a browser and print on A4 at 100% scale (no "fit to page"), with background
graphics on.
```sh
uv run python edition/build.py
```
Sources: `RULES.it.md`, `edition/it/riepilogo.md` (quick reference), `edition/it/carte.toml`
(card texts), `edition/it/style.css`, and the board files. Never edit the HTML by hand.
## Development ## Development
```sh ```sh
+219
View File
@@ -0,0 +1,219 @@
# Il Labirinto del Dojo — Regolamento
*Bozza per playtest v0.9, tradotta dal regolamento inglese (RULES.md) v0.9.*
## Scopo del gioco
Si parte cintura Bianca, si sale fino alla cintura Marrone, si raggiunge il Dojo Centrale e
si supera l'Esame Finale. Il primo giocatore che indossa la Cintura Nera vince.
## Componenti
- Tabellone: griglia 16×16 con muri, stanze e tunnel; 4 Stanze Sicure negli angoli; il Dojo
Centrale (4×4, al centro).
- Fino a 8 pedine.
- La scala delle cinture, con un segnalino per giocatore.
- 2 dadi a sei facce (d6).
- Mazzo Nebbia del Dojo (22 carte).
- Carta riepilogo con gli effetti delle caselle.
## La scala delle cinture
Ogni giocatore ha una posizione da **0 a 17**. La tua cintura è il tratto in cui ti trovi:
| Posizione | Cintura |
|---|---|
| 0–2 | Bianca |
| 3–5 | Gialla |
| 6–8 | Arancione |
| 9–11 | Verde |
| 12–14 | Blu |
| 15–17 | Marrone |
- Una **tacca** = 1 passo sulla scala. Una **cintura** = 3 tacche.
- La posizione non scende mai sotto 0 e non sale mai oltre 17.
- Chi è sotto 15 non è Marrone, qualunque cintura avesse prima.
## Preparazione
- 3–4 giocatori: una pedina per Stanza Sicura d'angolo.
- 5–8 giocatori: due pedine per Stanza Sicura. Chi divide la stessa stanza non gioca mai
subito dopo l'altro.
- Tutti partono dalla posizione 0 (Bianca). Con 6 o più giocatori, tutti partono invece
da 3 (Gialla).
- *Partita più breve:* con qualunque numero di giocatori potete partire tutti da 3 (Gialla).
Si risparmia circa un quinto del tempo di gioco.
- Sorteggiate il primo giocatore; si gioca in senso orario.
## Il turno
1. Tira **2d6**.
2. Muovi **esattamente** quel numero di passi:
- solo in orizzontale o in verticale;
- mai attraverso un muro;
- mai rientrando in una casella già visitata durante questo movimento.
Se non esiste un movimento del numero intero, muovi il più possibile. (Il tabellone è
disegnato senza vicoli ciechi, quindi non dovrebbe mai succedere: è solo una garanzia.)
3. **Entrare in una casella occupata da un avversario avvia un Kumite** (tu sei
l'attaccante). Vale anche per l'ultimo passo. Uscire da una casella non avvia mai un
Kumite.
- Se vinci, il difensore torna alla Partenza e tu continui con i passi che ti restano.
Puoi affrontare più avversari nello stesso movimento (catena).
- Se perdi, torni alla Partenza e il tuo turno finisce.
4. **Ogni casella Fatica in cui entri costa 1 tacca**, compreso l'ultimo passo, prima di un
eventuale Kumite su quella casella. Fermarti lì non costa altro.
5. Quando il movimento finisce, applica l'effetto della casella su cui ti sei fermato.
## Zone sicure
**Stanze Sicure d'angolo** (3×3, due uscite su lati diversi):
- Dentro non si combatte nessun Kumite.
- Nessun effetto di casella (sono tutte Tatami).
- Più pedine possono stare sulla stessa casella.
- Le pedine dentro non possono essere bersaglio di carte o di effetti delle caselle.
- Ogni giocatore può entrare in qualunque Stanza Sicura.
- "Tornare alla Partenza" significa sempre tornare nella **tua** Stanza Sicura. Metti la
pedina sulla casella della stanza che preferisci, sia all'inizio sia quando ci torni.
**Dojo Centrale**: nessuna pedina vi resta mai dentro (vedi l'Esame Finale).
## Kumite
**Niente Kumite nel primo giro.** Finché tutti i giocatori non hanno fatto il loro primo
turno, gli avversari si trattano come pedine in meditazione (vedi
[Meditazione](#meditazione)): li attraversi, un movimento che finirebbe su uno di loro fa un
passo in più, non puoi usare un Tunnel se la sua uscita è occupata da loro, e la Sfida non
ha bersagli. Irimi (Scambio) funziona comunque: non è un Kumite.
1. Entrambi i giocatori tirano **1d6**.
2. La cintura più alta aggiunge **+1 per ogni cintura di differenza, al massimo +2**.
3. Dopo aver tirato entrambi i dadi, l'attaccante può giocare una carta Tecnica, poi il
difensore può giocarne una. Ciascuno decide vedendo entrambi i dadi; il difensore vede
anche la carta dell'attaccante. Una Tecnica si gioca solo in questo momento (anche Uke:
la giochi prima di conoscere il risultato).
4. Vince il totale più alto.
5. **Regola del Senshu**: in caso di pareggio vince l'attaccante.
### Conseguenze
| Risultato | Vincitore | Sconfitto |
|---|---|---|
| Cinture uguali | +3 | −3, torna alla Partenza |
| Vince la cintura più bassa | +3 | −3, torna alla Partenza |
| Vince la cintura più alta | niente | torna alla Partenza |
## Le caselle
| Casella | Effetto quando ti fermi |
|---|---|
| Tatami | Niente. |
| Allenamento | +1 tacca. |
| Fatica | −1 tacca per **ogni** Fatica in cui entri camminando, che tu ti fermi o no (vedi [Il turno](#il-turno)); fermarti non costa altro. Essere spostato su una Fatica senza camminare (Tunnel, Passo Falso, Irimi, ritirata con Uke, Partenza) non costa niente. |
| Nebbia del Dojo | Pesca una carta. La casella resta attiva. Quando il mazzo finisce, rimescola gli scarti. |
| Tunnel | **Puoi** teletrasportarti al Tunnel con lo stesso numero. Il movimento è già finito; la casella d'uscita non ha effetto. Se l'uscita è occupata, inizia un Kumite (tu sei l'attaccante). Non puoi usare un'uscita occupata da una pedina in meditazione. |
| Passo Falso | Torna alla casella da cui è partito questo movimento. I Kumite già combattuti durante il movimento restano validi. |
| Sfida | **Devi** combattere un Kumite contro un avversario a tua scelta fuori da una zona sicura (tu sei l'attaccante). Se non ce n'è nessuno, non succede niente. |
## Meditazione
Una pedina in meditazione non può essere affrontata né presa di mira. Le altre pedine la
attraversano liberamente. Se il tuo movimento finisce esattamente sulla sua casella, fai un
passo in più seguendo le normali regole di movimento (può avviare un Kumite); si applica
l'effetto della casella su cui ti fermi alla fine. Se nessun passo in più è possibile,
resti dove sei.
Lo stesso passo in più si applica ogni volta che il tuo movimento finirebbe su una casella
(fuori dalle Stanze Sicure) con una pedina che non puoi affrontare: per esempio un
difensore che ha perso ma è stato salvato da Uke.
## Il mazzo Nebbia del Dojo
Le carte Nebbia sono imprevisti, non regali: **ogni carta va applicata**, anche quando ti
danneggia. Se una carta richiede un bersaglio e non ce n'è nessuno, non succede niente.
**Le carte Tecnica sono segrete:**
- Quando peschi una Tecnica, non mostrarla: tienila coperta davanti a te.
- Tutti possono vedere *che* ne hai una, non *quale*.
- Ne tieni **al massimo una**. Quando ne peschi una seconda, tienine una e scarta l'altra
**coperta**, così nessuno sa quale hai tenuto.
- Una Tecnica si rivela solo quando la giochi; poi va negli scarti.
Tutte le altre carte si mostrano e si applicano subito.
| Tipo | Carta | Effetto | Copie |
|---|---|---|---|
| Tecnica | Kiai | +2 al tuo totale nel Kumite. | 3 |
| Tecnica | Uke (Parata) | Se perdi, non torni alla Partenza (le tacche cambiano comunque). Un difensore sconfitto resta sulla sua casella; un attaccante sconfitto torna alla casella da cui veniva, e il suo turno finisce senza effetto di casella. | 2 |
| Tecnica | Kaeshi (Contrattacco) | Ritira il tuo dado. | 2 |
| Movimento | Tsugi-ashi | Tira 2d6 e fai un altro movimento completo. Quel movimento ha una sua casella di partenza (per il Passo Falso). | 2 |
| Movimento | Tunnel Segreto | Teletrasportati su un Tunnel a tua scelta (uscita occupata: Kumite, tu sei l'attaccante). Non puoi scegliere un Tunnel occupato da una pedina in meditazione. | 1 |
| Movimento | Irimi (Scambio) | Scambia il posto con un avversario a tua scelta fuori da una zona sicura. Nessun Kumite, nessun effetto di casella. | 2 |
| Interazione | Sfida | Combatti un Kumite contro un avversario a tua scelta fuori da una zona sicura. Tu sei l'attaccante. | 2 |
| Evento | Il Sensei Osserva | Il giocatore (o i giocatori) più in basso sulla scala guadagna +2. | 1 |
| Evento | Il Peso della Cintura | Il giocatore (o i giocatori) più in alto sulla scala perde 1. | 1 |
| Evento | Allenamento Intensivo | +1 tacca. | 2 |
| Evento | Colpo Proibito | −1 tacca. | 1 |
| Evento | Passo Falso | Torna alla casella da cui è partito questo movimento. | 2 |
| Evento | Meditazione | Salti il prossimo turno. Fino alla fine del turno saltato non puoi essere affrontato né preso di mira. | 1 |
## Il Dojo Centrale e l'Esame Finale
- Solo un giocatore Marrone (posizione 15 o più) può entrare. Per tutti gli altri il Dojo è
un muro.
- Entrare costa un passo e non serve il numero esatto: raggiungi una casella accanto a un
ingresso con almeno 1 passo rimasto ed entra. Il movimento finisce subito.
- Puoi entrare nello stesso movimento in cui sei diventato Marrone vincendo un Kumite.
- La cintura cambia subito anche al contrario: se una Fatica attraversata durante il
movimento ti porta sotto 15, il Dojo è chiuso per il resto di quel movimento.
- L'Esame Finale inizia subito. Difende **l'avversario più in alto sulla scala** (in caso
di parità, lo sfidante sceglie tra loro). La pedina del difensore non si muove. Chi è in
meditazione non può difendere; se nessuno può, torni alla casella da cui sei entrato e in
questo turno non c'è Esame.
- L'Esame è un Kumite con lo sfidante come attaccante; valgono il Senshu e le carte Tecnica.
- **Il difensore combatte come Cintura Nera**, qualunque sia la sua cintura: lo sfidante
Marrone è una cintura sotto, quindi il difensore aggiunge +1.
- **Vince lo sfidante**: indossa la Cintura Nera e vince la partita.
- **Perde lo sfidante**: −3 e torna alla Partenza. Il difensore non guadagna niente.
- Se lo sfidante gioca Uke e perde, torna alla casella da cui è entrato invece che alla
Partenza.
## Domande frequenti
**Quali effetti posso rifiutare?**
Solo la casella Tunnel ("puoi teletrasportarti"). Tutto il resto, caselle e carte Nebbia,
va applicato, comprese Sfida, Irimi e Tunnel Segreto.
**Sono stato spostato senza camminare (Passo Falso, Irimi, ritirata con Uke). Questo avvia
un Kumite o l'effetto della casella in cui arrivo?**
No in entrambi i casi (una Fatica costa una tacca solo quando ci entri camminando). Solo
arrivare con un Tunnel o con il Tunnel Segreto avvia un Kumite su una casella occupata, e
anche in quel caso la casella d'uscita non ha effetto.
**Sono diventato Marrone vincendo un Kumite a metà movimento. Posso entrare nel Dojo con i
passi che mi restano?**
Sì. La cintura cambia subito, quindi il Dojo si apre per il resto di quel movimento.
**Sono a 15 e la mia strada verso il Dojo passa su una Fatica. Posso prenderla?**
No: la Fatica ti porta a 14 prima dell'ingresso, e il Dojo si chiude. Prendi una strada
senza Fatica, oppure guadagna prima una tacca.
**Il mio movimento finirebbe su una pedina che non posso affrontare. Che faccio?**
Fai un passo in più (vedi [Meditazione](#meditazione)). Se non è possibile, resti dove sei.
**Sono bloccato e non posso muovere il numero intero.**
Muovi il più possibile. Su un tabellone ben disegnato non dovrebbe mai succedere.
**Uke mi ha salvato da attaccante. Applico l'effetto della casella in cui torno?**
No. Perdere un Kumite fa sempre finire il turno.
**Ho pescato Tsugi-ashi e poi sono finito su un Passo Falso nel movimento in più. Dove
torno?**
Alla casella da cui è partito il movimento in più, non a quella da cui è iniziato il turno.
**Gli avversari possono sapere quale Tecnica ho?**
No. Sanno solo se ne hai una. Vedi [Il mazzo Nebbia del Dojo](#il-mazzo-nebbia-del-dojo).
+34 -14
View File
@@ -1,9 +1,9 @@
# Il Labirinto del Dojo — Rules (draft v0.7) # Il Labirinto del Dojo — Rules (draft v0.9)
Working language: English. The final gift edition will be produced separately in Italian. Working language: English. The final gift edition will be produced separately in Italian.
Items marked **[VARIANT]** are unresolved and will be decided by simulation (see Every rule has been checked by simulation; alternatives that were tried and set aside are
[Rule variants under test](#rule-variants-under-test)). listed under [Tested alternatives](#tested-alternatives).
## Goal ## Goal
@@ -40,8 +40,10 @@ Each player has a position from **0 to 17**. Your belt is the segment you are in
- 3–4 players: one pawn per corner Safe Room. - 3–4 players: one pawn per corner Safe Room.
- 5–8 players: two pawns per corner Safe Room. Players sharing a corner are never consecutive in turn order. - 5–8 players: two pawns per corner Safe Room. Players sharing a corner are never consecutive in turn order.
- Everyone starts on track position 0. - Everyone starts on track position 0 (White). With 6 or more players, everyone starts on
*Variant to test with 6+ players: start on 3 (Yellow).* 3 (Yellow) instead.
- *Shorter game:* with any number of players you may all start on 3 (Yellow). It saves about
a fifth of the playing time.
- Choose the first player at random; play proceeds clockwise. - Choose the first player at random; play proceeds clockwise.
## Turn ## Turn
@@ -59,7 +61,9 @@ Each player has a position from **0 to 17**. Your belt is the segment you are in
- If you win, the defender is sent to Start and you continue with your remaining steps. - If you win, the defender is sent to Start and you continue with your remaining steps.
You may fight several opponents in a single move (chain). You may fight several opponents in a single move (chain).
- If you lose, you are sent to Start and your turn ends. - If you lose, you are sent to Start and your turn ends.
4. When movement ends, resolve the effect of the square you stopped on. 4. **Every Fatica square you step onto costs 1 stripe**, the last step included, before any
Kumite on that square. Stopping on it costs nothing more.
5. When movement ends, resolve the effect of the square you stopped on.
## Safe areas ## Safe areas
@@ -77,6 +81,11 @@ Each player has a position from **0 to 17**. Your belt is the segment you are in
## Kumite ## Kumite
**No Kumite in the first round.** Until every player has had a turn, opponents are treated
like meditating pawns (see [Meditazione](#meditazione)): you pass through them, a move that
would end on one takes an extra step, their Tunnel exit cannot be used, and Sfida has no
target. Irimi (scambio) still works: it is not a Kumite.
1. Both players roll **1d6**. 1. Both players roll **1d6**.
2. The higher belt adds **+1 per belt of difference, maximum +2**. 2. The higher belt adds **+1 per belt of difference, maximum +2**.
3. After both dice are rolled, the attacker may play one Tecnica card, then the defender may 3. After both dice are rolled, the attacker may play one Tecnica card, then the defender may
@@ -99,7 +108,7 @@ Each player has a position from **0 to 17**. Your belt is the segment you are in
|---|---|---| |---|---|---|
| `.` | Tatami | Nothing. | | `.` | Tatami | Nothing. |
| `A` | Allenamento | +1 stripe. | | `A` | Allenamento | +1 stripe. |
| `F` | Fatica | −1 stripe. | | `F` | Fatica | −1 stripe for **every** Fatica you step onto while walking, whether you stop there or not (see [Turn](#turn)); nothing more when you stop. Being moved onto it without walking (Tunnel, Passo Falso, Irimi, Uke retreat, Start) costs nothing. |
| `?` | Nebbia del Dojo | Draw a card. The square stays active. Reshuffle the discard pile when the deck is empty. | | `?` | Nebbia del Dojo | Draw a card. The square stays active. Reshuffle the discard pile when the deck is empty. |
| `T` | Tunnel | You **may** teleport to the paired Tunnel. Movement is already over; the exit square has no effect. If the exit is occupied, a Kumite starts (you are the attacker). An exit holding a meditating pawn cannot be used. | | `T` | Tunnel | You **may** teleport to the paired Tunnel. Movement is already over; the exit square has no effect. If the exit is occupied, a Kumite starts (you are the attacker). An exit holding a meditating pawn cannot be used. |
| `P` | Passo Falso | Return to the square where this move started. Kumite already fought during the move stand. | | `P` | Passo Falso | Return to the square where this move started. Kumite already fought during the move stand. |
@@ -132,7 +141,7 @@ All other cards are shown and resolved immediately.
| Type | Card | Effect | Qty | | Type | Card | Effect | Qty |
|---|---|---|---| |---|---|---|---|
| Tecnica | Kiai | +2 to your Kumite total. *(+1 to be tested)* | 3 | | Tecnica | Kiai | +2 to your Kumite total. | 3 |
| Tecnica | Uke (Parata) | If you lose, you are not sent to Start (the track change still applies). A losing defender stays on its square; a losing attacker returns to the square it came from, and its turn ends with no square effect. | 2 | | Tecnica | Uke (Parata) | If you lose, you are not sent to Start (the track change still applies). A losing defender stays on its square; a losing attacker returns to the square it came from, and its turn ends with no square effect. | 2 |
| Tecnica | Kaeshi (Contrattacco) | Reroll your die. | 2 | | Tecnica | Kaeshi (Contrattacco) | Reroll your die. | 2 |
| Movement | Tsugi-ashi | Roll 2d6 and make another full move. That move has its own starting square (for Passo Falso). | 2 | | Movement | Tsugi-ashi | Roll 2d6 and make another full move. That move has its own starting square (for Passo Falso). | 2 |
@@ -152,6 +161,8 @@ All other cards are shown and resolved immediately.
- Entering costs one step and needs no exact count: reach a square adjacent to an entrance - Entering costs one step and needs no exact count: reach a square adjacent to an entrance
with at least 1 step left and step in. Movement ends immediately. with at least 1 step left and step in. Movement ends immediately.
- You may enter in the same move in which you became Brown by winning a Kumite. - You may enter in the same move in which you became Brown by winning a Kumite.
- Your belt changes at once the other way too: if a Fatica walked during the move takes you
below 15, the Dojo is closed for the rest of that move.
- The Final Exam starts at once. The **opponent highest on the track** defends - The Final Exam starts at once. The **opponent highest on the track** defends
(tie: the challenger chooses among them). The defender's pawn does not move. (tie: the challenger chooses among them). The defender's pawn does not move.
Meditating players cannot defend; if nobody can, you return to the square you entered Meditating players cannot defend; if nobody can, you return to the square you entered
@@ -163,12 +174,16 @@ All other cards are shown and resolved immediately.
- **Challenger loses**: −3 and sent to Start. The defender gains nothing. - **Challenger loses**: −3 and sent to Start. The defender gains nothing.
- If the challenger plays Uke and loses, they return to the square they entered from instead of Start. - If the challenger plays Uke and loses, they return to the square they entered from instead of Start.
## Rule variants under test ## Tested alternatives
Parameters to validate by simulation: Kiai +2 vs +1; starting on 3 with 6+ players; Tried in simulation and set aside; the simulator keeps them as switches:
square mix; game length at 3/4/6/8 players.
Tested and set aside (simulator switches only): Exam ties to the defender; best-of-3 Exam. - Final Exam: ties to the defender; best of 3; the defender fighting with their own belt.
- Kiai +1 instead of +2.
- A failed Exam costing 0 or 1 stripe instead of 3.
- Fatica costing a stripe only when you stop on it.
- Kumite allowed in the first round.
- One extra starting stripe for the first player.
## FAQ ## FAQ
@@ -178,13 +193,18 @@ must be resolved, including Sfida, Irimi and Tunnel Segreto.
**I was moved without walking (Passo Falso, Irimi, Uke retreat). Does that start a Kumite **I was moved without walking (Passo Falso, Irimi, Uke retreat). Does that start a Kumite
or trigger the square I land on?** or trigger the square I land on?**
No to both. Only arriving through a Tunnel or Tunnel Segreto starts a Kumite on an occupied No to both (a Fatica costs a stripe only when you walk onto it). Only arriving through a
square, and even then the exit square has no effect. Tunnel or Tunnel Segreto starts a Kumite on an occupied square, and even then the exit square
has no effect.
**I became Brown by winning a Kumite in the middle of my move. Can I enter the Dojo with **I became Brown by winning a Kumite in the middle of my move. Can I enter the Dojo with
the steps I have left?** the steps I have left?**
Yes. Your belt changes immediately, so the Dojo opens for the rest of that move. Yes. Your belt changes immediately, so the Dojo opens for the rest of that move.
**I am on 15 and my route into the Dojo crosses a Fatica. Can I take it?**
No: the Fatica drops you to 14 before you reach the entrance, and the Dojo closes. Take a
route without Fatica, or gain a stripe first.
**My move would end on a pawn I cannot fight. What now?** **My move would end on a pawn I cannot fight. What now?**
Take one extra step (see [Meditazione](#meditazione)). If none is possible, stay. Take one extra step (see [Meditazione](#meditazione)). If none is possible, stay.
+304 -22
View File
@@ -26,7 +26,7 @@ Deliverables:
| D7 | Corner Safe Rooms: 3×3, 2 exits, no Kumite/effects/targeting, stacking allowed | Prevents spawn camping / spawn lock. | | D7 | Corner Safe Rooms: 3×3, 2 exits, no Kumite/effects/targeting, stacking allowed | Prevents spawn camping / spawn lock. |
| D8 | Movement 2d6, exact count, orthogonal, no re-entering a square in the same move | Developer requirement + clarity. | | D8 | Movement 2d6, exact count, orthogonal, no re-entering a square in the same move | Developer requirement + clarity. |
| D9 | Most squares carry an effect from a small vocabulary (7 codes); ~30–40% Tatami | Developer requirement; limits reading load and analysis paralysis. | | D9 | Most squares carry an effect from a small vocabulary (7 codes); ~30–40% Tatami | Developer requirement; limits reading load and analysis paralysis. |
| D10 | Effects trigger only when stopping on a square | Avoids multiple triggers per move. | | D10 | Effects trigger only when stopping on a square (except Fatica since D36) | Avoids multiple triggers per move. |
| D11 | Nebbia works like Monopoly Chance: the square stays active | Developer requirement. | | D11 | Nebbia works like Monopoly Chance: the square stays active | Developer requirement. |
| D12 | Progress track 0–17, 3 stripes per belt; squares ±1, Kumite ±3 | Fine-grained board income without breaking pacing. Physical tracking method decided later. | | D12 | Progress track 0–17, 3 stripes per belt; squares ±1, Kumite ±3 | Fine-grained board income without breaking pacing. Physical tracking method decided later. |
| D13 | Nebbia deck is mostly movement/interaction/Tecniche, very few flat stripes | Prevents "stripe farming" that would make players avoid fights. | | D13 | Nebbia deck is mostly movement/interaction/Tecniche, very few flat stripes | Prevents "stripe farming" that would make players avoid fights. |
@@ -50,6 +50,26 @@ Deliverables:
| D31 | Final Exam rule: **the defender fights as a Black belt** (option c; RULES.md v0.7). Options a (ties to the defender) and d (best of 3) stay as simulator switches only; `--exam` lists the options in force and defaults to `c` | c gave a 44% pass rate, more distinct challengers (1.58/game) and closer endings; d alone raised the pass rate to 91%. | | D31 | Final Exam rule: **the defender fights as a Black belt** (option c; RULES.md v0.7). Options a (ties to the defender) and d (best of 3) stay as simulator switches only; `--exam` lists the options in force and defaults to `c` | c gave a 44% pass rate, more distinct challengers (1.58/game) and closer endings; d alone raised the pass rate to 91%. |
| D32 | Phase 6 targets confirmed by the developer as a starting point (table in Phase 6) | Q5. | | D32 | Phase 6 targets confirmed by the developer as a starting point (table in Phase 6) | Q5. |
| D33 | Fatica fix: test **option 2 first** — a chance square (stop on it, roll 1d6: 1–2 lose a stripe, 3–6 gain one) replacing Fatica and some Allenamento; then **option 1 must also be tested** — Fatica costs a stripe when walked through as well as when stopped on (breaks D10 for that square only). Options 3 (fewer paths: board rework) and 4 (fewer / once-per-room Allenamento) kept in reserve. The casual bot (6.0.1) is built regardless | Q6. Any on-stop penalty is avoidable when a move reaches ~24 end squares; the chance outcome cannot be dodged. Developer likes 1 and 2. | | D33 | Fatica fix: test **option 2 first** — a chance square (stop on it, roll 1d6: 1–2 lose a stripe, 3–6 gain one) replacing Fatica and some Allenamento; then **option 1 must also be tested** — Fatica costs a stripe when walked through as well as when stopped on (breaks D10 for that square only). Options 3 (fewer paths: board rework) and 4 (fewer / once-per-room Allenamento) kept in reserve. The casual bot (6.0.1) is built regardless | Q6. Any on-stop penalty is avoidable when a move reaches ~24 end squares; the chance outcome cannot be dodged. Developer likes 1 and 2. |
| D34 | The "games finished within 200 rounds: 100%" target does not apply to the `random` lineup (it is a floor, not a player: 94% finished, 115 rounds on average) | 6.1 review. |
| D35 | **No Kumite in round 1**: opponents are treated like meditating pawns (pass through, extra step if the move would end on one, no Tunnel exit onto them, Sfida has no target; Irimi still works). `RulesConfig.first_kumite_round = 2`. RULES.md v0.8 | 6.1 review, finding 3: later seats find opponents already outside their Safe Rooms and early equal-belt Kumite are nearly free. |
| D36 | **Fatica on crossing is the baseline**: every Fatica square stepped onto while walking costs 1 stripe (last step and Meditazione extra step included, before any Kumite on it); stopping adds nothing; teleports and relocations cost nothing. `RulesConfig.fatica_on_crossing = True`. Supersedes the order in D33 (the chance square, option 2, remains a candidate). Paths to the same end square are offered separately when they cross Fatica differently. RULES.md v0.8 | 6.1 review, finding 4. |
| D37 | Out of contention = more than **3 belts** (9 stripes) behind the leader (`metrics.CONTENTION_BELTS`); the targets (≤ 20% of player-turns, worst player ≤ 35%) now apply to this threshold | 6.1 review, finding 1: one Kumite swings two players 6 stripes apart, so 2 belts was too strict. |
| D38 | Catch-up: only candidate **A** (Allenamento gives +2 instead of +1 to a player 2+ belts behind the leader) is to be simulated. Rejected: bounty on the leader, belt bonus capped at +1, more Sensei Osserva | 6.1 review, finding 1. |
| D39 | Game length is tuned **last**; target stays a mean of 60–90 minutes at 4 players | 6.1 review, finding 2. |
| D40 | Bots stay as they are for Phase 6 (casual included, although much weaker than the heuristic bots) | Q7. |
| D41 | Phase 6 order: board and Fatica (6.4 with 6.5) → seat order (6.2) → catch-up (6.9) → style dynamics (6.3) → Kiai (6.6), player counts (6.7), length → consolidation (6.8). Structural fixes before tuning | 6.1 review. |
| D42 | **Board draft 2 adopted** as `board/quadrant.txt` (`board/board.txt` regenerated; draft 1 is in git history, commit 4998931) | 6.4.0 results. |
| D43 | **6.2 seat order suspended**: measuring it needs 4,000+ games per scenario for an effect of a few points; D35 (no Kumite in round 1) stays in the rules. Seat rates are still reported by every run and watched in 6.8 | Developer, after the 4,000-game runs were started. |
| D44 | **6.9 catch-up skipped**: the v0.8 baseline already has 3–4% of player-turns out of contention (worst player 10–13%), so candidate A has little to fix; dropped from RULES.md "variants under test" (never implemented) | Developer, after the v0.8 baseline metrics. |
| D45 | The **`random` lineup is no longer run** in experiments: it never finishes a game and cost ~80% of a run's time. The bot stays in the code and the tests | Developer, 6.10. |
| D46 | Performance work must leave **every game identical** (same events for the same seed), so every result recorded in this file stays valid | Developer, 6.10. |
| D47 | **Kiai stays +2**: +2 and +1 are indistinguishable on every target (6.6); +2 decides more fights (5% vs 3% of Kumite in casual play). `kiai_bonus = 1` stays a simulator switch; RULES.md drops the "+1 to be tested" note | Developer, 6.6 results. |
| D48 | **6+ players start on 3 (Yellow)**; any player count may start on 3 as a "shorter game" option (saves 15–20% of the time, 6.7). 8 players stays long (107 / 142 min mixed / casual from Yellow) and is accepted: it will rarely be played. Balancing simulations stay at start 0 at every count (developer); `--start 3` reproduces the rules' 6+ setup or the shorter game | Developer, 6.7 results. |
| D49 | **Chance square parked** (6.4.1 and 6.4.3): Fatica on crossing (D36) on board draft 2 (D42) already makes squares matter; 6.4 closed | Developer. |
| D50 | **Length accepted as is; a failed Exam still costs 3 stripes.** L1 (Brown floor against Fatica) rejected; L2 (failed Exam −1 / 0) saves only 3–5 min on the mean and weakens comebacks (6.11.4). `exam_failure_loss` stays a simulator switch. At 4 players mixed and balanced are inside the target; casual (91 / 122) and all-aggressive (90 / 127) stay slightly outside, within the "1–2 h" promise; the shorter-game start on Yellow (D48) is the players' lever | Developer, 6.11 results. |
| D51 | **Bots decide alike in every corner** (start square and tied paths broken in the player's own frame), overriding D40 for this bias: the old tie-break by absolute board order made corners 1 and 2 win 25–28% and corners 0 and 3 23% | Developer, 6.2.1 diagnosis. |
| D52 | **No first-player compensation**: the seat order stays as it is. At 4 players every seat is inside 25% ± 3 pp; +1 stripe for the first player gains about 1 point (6.2.4), not worth an extra rule. `first_player_bonus` stays a simulator switch (no CLI axis) | Developer, 6.2.4 results. |
| D53 | **Italian playtest edition format**: source `RULES.it.md` (full rules, without the tested alternatives) plus `edition/it/riepilogo.md` and `edition/it/carte.toml`; `edition/build.py` renders one local printable A4 HTML (`edition/it/playtest.html`): quick reference, rules, belt track, board overview plus four A4 tiles, 22 cut-out cards. Board drawn from the board model; card copies from the engine's deck. Japanese names keep an Italian gloss ("Uke (Parata)"). Glossary: stripe = tacca, track = scala delle cinture, Safe Room = Stanza Sicura, Start = Partenza, round = giro, attacker / defender = attaccante / difensore | Developer (format, printable board, gloss, local HTML only). |
## Phases ## Phases
@@ -160,7 +180,7 @@ Deliverables:
- One change at a time. When a change is adopted, re-run the 6.1 baseline so later steps start from the current rules, and log the decision (D-number) here and in `RULES.md`. - One change at a time. When a change is adopted, re-run the 6.1 baseline so later steps start from the current rules, and log the decision (D-number) here and in `RULES.md`.
- Results go in this file as a table per step (command line, scenario, key measures). - Results go in this file as a table per step (command line, scenario, key measures).
**Next action:** 6.0 (tooling), then 6.1. Everything up to D33 and RULES.md v0.7 is committed; commit only when the developer asks. **Next action:** the developer's playtest with the Italian edition (7.2.0); then 7.2.1 and 7.1. 6.9 skipped (D44), 6.4.1 parked (D49). Everything up to D52 is committed; 7.2.0 and D53 are not; commit only when the developer asks.
**Targets** (confirmed by the developer as a starting point, D32): **Targets** (confirmed by the developer as a starting point, D32):
@@ -177,29 +197,291 @@ Deliverables:
**Steps:** **Steps:**
- [ ] 6.0 Tooling needed by the experiments - [x] 6.0 Tooling needed by the experiments
- [ ] 6.0.1 "Casual" bot: the heuristic bot scoring only a small random sample of the offered moves (sample size is a parameter, default 6), as a stand-in for a human who cannot compare ~24 end squares every move. Added to `BOTS` as `casual`. - [x] 6.0.1 "Casual" bot: the heuristic bot scoring only a small random sample of the offered moves (sample size is a parameter, default 6), as a stand-in for a human who cannot compare ~24 end squares every move. Added to `BOTS` as `casual`. Done: `CasualAgent(rng, profile=BALANCED, sample=CASUAL_SAMPLE)` in `bots.py`; only `choose_path` is sampled, and the sample is blind (a path into the Dojo is taken only if drawn).
- [ ] 6.0.2 `--summary-csv PATH`: one row per scenario with the summary measures, to compare scenarios side by side. - [x] 6.0.2 `--summary-csv PATH`: one row per scenario with the summary measures, to compare scenarios side by side. Done: `report.write_summary_csv`; spreads become `<name>_mean/_p10/_median/_p90`, income `<source>_gain/loss_per_turn`, seats `seat<i>_<stat>`, bots `win_rate_<bot>`; columns missing in a scenario are empty.
- [ ] 6.0.3 Seat breakdown in the summary: per seat, mean final track, Exam attempts, and share of games where that seat defended the Exam (for 6.2). - [x] 6.0.3 Seat breakdown in the summary: per seat, mean final track, Exam attempts, and share of games where that seat defended the Exam (for 6.2). Done: `GameMetrics.final_track / exam_attempts_by_seat / exam_defences_by_seat` (tuples, comma-joined in the games CSV), `Summary.seats` (`SeatSummary`), one `Seat i` line per seat in the text report.
- [ ] 6.0.4 Square reachability measure: share of moves where an Allenamento / Fatica end square is reachable, and mean distinct end squares per move (the Phase 6 probe, made a metric; for 6.4). - [x] 6.0.4 Square reachability measure: share of moves where an Allenamento / Fatica end square is reachable, and mean distinct end squares per move (the Phase 6 probe, made a metric; for 6.4). Done: the engine emits `MoveOptions(player, ends)` (square codes of the distinct end squares) before every path choice in `_move` (first choice, after a won Kumite, Tsugi Ashi; not the Meditazione extra step); report line `Move options`.
- [ ] 6.1 Baseline on rules v0.7 (Exam c): 4 players, 1,000 games, lineups mixed / `balanced` / `casual` / `random`. Record the table; check it against the targets. - [x] 6.1 Baseline on rules v0.7 (Exam c): 4 players, 1,000 games, lineups mixed / `balanced` / `casual` / `random`. Record the table; check it against the targets.
- [ ] 6.2 Seat order (monitor; developer concerned): 4,000 games, 4 players, lineups mixed and `random`; 2,000 games at 3 and 6 players. If a seat is outside 25% ± 3 pp, diagnose with the 6.0.3 breakdown (hypotheses: moving last means seeing everyone else move first; Exam defender selection; Sensei / Peso cards) and bring candidate fixes to the developer. No fix without agreement. - Command: `uv run dojo sim --games 1000 --seed 0 --lineup balanced,farmer,aggressive balanced casual random --summary-csv ... --csv ...` (~2 min on all cores).
- [ ] 6.3 Style dynamics: same-style lineups (`farmer`, `aggressive`, `balanced`, `casual`) and one-against-three (`aggressive,farmer,farmer,farmer`, `farmer,aggressive,aggressive,aggressive`, `balanced,farmer,farmer,farmer`). Watch: all-farmer stalling (length, games finished, Kumite per round), a dominant style, time out of contention.
- [ ] 6.4 Fatica and the square economy (D33). Each candidate is a board draft in `board/drafts/` (new square codes need `Square`, `board/FORMAT.md`, validator stats and `RULES.md` entries) and/or a `RulesConfig` switch, compared with the 6.1 baseline on square gains / losses per turn, reachability (6.0.4), game length, style win rates, casual vs heuristic gap: | Measure (target) | mixed | balanced | casual | random |
- [ ] 6.4.1 Option 2, chance square: new code (proposal `C`, name to choose with the developer, e.g. "Sorte" / "Randori") — on stop roll 1d6, 1–2 → −1 stripe, 3–6 → +1. Draft: replace every Fatica and some Allenamento with it (start: all F plus about a third of A, keep the square mix inside the validator's Tatami range). Bot: value = expected change (+1/3 stripe, risk-weighted: gain 2/3, loss 1/3). Try variations of the split (e.g. 1–3 / 4–6) if income is off. |---|---|---|---|---|
- [ ] 6.4.2 Option 1, Fatica on crossing: `RulesConfig.fatica_on_crossing` — each Fatica square entered during a move costs 1 stripe, **1 in total per square whether or not the move stops there** (developer, D33); stopping on it adds nothing more. Engine: apply in `_walk` per square entered (not on teleports / relocations, which never walk). Bot: count Fatica crossed in `_path_score`. Likely needs fewer Fatica squares; test on the current board and on a draft with fewer F. | Finished within 200 rounds (100%) | 100% | 100% | 100% | **94%** |
- [ ] 6.4.3 Compare 6.4.1, 6.4.2 and both together; bring the table to the developer for a decision (then `RULES.md` and D-number). | Rounds mean (p10–p90) | 21.8 (15–29) | 22.2 (15–30) | 24.9 (17–34) | 115 (67–179) |
- [ ] 6.5 Home farming: after 6.4, if the own-quadrant share of square income is still above 60% (baseline: 71%), move Allenamento squares away from the Safe Rooms in a board draft (`dojo sim --quadrant board/drafts/...`) and compare. The board must still pass `dojo board validate`. | Minutes mean (60–90), p90 (≤ 110) | 79, 108 | 81, **110.5** | 83, **114** | 365, 569 |
- [ ] 6.6 Kiai +2 vs +1: 1,000 games each on two lineups. Watch the Exam pass rate, upset rate and how often a Tecnica turns a fight. | Kumite per round; upsets | 1.24; 29% | 1.26; 30% | 0.66; 28% | 0.36; 23% |
- [ ] 6.7 Player counts: 3 / 4 / 6 / 8 players, start 0; plus start 3 at 6 and 8 players. Minutes per count; decide whether 6+ players start on Yellow. | Exam attempts per game; pass rate (35–55%) | 2.33; 43% | 2.45; 41% | 2.39; 42% | 2.24; 42% |
- [ ] 6.8 Consolidation: the final configuration at 3 / 4 / 6 / 8 players with all lineups, 2,000 games each; every target checked; `RULES.md` v0.8 with the decisions; open items moved to Phase 7. | Halfway leader wins (35–55%) | 39% | 38% | 38% | 29% |
| Player-turns out of contention (≤ 20%) | **21%** | **21%** | 17% | 19% |
| Worst player's share out, mean (≤ 35%) | **44%** | **43%** | **40%** | **39%** |
| Rivals within one belt at the end (≥ 0.5) | 0.60 | 0.57 | 1.22 | 1.46 |
| Winner margin; lead changes | 4.0; 7.0 | 4.0; 7.4 | 2.4; 5.3 | 1.4; 13.2 |
| Win rate by seat 0–3 (25% ± 3 pp) | 24 / 23 / 24 / 28 | 25 / 22 / 26 / 27 | 26 / 23 / 26 / 26 | 23 / 23 / 24 / 23 |
| Win rate by style (spread ≤ 6 pp) | aggr. 25, bal. 27, farmer 22 (5 pp) | — | — | — |
| Income per turn: square, card, Kumite (gain / loss), Exam loss | +0.51/0, +0.01/0, +0.43/−0.40, −0.05 | same ±0.01 | +0.53/0, +0.01/−0.01, +0.24/−0.23, −0.04 | +0.19/−0.08, +0.02/−0.01, +0.12/−0.12, −0.01 |
| Square income home / away | +0.38 / +0.14 | +0.38 / +0.13 | +0.36 / +0.17 | +0.07 / +0.05 |
| End squares per move; can end on Allenamento / Fatica | 23.9; 78% / 76% | 23.9; 78% / 76% | 24.5; 83% / 80% | 24.0; 81% / 78% |
| Seat 3: Exam attempts per game (others) | 0.65 (0.55–0.57) | 0.67 (0.58–0.61) | 0.62 (0.56–0.63) | 0.56 (0.53–0.60) |
Findings:
- Met: pass rate, halfway leader, rivals within one belt, style spread, seats (mixed seat 3 at the edge, 28%).
- Missed: the worst player's time out of contention (40–44%, target ≤ 35%) on every lineup; overall time out 21% for heuristic lineups; p90 minutes slightly over 110 for `balanced` and `casual`.
- `random` does not finish 6% of games in 200 rounds (115 rounds on average): it is a floor, not a player; the "100% finished" target is read as applying to the other lineups.
- Seat 3 makes more Exam attempts in the heuristic lineups (0.65–0.67 vs ≤ 0.61) and not with `random`: first lead for 6.2.
- Fatica is reachable on 76% of moves but heuristic bots still avoid it (square losses ≈ 0), confirming Q6; the casual bot also avoids it, since 6 sampled moves nearly always include a better one.
- The casual bot fights half as often (0.66 Kumite/round) and gives closer games (winner margin 2.4, 1.22 rivals within one belt).
- Q7 follow-up (1,000 games, seed 0): `casual,balanced` → balanced 36%, casual 14% per seat; `casual,casual,casual,balanced` → balanced 46%, casual 18%. The blind sample of 6 is far weaker than the heuristic bot.
- Seat probe (4,000 games, scratch script, not in the repo): Kumite attacked in rounds 1–2 per game by seat 0/1/2/3 = 0.25 / 0.40 / 0.54 / 0.67 (mixed lineup), 0.03–0.05 for every seat with `random`; mean track after round 3 = 2.04 / 2.04 / 2.35 / 2.50. Later seats find opponents already out of their Safe Rooms, and an early equal-belt Kumite is nearly free for the attacker (Senshu 58%, and a loss at track 0–2 costs at most what is there). Matches the developer's hypothesis; candidate fixes to be proposed in 6.2.
- Developer review: D34–D41.
- Re-baseline on rules v0.8 (D35, D36, 3-belt threshold D37), 1,000 games, seed 0, each rule alone against v0.7, after the Dojo-closing fix below (scratch script: `Scenario` configs with the two switches replaced; the CLI has no switch for them):
| Measure | v0.7 | + Fatica on crossing | + no Kumite in round 1 | v0.8 (both) |
|---|---|---|---|---|
| Minutes mean / p90, mixed | 79 / 108 | 83 / 114 | 78 / 107 | 83 / 114 |
| Minutes mean / p90, balanced | 81 / 111 | 79 / 109 | 80 / 110 | 78 / 108 |
| Minutes mean / p90, casual | 83 / 114 | **126 / 173** | 83 / 114 | **127 / 174** |
| Square gain / loss per turn: mixed; casual | +0.51 / 0.00; +0.53 / 0.00 | +0.52 / −0.05; +0.45 / −0.12 | = v0.7 | +0.52 / −0.05; +0.45 / −0.12 |
| Kumite per round: mixed; casual | 1.24; 0.66 | 1.03; 0.51 | 1.23; 0.65 | 1.02; 0.51 |
| Exam pass rate, mixed | 43% | 44% | 44% | 44% |
| Out of contention (3 belts): share; worst player, mixed | 5.1%; 15% | 5.2%; 16% | 5.1%; 15% | 5.1%; 15% |
| Win rate by seat 0–3, mixed | 24 / 23 / 24 / 29 | 30 / 21 / 22 / 27 | 24 / 24 / 25 / 28 | **29 / 23 / 20 / 28** |
| Win rate by seat 0–3, balanced | 25 / 22 / 26 / 27 | 24 / 23 / 24 / 30 | 24 / 24 / 26 / 26 | 25 / 24 / 23 / 29 |
| Win rate by seat 0–3, casual | 26 / 23 / 26 / 26 | 25 / 25 / 24 / 26 | 26 / 23 / 26 / 24 | 25 / 25 / 24 / 26 |
Fix found while planning Dojo tolls: a Brown player (15) walking a Fatica drops to 14 and must not enter the Dojo on that move (the belt changes at once, FAQ). `_path_choices` no longer offers such Dojo paths (`_still_brown_at_dojo`; a Kumite won on the way re-offers paths). The first run without the fix differed by ≤ 3 minutes and ≤ 2 pp per seat.
Findings: (1) at 3 belts the contention targets are met with a wide margin (5% of player-turns, worst player 10–15%). (2) Fatica on crossing on the current board (5 F per quadrant, 20 in all, 10% of the squares) barely touches the heuristic bots (−0.03 to −0.05 per turn) but costs the casual bot −0.12 per turn and adds ~44 minutes (127 mean, p90 174): the board has too many Fatica on ordinary routes for players who do not plan around them. (3) No Kumite in round 1 alone moves seat 3 by about 1 pp (within the ±1.4 pp noise of 1,000 games); 6.2 measures it on 4,000 games. (4) Mixed v0.8 shows seat 0 at 30% and seat 2 at 20%: not seen in the same-bot lineups; to confirm in 6.2 before reading anything into it.
- [x] 6.2 Seat order (closed: D51, D52) — **reopened by the developer after 6.8.4** (was suspended, D43); runs stopped before any result (monitor; developer concerned): 4,000 games, 4 players, lineups mixed and `random`; 2,000 games at 3 and 6 players. If a seat is outside 25% ± 3 pp, diagnose with the 6.0.3 breakdown (hypotheses: moving last means seeing everyone else move first; Exam defender selection; Sensei / Peso cards) and bring candidate fixes to the developer. No fix without agreement.
- [x] 6.2.1 Diagnosis, 4 players, mixed lineup, 4,000 games per run (scratch `seats.py`). Seat and corner are the same at 4 players, so the run was repeated with the corners rotated by 1, 2, 3 seats and averaged, separating turn order from board position:
| Averaged over | Win rate 0 / 1 / 2 / 3 | First to Brown | Track after round 1 | Net Kumite stripes per game | Attacked per game in rounds 2–3 |
|---|---|---|---|---|---|
| seats (turn order) | 23.9 / 24.2 / 25.5 / 26.4 | 23.9 / 24.0 / 25.7 / 26.4 | 0.74 / 0.76 / 0.76 / 0.76 | +0.08 / +0.44 / +0.79 / +1.12 | 0.95 / 0.78 / 0.54 / 0.41 |
| corners (board position) | 23.3 / 27.6 / 25.5 / 23.6 | 22.6 / 30.4 / 25.4 / 21.6 | 0.70 / 0.81 / 0.81 / 0.69 | +0.56 / +0.54 / +0.49 / +0.84 | 0.64 / 0.76 / 0.70 / 0.57 |
- Corner effect (the larger one, 23–28%) is a **bot artifact**: `bots.start_square` breaks the tie between equally distant Safe Room squares by absolute (row, col), so in the quadrant's own frame corners 0 and 3 start on (1, 2) and corners 1 and 2 on (2, 1), which is the better square. The board itself is symmetric. It also shows that the Safe Room squares are not equivalent (a player picks where to start, RULES.md "Safe areas").
- Turn-order effect (smaller, 24 → 26%, within the 25% ± 3 pp target): later seats are attacked less early in the game (rounds 2–3: 0.95 vs 0.41 times) and end up +1 stripe from Kumite over a game. The earlier seats walk into the open first and the later ones answer.
- Other bot tie-breaks (`max` over paths offered in N, E, S, W order) are also orientation-dependent; not measured yet.
- [x] 6.2.2 Bot fix (developer, D51): `bots.start_square` breaks ties in the Safe Room's own frame (every corner starts from (2, 1) in the quadrant frame, the better square); `HeuristicAgent.place_pawn` uses it; `HeuristicAgent.choose_path` breaks score ties by the path in the player's own frame (`bots.best_of`, `bots.own_frame`). Tests: same start square from every corner, tied paths chosen alike from every corner. Every game changes (fingerprints must be re-recorded; earlier tables stay as recorded, measured with the biased bots).
- Re-run of 6.2.1 with the fix (4 × 4,000 games): corners 24.0 / 25.3 / 25.4 / 25.4 (was 23.3–27.6), track after round 1 equal (0.80–0.81); seats 24.3 / 24.6 / 25.4 / 25.7. Attacked in rounds 2–3 still 0.81 / 0.59 / 0.39 / 0.27 by seat.
- [x] 6.2.3 Seat order with the fixed bots (`dojo sim`, start 0; 4,000 games at 4 players, 2,000 at 3 / 6 / 8):
| Players | Fair share | mixed, seats in turn order | casual, seats in turn order |
|---|---|---|---|
| 3 | 33.3 | 30.6 / 33.7 / 35.7 | 34.0 / 32.0 / 34.0 |
| 4 | 25.0 | 23.2 / 25.5 / 25.5 / 25.8 | 24.0 / 26.0 / 25.7 / 24.3 |
| 6 | 16.7 | 13.4 / 17.3 / 16.4 / 15.4 / 17.9 / 19.6 | 15.2 / 16.1 / 16.2 / 16.2 / 19.0 / 17.2 |
| 8 | 12.5 | 10.1 / 11.5 / 12.0 / 13.5 / 12.0 / 12.5 / 13.8 / 14.6 | 10.8 / 12.6 / 11.9 / 13.1 / 12.2 / 13.9 / 11.7 / 14.0 |
Mixed 4 players now takes 68 min (72 with the biased bots). Reading: the first seat is 1–3 points under its fair share at every count (more with heuristic than casual bots); at 4 players every seat is inside 25% ± 3 pp. At 6 players the two seats alone in a corner (seats 4 and 5, `seat_corners(6)` = 0, 1, 0, 1, 2, 3) win 17–20%, the four sharing a Safe Room 13–17%.
- [x] 6.2.4 First-player compensation, +1 stripe (developer's choice to test): `RulesConfig.first_player_bonus` (default 0; `RulesConfig.start_track(seat)` used by the engine and the metrics). No sim axis yet (scratch `bonus.py`, same seeds as 6.2.3; the bonus-0 rows reproduce 6.2.3 exactly).
| Players | Lineup | First seat, bonus 0 → 1 (fair share) | All seats with bonus 1 | Minutes 0 → 1 |
|---|---|---|---|---|
| 3 | mixed | 30.6 → 33.2 (33.3) | 33.2 / 32.5 / 34.2 | 53 → 52 |
| 3 | casual | 34.0 → 33.8 | 33.8 / 30.9 / 35.4 | 70 → 70 |
| 4 | mixed | 23.2 → 22.6 (25.0) | 22.6 / 25.2 / 26.0 / 26.2 | 68 → 68 |
| 4 | casual | 24.0 → 25.4 | 25.4 / 25.9 / 24.4 / 24.3 | 90 → 90 |
| 6 | mixed | 13.3 → 14.8 (16.7) | 14.8 / 17.3 / 14.8 / 14.9 / 18.1 / 20.1 | 101 → 100 |
| 6 | casual | 15.2 → 16.1 | 16.1 / 16.8 / 15.4 / 16.2 / 19.3 / 16.1 | 130 → 131 |
| 8 | mixed | 10.1 → 10.7 (12.5) | 10.7 / 12.3 / 10.8 / 12.6 / 12.3 / 14.4 / 13.8 / 13.1 | 130 → 128 |
| 8 | casual | 10.8 → 12.0 | 12.0 / 12.1 / 12.6 / 13.1 / 12.2 / 12.0 / 13.6 / 12.4 | 168 → 168 |
Reading: +1 stripe lifts the first seat by about 1 point on average (−0.6 to +2.6; the noise is ±0.7–1 point), not enough to close the gap at 6–8 players with heuristic bots, and nothing at 4 players mixed. No other seat is hurt systematically; length unchanged.
- [x] 6.3 Style dynamics: same-style lineups (`farmer`, `aggressive`, `balanced`, `casual`) and one-against-three (`aggressive,farmer,farmer,farmer`, `farmer,aggressive,aggressive,aggressive`, `balanced,farmer,farmer,farmer`). Watch: all-farmer stalling (length, games finished, Kumite per round), a dominant style, time out of contention.
- Run: `uv run dojo sim --games 1000 --seed 0 --lineup farmer aggressive balanced casual aggressive,farmer,farmer,farmer farmer,aggressive,aggressive,aggressive balanced,farmer,farmer,farmer` (rules v0.8, board draft 2, 4 players; 17 s on 16 workers).
| Lineup | Minutes mean / p90 | Kumite / round | Square gain / loss per turn | Halfway leader wins | Out of contention; worst player | Rivals within 1 belt | Win rate by bot |
|---|---|---|---|---|---|---|---|
| farmer ×4 | 63 / 81 | 0.80 | +0.71 / −0.01 | 34% | 1%; 4% | 1.47 | — |
| aggressive ×4 | **90 / 127** | 1.32 | +0.56 / −0.13 | 41% | 8%; 21% | 0.50 | — |
| balanced ×4 | 68 / 92 | 1.04 | +0.64 / −0.03 | 39% | 3%; 10% | 0.81 | — |
| casual ×4 | 91 / 122 | 0.61 | +0.58 / −0.10 | 39% | 4%; 12% | 1.42 | — |
| aggressive + 3 farmer | 68 / 92 | 0.94 | +0.67 / −0.04 | 43% | 3%; 11% | 1.14 | aggressive 26%, farmer 25% |
| farmer + 3 aggressive | 81 / 117 | 1.20 | +0.60 / −0.10 | 42% | 7%; 20% | 0.64 | farmer 24%, aggressive 26% |
| balanced + 3 farmer | 64 / 84 | 0.87 | +0.69 / −0.01 | 36% | 2%; 7% | 1.24 | balanced 29%, farmer 24% |
All games finished; Exam pass rate 42–43% everywhere.
- Reading: no stalling (all-farmer is the shortest game). No dominant style: a lone bot wins 24–26%, except balanced among farmers at 29% (±1.4 pp at 1,000 games, so a real but small edge; the style spread target, ≤ 6 pp, holds). More aggression makes games longer and less close: all-aggressive is the only heuristic lineup over the p90 limit (127 min) and at the edge of the rivals target (0.50); fights cost stripes both ways (+0.45 / −0.43 per turn) and aggressive bots also pay more Fatica (−0.13). Input for the length tuning (D39).
- [x] 6.4 Fatica and the square economy (closed: D49) (D33; Fatica on crossing became the baseline, D36; board draft 2 adopted, D42). Each candidate is a board draft in `board/drafts/` (new square codes need `Square`, `board/FORMAT.md`, validator stats and `RULES.md` entries) and/or a `RulesConfig` switch, compared with the 6.1 baseline on square gains / losses per turn, reachability (6.0.4), game length, style win rates, casual vs heuristic gap:
- [x] 6.4.0 Board draft 2 (drafted as `board/drafts/quadrant-2.txt`, now `board/quadrant.txt`), built for Fatica on crossing. Principles approved by the developer: Fatica as tolls on short routes, fewer Fatica, Allenamento away from the Safe Rooms (covers 6.5), validator / Tatami 30–40% / 11-step walk / tunnels kept; judged on casual minutes (≤ ~93, i.e. v0.7 casual + 10) and heuristic bots paying some tolls.
- Method: walls of draft 1 kept (approved layout); only square codes changed. Route analysis showed every square on a shortest Safe Room → Dojo walk has a detour of at most 1 step, so "detour 2–4" is not reachable without wall changes; tolls were placed by measured traffic instead (bots' walks per square, home players vs visitors, on the board without Fatica). Home players load the row-3 corridor and the inner Dojo approach (5,5); visitors load the ring next to the Dojo.
- Variants (1,000 games, seed 0, v0.8; scratch scripts `routes.py`, `traffic.py`, `recode.py`, `rules_compare.py`):
| Board | Fatica (per quadrant) | Minutes mean / p90: mixed; casual | Square gain / loss per turn: mixed; casual | Home share of square income: mixed; casual | Seats 0–3, mixed |
|---|---|---|---|---|---|
| draft 1 (v0.7 rules, reference) | 5, on stop | 79 / 108; 83 / 114 | +0.51 / 0; +0.53 / 0 | 74%; 69% | 24 / 23 / 24 / 28 |
| draft 1 | 5 | 83 / 114; 127 / 174 | +0.52 / −0.05; +0.45 / −0.12 | 74%; 76% | 29 / 23 / 20 / 28 |
| 2a | 3: (3,4), (5,5), (7,4) | 73 / 99; 110 / 152 | +0.66 / −0.09; +0.55 / −0.16 | 68%; 69% | 25 / 24 / 25 / 26 |
| 2b | 2: (3,4), (5,5) | 72 / 98; 89 / 121 | +0.62 / −0.05; +0.58 / −0.09 | 70%; 68% | 23 / 28 / 24 / 25 |
| 2c | 1: (7,4); Allenamento 16 | 73 / 98; 82 / 112 | +0.59 / −0.02; +0.60 / −0.06 | 68%; 65% | 24 / 23 / 26 / 27 |
| **2d = draft 2** | 2: (3,4), (5,5); A moved to the ring | 72 / 95; 91 / 122 | +0.64 / −0.05; +0.58 / −0.10 | 67%; 62% | 23 / 27 / 27 / 23 |
**Current baseline** (rules v0.8, board draft 2) = the draft 2 row above plus the balanced lineup: 68 / 92 minutes, +0.64 / −0.03, home 67%, seats 22 / 23 / 27 / 28. Square mix per quadrant: Tatami 20 (39%), Allenamento 14, Nebbia 8 (16%, up from 6), Fatica 2, Passo Falso 2, Sfida 1, Tunnel 4.
- Reading: casual within the ~93-minute bound (91); heuristic bots pay tolls (−0.03 to −0.05); every lineup 68–72 minutes, inside 60–90. Home share down from 74% to 62–67%, not to 60%: players spend most turns crossing their own quadrant, so placement alone reaches its limit. Seat rates move by ±4 pp between variants at 1,000 games: noise, measured properly in 6.2. Adopted by the developer (D42); `board/drafts/` removed.
- [ ] 6.4.1 **Parked (D49).** Option 2, chance square: new code (proposal `C`, name to choose with the developer, e.g. "Sorte" / "Randori") — on stop roll 1d6, 1–2 → −1 stripe, 3–6 → +1. Draft: replace every Fatica and some Allenamento with it (start: all F plus about a third of A, keep the square mix inside the validator's Tatami range). Bot: value = expected change (+1/3 stripe, risk-weighted: gain 2/3, loss 1/3). Try variations of the split (e.g. 1–3 / 4–6) if income is off.
- [x] 6.4.2 Option 1, Fatica on crossing: `RulesConfig.fatica_on_crossing` — each Fatica square entered during a move costs 1 stripe, **1 in total per square whether or not the move stops there** (developer, D33); stopping on it adds nothing more. Engine: apply in `_walk` per square entered (not on teleports / relocations, which never walk). Bot: count Fatica crossed in `_path_score`. Likely needs fewer Fatica squares; test on the current board and on a draft with fewer F. Done as the baseline rule (D36): engine `_step` / `_path_choices`, bot `_path_score`, tests; measured on the current board in the 6.1 re-baseline.
- [ ] 6.4.3 **Parked (D49).** If 6.4.1 is run: compare it with the v0.8 baseline on draft 2 (Fatica on crossing, D36) and bring the table to the developer for a decision (then `RULES.md` and D-number).
- [x] 6.5 Home farming: covered by board draft 2 (6.4.0): Allenamento moved from the south training room onto the Dojo ring; own-quadrant share of square income 74% → 62–67%, short of the 60% aim (players cross their own quadrant for most of the game); draft 2 adopted as is (D42). Original step: after 6.4, if the own-quadrant share of square income is still above 60% (baseline: 71%), move Allenamento squares away from the Safe Rooms in a board draft (`dojo sim --quadrant board/drafts/...`) and compare. The board must still pass `dojo board validate`.
- [x] 6.6 Kiai +2 vs +1: 1,000 games each on two lineups. Watch the Exam pass rate, upset rate and how often a Tecnica turns a fight. **Kiai stays +2 (D47).**
- [x] 6.6.1 Metric: `GameMetrics.tecnica_kumite`, `kiai_decided`, `exam_fights`, `exam_kiai_decided`; report line "Tecnica". A fight is decided by Kiai when the result flips with the Kiai bonus removed, dice and other cards unchanged. The tie rule moved to `RulesConfig.attacker_wins`, shared by the engine and the metric (fingerprinted games identical).
- [x] 6.6.2 Run: `uv run dojo sim --games 1000 --seed 0 --kiai 2 1 --lineup balanced,farmer,aggressive balanced casual`.
| Lineup | Kiai | Minutes mean / p90 | Exam passed | Upsets | Tecnica played in; Kiai decided (Kumite / Exam) | Halfway leader wins | Rivals within 1 belt |
|---|---|---|---|---|---|---|---|
| mixed | +2 | 72 / 95 | 40% | 31% | 2%; 1% / 1% | 40% | 0.84 |
| mixed | +1 | 72 / 95 | 41% | 31% | 2%; 1% / 1% | 41% | 0.85 |
| balanced | +2 | 68 / 92 | 42% | 30% | 2%; 1% / 1% | 39% | 0.81 |
| balanced | +1 | 69 / 93 | 41% | 30% | 2%; 1% / 1% | 39% | 0.82 |
| casual | +2 | 91 / 122 | 43% | 27% | 12%; 5% / 5% | 39% | 1.42 |
| casual | +1 | 91 / 121 | 44% | 27% | 10%; 3% / 4% | 39% | 1.38 |
- Reading: Kiai +2 and +1 are indistinguishable on every target measure (≤ 1 pp, inside the noise). Tecnica are rare: heuristic bots draw a Nebbia card on 2.3% of turns (0.5 Tecnica per game; Nebbia is worth 0.2 to them, below an Allenamento), casual bots on 9.7% (3.3 Tecnica per game). Even for casual play Kiai decides 5% (+2) vs 3% (+1) of Kumite. Scratch script `tecnica.py` (200 games per lineup).
- [x] 6.7 Player counts: 3 / 4 / 6 / 8 players, start 0; plus start 3 at 6 and 8 players. Minutes per count; decide whether 6+ players start on Yellow. **6+ players start on Yellow; shorter-game option for any count (D48).**
- Run: `uv run dojo sim --games 1000 --seed 0 --players 3 4 6 8 --start 0 3 --lineup balanced,farmer,aggressive casual` (48 s).
| Players | Start | Minutes mean / p90: mixed; casual | Rounds: mixed; casual | Halfway leader wins: mixed; casual | Out of contention; worst player (mixed) | Rivals within 1 belt: mixed; casual | Wins by bot (mixed) |
|---|---|---|---|---|---|---|---|
| 3 | 0 | 55 / 75; 71 / 99 | 20.7; 28.9 | 49%; 45% | 3%; 9% | 0.61; 1.05 | 34 / 34 / 31 |
| 3 | 3 | 46 / 67; 59 / 84 | 17.4; 24.2 | 50%; 46% | 3%; 8% | 0.65; 1.05 | 34 / 35 / 31 |
| 4 | 0 | 72 / 95; 91 / 122 | 20.4; 27.5 | 40%; 39% | 4%; 13% | 0.84; 1.42 | 24 / 26 / 24 |
| 4 | 3 | 57 / 80; 77 / 110 | 16.2; 23.5 | 42%; 40% | 4%; 12% | 0.85; 1.47 | 24 / 25 / 26 |
| 6 | 0 | 107 / 145; 136 / 182 | 19.4; 26.4 | 32%; 27% | 9%; 29% | 1.19; 1.92 | 17 / 16 / 17 |
| 6 | 3 | 86 / 127; 113 / 161 | 15.8; 22.1 | 30%; 29% | 8%; 28% | 1.16; 1.96 | 15 / 18 / 17 |
| 8 | 0 | 133 / 181; 170 / 231 | 17.8; 24.3 | 26%; 20% | 11%; 37% | 1.41; 2.16 | 13 / 14 / 11 |
| 8 | 3 | 107 / 152; 142 / 200 | 14.5; 20.5 | 27%; 24% | 10%; 38% | 1.53; 2.29 | 13 / 14 / 11 |
Wins by bot: aggressive / balanced / farmer. All games finished; Exam pass rate 40–44% everywhere.
- Reading: rounds stay ~15–29 whatever the count, so table time grows with the number of players (45 s per turn): 6 players take ~1.5x and 8 players ~1.9x the 4-player time. Starting on 3 saves ~3–4 rounds (15–20% of the time) at every count, and changes no other measure by more than a few points. At 6–8 players the worst player is out of contention 28–40% of turns (4-player target ≤ 35%), and the halfway leader wins less (20–32%): more players, more Kumite (2–3 per round), more reshuffling. 8 players on casual stays above 2 hours even from Yellow (142 min).
- [x] 6.10 Simulation speed (before 6.3; D46: games unchanged). Profile: ~95% of the time was listing moves (`movement._extend`, `Board.neighbours`, the outcome keys in `Engine._path_choices`).
- [x] 6.10.1 Guard: `agents/scripts/fingerprint_games.py` hashes every event of 210 games (mixed / casual at 4 players, 6 players, Fatica on stop with Kumite in round 1, random bots, 60-round cap); `write` before an engine change, `check` after. A tool for refactoring and speed work, not a test (rule changes are expected to differ). Timings came from a scratch benchmark.
- [x] 6.10.2 `Board`: neighbour table, squares per code and Safe Rooms computed once (`cached_property`); neighbour order N, E, S, W kept (the random and casual bots pick by position in the offered list). `Engine`: Fatica squares as a set (`_tolls`), opponents' squares looked up once per move (`_fightable_squares`), and paths crossing no opponent keyed by their Fatica count with C-level set operations.
- [x] 6.10.3 `movement.walks`: walks that exclude nothing but the start are cached per (board, start, steps, Dojo open) with `functools.cache` (same pattern as `bots.dojo_distance`); callers get a fresh list. ~3,200 entries per process, ~80 MB peak.
- [x] 6.10.4 Parallel scaling: 8 processes running at once each take ~1.6x their solo time on this machine (8 real cores, clock and cache sharing), so ~5x on 8 workers is the hardware limit; no pool change. 16 workers (hyperthreads) add ~12%.
- Result: 100 games on one process 8–12 s → 1.4–1.7 s; mixed + balanced + casual at 1,000 games on 16 workers 57 s → 8 s. All 210 fingerprinted games identical; the v0.8 baseline run prints the same metrics.
- [x] 6.11 Length tuning (D39; closed by D50): at 4 players, bring every lineup to a mean of 60–90 minutes with p90 ≤ 110 (v0.8: mixed 72 / 95 and balanced 68 / 92 inside; casual 91 / 122 and all-aggressive 90 / 127 outside). Find where the time goes (climb to Brown vs Exam phase), bring candidate levers to the developer, simulate the chosen ones on at least two lineups.
- [x] 6.11.1 Where the time goes (scratch `phases.py`, 400 games per lineup; "endgame" = from the first Brown on the table to the win):
| Lineup | Climb / endgame (min) | Winner's stripes gained (15 needed) | Drops out of Brown per game | Stripes lost per game: square / Kumite / Exam |
|---|---|---|---|---|
| mixed | 49 / 22 (31%) | 23.3 | 2.2 | 3.9 / 27.8 / 4.3 |
| balanced | 48 / 21 (30%) | 22.6 | 2.2 | 2.4 / 26.6 / 4.3 |
| casual | 56 / 36 (39%) | 24.7 | 4.0 | 11.2 / 23.1 / 4.4 |
| aggressive | 60 / 32 (35%) | 28.6 | 2.7 | 12.4 / 42.7 / 4.4 |
Casual and aggressive are longer in both phases, most of all in the endgame (+11 to +15 min): casual players fall out of Brown twice as often (Fatica tolls, 11 stripes per game), aggressive ones trade stripes in Kumite (43 lost per game).
- [x] 6.11.2 Existing switches (`--start`, `--exam`), 1,000 games, 4 players, minutes mean / p90:
| Setting | mixed | balanced | casual | aggressive |
|---|---|---|---|---|
| v0.8 (start 0) | 72 / 95 | 68 / 92 | 91 / 122 | 90 / 127 |
| start 1 | 68 / 92 | 65 / 87 | 88 / 122 | 88 / 124 |
| start 2 | 63 / 88 | 59 / 83 | 81 / 112 | 82 / 122 |
| start 3 | 57 / 80 | — | 77 / 110 | 74 / 111 |
| Exam defender not Black (`none`) | 60 / 77 | — | 79 / 103 | 74 / 97 |
The start position shifts every lineup by about the same amount, so it cannot close the ~23-minute gap between balanced and casual: start 2 puts balanced under 60 while aggressive stays at p90 122. The Black-belt defender off pushes the Exam pass rate to 67–80% (target 35–55%) and the rivals at the end to 0.27–0.49: rejected again (as in D31). Other measures barely move with the start position.
- [x] 6.11.3 Candidate levers brought to the developer: L1 Fatica cannot drop a player out of Brown (**rejected by the developer**); L2 a failed challenger loses 0 or 1 stripe instead of 3 (to test); L3 accept the current length (developer: "satisfied enough").
- [x] 6.11.4 L2 switch: `RulesConfig.exam_failure_loss` (default 3; engine and bot `_exam_score`), sim axis `Scenario.exam_loss` / `--exam-loss` (scenario names gain `fail-N`). Fingerprinted games identical at the default. Run: `uv run dojo sim --games 1000 --seed 0 --exam-loss 3 1 0 --lineup balanced,farmer,aggressive balanced casual aggressive`.
| Lineup | Failed Exam loses | Minutes mean / p90 | Exam challengers per game | Lead changes | Halfway leader wins | Worst player out | Rivals within 1 belt |
|---|---|---|---|---|---|---|---|
| mixed | 3 | 72 / 95 | 1.68 | 6.1 | 40% | 13% | 0.84 |
| mixed | 1 | 68 / 88 | 1.48 | 5.0 | 44% | 15% | 0.94 |
| mixed | 0 | 67 / 86 | 1.45 | 4.6 | 44% | 16% | 0.87 |
| balanced | 3 | 68 / 92 | 1.69 | 6.0 | 39% | 10% | 0.81 |
| balanced | 1 | 65 / 84 | 1.50 | 5.0 | 43% | 12% | 0.97 |
| balanced | 0 | 65 / 83 | 1.46 | 4.5 | 43% | 13% | 0.90 |
| casual | 3 | 91 / 122 | 1.74 | 5.7 | 39% | 12% | 1.42 |
| casual | 1 | 87 / 116 | 1.61 | 4.9 | 42% | 13% | 1.47 |
| casual | 0 | 86 / 114 | 1.55 | 4.6 | 41% | 14% | 1.40 |
| aggressive | 3 | 90 / 127 | 1.58 | 7.3 | 41% | 21% | 0.50 |
| aggressive | 1 | 85 / 115 | 1.40 | 6.2 | 45% | 23% | 0.59 |
| aggressive | 0 | 83 / 111 | 1.32 | 5.8 | 49% | 25% | 0.54 |
Exam pass rate unchanged (40–44%). Reading: −1 saves 3–5 minutes on the mean and 6–12 on the p90; 0 adds only 1–2 more. The price is the comeback: the first Brown keeps retrying instead of dropping back (fewer different challengers), lead changes fall by ~1 per game, the halfway leader wins 3–8 points more, the worst player is out a little longer. No setting brings casual under p90 110.
- [ ] 6.9 Catch-up candidate A — **skipped (D44)**. Was: `RulesConfig` switch, Allenamento +2 for a player 2+ belts behind the leader (bot aware of it); compare on the contention, lead and length measures and on the style win rates.
- [x] 6.8 Consolidation: the final configuration at 3 / 4 / 6 / 8 players (start 0 as for balancing, plus start 3 at 6 and 8 as in the rules, D48) with all lineups except `random` (D45), 2,000 games each; every target checked; `RULES.md` updated to the next version with the decisions; open items moved to Phase 7.
- [x] 6.8.1 First run, **with the biased bots (before D51; superseded by 6.8.6)** (rules v0.9 = v0.8 + D47–D50, board draft 2; 2,000 games per scenario, 3 min 27 s): `uv run dojo sim --games 2000 --seed 0 --players 3 4 6 8 --start 0 --lineup balanced,farmer,aggressive balanced casual aggressive`, and the same with `--players 6 8 --start 3`. All games finished; Exam pass rate 41–44% everywhere; win rate by bot style (mixed) within 4 pp at every count.
| Players | Start | Lineup | Minutes mean / p90 | Exam passed | Halfway leader wins | Out of contention; worst player | Rivals within 1 belt | Win rate by seat |
|---|---|---|---|---|---|---|---|---|
| 3 | 0 | mixed | 54 / 75 | 42% | 50% | 3%; 9% | 0.60 | 29 / 37 / 34 |
| 3 | 0 | balanced | 53 / 72 | 42% | 46% | 3%; 7% | 0.59 | 33 / 34 / 34 |
| 3 | 0 | casual | 71 / 98 | 44% | 45% | 2%; 6% | 1.04 | 33 / 33 / 34 |
| 3 | 0 | aggressive | 64 / 90 | 42% | 52% | 6%; 14% | 0.36 | 29 / 34 / 37 |
| 4 | 0 | mixed | 72 / 98 | 41% | 40% | 4%; 13% | 0.86 | 24 / 27 / 26 / 24 |
| 4 | 0 | balanced | 68 / 92 | 42% | 40% | 3%; 11% | 0.82 | 22 / 26 / 26 / 26 |
| 4 | 0 | casual | 91 / 123 | 43% | 38% | 4%; 12% | 1.41 | 22 / 27 / 24 / 26 |
| 4 | 0 | aggressive | 90 / 128 | 42% | 43% | 8%; 21% | 0.51 | 21 / 26 / 29 / 24 |
| 6 | 0 | mixed | 106 / 144 | 42% | 31% | 9%; 29% | 1.17 | 12 / 16 / 15 / 18 / 20 / 20 |
| 6 | 0 | balanced | 100 / 135 | 42% | 30% | 8%; 28% | 1.07 | 13 / 17 / 13 / 18 / 19 / 20 |
| 6 | 0 | casual | 135 / 182 | 42% | 27% | 8%; 27% | 1.90 | 16 / 15 / 15 / 16 / 20 / 18 |
| 6 | 0 | aggressive | 131 / 183 | 42% | 29% | 12%; 35% | 0.78 | 13 / 19 / 14 / 17 / 20 / 17 |
| 8 | 0 | mixed | 133 / 183 | 42% | 27% | 11%; 37% | 1.44 | 9 / 12 / 11 / 12 / 13 / 13 / 14 / 15 |
| 8 | 0 | balanced | 127 / 172 | 43% | 25% | 11%; 38% | 1.30 | 9 / 11 / 11 / 13 / 13 / 13 / 14 / 15 |
| 8 | 0 | casual | 170 / 231 | 44% | 23% | 11%; 38% | 2.15 | 12 / 12 / 12 / 13 / 14 / 12 / 14 / 12 |
| 8 | 0 | aggressive | 167 / 234 | 43% | 25% | 16%; 43% | 1.01 | 9 / 13 / 11 / 14 / 14 / 12 / 15 / 13 |
| 6 | 3 | mixed | 85 / 123 | 42% | 31% | 8%; 28% | 1.16 | 13 / 17 / 13 / 17 / 20 / 20 |
| 6 | 3 | balanced | 81 / 115 | 42% | 29% | 7%; 28% | 1.17 | 15 / 15 / 15 / 17 / 21 / 18 |
| 6 | 3 | casual | 113 / 160 | 42% | 30% | 8%; 28% | 1.92 | 14 / 16 / 15 / 16 / 19 / 20 |
| 6 | 3 | aggressive | 106 / 158 | 42% | 32% | 12%; 38% | 0.81 | 11 / 18 / 14 / 18 / 22 / 18 |
| 8 | 3 | mixed | 107 / 150 | 42% | 27% | 10%; 38% | 1.51 | 10 / 12 / 10 / 13 / 14 / 13 / 14 / 13 |
| 8 | 3 | balanced | 101 / 145 | 42% | 28% | 11%; 40% | 1.35 | 10 / 12 / 11 / 12 / 15 / 14 / 13 / 13 |
| 8 | 3 | casual | 144 / 203 | 42% | 24% | 11%; 40% | 2.33 | 11 / 12 / 11 / 13 / 13 / 13 / 14 / 13 |
| 8 | 3 | aggressive | 135 / 202 | 41% | 27% | 17%; 49% | 1.05 | 10 / 12 / 10 / 14 / 14 / 14 / 14 / 13 |
- [x] 6.8.2 Targets at 4 players (start 0; first run, see 6.8.6 for the final figures): inside for mixed and balanced on every measure. Accepted misses (D50): casual 91 / 123 and aggressive 90 / 128 minutes; aggressive seats 21–29% and rivals 0.51 (edge). 3 players: short (53–71 min), halfway leader wins 45–52% (edge of 35–55%), rivals 0.36–0.60 with heuristic bots. 6–8 players: long (D48 accepted), worst player out 27–49% of turns, halfway leader wins 23–32%.
- [x] 6.8.3 `RULES.md` v0.9: D47–D50 in the text; "Rule variants under test" replaced by "Tested alternatives" (the simulator switches).
- [x] 6.8.4 Seat order (finding; studied in 6.2, closed by D51 and D52): the first seat is behind at every count, and more so with more players — 4 players 21–24% (fair 25%), 6 players 11–16% (fair 17%), 8 players 9–12% (fair 12.5%); later seats rise to 20% (6) and 15% (8). At 6 players seats 4 and 5 are the two players alone in a corner (`seat_corners` gives 0, 1, 0, 1, 2, 3) and win most (17–22%), so sharing a Safe Room costs too. 6.2 (suspended, D43) would be the step to study it.
- [x] 6.8.5 Open items moved to Phase 7 (7.0).
- [x] 6.8.6 Final run with the fixed bots (D51, D52; same commands as 6.8.1). All games finished; Exam pass rate 40–44% everywhere; bot-style spread (mixed) 2–4 pp.
| Players | Start | Lineup | Minutes mean / p90 | Exam passed | Halfway leader wins | Out of contention; worst player | Rivals within 1 belt | Win rate by seat | Bot spread |
|---|---|---|---|---|---|---|---|---|---|
| 3 | 0 | mixed | 53 / 72 | 41% | 50% | 2%; 6% | 0.71 | 31 / 34 / 36 | 4 pp |
| 3 | 0 | balanced | 51 / 70 | 40% | 48% | 1%; 4% | 0.74 | 31 / 32 / 37 | — |
| 3 | 0 | casual | 70 / 97 | 44% | 45% | 2%; 5% | 1.06 | 34 / 32 / 34 | — |
| 3 | 0 | aggressive | 61 / 86 | 41% | 52% | 4%; 11% | 0.45 | 33 / 33 / 35 | — |
| 4 | 0 | mixed | 67 / 91 | 42% | 43% | 3%; 11% | 0.95 | 23 / 25 / 26 / 26 | 3 pp |
| 4 | 0 | balanced | 65 / 86 | 42% | 38% | 2%; 8% | 0.96 | 24 / 25 / 26 / 25 | — |
| 4 | 0 | casual | 90 / 122 | 43% | 38% | 3%; 10% | 1.48 | 24 / 26 / 26 / 24 | — |
| 4 | 0 | aggressive | 82 / 114 | 41% | 40% | 6%; 17% | 0.65 | 22 / 26 / 26 / 27 | — |
| 6 | 0 | mixed | 101 / 135 | 42% | 33% | 8%; 27% | 1.27 | 13 / 17 / 16 / 15 / 18 / 20 | 3 pp |
| 6 | 0 | balanced | 96 / 128 | 42% | 30% | 7%; 25% | 1.23 | 14 / 16 / 15 / 17 / 18 / 20 | — |
| 6 | 0 | casual | 130 / 176 | 44% | 29% | 7%; 25% | 1.91 | 15 / 16 / 16 / 16 / 19 / 17 | — |
| 6 | 0 | aggressive | 121 / 167 | 41% | 31% | 11%; 32% | 0.96 | 15 / 15 / 16 / 16 / 18 / 20 | — |
| 8 | 0 | mixed | 130 / 171 | 43% | 25% | 10%; 34% | 1.56 | 10 / 12 / 12 / 13 / 12 / 12 / 14 / 15 | 3 pp |
| 8 | 0 | balanced | 123 / 164 | 43% | 25% | 10%; 36% | 1.41 | 11 / 11 / 12 / 13 / 14 / 13 / 13 / 13 | — |
| 8 | 0 | casual | 168 / 224 | 44% | 23% | 10%; 36% | 2.25 | 11 / 13 / 12 / 13 / 12 / 14 / 12 / 14 | — |
| 8 | 0 | aggressive | 154 / 212 | 43% | 27% | 14%; 42% | 1.18 | 11 / 11 / 11 / 14 / 13 / 11 / 15 / 14 | — |
| 6 | 3 | mixed | 80 / 111 | 43% | 33% | 6%; 25% | 1.35 | 15 / 14 / 17 / 17 / 18 / 19 | 3 pp |
| 6 | 3 | balanced | 77 / 106 | 42% | 30% | 6%; 24% | 1.29 | 14 / 15 / 16 / 18 / 19 / 18 | — |
| 6 | 3 | casual | 108 / 150 | 44% | 30% | 7%; 26% | 1.97 | 16 / 15 / 16 / 16 / 18 / 19 | — |
| 6 | 3 | aggressive | 95 / 136 | 43% | 34% | 10%; 34% | 1.01 | 14 / 16 / 15 / 19 / 18 / 18 | — |
| 8 | 3 | mixed | 102 / 144 | 43% | 28% | 8%; 34% | 1.68 | 10 / 12 / 10 / 14 / 13 / 13 / 14 / 14 | 2 pp |
| 8 | 3 | balanced | 97 / 138 | 42% | 28% | 10%; 37% | 1.49 | 11 / 10 / 12 / 12 / 15 / 14 / 14 / 12 | — |
| 8 | 3 | casual | 140 / 198 | 43% | 25% | 10%; 38% | 2.39 | 12 / 11 / 12 / 14 / 11 / 13 / 15 / 12 | — |
| 8 | 3 | aggressive | 122 / 178 | 41% | 28% | 14%; 45% | 1.27 | 10 / 12 / 12 / 14 / 12 / 15 / 13 / 12 | — |
- Targets at 4 players: mixed (67 / 91) and balanced (65 / 86) inside on every measure; seats 22–27% in every lineup. Accepted misses (D50): casual 90 / 122 (mean at the edge, p90 over), aggressive 82 / 114 (p90 over). Rivals within one belt 0.65–1.48.
- 3 players: 51–70 min; halfway leader wins 45–52% (edge); rivals 0.45–1.06. 6–8 players: long (D48), worst player out 24–45% of turns, halfway leader wins 23–34%; from Yellow, 6 players take 77–108 min.
### Phase 7 — Finalisation (later) ### Phase 7 — Finalisation (later)
- [ ] 7.0 Open items carried over from Phase 6 (none blocks 7.1):
- Q2 board asymmetry (deferred by the developer).
- 6.4.1 chance square (parked, D49).
- Heuristic bots draw a Nebbia card on only 2.3% of turns (6.6): card effects are measured mostly through the casual bot.
- Remaining small bot tie-breaks by absolute board order (Tunnel destination; the engine keeps the first path per outcome): corner residual 24.0–25.4% after D51.
- At 6 players the two seats alone in a Safe Room win 17–20% vs 13–17% for the four sharing (6.2.3); accepted with D52.
- [ ] 7.1 Freeze rules. - [ ] 7.1 Freeze rules.
- [ ] 7.2 Italian edition. - [ ] 7.2 Italian edition.
- [x] 7.2.0 Playtest edition (D53): `RULES.it.md` translated from RULES.md v0.9; quick reference; `edition/build.py` (dev dependency `markdown`) writes `edition/it/playtest.html`, 15 A4 pages when printed (checked by printing to PDF with headless Chromium): quick reference, rules (5 pages), belt track, board overview, 4 board tiles (23 mm squares, Tunnel pairs numbered 1–8 across the board, Dojo entrances marked in gold), 3 card sheets (63 × 88 mm). No card backs.
- [ ] 7.2.1 Playtest feedback folded into `RULES.md` and `RULES.it.md`.
- [ ] 7.3 Physical tracking method and components; printable board. - [ ] 7.3 Physical tracking method and components; printable board.
- [ ] Tunnel pairs need unique labels on the printed board (the text format repeats each digit in all 4 quadrants). - [x] Tunnel pairs need unique labels on the printed board (the text format repeats each digit in all 4 quadrants): numbered 1–8 by `edition/build.py`.
## Questions ## Questions
@@ -221,19 +503,19 @@ Deliverables:
- Final Exam → the defender fights as a Black belt (D31). - Final Exam → the defender fights as a Black belt (D31).
- Q5 Phase 6 targets → confirmed as a starting point (D32). - Q5 Phase 6 targets → confirmed as a starting point (D32).
- Q6 Fatica avoidable (bots stop on it once in ~4,000 turns; ~24 distinct end squares per move; Allenamento reachable on 79% of moves, Fatica never forced) → test the chance square first, then Fatica on crossing (D33). - Q6 Fatica avoidable (bots stop on it once in ~4,000 turns; ~24 distinct end squares per move; Allenamento reachable on 79% of moves, Fatica never forced) → test the chance square first, then Fatica on crossing (D33).
- Q7 Casual bot sample size → the blind sample of 6 is far weaker than the heuristic bot (14–18% vs 36–46% win rate per seat); bots stay as they are (D40).
- Safe Room placement → the owner places the pawn on any square of their room (setup and when sent to Start). - Safe Room placement → the owner places the pawn on any square of their room (setup and when sent to Start).
### Open ### Open
- Q7 Casual bot sample size: 6 offered moves is a guess at human play; revisit if 6.1 shows it is far weaker or stronger than the heuristic bot.
- Q2 Board asymmetry: keep the quadrant as source of truth, or later switch to the full board as source of truth with a symmetry *report* (warnings) instead of generation? *(Deferred by the developer.)* - Q2 Board asymmetry: keep the quadrant as source of truth, or later switch to the full board as source of truth with a symmetry *report* (warnings) instead of generation? *(Deferred by the developer.)*
### Risks ### Risks
- Movement enumeration: self-avoiding walks of length up to 12 can explode in open rooms. Keep rooms small; prune by outcome (destination + ordered opponents crossed + Dojo entry). - Movement enumeration: self-avoiding walks of length up to 12 can explode in open rooms. Keep rooms small; prune by outcome (destination + ordered opponents crossed + Dojo entry).
- Engine speed ~1,400 turns/s (random and heuristic bots, real board). If Phase 5 needs more: precompute neighbour lists in `Board`. - Engine speed: after 6.10, ~65 games/s per process (heuristic and casual bots). Further gains would need changing the move enumeration itself (e.g. grouping walks by outcome), not attempted.
- Simulation conclusions depend on bot quality; compare several profiles before trusting a result. - Simulation conclusions depend on bot quality; compare several profiles before trusting a result.
- Board draft 1 puts 4 Allenamento in each 3x3 room next to the Safe Room: possible "home farming" (see the Phase 2 review). Measured (Phase 5 baseline): square income is +0.38/turn in the own quadrant vs +0.14/turn elsewhere; 6.5 decides whether that is a problem. - Home farming: board draft 2 (D42) moved the Allenamento onto the Dojo ring; the own-quadrant share of square income is 62–67% (draft 1: 74%), above the 60% aim because players cross their own quadrant for most of the game. Accepted with draft 2 (6.5).
## External documentation ## External documentation
+69
View File
@@ -0,0 +1,69 @@
"""Check that an engine change leaves every game identical (events hashed per game).
Record before the change, check after (a rule change is expected to differ):
uv run python agents/scripts/fingerprint_games.py write /tmp/fingerprints.json
uv run python agents/scripts/fingerprint_games.py check /tmp/fingerprints.json
"""
import hashlib
import json
import sys
from concurrent.futures import ProcessPoolExecutor
from pathlib import Path
from dojo.board import load_board
from dojo.engine import Game, RandomSource
from dojo.rules import RulesConfig
from dojo.sim import make_agent
BOARD = Path(__file__).resolve().parents[2] / "board"
GAMES = 60
CASES = [
("mixed4", RulesConfig(), ("balanced", "farmer", "aggressive", "balanced"), GAMES),
("casual4", RulesConfig(), ("casual",) * 4, GAMES),
(
"mixed6",
RulesConfig(),
("casual", "farmer", "aggressive", "balanced", "casual", "farmer"),
30,
),
(
"stop-r1",
RulesConfig(fatica_on_crossing=False, first_kumite_round=1),
("casual", "balanced", "farmer"),
30,
),
("random", RulesConfig(max_rounds=60), ("random", "casual", "random", "balanced"), 30),
]
def play(case: int, seed: int) -> tuple[str, int, str]:
name, config, bots, _ = CASES[case]
board = load_board(BOARD / "quadrant.txt", BOARD / "legend.toml")
agents = [make_agent(bot, seed, seat) for seat, bot in enumerate(bots)]
game = Game(board, config, agents, RandomSource(seed))
game.run()
digest = hashlib.sha256("\n".join(map(repr, game.events)).encode()).hexdigest()
return name, seed, digest
def main() -> None:
mode, path = sys.argv[1], Path(sys.argv[2])
tasks = [(i, seed) for i, case in enumerate(CASES) for seed in range(case[3])]
with ProcessPoolExecutor(8) as pool:
results = {f"{n}:{s}": d for n, s, d in pool.map(play, *zip(*tasks, strict=True))}
if mode == "write":
path.write_text(json.dumps(results, indent=0))
print(f"wrote {len(results)} games")
return
expected = json.loads(path.read_text())
bad = [k for k in expected if results.get(k) != expected[k]]
print(f"{len(expected) - len(bad)}/{len(expected)} games identical")
if bad:
print("differ:", bad[:10])
sys.exit(1)
if __name__ == "__main__":
main()
+14 -14
View File
@@ -1,33 +1,33 @@
+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+ +---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+
| S S S | . 1 ? F A | A . 2 P . | S S S | | S S S | . 1 ? A A | A . 2 P . | S S S |
+ + + + +---+ +---+ + +---+---+---+ + + + + + + + + +---+ +---+ + +---+---+---+ + + + +
| S S S . | A . A | P | 1 | A . A | . S S S | | S S S . | ? . A | P | 1 | ? . . | . S S S |
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
| S S S | ? | . ? . | 2 | . . ? . . | S S S | | S S S | ? | . ? . | 2 | . . ? . . | S S S |
+---+ +---+ + + + + + + + + + +---+ +---+ +---+ +---+ + + + + + + + + + +---+ +---+
| . . . . A F A | . F | A . A | . ? . . | | . . . . F . A | . A | A . A | . ? . . |
+ +---+ +---+---+---+ + + + +---+---+ +---+---+ + + +---+ +---+---+---+ + + + +---+---+ +---+---+ +
| P | A . A | . ? . | F | F . A . | A . A | 1 | | P | . . A | . ? . | A | ? A A . | F . ? | 1 |
+ + + + + + +---+ + +---+---+ + + + + + + + + + + + +---+ + +---+---+ + + + + +
| 2 | . ? . | A | A . ? A K A ? | F ? . ? | | 2 | . ? . | A | F A A A K F ? | . ? . ? |
+ + + + + + + +---+---+---+ + + + + + + + + + + + + + +---+---+---+ + + + + + +
| . | A . A . | K | D D D D . | . A . A | F | | . | ? . A A | K | D D D D A | . A . A | A |
+ +---+ +---+ + + + + + + +---+---+---+---+ + + +---+ +---+ + + + + + + +---+---+---+---+ +
| A 1 . F F A | D D D D | ? F . 2 P A | | A 1 . A ? A | D D D D | A A . 2 P A |
+---+---+---+ +---+ + + + + + +---+ +---+---+---+ +---+---+---+ +---+ + + + + + +---+ +---+---+---+
| A P 2 . F ? | D D D D | A F F . 1 A | | A P 2 . A A | D D D D | A ? A . 1 A |
+ +---+---+---+---+ + + + + + + +---+ +---+ + + +---+---+---+---+ + + + + + + +---+ +---+ +
| F | A . A . | . D D D D | K | . A . A | . | | A | A . A . | A D D D D | K | A A . ? | . |
+ + + + + + +---+---+---+ + + + + + + + + + + + + + +---+---+---+ + + + + + + +
| ? . ? F | ? A K A ? . A | A | . ? . | 2 | | ? . ? . | ? F K A A A F | A | . ? . | 2 |
+ + + + + +---+---+ + +---+ + + + + + + + + + + + +---+---+ + +---+ + + + + + +
| 1 | A . A | . A . F | F | . ? . | A . A | P | | 1 | ? . F | . A A ? | A | . ? . | A . . | P |
+ +---+---+ +---+---+ + + + +---+---+---+ +---+ + + +---+---+ +---+---+ + + + +---+---+---+ +---+ +
| . . ? . | A . A | F . | A F A . . . . | | . . ? . | A . A | A . | A . F . . . . |
+---+ +---+ + + + + + + + + + +---+ +---+ +---+ +---+ + + + + + + + + + +---+ +---+
| S S S | . . ? . . | 2 | . ? . | ? | S S S | | S S S | . . ? . . | 2 | . ? . | ? | S S S |
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
| S S S . | A . A | 1 | P | A . A | . S S S | | S S S . | . . ? | 1 | P | A . ? | . S S S |
+ + + + +---+---+---+ + +---+ +---+ + + + + + + + + +---+---+---+ + +---+ +---+ + + + +
| S S S | . P 2 . A | A F ? 1 . | S S S | | S S S | . P 2 . A | A A ? 1 . | S S S |
+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+ +---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+
+11 -9
View File
@@ -1,20 +1,22 @@
# Quadrant 0 (top-left). Rotated 3x into the full board. Format: board/FORMAT.md # Quadrant 0 (top-left). Rotated 3x into the full board. Format: board/FORMAT.md
# Draft 1: north/east/west corridors, two 3x3 training rooms (rows 1-3 and rows 4-6), # Draft 2: walls as draft 1 (north/east/west corridors, two training rooms, ring around
# middle corridor, ring around the Dojo shared with the neighbouring quadrants. # the Dojo). Built for Fatica on crossing (RULES.md v0.8): two Fatica per quadrant as tolls,
# on the row-3 corridor (3,4) and the inner Dojo approach (5,5); Allenamento moved from the
# south training room onto the Dojo ring, where visitors walk.
+---+---+---+---+---+---+---+---+ +---+---+---+---+---+---+---+---+
| S S S | . 1 ? F A | | S S S | . 1 ? A A |
+ + + + +---+ +---+ + + + + + +---+ +---+ +
| S S S . | A . A | P | | S S S . | ? . A | P |
+ + + + + + + + + + + + + + + + + +
| S S S | ? | . ? . | 2 | | S S S | ? | . ? . | 2 |
+---+ +---+ + + + + + +---+ +---+ + + + + +
| . . . . A F A | . | . . . . F . A | .
+ +---+ +---+---+---+ + + + +---+ +---+---+---+ + +
| P | A . A | . ? . | F | | P | . . A | . ? . | A |
+ + + + + + +---+ + + + + + + + +---+ +
| 2 | . ? . | A | A . ? | 2 | . ? . | A | F A A
+ + + + + + + +---+ + + + + + + + +---+
| . | A . A . | K | D D | . | ? . A A | K | D D
+ +---+ +---+ + + + + + +---+ +---+ + + + +
| A 1 . F F A | D D | A 1 . A ? A | D D
+---+---+---+ +---+ + + + +---+---+---+ +---+ + + +
+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;
}
}
+1
View File
@@ -17,6 +17,7 @@ build-backend = "uv_build"
[dependency-groups] [dependency-groups]
dev = [ dev = [
"markdown>=3.11",
"pytest>=9.1.1", "pytest>=9.1.1",
"ruff>=0.16.9", "ruff>=0.16.9",
] ]
+36 -7
View File
@@ -97,21 +97,50 @@ class Board:
def in_bounds(self, p: Pos) -> bool: def in_bounds(self, p: Pos) -> bool:
return 0 <= p.row < self.size and 0 <= p.col < self.size return 0 <= p.row < self.size and 0 <= p.col < self.size
def neighbours(self, p: Pos) -> Iterator[Pos]: def neighbours(self, p: Pos) -> tuple[Pos, ...]:
for dr, dc in ((-1, 0), (0, 1), (1, 0), (0, -1)): """Squares one open step from ``p`` (in bounds), in the order N, E, S, W."""
q = Pos(p.row + dr, p.col + dc) return self._adjacency[p]
if self.in_bounds(q) and edge(p, q) not in self.walls:
yield q @cached_property
def _adjacency(self) -> dict[Pos, tuple[Pos, ...]]:
return {
p: tuple(
q
for q in (
Pos(p.row - 1, p.col),
Pos(p.row, p.col + 1),
Pos(p.row + 1, p.col),
Pos(p.row, p.col - 1),
)
if self.in_bounds(q) and edge(p, q) not in self.walls
)
for p in self.positions()
}
def cells_with(self, code: str) -> frozenset[Pos]: def cells_with(self, code: str) -> frozenset[Pos]:
return frozenset(p for p in self.positions() if self.code(p) == code) return self._cells_by_code.get(code, frozenset())
@cached_property
def _cells_by_code(self) -> dict[str, frozenset[Pos]]:
by_code: dict[str, set[Pos]] = {}
for p in self.positions():
by_code.setdefault(self.code(p), set()).add(p)
return {code: frozenset(cells) for code, cells in by_code.items()}
@cached_property @cached_property
def dojo(self) -> frozenset[Pos]: def dojo(self) -> frozenset[Pos]:
return self.cells_with(Square.DOJO) return self.cells_with(Square.DOJO)
def safe_room(self, quadrant: int) -> frozenset[Pos]: def safe_room(self, quadrant: int) -> frozenset[Pos]:
return frozenset(p for p in self.cells_with(Square.SAFE) if quadrant_of(p) == quadrant) return self._safe_rooms.get(quadrant, frozenset())
@cached_property
def _safe_rooms(self) -> dict[int, frozenset[Pos]]:
safe = self.cells_with(Square.SAFE)
return {
quadrant: frozenset(p for p in safe if quadrant_of(p) == quadrant)
for quadrant in range(QUADRANTS)
}
def to_grid(self) -> Grid: def to_grid(self) -> Grid:
n = self.size n = self.size
+81 -11
View File
@@ -8,11 +8,11 @@ position, plus closeness to the Dojo once Brown); a ``Profile`` sets its attitud
import math import math
import random import random
from collections import deque from collections import deque
from collections.abc import Mapping, Sequence from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass from dataclasses import dataclass
from functools import cache from functools import cache
from dojo.board import Board, Pos, Square, is_tunnel from dojo.board import Board, Pos, Square, is_tunnel, rotate
from dojo.engine import FightView, GameView, PlayerView from dojo.engine import FightView, GameView, PlayerView
from dojo.movement import Path from dojo.movement import Path
from dojo.rules import BLACK, Card from dojo.rules import BLACK, Card
@@ -27,6 +27,8 @@ CARD_VALUE = 0.2
TECNICA_EDGE = 0.1 TECNICA_EDGE = 0.1
# Passing the Final Exam wins the game; this dwarfs every other score. # Passing the Final Exam wins the game; this dwarfs every other score.
EXAM_WIN = 100.0 EXAM_WIN = 100.0
# Moves a casual player compares (Q7: a guess at human play).
CASUAL_SAMPLE = 6
class RandomAgent: class RandomAgent:
@@ -126,9 +128,33 @@ def farthest_from_dojo(board: Board) -> int:
@cache @cache
def start_square(board: Board, corner: int) -> Pos: def start_square(board: Board, corner: int) -> Pos:
"""The square of a Safe Room a bot starts from: the one nearest to the Dojo.""" """The square of a Safe Room a bot starts from: the one nearest to the Dojo.
Ties go to the square furthest down, then right, in the room's own frame, so every corner
starts alike; on board draft 2 that is the better of the two (plan 6.2.1).
"""
distance = dojo_distance(board) distance = dojo_distance(board)
return min(sorted(board.safe_room(corner)), key=lambda s: distance[s]) return min(board.safe_room(corner), key=lambda s: (distance[s], _negated(own_frame(s, corner))))
def own_frame(square: Pos, corner: int) -> Pos:
"""``square`` as seen from quadrant 0, i.e. rotated back from ``corner``'s quadrant."""
return rotate(square, -corner)
def _negated(p: Pos) -> tuple[int, int]:
return (-p.row, -p.col)
def best_of[T](
options: Sequence[T], score: Callable[[T], float], frame: Callable[[T], tuple[Pos, ...]]
) -> T:
"""The highest-scoring option; ties go to the lowest ``frame`` key, so that a bot decides
alike in every corner instead of by absolute board order."""
scored = [(score(option), option) for option in options]
top = max(value for value, _ in scored)
tied = [option for value, option in scored if value == top]
return tied[0] if len(tied) == 1 else min(tied, key=frame)
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -156,12 +182,18 @@ class HeuristicAgent:
# Agent protocol --------------------------------------------------------------------- # Agent protocol ---------------------------------------------------------------------
def place_pawn(self, view: GameView, squares: Sequence[Pos]) -> Pos: def place_pawn(self, view: GameView, squares: Sequence[Pos]) -> Pos:
return self._nearest_to_dojo(view.board, squares) square = start_square(view.board, self._me(view).corner)
return square if square in squares else self._nearest_to_dojo(view.board, squares)
def choose_path(self, view: GameView, paths: Sequence[Path]) -> Path: def choose_path(self, view: GameView, paths: Sequence[Path]) -> Path:
v0 = self._value(view, self._me(view)) v0 = self._value(view, self._me(view))
opponents = self._fightable(view) opponents = self._fightable(view)
return max(paths, key=lambda path: self._path_score(view, path, opponents, v0)) corner = self._me(view).corner
return best_of(
paths,
lambda path: self._path_score(view, path, opponents, v0),
lambda path: tuple(own_frame(cell, corner) for cell in path),
)
def use_tunnel(self, view: GameView, exit: Pos) -> bool: def use_tunnel(self, view: GameView, exit: Pos) -> bool:
me = self._me(view) me = self._me(view)
@@ -238,6 +270,8 @@ class HeuristicAgent:
for square in path: for square in path:
if square in view.board.dojo: if square in view.board.dojo:
return score + alive * self._exam_score(view, track, previous, v0) return score + alive * self._exam_score(view, track, previous, v0)
if self._costs_fatica(view, square):
track = view.config.shifted(track, -1)
opponent = opponents.get(square) opponent = opponents.get(square)
if opponent is not None: if opponent is not None:
fight = self._fight(view, track, opponent, v0) fight = self._fight(view, track, opponent, v0)
@@ -259,7 +293,7 @@ class HeuristicAgent:
match code: match code:
case Square.ALLENAMENTO: case Square.ALLENAMENTO:
return at(config.shifted(track, 1), square) return at(config.shifted(track, 1), square)
case Square.FATICA: case Square.FATICA if not config.fatica_on_crossing:
return at(config.shifted(track, -1), square) return at(config.shifted(track, -1), square)
case Square.NEBBIA: case Square.NEBBIA:
return at(track, square, CARD_VALUE) return at(track, square, CARD_VALUE)
@@ -273,7 +307,7 @@ class HeuristicAgent:
case _ if is_tunnel(code): case _ if is_tunnel(code):
stay = at(track, square) stay = at(track, square)
exit_square = view.board.tunnels[square] exit_square = view.board.tunnels[square]
if self._has_meditator(view, exit_square): if self._has_unfightable_pawn(view, exit_square):
return stay return stay
return max(stay, self._arrival_score(view, track, exit_square, v0)) return max(stay, self._arrival_score(view, track, exit_square, v0))
case _: case _:
@@ -339,7 +373,7 @@ class HeuristicAgent:
chance = series_probability(round_chance, view.config.exam_rounds) chance = series_probability(round_chance, view.config.exam_rounds)
failed = self._value_at( failed = self._value_at(
view, view,
view.config.shifted(track, -view.config.stripes_per_belt), view.config.shifted(track, -view.config.exam_failure_loss),
start_square(view.board, me.corner), start_square(view.board, me.corner),
) )
return chance * EXAM_WIN + (1 - chance) * self._weigh(failed - v0) return chance * EXAM_WIN + (1 - chance) * self._weigh(failed - v0)
@@ -385,14 +419,28 @@ class HeuristicAgent:
return view.players[view.me] return view.players[view.me]
def _fightable(self, view: GameView) -> dict[Pos, PlayerView]: def _fightable(self, view: GameView) -> dict[Pos, PlayerView]:
if not self._kumite_allowed(view):
return {}
return { return {
p.position: p p.position: p
for p in view.players for p in view.players
if p.id != view.me and not p.meditating and not self._is_safe(view, p.position) if p.id != view.me and not p.meditating and not self._is_safe(view, p.position)
} }
def _has_meditator(self, view: GameView, square: Pos) -> bool: def _has_unfightable_pawn(self, view: GameView, square: Pos) -> bool:
return any(p.id != view.me and p.meditating and p.position == square for p in view.players) allowed = self._kumite_allowed(view)
return any(
p.id != view.me and p.position == square and (p.meditating or not allowed)
for p in view.players
)
@staticmethod
def _kumite_allowed(view: GameView) -> bool:
return view.round >= view.config.first_kumite_round
@staticmethod
def _costs_fatica(view: GameView, square: Pos) -> bool:
return view.config.fatica_on_crossing and view.board.code(square) == Square.FATICA
@staticmethod @staticmethod
def _is_safe(view: GameView, square: Pos) -> bool: def _is_safe(view: GameView, square: Pos) -> bool:
@@ -402,3 +450,25 @@ class HeuristicAgent:
def _nearest_to_dojo(board: Board, squares: Sequence[Pos]) -> Pos: def _nearest_to_dojo(board: Board, squares: Sequence[Pos]) -> Pos:
distance = dojo_distance(board) distance = dojo_distance(board)
return min(squares, key=lambda s: distance[s]) return min(squares, key=lambda s: distance[s])
class CasualAgent(HeuristicAgent):
"""The heuristic bot comparing only a random sample of the offered moves.
A stand-in for a person, who cannot weigh the ~24 end squares of a typical roll.
The sample is blind: a path into the Dojo is found only if it is drawn.
"""
def __init__(
self, rng: random.Random, profile: Profile = BALANCED, sample: int = CASUAL_SAMPLE
) -> None:
if sample < 1:
raise ValueError(f"sample size {sample}: expected at least 1")
super().__init__(profile)
self._rng = rng
self.sample = sample
def choose_path(self, view: GameView, paths: Sequence[Path]) -> Path:
if len(paths) > self.sample:
paths = self._rng.sample(paths, self.sample)
return super().choose_path(view, paths)
+29 -4
View File
@@ -7,8 +7,17 @@ from pathlib import Path
from dojo.board import QUADRANTS, Board, BoardError, load_board from dojo.board import QUADRANTS, Board, BoardError, load_board
from dojo.grid import GridFormatError, render_grid from dojo.grid import GridFormatError, render_grid
from dojo.metrics import summarize from dojo.metrics import summarize
from dojo.report import format_summary, write_games_csv from dojo.report import format_summary, write_games_csv, write_summary_csv
from dojo.sim import BOTS, EXAM_OPTIONS, NO_EXAM_OPTIONS, RULES_EXAM, BoardFiles, matrix, run from dojo.sim import (
BOTS,
EXAM_OPTIONS,
NO_EXAM_OPTIONS,
RULES_EXAM,
RULES_EXAM_LOSS,
BoardFiles,
matrix,
run,
)
from dojo.validate import Report, validate from dojo.validate import Report, validate
DEFAULT_QUADRANT = Path("board/quadrant.txt") DEFAULT_QUADRANT = Path("board/quadrant.txt")
@@ -58,6 +67,13 @@ def main(argv: list[str] | None = None) -> int:
sim_parser.add_argument("--players", nargs="+", type=int, default=[4]) sim_parser.add_argument("--players", nargs="+", type=int, default=[4])
sim_parser.add_argument("--kiai", nargs="+", type=int, default=[2], help="Kiai bonus") sim_parser.add_argument("--kiai", nargs="+", type=int, default=[2], help="Kiai bonus")
sim_parser.add_argument("--start", nargs="+", type=int, default=[0], help="start position") sim_parser.add_argument("--start", nargs="+", type=int, default=[0], help="start position")
sim_parser.add_argument(
"--exam-loss",
nargs="+",
type=int,
default=[RULES_EXAM_LOSS],
help=f"stripes a failed Exam challenger loses (rules: {RULES_EXAM_LOSS})",
)
sim_parser.add_argument( sim_parser.add_argument(
"--lineup", "--lineup",
nargs="+", nargs="+",
@@ -68,6 +84,9 @@ def main(argv: list[str] | None = None) -> int:
"--jobs", type=int, default=os.cpu_count() or 1, help="worker processes" "--jobs", type=int, default=os.cpu_count() or 1, help="worker processes"
) )
sim_parser.add_argument("--csv", type=Path, help="write one row per game to this file") sim_parser.add_argument("--csv", type=Path, help="write one row per game to this file")
sim_parser.add_argument(
"--summary-csv", type=Path, help="write one row per scenario to this file"
)
args = parser.parse_args(argv) args = parser.parse_args(argv)
legend = _resolve_legend(args.quadrant, args.legend) legend = _resolve_legend(args.quadrant, args.legend)
@@ -101,14 +120,20 @@ def _simulate(args: argparse.Namespace, files: BoardFiles, parser: argparse.Argu
args.kiai, args.kiai,
args.start, args.start,
[tuple(lineup.split(",")) for lineup in args.lineup], [tuple(lineup.split(",")) for lineup in args.lineup],
args.exam_loss,
) )
except ValueError as error: except ValueError as error:
parser.error(str(error)) parser.error(str(error))
games = run(files, scenarios, args.games, seed=args.seed, jobs=max(1, args.jobs)) games = run(files, scenarios, args.games, seed=args.seed, jobs=max(1, args.jobs))
for scenario, rows in groupby(games, key=lambda game: game.scenario): summaries = [
print(format_summary(summarize(scenario, rows)), end="\n\n") summarize(scenario, rows) for scenario, rows in groupby(games, key=lambda g: g.scenario)
]
for summary in summaries:
print(format_summary(summary), end="\n\n")
if args.csv is not None: if args.csv is not None:
write_games_csv(args.csv, games) write_games_csv(args.csv, games)
if args.summary_csv is not None:
write_summary_csv(args.summary_csv, summaries)
return EXIT_OK return EXIT_OK
+54 -13
View File
@@ -23,6 +23,7 @@ from dojo.events import (
GameOver, GameOver,
KumiteFought, KumiteFought,
Moved, Moved,
MoveOptions,
SentToStart, SentToStart,
Source, Source,
Teleported, Teleported,
@@ -168,6 +169,7 @@ class Game:
self.winner: int | None = None self.winner: int | None = None
self._random = randomness self._random = randomness
self._dojo = board.dojo self._dojo = board.dojo
self._tolls = board.cells_with(Square.FATICA) if config.fatica_on_crossing else frozenset()
cards = [card for card, quantity in config.deck for _ in range(quantity)] cards = [card for card, quantity in config.deck for _ in range(quantity)]
randomness.shuffle(cards) randomness.shuffle(cards)
@@ -175,7 +177,7 @@ class Game:
self._discard: list[Card] = [] self._discard: list[Card] = []
self.players = [ self.players = [
Player(seat, corner, min(board.safe_room(corner)), config.start_position) Player(seat, corner, min(board.safe_room(corner)), config.start_track(seat))
for seat, corner in enumerate(seat_corners(len(self.agents))) for seat, corner in enumerate(seat_corners(len(self.agents)))
] ]
for player in self.players: for player in self.players:
@@ -238,6 +240,8 @@ class Game:
paths = self._path_choices(player, remaining, visited) paths = self._path_choices(player, remaining, visited)
if not paths: if not paths:
break break
ends = sorted({path[-1] for path in paths})
self._emit(MoveOptions(player.id, tuple(self.board.code(end) for end in ends)))
path = self._pick( path = self._pick(
self._agent(player).choose_path(self.view(player.id), paths), paths, "path" self._agent(player).choose_path(self.view(player.id), paths), paths, "path"
) )
@@ -265,20 +269,40 @@ class Game:
return walked, step return walked, step
def _path_choices(self, player: Player, steps: int, visited: set[Pos]) -> list[Path]: def _path_choices(self, player: Player, steps: int, visited: set[Pos]) -> list[Path]:
"""Legal walks, one per distinct outcome (end square and opponents crossed in order).""" """Legal walks, one per distinct outcome: the end square, and in order the opponents
crossed and the Fatica squares that cost a stripe (``None``)."""
brown = self.config.is_brown(player.track) brown = self.config.is_brown(player.track)
choices: dict[tuple[Pos, tuple[Pos, ...]], Path] = {} tolls = self._tolls
opponents = self._fightable_squares(player)
choices: dict[tuple[Pos, tuple[Pos | None, ...]], Path] = {}
for path in walks(self.board, player.position, steps, visited, can_enter_dojo=brown): for path in walks(self.board, player.position, steps, visited, can_enter_dojo=brown):
crossed = tuple(cell for cell in path if self._fightable_at(cell, player)) if path[-1] in self._dojo and not self._still_brown_at_dojo(player, path):
continue
if opponents.isdisjoint(path):
crossed = (None,) * len(tolls.intersection(path))
else:
crossed = tuple(
None if cell in tolls else cell
for cell in path
if cell in tolls or cell in opponents
)
choices.setdefault((path[-1], crossed), path) choices.setdefault((path[-1], crossed), path)
return list(choices.values()) return list(choices.values())
def _still_brown_at_dojo(self, player: Player, path: Path) -> bool:
"""Whether the Fatica walked before the Dojo leave the player Brown (the belt changes
at once, so losing Brown closes the Dojo). A Kumite won on the way re-offers paths."""
fatica = sum(self._costs_fatica(cell) for cell in path)
return self.config.is_brown(self.config.shifted(player.track, -fatica))
def _step(self, player: Player, cell: Pos, previous: Pos) -> Step: def _step(self, player: Player, cell: Pos, previous: Pos) -> Step:
if cell in self._dojo: if cell in self._dojo:
self._exam(player, entrance=previous) self._exam(player, entrance=previous)
return Step.EXAM return Step.EXAM
defender = self._fightable_at(cell, player) defender = self._fightable_at(cell, player)
player.position = cell player.position = cell
if self._costs_fatica(cell):
self._change_track(player, -1, Source.SQUARE)
if defender is None: if defender is None:
return Step.MOVED return Step.MOVED
result = self._kumite(player, defender) result = self._kumite(player, defender)
@@ -344,10 +368,7 @@ class Game:
uke[side] = True uke[side] = True
totals = (dice[0] + extra[0], dice[1] + extra[1]) totals = (dice[0] + extra[0], dice[1] + extra[1])
if exam and config.exam_ties_to_defender: attacker_won = config.attacker_wins(*totals, exam=exam)
attacker_won = totals[0] > totals[1]
else:
attacker_won = totals[0] >= totals[1] # Senshu: ties go to the attacker.
winner_belt, loser_belt = belts if attacker_won else belts[::-1] winner_belt, loser_belt = belts if attacker_won else belts[::-1]
if winner_belt == loser_belt: if winner_belt == loser_belt:
kind = FightKind.EQUAL kind = FightKind.EQUAL
@@ -421,7 +442,7 @@ class Game:
if passed: if passed:
self.winner = challenger.id self.winner = challenger.id
return return
self._change_track(challenger, -self.config.stripes_per_belt, Source.EXAM) self._change_track(challenger, -self.config.exam_failure_loss, Source.EXAM)
if not uke: if not uke:
self._send_to_start(challenger) self._send_to_start(challenger)
@@ -432,7 +453,7 @@ class Game:
match code: match code:
case Square.ALLENAMENTO: case Square.ALLENAMENTO:
self._change_track(player, 1, Source.SQUARE) self._change_track(player, 1, Source.SQUARE)
case Square.FATICA: case Square.FATICA if not self.config.fatica_on_crossing:
self._change_track(player, -1, Source.SQUARE) self._change_track(player, -1, Source.SQUARE)
case Square.NEBBIA: case Square.NEBBIA:
self._draw(player, origin) self._draw(player, origin)
@@ -448,7 +469,7 @@ class Game:
self._teleport(player, exit_square, "tunnel") self._teleport(player, exit_square, "tunnel")
def _sfida(self, player: Player) -> None: def _sfida(self, player: Player) -> None:
targets = self._targets(player) targets = self._targets(player) if self._kumite_allowed else []
if not targets: if not targets:
return return
choice = self._agent(player).choose_sfida_target(self.view(player.id), targets) choice = self._agent(player).choose_sfida_target(self.view(player.id), targets)
@@ -561,16 +582,36 @@ class Game:
def _in_safe_room(self, square: Pos) -> bool: def _in_safe_room(self, square: Pos) -> bool:
return self.board.code(square) == Square.SAFE return self.board.code(square) == Square.SAFE
@property
def _kumite_allowed(self) -> bool:
return self.round >= self.config.first_kumite_round
def _costs_fatica(self, square: Pos) -> bool:
return square in self._tolls
def _fightable_at(self, square: Pos, attacker: Player) -> Player | None: def _fightable_at(self, square: Pos, attacker: Player) -> Player | None:
if self._in_safe_room(square): if self._in_safe_room(square) or not self._kumite_allowed:
return None return None
for p in self.players: for p in self.players:
if p is not attacker and p.position == square and not p.meditating: if p is not attacker and p.position == square and not p.meditating:
return p return p
return None return None
def _fightable_squares(self, attacker: Player) -> set[Pos]:
"""Squares where ``_fightable_at`` finds a defender for ``attacker``."""
if not self._kumite_allowed:
return set()
return {
p.position
for p in self.players
if p is not attacker and not p.meditating and not self._in_safe_room(p.position)
}
def _has_unfightable_pawn(self, square: Pos, player: Player) -> bool: def _has_unfightable_pawn(self, square: Pos, player: Player) -> bool:
return any(p is not player and p.position == square and p.meditating for p in self.players) return any(
p is not player and p.position == square and (p.meditating or not self._kumite_allowed)
for p in self.players
)
def _shares_square(self, player: Player) -> bool: def _shares_square(self, player: Player) -> bool:
if self._in_safe_room(player.position): if self._in_safe_room(player.position):
+9
View File
@@ -34,6 +34,14 @@ class TurnSkipped:
player: int player: int
@dataclass(frozen=True)
class MoveOptions:
"""The squares a move could end on, as square codes, one per distinct square."""
player: int
ends: tuple[str, ...]
@dataclass(frozen=True) @dataclass(frozen=True)
class Moved: class Moved:
player: int player: int
@@ -98,6 +106,7 @@ class GameOver:
type Event = ( type Event = (
TurnStarted TurnStarted
| TurnSkipped | TurnSkipped
| MoveOptions
| Moved | Moved
| Teleported | Teleported
| KumiteFought | KumiteFought
+105 -10
View File
@@ -10,7 +10,7 @@ from collections import Counter
from collections.abc import Iterable, Sequence from collections.abc import Iterable, Sequence
from dataclasses import dataclass from dataclasses import dataclass
from dojo.board import Pos, quadrant_of from dojo.board import Pos, Square, quadrant_of
from dojo.events import ( from dojo.events import (
Event, Event,
ExamTaken, ExamTaken,
@@ -18,6 +18,7 @@ from dojo.events import (
GameOver, GameOver,
KumiteFought, KumiteFought,
Moved, Moved,
MoveOptions,
SentToStart, SentToStart,
Source, Source,
Teleported, Teleported,
@@ -25,20 +26,20 @@ from dojo.events import (
TurnSkipped, TurnSkipped,
TurnStarted, TurnStarted,
) )
from dojo.rules import RulesConfig from dojo.rules import Card, RulesConfig
# Table-time estimate (plan, resolved questions). # Table-time estimate (plan, resolved questions).
TURN_SECONDS = 45 TURN_SECONDS = 45
KUMITE_SECONDS = 30 KUMITE_SECONDS = 30
# A player more than this many belts behind the leader is out of contention. # A player more than this many belts behind the leader is out of contention.
CONTENTION_BELTS = 2 CONTENTION_BELTS = 3
type Standings = tuple[int, ...] type Standings = tuple[int, ...]
@dataclass(frozen=True) @dataclass(frozen=True)
class GameMetrics: class GameMetrics:
"""One game, flat so it can be written as a CSV row. Kumite counts exclude the Exam. """One game, one CSV row. Kumite counts exclude the Exam; tuples are indexed by seat.
Optional fields are None when they do not apply (no winner, no lead change, ...). Optional fields are None when they do not apply (no winner, no lead change, ...).
""" """
@@ -58,6 +59,12 @@ class GameMetrics:
exam_attempts: int exam_attempts: int
exam_passes: int exam_passes: int
exam_challengers: int exam_challengers: int
exam_fights: int
# Fights with a Tecnica played, and fights whose result flips without the Kiai played
# (dice and other cards unchanged).
tecnica_kumite: int
kiai_decided: int
exam_kiai_decided: int
square_gain: int square_gain: int
square_loss: int square_loss: int
card_gain: int card_gain: int
@@ -80,6 +87,15 @@ class GameMetrics:
leader_kumite_losses: int leader_kumite_losses: int
winner_turns_to_brown: int | None winner_turns_to_brown: int | None
final_spread: int final_spread: int
# Path choices offered, and over them: distinct end squares, and how many could end
# on an Allenamento / a Fatica.
move_choices: int
end_squares: int
allenamento_reachable: int
fatica_reachable: int
final_track: tuple[int, ...]
exam_attempts_by_seat: tuple[int, ...]
exam_defences_by_seat: tuple[int, ...]
def measure( def measure(
@@ -93,16 +109,20 @@ def measure(
) -> GameMetrics: ) -> GameMetrics:
"""Replay ``events`` of one game. ``corners[seat]`` and ``bots[seat]`` describe each seat.""" """Replay ``events`` of one game. ``corners[seat]`` and ``bots[seat]`` describe each seat."""
players = len(corners) players = len(corners)
track = [config.start_position] * players track = [config.start_track(seat) for seat in range(players)]
position: list[Pos | None] = [None] * players position: list[Pos | None] = [None] * players
history: list[Standings] = [] history: list[Standings] = []
gains = Counter[Source]() gains = Counter[Source]()
losses = Counter[Source]() losses = Counter[Source]()
home = away = 0 home = away = 0
kumite = upsets = expected = exam_fights = 0 kumite = upsets = expected = exam_fights = 0
tecnica_kumite = kiai_decided = exam_kiai_decided = 0
chain = max_chain = 0 chain = max_chain = 0
exam_attempts = exam_passes = 0 exam_attempts = exam_passes = 0
attempts_by_seat = [0] * players
defences_by_seat = [0] * players
challengers: set[int] = set() challengers: set[int] = set()
move_choices = end_squares = allenamento = fatica = 0
own_turns = [0] * players own_turns = [0] * players
reached_brown: list[int | None] = [None] * players reached_brown: list[int | None] = [None] * players
leader_losses = 0 leader_losses = 0
@@ -120,16 +140,28 @@ def measure(
own_turns[player] += 1 own_turns[player] += 1
chain = 0 chain = 0
turns += isinstance(event, TurnStarted) turns += isinstance(event, TurnStarted)
case MoveOptions(ends=ends):
move_choices += 1
end_squares += len(ends)
allenamento += Square.ALLENAMENTO in ends
fatica += Square.FATICA in ends
case Moved(player=player, path=path) if path: case Moved(player=player, path=path) if path:
position[player] = path[-1] position[player] = path[-1]
case Teleported(player=player, destination=destination): case Teleported(player=player, destination=destination):
position[player] = destination position[player] = destination
case SentToStart(player=player, square=square): case SentToStart(player=player, square=square):
position[player] = square position[player] = square
case KumiteFought(exam=True): case KumiteFought(exam=True) as fight:
exam_fights += 1 exam_fights += 1
case KumiteFought(attacker=attacker, defender=defender, attacker_won=won, kind=kind): exam_kiai_decided += _decided_by_kiai(fight, config)
case (
KumiteFought(
attacker=attacker, defender=defender, attacker_won=won, kind=kind
) as fight
):
kumite += 1 kumite += 1
tecnica_kumite += bool(fight.cards)
kiai_decided += _decided_by_kiai(fight, config)
upsets += kind is FightKind.UPSET upsets += kind is FightKind.UPSET
expected += kind is FightKind.EXPECTED expected += kind is FightKind.EXPECTED
if attacker == active: if attacker == active:
@@ -138,8 +170,10 @@ def measure(
loser = defender if won else attacker loser = defender if won else attacker
if history and _leader(history[-1]) == loser: if history and _leader(history[-1]) == loser:
leader_losses += 1 leader_losses += 1
case ExamTaken(challenger=challenger, passed=passed): case ExamTaken(challenger=challenger, defender=defender, passed=passed):
exam_attempts += 1 exam_attempts += 1
attempts_by_seat[challenger] += 1
defences_by_seat[defender] += 1
exam_passes += passed exam_passes += passed
challengers.add(challenger) challengers.add(challenger)
case TrackChanged(player=player, before=before, after=after, source=source): case TrackChanged(player=player, before=before, after=after, source=source):
@@ -181,6 +215,10 @@ def measure(
exam_attempts=exam_attempts, exam_attempts=exam_attempts,
exam_passes=exam_passes, exam_passes=exam_passes,
exam_challengers=len(challengers), exam_challengers=len(challengers),
exam_fights=exam_fights,
tecnica_kumite=tecnica_kumite,
kiai_decided=kiai_decided,
exam_kiai_decided=exam_kiai_decided,
square_gain=gains[Source.SQUARE], square_gain=gains[Source.SQUARE],
square_loss=losses[Source.SQUARE], square_loss=losses[Source.SQUARE],
card_gain=gains[Source.CARD], card_gain=gains[Source.CARD],
@@ -193,6 +231,13 @@ def measure(
leader_kumite_losses=leader_losses, leader_kumite_losses=leader_losses,
winner_turns_to_brown=None if winner is None else reached_brown[winner], winner_turns_to_brown=None if winner is None else reached_brown[winner],
final_spread=max(track) - min(track), final_spread=max(track) - min(track),
move_choices=move_choices,
end_squares=end_squares,
allenamento_reachable=allenamento,
fatica_reachable=fatica,
final_track=tuple(track),
exam_attempts_by_seat=tuple(attempts_by_seat),
exam_defences_by_seat=tuple(defences_by_seat),
lead_changes=standings.lead_changes, lead_changes=standings.lead_changes,
last_lead_change=standings.last_lead_change, last_lead_change=standings.last_lead_change,
lead_gap_mean=standings.lead_gap_mean, lead_gap_mean=standings.lead_gap_mean,
@@ -206,6 +251,19 @@ def measure(
) )
def _decided_by_kiai(fight: KumiteFought, config: RulesConfig) -> bool:
kiai = [0, 0]
for player, card in fight.cards:
if card is Card.KIAI:
kiai[0 if player == fight.attacker else 1] += config.kiai_bonus
if not any(kiai):
return False
without = config.attacker_wins(
fight.attacker_total - kiai[0], fight.defender_total - kiai[1], exam=fight.exam
)
return without != fight.attacker_won
def _leader(standings: Standings) -> int | None: def _leader(standings: Standings) -> int | None:
top = max(standings) top = max(standings)
leaders = [p for p, t in enumerate(standings) if t == top] leaders = [p for p, t in enumerate(standings) if t == top]
@@ -302,6 +360,15 @@ class Spread:
return cls(statistics.fmean(present), deciles[0], statistics.median(present), deciles[-1]) return cls(statistics.fmean(present), deciles[0], statistics.median(present), deciles[-1])
@dataclass(frozen=True)
class SeatSummary:
win_rate: float
final_track: float
exam_attempts_per_game: float
# Share of games in which this seat defended at least one Exam.
exam_defended: float
@dataclass(frozen=True) @dataclass(frozen=True)
class Summary: class Summary:
"""Aggregate of one scenario. Income is in stripes per turn, as (gains, losses).""" """Aggregate of one scenario. Income is in stripes per turn, as (gains, losses)."""
@@ -318,6 +385,11 @@ class Summary:
exam_attempts_per_game: float exam_attempts_per_game: float
exam_success: float exam_success: float
exam_challengers: float exam_challengers: float
# Shares of Kumite (Exam excluded) with a Tecnica played / decided by Kiai; share of
# Exam fights decided by Kiai.
tecnica_rate: float
kiai_decided_rate: float
exam_kiai_decided_rate: float
income_per_turn: dict[Source, tuple[float, float]] income_per_turn: dict[Source, tuple[float, float]]
square_income_home_per_turn: float square_income_home_per_turn: float
square_income_away_per_turn: float square_income_away_per_turn: float
@@ -335,9 +407,16 @@ class Summary:
leader_kumite_losses: float leader_kumite_losses: float
winner_turns_to_brown: Spread winner_turns_to_brown: Spread
final_spread: Spread final_spread: Spread
win_rate_by_seat: tuple[float, ...] end_squares_per_move: float
allenamento_reachable: float
fatica_reachable: float
seats: tuple[SeatSummary, ...]
win_rate_by_bot: dict[str, float] win_rate_by_bot: dict[str, float]
@property
def win_rate_by_seat(self) -> tuple[float, ...]:
return tuple(seat.win_rate for seat in self.seats)
def summarize(scenario: str, games: Iterable[GameMetrics]) -> Summary: def summarize(scenario: str, games: Iterable[GameMetrics]) -> Summary:
rows = list(games) rows = list(games)
@@ -347,7 +426,9 @@ def summarize(scenario: str, games: Iterable[GameMetrics]) -> Summary:
turns = sum(r.turns for r in rows) or 1 turns = sum(r.turns for r in rows) or 1
rounds = sum(r.rounds for r in rows) or 1 rounds = sum(r.rounds for r in rows) or 1
kumite = sum(r.kumite for r in rows) kumite = sum(r.kumite for r in rows)
exam_fights = sum(r.exam_fights for r in rows)
attempts = sum(r.exam_attempts for r in rows) attempts = sum(r.exam_attempts for r in rows)
choices = sum(r.move_choices for r in rows) or 1
seats = len(rows[0].lineup.split(",")) seats = len(rows[0].lineup.split(","))
appearances = Counter[str]() appearances = Counter[str]()
@@ -376,6 +457,9 @@ def summarize(scenario: str, games: Iterable[GameMetrics]) -> Summary:
exam_attempts_per_game=attempts / count, exam_attempts_per_game=attempts / count,
exam_success=sum(r.exam_passes for r in rows) / (attempts or 1), exam_success=sum(r.exam_passes for r in rows) / (attempts or 1),
exam_challengers=sum(r.exam_challengers for r in rows) / count, exam_challengers=sum(r.exam_challengers for r in rows) / count,
tecnica_rate=sum(r.tecnica_kumite for r in rows) / (kumite or 1),
kiai_decided_rate=sum(r.kiai_decided for r in rows) / (kumite or 1),
exam_kiai_decided_rate=sum(r.exam_kiai_decided for r in rows) / (exam_fights or 1),
income_per_turn={ income_per_turn={
Source.SQUARE: ( Source.SQUARE: (
per_turn(r.square_gain for r in rows), per_turn(r.square_gain for r in rows),
@@ -406,7 +490,18 @@ def summarize(scenario: str, games: Iterable[GameMetrics]) -> Summary:
leader_kumite_losses=sum(r.leader_kumite_losses for r in rows) / count, leader_kumite_losses=sum(r.leader_kumite_losses for r in rows) / count,
winner_turns_to_brown=Spread.of(r.winner_turns_to_brown for r in rows), winner_turns_to_brown=Spread.of(r.winner_turns_to_brown for r in rows),
final_spread=Spread.of(r.final_spread for r in rows), final_spread=Spread.of(r.final_spread for r in rows),
win_rate_by_seat=tuple(seat_wins[s] / count for s in range(seats)), end_squares_per_move=sum(r.end_squares for r in rows) / choices,
allenamento_reachable=sum(r.allenamento_reachable for r in rows) / choices,
fatica_reachable=sum(r.fatica_reachable for r in rows) / choices,
seats=tuple(
SeatSummary(
win_rate=seat_wins[s] / count,
final_track=statistics.fmean(r.final_track[s] for r in rows),
exam_attempts_per_game=statistics.fmean(r.exam_attempts_by_seat[s] for r in rows),
exam_defended=sum(r.exam_defences_by_seat[s] > 0 for r in rows) / count,
)
for s in range(seats)
),
# Wins per seat occupied: an average bot scores 1 / players. # Wins per seat occupied: an average bot scores 1 / players.
win_rate_by_bot={bot: wins[bot] / seated for bot, seated in sorted(appearances.items())}, win_rate_by_bot={bot: wins[bot] / seated for bot, seated in sorted(appearances.items())},
) )
+15
View File
@@ -1,6 +1,7 @@
"""Legal walks on the board, ignoring pawns (the engine resolves encounters).""" """Legal walks on the board, ignoring pawns (the engine resolves encounters)."""
from collections.abc import Iterator, Set from collections.abc import Iterator, Set
from functools import cache
from dojo.board import Board, Pos from dojo.board import Board, Pos
@@ -15,7 +16,21 @@ def walks(
Squares in ``visited`` are never entered. The Dojo is a wall unless ``can_enter_dojo``; Squares in ``visited`` are never entered. The Dojo is a wall unless ``can_enter_dojo``;
entering it ends the walk early, so Dojo walks may be shorter than ``steps``. entering it ends the walk early, so Dojo walks may be shorter than ``steps``.
If no walk of full length exists, the longest possible walks are returned instead. If no walk of full length exists, the longest possible walks are returned instead.
Walks that exclude nothing but ``start`` are computed once per board and reused.
""" """
if visited <= {start}:
return list(_open_walks(board, start, steps, can_enter_dojo))
return _walks(board, start, steps, visited, can_enter_dojo)
@cache
def _open_walks(board: Board, start: Pos, steps: int, can_enter_dojo: bool) -> tuple[Path, ...]:
return tuple(_walks(board, start, steps, frozenset(), can_enter_dojo))
def _walks(
board: Board, start: Pos, steps: int, visited: Set[Pos], can_enter_dojo: bool
) -> list[Path]:
dojo = board.dojo dojo = board.dojo
seen = set(visited) | {start} seen = set(visited) | {start}
found = list(_extend(board, dojo, (start,), steps, seen, can_enter_dojo)) found = list(_extend(board, dojo, (start,), steps, seen, can_enter_dojo))
+60 -6
View File
@@ -1,15 +1,15 @@
"""Simulation output: a plain-text summary per scenario and one CSV row per game.""" """Simulation output: a plain-text summary per scenario, and CSV files with one row per game
or one row per scenario."""
import csv import csv
import dataclasses import dataclasses
from collections.abc import Iterable from collections.abc import Iterable
from pathlib import Path from pathlib import Path
from dojo.metrics import GameMetrics, Spread, Summary from dojo.metrics import CONTENTION_BELTS, GameMetrics, SeatSummary, Spread, Summary
def format_summary(summary: Summary) -> str: def format_summary(summary: Summary) -> str:
seats = ", ".join(f"{seat} {rate:.0%}" for seat, rate in enumerate(summary.win_rate_by_seat))
bots = ", ".join(f"{bot} {rate:.0%}" for bot, rate in summary.win_rate_by_bot.items()) bots = ", ".join(f"{bot} {rate:.0%}" for bot, rate in summary.win_rate_by_bot.items())
income = ", ".join( income = ", ".join(
f"{source} +{gain:.2f}/{loss:.2f}" f"{source} +{gain:.2f}/{loss:.2f}"
@@ -24,6 +24,9 @@ def format_summary(summary: Summary) -> str:
f"Longest chain {_spread(summary.max_chain)} (Kumite in one turn, per game)", f"Longest chain {_spread(summary.max_chain)} (Kumite in one turn, per game)",
f"Exam {summary.exam_attempts_per_game:.2f} attempts/game, " f"Exam {summary.exam_attempts_per_game:.2f} attempts/game, "
f"{summary.exam_success:.0%} passed, {summary.exam_challengers:.2f} challengers/game", f"{summary.exam_success:.0%} passed, {summary.exam_challengers:.2f} challengers/game",
f"Tecnica played in {summary.tecnica_rate:.0%} of Kumite; Kiai decided "
f"{summary.kiai_decided_rate:.0%} of Kumite, {summary.exam_kiai_decided_rate:.0%} of "
"Exam fights",
f"Income/turn {income}", f"Income/turn {income}",
f"Square income home {summary.square_income_home_per_turn:+.2f}/turn, " f"Square income home {summary.square_income_home_per_turn:+.2f}/turn, "
f"away {summary.square_income_away_per_turn:+.2f}/turn (net)", f"away {summary.square_income_away_per_turn:+.2f}/turn (net)",
@@ -35,12 +38,14 @@ def format_summary(summary: Summary) -> str:
f"Winner led {summary.winner_lead_share:.0%} of turns; halfway leader won " f"Winner led {summary.winner_lead_share:.0%} of turns; halfway leader won "
f"{summary.halfway_leader_wins:.0%} of {summary.halfway_games} games", f"{summary.halfway_leader_wins:.0%} of {summary.halfway_games} games",
f"Contention {summary.contenders_at_end:.2f} rivals within one belt at the end; " f"Contention {summary.contenders_at_end:.2f} rivals within one belt at the end; "
f"{summary.out_of_contention:.0%} of player-turns out (>2 belts behind)", f"{summary.out_of_contention:.0%} of player-turns out (>{CONTENTION_BELTS} belts behind)",
f"Worst player {_shares(summary.worst_out_of_contention)} (share of turns out)", f"Worst player {_shares(summary.worst_out_of_contention)} (share of turns out)",
f"Rank swaps {_spread(summary.rank_swaps)}", f"Rank swaps {_spread(summary.rank_swaps)}",
f"Leader beaten {summary.leader_kumite_losses:.2f} Kumite lost by the leader per game", f"Leader beaten {summary.leader_kumite_losses:.2f} Kumite lost by the leader per game",
f"Final spread {_spread(summary.final_spread)}", f"Final spread {_spread(summary.final_spread)}",
f"Wins by seat {seats}", f"Move options {summary.end_squares_per_move:.1f} end squares/move; can end on "
f"Allenamento {summary.allenamento_reachable:.0%}, Fatica {summary.fatica_reachable:.0%}",
*(f"Seat {seat:<11}{_seat(stats)}" for seat, stats in enumerate(summary.seats)),
f"Wins by bot {bots}", f"Wins by bot {bots}",
] ]
return "\n".join(lines) return "\n".join(lines)
@@ -52,7 +57,56 @@ def write_games_csv(path: Path, games: Iterable[GameMetrics]) -> None:
writer = csv.DictWriter(file, fieldnames=fields) writer = csv.DictWriter(file, fieldnames=fields)
writer.writeheader() writer.writeheader()
for game in games: for game in games:
writer.writerow(dataclasses.asdict(game)) writer.writerow(
{
name: ",".join(map(str, value)) if isinstance(value, tuple) else value
for name, value in dataclasses.asdict(game).items()
}
)
def write_summary_csv(path: Path, summaries: Iterable[Summary]) -> None:
"""One row per scenario; a column missing from a scenario (fewer seats, other bots) is empty."""
rows = [_summary_row(summary) for summary in summaries]
fields = list(dict.fromkeys(name for row in rows for name in row))
with path.open("w", encoding="utf-8", newline="") as file:
writer = csv.DictWriter(file, fieldnames=fields, restval="")
writer.writeheader()
writer.writerows(rows)
def _summary_row(summary: Summary) -> dict[str, object]:
row: dict[str, object] = {}
for field in dataclasses.fields(Summary):
name, value = field.name, getattr(summary, field.name)
match value:
case Spread():
row |= {f"{name}_{stat}": round(v, 3) for stat, v in _numbers(value).items()}
case float():
row[name] = round(value, 4)
case str() | int():
row[name] = value
case _:
pass # Income, seats and bots are flattened below.
for source, (gain, loss) in summary.income_per_turn.items():
row[f"{source}_gain_per_turn"] = round(gain, 4)
row[f"{source}_loss_per_turn"] = round(loss, 4)
for seat, stats in enumerate(summary.seats):
row |= {f"seat{seat}_{stat}": round(v, 4) for stat, v in _numbers(stats).items()}
row |= {f"win_rate_{bot}": round(v, 4) for bot, v in summary.win_rate_by_bot.items()}
return row
def _numbers(record: Spread | SeatSummary) -> dict[str, float]:
return dataclasses.asdict(record)
def _seat(stats: SeatSummary) -> str:
return (
f"won {stats.win_rate:.0%}, final track {stats.final_track:.1f}, "
f"Exam {stats.exam_attempts_per_game:.2f} attempts/game, "
f"defended in {stats.exam_defended:.0%} of games"
)
def _spread(spread: Spread) -> str: def _spread(spread: Spread) -> str:
+26
View File
@@ -51,11 +51,21 @@ class RulesConfig:
exam_defender_black: bool = True exam_defender_black: bool = True
exam_ties_to_defender: bool = False exam_ties_to_defender: bool = False
exam_rounds: int = 1 exam_rounds: int = 1
# Stripes a failed challenger loses (6.11: 0 and 1 tested against the rules' 3).
exam_failure_loss: int = 3
# Fatica costs a stripe for every Fatica square walked onto, stopping included (D36);
# off: only stopping on it costs.
fatica_on_crossing: bool = True
# No Kumite before this round (D35); 1 allows Kumite from the first turn.
first_kumite_round: int = 2
kiai_bonus: int = 2 kiai_bonus: int = 2
stripes_per_belt: int = 3 stripes_per_belt: int = 3
max_belt_bonus: int = 2 max_belt_bonus: int = 2
start_position: int = 0 start_position: int = 0
# Extra stripes for the first player in turn order (6.2: compensation under test).
first_player_bonus: int = 0
deck: tuple[tuple[Card, int], ...] = DEFAULT_DECK deck: tuple[tuple[Card, int], ...] = DEFAULT_DECK
# Safety stop for simulations; not a rule of the game. # Safety stop for simulations; not a rule of the game.
max_rounds: int = 200 max_rounds: int = 200
@@ -63,6 +73,12 @@ class RulesConfig:
def __post_init__(self) -> None: def __post_init__(self) -> None:
if self.exam_rounds < 1 or self.exam_rounds % 2 == 0: if self.exam_rounds < 1 or self.exam_rounds % 2 == 0:
raise ValueError(f"exam_rounds must be odd and positive, got {self.exam_rounds}") raise ValueError(f"exam_rounds must be odd and positive, got {self.exam_rounds}")
if self.exam_failure_loss < 0:
raise ValueError(f"exam_failure_loss must be >= 0, got {self.exam_failure_loss}")
if self.first_player_bonus < 0:
raise ValueError(f"first_player_bonus must be >= 0, got {self.first_player_bonus}")
if self.first_kumite_round < 1:
raise ValueError(f"first_kumite_round must be positive, got {self.first_kumite_round}")
@property @property
def max_position(self) -> int: def max_position(self) -> int:
@@ -81,6 +97,16 @@ class RulesConfig:
def shifted(self, position: int, delta: int) -> int: def shifted(self, position: int, delta: int) -> int:
return max(0, min(position + delta, self.max_position)) return max(0, min(position + delta, self.max_position))
def attacker_wins(self, attacker_total: int, defender_total: int, *, exam: bool) -> bool:
"""Senshu: ties go to the attacker, unless the Exam option gives them to the defender."""
if exam and self.exam_ties_to_defender:
return attacker_total > defender_total
return attacker_total >= defender_total
def start_track(self, seat: int) -> int:
bonus = self.first_player_bonus if seat == 0 else 0
return self.shifted(self.start_position, bonus)
@property @property
def exam_wins_needed(self) -> int: def exam_wins_needed(self) -> int:
"""Rounds the challenger must win: the Exam is a best-of-``exam_rounds`` series.""" """Rounds the challenger must win: the Exam is a best-of-``exam_rounds`` series."""
+16 -5
View File
@@ -14,7 +14,7 @@ from functools import cache
from pathlib import Path from pathlib import Path
from dojo.board import Board, load_board from dojo.board import Board, load_board
from dojo.bots import PROFILES, HeuristicAgent, RandomAgent from dojo.bots import PROFILES, CasualAgent, HeuristicAgent, RandomAgent
from dojo.engine import Agent, Game, RandomSource, seat_corners from dojo.engine import Agent, Game, RandomSource, seat_corners
from dojo.metrics import GameMetrics, measure from dojo.metrics import GameMetrics, measure
from dojo.rules import RulesConfig from dojo.rules import RulesConfig
@@ -28,8 +28,10 @@ EXAM_OPTIONS = {
"d": "best of 3 rounds", "d": "best of 3 rounds",
} }
BEST_OF_ROUNDS = 3 BEST_OF_ROUNDS = 3
RULES_EXAM_LOSS = RulesConfig().exam_failure_loss
RANDOM_BOT = "random" RANDOM_BOT = "random"
BOTS = (RANDOM_BOT, *PROFILES) CASUAL_BOT = "casual"
BOTS = (RANDOM_BOT, CASUAL_BOT, *PROFILES)
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -41,6 +43,7 @@ class Scenario:
start_position: int start_position: int
# Bot names, repeated in order to fill the seats. # Bot names, repeated in order to fill the seats.
lineup: tuple[str, ...] lineup: tuple[str, ...]
exam_loss: int = RULES_EXAM_LOSS
def __post_init__(self) -> None: def __post_init__(self) -> None:
options = "" if self.exam == NO_EXAM_OPTIONS else self.exam options = "" if self.exam == NO_EXAM_OPTIONS else self.exam
@@ -53,12 +56,13 @@ class Scenario:
if unknown or not self.lineup: if unknown or not self.lineup:
raise ValueError(f"unknown bots {unknown}: expected some of {list(BOTS)}") raise ValueError(f"unknown bots {unknown}: expected some of {list(BOTS)}")
seat_corners(self.players) seat_corners(self.players)
_ = self.config # RulesConfig validates the remaining axes.
@property @property
def name(self) -> str: def name(self) -> str:
return ( return (
f"exam:{self.exam} p{self.players} kiai+{self.kiai_bonus} start{self.start_position} " f"exam:{self.exam} p{self.players} kiai+{self.kiai_bonus} start{self.start_position} "
+ ",".join(self.lineup) f"fail-{self.exam_loss} " + ",".join(self.lineup)
) )
@property @property
@@ -67,6 +71,7 @@ class Scenario:
exam_ties_to_defender="a" in self.exam, exam_ties_to_defender="a" in self.exam,
exam_defender_black="c" in self.exam, exam_defender_black="c" in self.exam,
exam_rounds=BEST_OF_ROUNDS if "d" in self.exam else 1, exam_rounds=BEST_OF_ROUNDS if "d" in self.exam else 1,
exam_failure_loss=self.exam_loss,
kiai_bonus=self.kiai_bonus, kiai_bonus=self.kiai_bonus,
start_position=self.start_position, start_position=self.start_position,
) )
@@ -83,10 +88,13 @@ def matrix(
kiai_bonuses: Sequence[int], kiai_bonuses: Sequence[int],
start_positions: Sequence[int], start_positions: Sequence[int],
lineups: Sequence[tuple[str, ...]], lineups: Sequence[tuple[str, ...]],
exam_losses: Sequence[int] = (RULES_EXAM_LOSS,),
) -> list[Scenario]: ) -> list[Scenario]:
return [ return [
Scenario(*axes) Scenario(*axes)
for axes in itertools.product(exams, players, kiai_bonuses, start_positions, lineups) for axes in itertools.product(
exams, players, kiai_bonuses, start_positions, lineups, exam_losses
)
] ]
@@ -97,8 +105,11 @@ class BoardFiles:
def make_agent(bot: str, seed: int, seat: int) -> Agent: def make_agent(bot: str, seed: int, seat: int) -> Agent:
rng = random.Random(f"bot:{seed}:{seat}")
if bot == RANDOM_BOT: if bot == RANDOM_BOT:
return RandomAgent(random.Random(f"bot:{seed}:{seat}")) return RandomAgent(rng)
if bot == CASUAL_BOT:
return CasualAgent(rng)
return HeuristicAgent(PROFILES[bot]) return HeuristicAgent(PROFILES[bot])
+5 -1
View File
@@ -96,10 +96,14 @@ def make_game(
deck: tuple[tuple[Card, int], ...] = NO_CARDS, deck: tuple[tuple[Card, int], ...] = NO_CARDS,
board: Board | None = None, board: Board | None = None,
) -> tuple[Game, list[ScriptedAgent], ScriptedDice]: ) -> tuple[Game, list[ScriptedAgent], ScriptedDice]:
"""A game on the fixture board with an empty deck unless ``deck`` is given.""" """A game on the fixture board with an empty deck unless ``deck`` is given.
It starts in the first round that allows Kumite; tests of round 1 set ``game.round``.
"""
agents = [ScriptedAgent() for _ in range(players)] agents = [ScriptedAgent() for _ in range(players)]
randomness = ScriptedDice(dice) randomness = ScriptedDice(dice)
game = Game(board or fixture_board(), replace(config, deck=deck), agents, randomness) game = Game(board or fixture_board(), replace(config, deck=deck), agents, randomness)
game.round = config.first_kumite_round
return game, agents, randomness return game, agents, randomness
+12
View File
@@ -70,6 +70,18 @@ def test_neighbours_respect_walls_and_border(board: Board) -> None:
assert Pos(1, 3) in set(board.neighbours(Pos(1, 2))) assert Pos(1, 3) in set(board.neighbours(Pos(1, 2)))
def test_neighbours_come_north_east_south_west(board: Board) -> None:
square = Pos(1, 2)
compass = [Pos(0, 2), Pos(1, 3), Pos(2, 2), Pos(1, 1)]
assert list(board.neighbours(square)) == [q for q in compass if q in board.neighbours(square)]
def test_square_lookups_without_matches_are_empty(board: Board) -> None:
assert board.cells_with("unknown") == frozenset()
assert board.safe_room(4) == frozenset()
def test_seams_join_neighbouring_quadrants(board: Board) -> None: def test_seams_join_neighbouring_quadrants(board: Board) -> None:
assert Pos(0, 8) in set(board.neighbours(Pos(0, 7))) assert Pos(0, 8) in set(board.neighbours(Pos(0, 7)))
assert Pos(8, 0) in set(board.neighbours(Pos(7, 0))) assert Pos(8, 0) in set(board.neighbours(Pos(7, 0)))
+67 -2
View File
@@ -3,18 +3,21 @@
import random import random
import pytest import pytest
from scripted import make_game, put from scripted import fixture_board, make_game, put
from dojo.board import Board, Pos from dojo.board import Board, Pos, rotate
from dojo.bots import ( from dojo.bots import (
AGGRESSIVE, AGGRESSIVE,
BALANCED, BALANCED,
FARMER, FARMER,
PROFILES, PROFILES,
CasualAgent,
HeuristicAgent, HeuristicAgent,
Profile, Profile,
RandomAgent, RandomAgent,
own_frame,
series_probability, series_probability,
start_square,
win_probability, win_probability,
) )
from dojo.engine import Agent, FightView, Game, RandomSource from dojo.engine import Agent, FightView, Game, RandomSource
@@ -57,6 +60,46 @@ def test_heuristic_prefers_allenamento_to_fatica() -> None:
assert choice == (ALLENAMENTO,) assert choice == (ALLENAMENTO,)
def test_heuristic_walks_around_fatica() -> None:
game, _, _ = make_game()
put(game, 0, Pos(2, 6), track=3)
paths = [(FATICA, Pos(3, 7)), (Pos(2, 7), Pos(3, 7))]
assert HeuristicAgent().choose_path(game.view(0), paths) == paths[1]
def test_heuristic_sees_no_opponent_to_fight_in_round_one() -> None:
game, _, _ = make_game()
game.round = 1
put(game, 0, Pos(4, 3), track=3)
put(game, 1, TATAMI, track=3)
# In round 2 the aggressive bot picks the fight (test_profiles_differ_on_an_even_fight).
# Two plain Tatami tie; the tie goes to the lower square in the bot's frame, (4, 2).
choice = HeuristicAgent(AGGRESSIVE).choose_path(game.view(0), [(TATAMI,), (Pos(4, 2),)])
assert choice == (Pos(4, 2),)
def test_bots_start_from_the_same_square_of_every_safe_room() -> None:
board = fixture_board()
assert len({own_frame(start_square(board, corner), corner) for corner in range(4)}) == 1
def test_tied_paths_are_chosen_alike_from_every_corner() -> None:
game, _, _ = make_game(players=4)
agent = HeuristicAgent(BALANCED)
chosen = set()
for seat, player in enumerate(game.players):
player.position = rotate(Pos(4, 3), player.corner)
tatami = [(rotate(square, player.corner),) for square in (Pos(5, 3), Pos(3, 3), Pos(4, 2))]
choice = agent.choose_path(game.view(seat), tatami)
chosen.add(own_frame(choice[0], player.corner))
assert chosen == {Pos(3, 3)}
def test_brown_heuristic_takes_the_exam_when_it_can() -> None: def test_brown_heuristic_takes_the_exam_when_it_can() -> None:
game, _, _ = make_game() game, _, _ = make_game()
put(game, 0, DOJO_ENTRANCE, track=15) put(game, 0, DOJO_ENTRANCE, track=15)
@@ -108,6 +151,28 @@ def test_heuristic_keeps_the_tecnica_its_profile_ranks_first() -> None:
assert HeuristicAgent(FARMER).keep_tecnica(game.view(0), Card.UKE, Card.KIAI) is Card.UKE assert HeuristicAgent(FARMER).keep_tecnica(game.view(0), Card.UKE, Card.KIAI) is Card.UKE
def test_casual_bot_compares_every_move_when_offered_few() -> None:
game, _, _ = make_game()
choice = CasualAgent(random.Random(0)).choose_path(game.view(0), [(FATICA,), (ALLENAMENTO,)])
assert choice == (ALLENAMENTO,)
def test_casual_bot_compares_only_a_sample_of_many_moves() -> None:
game, _, _ = make_game()
paths = [(Pos(4, col),) for col in range(8)]
choice = CasualAgent(random.Random(3), sample=1).choose_path(game.view(0), paths)
assert choice == random.Random(3).sample(paths, 1)[0]
def test_casual_bot_needs_a_sample() -> None:
with pytest.raises(ValueError, match="sample size 0"):
CasualAgent(random.Random(0), sample=0)
def play(board: Board, config: RulesConfig, seed: int) -> Game: def play(board: Board, config: RulesConfig, seed: int) -> Game:
rng = random.Random(seed) rng = random.Random(seed)
agents: list[Agent] = [HeuristicAgent(p) for p in PROFILES.values()] agents: list[Agent] = [HeuristicAgent(p) for p in PROFILES.values()]
+23 -2
View File
@@ -1,3 +1,4 @@
import csv
from pathlib import Path from pathlib import Path
import pytest import pytest
@@ -67,15 +68,35 @@ def test_sim_prints_a_summary_per_scenario_and_writes_games(
assert main([*argv, "--csv", str(games)]) == EXIT_OK assert main([*argv, "--csv", str(games)]) == EXIT_OK
output = capsys.readouterr().out output = capsys.readouterr().out
assert "== exam:none p3 kiai+2 start0 balanced,farmer,aggressive (2 games" in output assert "== exam:none p3 kiai+2 start0 fail-3 balanced,farmer,aggressive (2 games" in output
assert "== exam:c p3 kiai+2 start0" in output assert "== exam:c p3 kiai+2 start0" in output
rows = games.read_text(encoding="utf-8").splitlines() rows = games.read_text(encoding="utf-8").splitlines()
assert rows[0].startswith("scenario,seed,lineup,winner_seat") assert rows[0].startswith("scenario,seed,lineup,winner_seat")
assert len(rows) == 1 + 4 assert len(rows) == 1 + 4
def test_sim_writes_one_summary_row_per_scenario(tmp_path: Path) -> None:
path = tmp_path / "summary.csv"
argv = ["sim", "--games", "2", "--players", "3", "4", "--lineup", "casual", "--jobs", "1"]
argv += ["--exam-loss", "3", "0"]
assert main([*argv, "--summary-csv", str(path)]) == EXIT_OK
with path.open(encoding="utf-8", newline="") as file:
rows = list(csv.DictReader(file))
assert [row["scenario"] for row in rows] == [
"exam:c p3 kiai+2 start0 fail-3 casual",
"exam:c p3 kiai+2 start0 fail-0 casual",
"exam:c p4 kiai+2 start0 fail-3 casual",
"exam:c p4 kiai+2 start0 fail-0 casual",
]
assert {"rounds_mean", "square_gain_per_turn", "win_rate_casual"} <= rows[0].keys()
assert (rows[0]["seat3_win_rate"], rows[2]["seat3_win_rate"] != "") == ("", True)
@pytest.mark.parametrize( @pytest.mark.parametrize(
"bad", [["--lineup", "balanced,cheater"], ["--games", "0"], ["--exam", "b"]] "bad",
[["--lineup", "balanced,cheater"], ["--games", "0"], ["--exam", "b"], ["--exam-loss", "-1"]],
) )
def test_sim_rejects_bad_arguments(bad: list[str], capsys: pytest.CaptureFixture[str]) -> None: def test_sim_rejects_bad_arguments(bad: list[str], capsys: pytest.CaptureFixture[str]) -> None:
with pytest.raises(SystemExit) as exit_info: with pytest.raises(SystemExit) as exit_info:
+139 -1
View File
@@ -17,6 +17,7 @@ from dojo.events import (
GameOver, GameOver,
KumiteFought, KumiteFought,
Moved, Moved,
MoveOptions,
SentToStart, SentToStart,
Source, Source,
TrackChanged, TrackChanged,
@@ -159,6 +160,21 @@ def test_winning_attacker_continues_its_move() -> None:
] ]
def test_every_path_choice_records_the_squares_it_could_end_on() -> None:
game, agents, _ = make_game(dice=[1, 2, 6, 1])
put(game, 0, Pos(4, 2), track=3)
put(game, 1, Pos(4, 3), track=3)
turn(game, agents, 0, Pos(4, 5), Pos(4, 5))
offered = agents[0].offered_paths
assert len(offered) == 2
assert [e.ends for e in events_of(game, MoveOptions)] == [
tuple(game.board.code(end) for end in sorted({path[-1] for path in paths}))
for paths in offered
]
def test_losing_attacker_stops_even_with_steps_left() -> None: def test_losing_attacker_stops_even_with_steps_left() -> None:
game, agents, dice = make_game(dice=[1, 2, 1, 6]) game, agents, dice = make_game(dice=[1, 2, 1, 6])
put(game, 0, Pos(4, 2), track=3) put(game, 0, Pos(4, 2), track=3)
@@ -182,6 +198,70 @@ def test_no_kumite_inside_a_safe_room() -> None:
assert game.players[0].position == game.players[1].position == Pos(1, 1) assert game.players[0].position == game.players[1].position == Pos(1, 1)
def test_no_kumite_in_round_one_opponents_are_passed_through() -> None:
game, agents, _ = make_game(dice=[1, 2])
game.round = 1
put(game, 0, Pos(4, 2), track=3)
put(game, 1, Pos(4, 3), track=3)
turn(game, agents, 0, Pos(4, 5))
assert not events_of(game, KumiteFought)
assert (game.players[0].position, game.players[1].position) == (Pos(4, 5), Pos(4, 3))
def test_no_kumite_in_round_one_a_move_cannot_end_on_an_opponent() -> None:
game, agents, _ = make_game(dice=[1, 1])
game.round = 1
put(game, 0, Pos(4, 2))
put(game, 1, Pos(4, 4))
turn(game, agents, 0, Pos(4, 4))
assert not events_of(game, KumiteFought)
assert game.players[0].position != Pos(4, 4)
assert len(agents[0].offered_paths) == 2 # The move, then the Meditazione extra step.
def test_no_kumite_in_round_one_sfida_has_no_target() -> None:
game, agents, _ = make_game(dice=[1, 1])
game.round = 1
put(game, 0, Pos(5, 0))
put(game, 1, Pos(4, 4))
turn(game, agents, 0, Pos(5, 2))
assert not events_of(game, KumiteFought)
def test_no_kumite_in_round_one_an_occupied_tunnel_exit_cannot_be_used() -> None:
game, agents, _ = make_game(dice=[1, 1])
game.round = 1
put(game, 0, NEBBIA)
put(game, 1, Pos(3, 4))
agents[0].use_tunnels = True
turn(game, agents, 0, Pos(1, 6))
assert game.players[0].position == Pos(1, 6)
def test_first_player_bonus_raises_only_the_first_seat() -> None:
game, _, _ = make_game(players=3, config=RulesConfig(start_position=3, first_player_bonus=1))
assert [p.track for p in game.players] == [4, 3, 3]
def test_first_player_bonus_cannot_be_negative() -> None:
with pytest.raises(ValueError, match="first_player_bonus"):
RulesConfig(first_player_bonus=-1)
def test_first_kumite_round_must_be_positive() -> None:
with pytest.raises(ValueError, match="first_kumite_round"):
RulesConfig(first_kumite_round=0)
# Tecniche --------------------------------------------------------------------------------- # Tecniche ---------------------------------------------------------------------------------
@@ -289,10 +369,11 @@ def test_ending_on_a_meditating_pawn_takes_one_extra_step() -> None:
def test_meditating_players_turn_is_skipped_once() -> None: def test_meditating_players_turn_is_skipped_once() -> None:
game, agents, _ = make_game() game, agents, _ = make_game()
game.players[1].meditating = True game.players[1].meditating = True
start = game.round
turn(game, agents, 1) turn(game, agents, 1)
assert events_of(game, TurnSkipped) == [TurnSkipped(round=1, player=1)] assert events_of(game, TurnSkipped) == [TurnSkipped(round=start, player=1)]
assert not game.players[1].meditating assert not game.players[1].meditating
@@ -310,6 +391,47 @@ def test_allenamento_and_fatica() -> None:
assert game.players[0].track == 3 assert game.players[0].track == 3
def test_fatica_costs_a_stripe_when_walked_over() -> None:
game, agents, _ = make_game(dice=[1, 1])
put(game, 0, Pos(3, 5), track=3)
turn(game, agents, 0, (Pos(3, 6), Pos(3, 7)))
assert game.players[0].track == 2
def test_fatica_walked_over_is_free_when_only_stopping_costs() -> None:
game, agents, _ = make_game(dice=[1, 1], config=RulesConfig(fatica_on_crossing=False))
put(game, 0, Pos(3, 5), track=3)
turn(game, agents, 0, (Pos(3, 6), Pos(3, 7)))
assert game.players[0].track == 3
def test_routes_to_one_square_are_offered_apart_when_one_crosses_fatica() -> None:
game, agents, _ = make_game(dice=[1, 1])
put(game, 0, Pos(2, 6))
turn(game, agents, 0, (Pos(2, 7), Pos(3, 7)))
to_corner = [path for path in agents[0].offered_paths[0] if path[-1] == Pos(3, 7)]
assert sorted(to_corner) == [(Pos(2, 7), Pos(3, 7)), (Pos(3, 6), Pos(3, 7))]
assert game.players[0].track == 0
def test_fatica_that_ends_brown_closes_the_dojo_for_that_route() -> None:
game, agents, _ = make_game(dice=[2, 3])
put(game, 0, Pos(3, 5), track=15)
agents[0].ends.append(Pos(3, 4))
game.play_turn()
into_dojo = [path for path in agents[0].offered_paths[0] if path[-1] == Pos(6, 7)]
assert into_dojo
assert all(Pos(3, 6) not in path for path in into_dojo)
def test_squares_can_make_a_player_brown() -> None: def test_squares_can_make_a_player_brown() -> None:
game, agents, _ = make_game(dice=[1, 1]) game, agents, _ = make_game(dice=[1, 1])
put(game, 0, Pos(0, 3), track=14) put(game, 0, Pos(0, 3), track=14)
@@ -566,6 +688,22 @@ def test_failing_the_exam_costs_a_belt_and_the_defender_gains_nothing() -> None:
assert game.players[2].track == 12 assert game.players[2].track == 12
@pytest.mark.parametrize(("loss", "track"), [(0, 15), (1, 14)])
def test_failed_exam_loss_is_configurable(loss: int, track: int) -> None:
config = RulesConfig(exam_failure_loss=loss)
game, agents = dojo_game([1, 1, 1, 6], config=config, p1=9, p2=12)
turn(game, agents, 0, Pos(6, 7))
assert game.players[0].track == track
assert game.players[0].position == START_0
def test_failed_exam_loss_cannot_be_negative() -> None:
with pytest.raises(ValueError, match="exam_failure_loss"):
RulesConfig(exam_failure_loss=-1)
def test_uke_in_the_exam_returns_to_the_entrance() -> None: def test_uke_in_the_exam_returns_to_the_entrance() -> None:
game, agents = dojo_game([1, 1, 1, 6], p1=9, p2=12) game, agents = dojo_game([1, 1, 1, 6], p1=9, p2=12)
game.players[0].tecnica = Card.UKE game.players[0].tecnica = Card.UKE
+57 -5
View File
@@ -10,13 +10,14 @@ from dojo.events import (
GameOver, GameOver,
KumiteFought, KumiteFought,
Moved, Moved,
MoveOptions,
Source, Source,
TrackChanged, TrackChanged,
TurnSkipped, TurnSkipped,
TurnStarted, TurnStarted,
) )
from dojo.metrics import KUMITE_SECONDS, TURN_SECONDS, GameMetrics, measure, summarize from dojo.metrics import KUMITE_SECONDS, TURN_SECONDS, GameMetrics, measure, summarize
from dojo.rules import DEFAULT_RULES from dojo.rules import DEFAULT_RULES, Card, RulesConfig
HOME_0 = Pos(0, 1) # quadrant 0 HOME_0 = Pos(0, 1) # quadrant 0
@@ -37,9 +38,11 @@ def test_measure_counts_fights_income_and_exams() -> None:
metrics = run( metrics = run(
[ [
TurnStarted(1, 0, 7), TurnStarted(1, 0, 7),
MoveOptions(0, ("A", "F", ".")),
Moved(0, (HOME_0,)), Moved(0, (HOME_0,)),
TrackChanged(0, 0, 1, Source.SQUARE), TrackChanged(0, 0, 1, Source.SQUARE),
TurnStarted(1, 1, 5), TurnStarted(1, 1, 5),
MoveOptions(1, ("A", "?")),
Moved(1, (HOME_0,)), Moved(1, (HOME_0,)),
TrackChanged(1, 0, 1, Source.SQUARE), TrackChanged(1, 0, 1, Source.SQUARE),
fought(1, 0, FightKind.EQUAL), fought(1, 0, FightKind.EQUAL),
@@ -65,10 +68,54 @@ def test_measure_counts_fights_income_and_exams() -> None:
assert (metrics.card_gain, metrics.card_loss) == (5, -1) assert (metrics.card_gain, metrics.card_loss) == (5, -1)
assert (metrics.square_income_home, metrics.square_income_away) == (1, 1) assert (metrics.square_income_home, metrics.square_income_away) == (1, 1)
assert (metrics.winner_seat, metrics.winner_bot, metrics.rounds) == (0, "balanced", 2) assert (metrics.winner_seat, metrics.winner_bot, metrics.rounds) == (0, "balanced", 2)
assert (metrics.move_choices, metrics.end_squares) == (2, 5)
assert (metrics.allenamento_reachable, metrics.fatica_reachable) == (2, 1)
assert metrics.final_track == (4, 4)
assert (metrics.exam_attempts_by_seat, metrics.exam_defences_by_seat) == ((1, 0), (0, 1))
# Both Exam rounds count as fights for the time estimate. # Both Exam rounds count as fights for the time estimate.
assert metrics.minutes == pytest.approx(round((3 * TURN_SECONDS + 5 * KUMITE_SECONDS) / 60, 1)) assert metrics.minutes == pytest.approx(round((3 * TURN_SECONDS + 5 * KUMITE_SECONDS) / 60, 1))
@pytest.mark.parametrize(
("totals", "attacker_won", "cards", "decided"),
[
((5, 4), True, ((0, Card.KIAI),), True),
((5, 3), True, ((0, Card.KIAI),), False), # 3 vs 3 without it: Senshu, still won.
((8, 4), True, ((0, Card.KIAI),), False),
((4, 5), False, ((1, Card.KIAI),), True),
((6, 5), True, ((0, Card.KIAI), (1, Card.KIAI)), False),
((5, 4), True, ((0, Card.KAESHI),), False),
],
)
def test_kiai_decides_a_fight_only_when_the_result_flips_without_it(
totals: tuple[int, int],
attacker_won: bool,
cards: tuple[tuple[int, Card], ...],
decided: bool,
) -> None:
fight = KumiteFought(0, 1, 0, 0, *totals, attacker_won, FightKind.EQUAL, False, cards)
exam = replace(fight, exam=True)
metrics = run([TurnStarted(2, 0, 7), fight, fought(1, 0, FightKind.EQUAL), exam])
summary = summarize("test", [metrics])
assert (metrics.tecnica_kumite, metrics.kiai_decided) == (1, decided)
assert (metrics.exam_fights, metrics.exam_kiai_decided) == (1, decided)
assert summary.tecnica_rate == 0.5
assert summary.kiai_decided_rate == decided / 2
assert summary.exam_kiai_decided_rate == decided
def test_first_player_bonus_is_counted_from_the_start() -> None:
config = RulesConfig(first_player_bonus=1)
metrics = measure(
[GameOver(None, 1)], config, (0, 1), ("balanced", "farmer"), scenario="t", seed=0
)
assert metrics.final_track == (1, 0)
def test_standings_are_sampled_at_turn_ends() -> None: def test_standings_are_sampled_at_turn_ends() -> None:
metrics = run( metrics = run(
[ [
@@ -94,9 +141,9 @@ def test_standings_are_sampled_at_turn_ends() -> None:
# Halfway is the end of turn 2, led by player 1. # Halfway is the end of turn 2, led by player 1.
assert metrics.halfway_leader_won is False assert metrics.halfway_leader_won is False
assert metrics.contenders_at_end == 0 assert metrics.contenders_at_end == 0
# Out (more than 6 behind): players 1 and 2 after turn 1, player 2 after turn 2. # Out (more than 9 behind): player 2 after turn 2 only.
assert metrics.out_of_contention_share == pytest.approx(3 / 12) assert metrics.out_of_contention_share == round(1 / 12, 3)
assert metrics.worst_out_of_contention_share == pytest.approx(0.5) assert metrics.worst_out_of_contention_share == pytest.approx(1 / 4)
# 0>1 becomes 1>0; 0>2 becomes 2>0; 1>2 (ties skipped) becomes 2>1. # 0>1 becomes 1>0; 0>2 becomes 2>0; 1>2 (ties skipped) becomes 2>1.
assert metrics.rank_swaps == 3 assert metrics.rank_swaps == 3
assert metrics.winner_turns_to_brown == 1 assert metrics.winner_turns_to_brown == 1
@@ -127,13 +174,18 @@ def test_summarize_splits_wins_by_seat_and_bot() -> None:
replace(base, lineup="balanced,random", winner_seat=0, winner_bot="balanced"), replace(base, lineup="balanced,random", winner_seat=0, winner_bot="balanced"),
replace(base, lineup="random,balanced", winner_seat=1, winner_bot="balanced"), replace(base, lineup="random,balanced", winner_seat=1, winner_bot="balanced"),
replace(base, lineup="random,balanced", winner_seat=0, winner_bot="random"), replace(base, lineup="random,balanced", winner_seat=0, winner_bot="random"),
base, replace(
base, final_track=(2, 9), exam_attempts_by_seat=(0, 3), exam_defences_by_seat=(2, 0)
),
] ]
summary = summarize("test", rows) summary = summarize("test", rows)
assert summary.finished == 0.75 assert summary.finished == 0.75
assert summary.win_rate_by_seat == (0.5, 0.25) assert summary.win_rate_by_seat == (0.5, 0.25)
assert summary.seats[0].final_track == pytest.approx(0.5)
assert summary.seats[1].exam_attempts_per_game == pytest.approx(0.75)
assert (summary.seats[0].exam_defended, summary.seats[1].exam_defended) == (0.25, 0.0)
assert summary.win_rate_by_bot == {"balanced": 0.5, "random": 0.25} assert summary.win_rate_by_bot == {"balanced": 0.5, "random": 0.25}
assert summary.rounds.median == 10 assert summary.rounds.median == 10
+11
View File
@@ -48,3 +48,14 @@ def test_boxed_in_pawn_moves_as_far_as_possible() -> None:
paths = walks(corridor, Pos(0, 0), 5, {Pos(0, 0)}, can_enter_dojo=False) paths = walks(corridor, Pos(0, 0), 5, {Pos(0, 0)}, can_enter_dojo=False)
assert paths == [(Pos(0, 1), Pos(0, 2))] assert paths == [(Pos(0, 1), Pos(0, 2))]
def test_reused_walks_match_a_fresh_search_and_are_independent_copies() -> None:
start = Pos(4, 4)
off_board = Pos(-1, -1)
walks(BOARD, start, 6, {start}, can_enter_dojo=True).clear()
reused = walks(BOARD, start, 6, {start}, can_enter_dojo=True)
assert reused
assert reused == walks(BOARD, start, 6, {start, off_board}, can_enter_dojo=True)
+13 -4
View File
@@ -11,13 +11,17 @@ LINEUP = ("balanced", "random")
def test_matrix_crosses_every_axis() -> None: def test_matrix_crosses_every_axis() -> None:
scenarios = matrix(["none", "acd"], [3, 4], [2, 1], [0], [LINEUP]) scenarios = matrix(["none", "acd"], [3, 4], [2, 1], [0], [LINEUP], [3, 0])
assert len(scenarios) == 8 assert len(scenarios) == 16
assert scenarios[0].name == "exam:none p3 kiai+2 start0 balanced,random" assert scenarios[0].name == "exam:none p3 kiai+2 start0 fail-3 balanced,random"
assert scenarios[0].config == RulesConfig(exam_defender_black=False) assert scenarios[0].config == RulesConfig(exam_defender_black=False)
assert scenarios[-1].config == RulesConfig( assert scenarios[-1].config == RulesConfig(
exam_ties_to_defender=True, exam_defender_black=True, exam_rounds=3, kiai_bonus=1 exam_ties_to_defender=True,
exam_defender_black=True,
exam_rounds=3,
exam_failure_loss=0,
kiai_bonus=1,
) )
@@ -64,6 +68,11 @@ def test_invalid_scenarios_are_rejected(
Scenario(exam, players, 2, 0, lineup) Scenario(exam, players, 2, 0, lineup)
def test_invalid_exam_loss_is_rejected() -> None:
with pytest.raises(ValueError, match="exam_failure_loss"):
Scenario("c", 4, 2, 0, LINEUP, exam_loss=-1)
def test_runs_are_reproducible_and_independent_of_worker_count() -> None: def test_runs_are_reproducible_and_independent_of_worker_count() -> None:
scenarios = matrix(["none", "acd"], [3], [2], [0], [LINEUP]) scenarios = matrix(["none", "acd"], [3], [2], [0], [LINEUP])
Generated
+11
View File
@@ -18,6 +18,7 @@ source = { editable = "." }
[package.dev-dependencies] [package.dev-dependencies]
dev = [ dev = [
{ name = "markdown" },
{ name = "pytest" }, { name = "pytest" },
{ name = "ruff" }, { name = "ruff" },
] ]
@@ -26,6 +27,7 @@ dev = [
[package.metadata.requires-dev] [package.metadata.requires-dev]
dev = [ dev = [
{ name = "markdown", specifier = ">=3.11" },
{ name = "pytest", specifier = ">=9.1.1" }, { name = "pytest", specifier = ">=9.1.1" },
{ name = "ruff", specifier = ">=0.16.9" }, { name = "ruff", specifier = ">=0.16.9" },
] ]
@@ -39,6 +41,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
] ]
[[package]]
name = "markdown"
version = "3.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f8/4f/700155c8c20d9e655dd0732b5fc3c7614f291b9148da271d7388e50bf774/markdown-3.11.tar.gz", hash = "sha256:180224db6aed87ba9ce1f2781ebcd5826253de8ff637112090e24b84502bbf9f", size = 485623, upload-time = "2026-09-25T13:46:23.473Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/1e/32971905a7ab47f8b66866ed949fa48b104ba1c4a6fa57794c4f2c4b2cb8/markdown-3.11-py3-none-any.whl", hash = "sha256:cd6c89e7eb308c8b332ed673215a52d208a43f8bacc030b1419376129408719e", size = 111296, upload-time = "2026-09-25T13:46:22.163Z" },
]
[[package]] [[package]]
name = "packaging" name = "packaging"
version = "26.3" version = "26.3"