Compare commits
10 Commits
4998931cb0
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
84673c573d
|
|||
|
fea6792f25
|
|||
|
79cbbccbb2
|
|||
|
52d8af620b
|
|||
|
542ac1b15f
|
|||
|
f67514b57a
|
|||
|
23ae7a2572
|
|||
|
f0e112590a
|
|||
|
0ca905eb71
|
|||
|
bbc95b6722
|
@@ -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
@@ -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).
|
||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
+---+---+---+ +---+ + + +
|
+---+---+---+ +---+ + + +
|
||||||
|
|||||||
@@ -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)}")
|
||||||
@@ -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
@@ -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.
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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):
|
||||||
|
|||||||
@@ -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
@@ -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())},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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])
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user