Rules v0.8: Fatica on crossing, no Kumite in round 1, board draft 2

- Fatica costs a stripe for every Fatica walked onto; a route whose Fatica
  drops a Brown player below 15 no longer enters the Dojo
- No Kumite in the first round: opponents are treated like meditating pawns
- Board draft 2: two Fatica tolls per quadrant, Allenamento moved to the ring
- Casual bot, --summary-csv, per-seat breakdown and move-options metric
- Out of contention measured at 3 belts
- Plan: Phase 6 baselines, decisions D34-D43

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-27 15:59:45 +02:00
parent 4998931cb0
commit bbc95b6722
18 changed files with 566 additions and 80 deletions
+3 -1
View File
@@ -36,11 +36,13 @@ 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 random --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
+23 -6
View File
@@ -1,4 +1,4 @@
# Il Labirinto del Dojo — Rules (draft v0.7) # Il Labirinto del Dojo — Rules (draft v0.8)
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.
@@ -59,7 +59,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 +79,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 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 +106,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. |
@@ -152,6 +159,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
@@ -166,9 +175,12 @@ All other cards are shown and resolved immediately.
## Rule variants under test ## Rule variants under test
Parameters to validate by simulation: Kiai +2 vs +1; starting on 3 with 6+ players; Parameters to validate by simulation: Kiai +2 vs +1; starting on 3 with 6+ players;
square mix; game length at 3/4/6/8 players. square mix; game length at 3/4/6/8 players; a catch-up rule (Allenamento gives +2 instead
of +1 to a player two or more belts behind the leader).
Tested and set aside (simulator switches only): Exam ties to the defender; best-of-3 Exam. Tested and set aside (simulator switches only): Exam ties to the defender; best-of-3 Exam.
Earlier rules kept as simulator switches: Fatica costing only when you stop on it; Kumite
allowed in the first round.
## FAQ ## FAQ
@@ -178,13 +190,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.
+90 -15
View File
@@ -26,7 +26,7 @@ Deliverables:
| D7 | Corner Safe Rooms: 3×3, 2 exits, no Kumite/effects/targeting, stacking allowed | Prevents spawn camping / spawn lock. | | D7 | Corner Safe Rooms: 3×3, 2 exits, no Kumite/effects/targeting, stacking allowed | Prevents spawn camping / spawn lock. |
| D8 | Movement 2d6, exact count, orthogonal, no re-entering a square in the same move | Developer requirement + clarity. | | D8 | Movement 2d6, exact count, orthogonal, no re-entering a square in the same move | Developer requirement + clarity. |
| D9 | Most squares carry an effect from a small vocabulary (7 codes); ~30–40% Tatami | Developer requirement; limits reading load and analysis paralysis. | | D9 | Most squares carry an effect from a small vocabulary (7 codes); ~30–40% Tatami | Developer requirement; limits reading load and analysis paralysis. |
| D10 | Effects trigger only when stopping on a square | Avoids multiple triggers per move. | | D10 | Effects trigger only when stopping on a square (except Fatica since D36) | Avoids multiple triggers per move. |
| D11 | Nebbia works like Monopoly Chance: the square stays active | Developer requirement. | | D11 | Nebbia works like Monopoly Chance: the square stays active | Developer requirement. |
| D12 | Progress track 0–17, 3 stripes per belt; squares ±1, Kumite ±3 | Fine-grained board income without breaking pacing. Physical tracking method decided later. | | D12 | Progress track 0–17, 3 stripes per belt; squares ±1, Kumite ±3 | Fine-grained board income without breaking pacing. Physical tracking method decided later. |
| D13 | Nebbia deck is mostly movement/interaction/Tecniche, very few flat stripes | Prevents "stripe farming" that would make players avoid fights. | | D13 | Nebbia deck is mostly movement/interaction/Tecniche, very few flat stripes | Prevents "stripe farming" that would make players avoid fights. |
@@ -50,6 +50,16 @@ 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. |
## Phases ## Phases
@@ -160,7 +170,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:** 6.9 catch-up candidate A, when the developer says to continue. 6.2 is suspended (D43); 6.4.1 (chance square) stays optional. Order of the steps: D41. Everything up to D43 and RULES.md v0.8 is committed; commit only when the developer asks. 6.0, 6.1, D35 and D36 are not committed yet; 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,21 +187,86 @@ 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).
| Measure (target) | mixed | balanced | casual | random |
|---|---|---|---|---|
| Finished within 200 rounds (100%) | 100% | 100% | 100% | **94%** |
| Rounds mean (p10–p90) | 21.8 (15–29) | 22.2 (15–30) | 24.9 (17–34) | 115 (67–179) |
| Minutes mean (60–90), p90 (≤ 110) | 79, 108 | 81, **110.5** | 83, **114** | 365, 569 |
| Kumite per round; upsets | 1.24; 29% | 1.26; 30% | 0.66; 28% | 0.36; 23% |
| Exam attempts per game; pass rate (35–55%) | 2.33; 43% | 2.45; 41% | 2.39; 42% | 2.24; 42% |
| 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.
- [ ] 6.2 Seat order — **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.
- [ ] 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.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: - [ ] 6.4 Fatica and the square economy (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 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.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. - [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 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). - [ ] 6.4.3 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).
- [ ] 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`. - [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`.
- [ ] 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. - [ ] 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.
- [ ] 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.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.9 Catch-up candidate A (D38), next in the order (D41; 6.2 suspended, D43): `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.
- [ ] 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. - [ ] 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.
### Phase 7 — Finalisation (later) ### Phase 7 — Finalisation (later)
@@ -221,11 +296,11 @@ 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
@@ -233,7 +308,7 @@ Deliverables:
- 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 ~1,400 turns/s (random and heuristic bots, real board). If Phase 5 needs more: precompute neighbour lists in `Board`.
- 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. - 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. Board draft 2 (D42) moved them to the Dojo ring: 62–67%.
## External documentation ## External documentation
+14 -14
View File
@@ -1,33 +1,33 @@
+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+ +---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+
| S S S | . 1 ? F A | A . 2 P . | S S S | | S S S | . 1 ? A A | A . 2 P . | S S S |
+ + + + +---+ +---+ + +---+---+---+ + + + + + + + + +---+ +---+ + +---+---+---+ + + + +
| S S S . | A . A | P | 1 | A . A | . S S S | | S S S . | ? . A | P | 1 | ? . . | . S S S |
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
| S S S | ? | . ? . | 2 | . . ? . . | S S S | | S S S | ? | . ? . | 2 | . . ? . . | S S S |
+---+ +---+ + + + + + + + + + +---+ +---+ +---+ +---+ + + + + + + + + + +---+ +---+
| . . . . A F A | . F | A . A | . ? . . | | . . . . F . A | . A | A . A | . ? . . |
+ +---+ +---+---+---+ + + + +---+---+ +---+---+ + + +---+ +---+---+---+ + + + +---+---+ +---+---+ +
| P | A . A | . ? . | F | F . A . | A . A | 1 | | P | . . A | . ? . | A | ? A A . | F . ? | 1 |
+ + + + + + +---+ + +---+---+ + + + + + + + + + + + +---+ + +---+---+ + + + + +
| 2 | . ? . | A | A . ? A K A ? | F ? . ? | | 2 | . ? . | A | F A A A K F ? | . ? . ? |
+ + + + + + + +---+---+---+ + + + + + + + + + + + + + +---+---+---+ + + + + + +
| . | A . A . | K | D D D D . | . A . A | F | | . | ? . A A | K | D D D D A | . A . A | A |
+ +---+ +---+ + + + + + + +---+---+---+---+ + + +---+ +---+ + + + + + + +---+---+---+---+ +
| A 1 . F F A | D D D D | ? F . 2 P A | | A 1 . A ? A | D D D D | A A . 2 P A |
+---+---+---+ +---+ + + + + + +---+ +---+---+---+ +---+---+---+ +---+ + + + + + +---+ +---+---+---+
| A P 2 . F ? | D D D D | A F F . 1 A | | A P 2 . A A | D D D D | A ? A . 1 A |
+ +---+---+---+---+ + + + + + + +---+ +---+ + + +---+---+---+---+ + + + + + + +---+ +---+ +
| F | A . A . | . D D D D | K | . A . A | . | | A | A . A . | A D D D D | K | A A . ? | . |
+ + + + + + +---+---+---+ + + + + + + + + + + + + + +---+---+---+ + + + + + + +
| ? . ? F | ? A K A ? . A | A | . ? . | 2 | | ? . ? . | ? F K A A A F | A | . ? . | 2 |
+ + + + + +---+---+ + +---+ + + + + + + + + + + + +---+---+ + +---+ + + + + + +
| 1 | A . A | . A . F | F | . ? . | A . A | P | | 1 | ? . F | . A A ? | A | . ? . | A . . | P |
+ +---+---+ +---+---+ + + + +---+---+---+ +---+ + + +---+---+ +---+---+ + + + +---+---+---+ +---+ +
| . . ? . | A . A | F . | A F A . . . . | | . . ? . | A . A | A . | A . F . . . . |
+---+ +---+ + + + + + + + + + +---+ +---+ +---+ +---+ + + + + + + + + + +---+ +---+
| S S S | . . ? . . | 2 | . ? . | ? | S S S | | S S S | . . ? . . | 2 | . ? . | ? | S S S |
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
| S S S . | A . A | 1 | P | A . A | . S S S | | S S S . | . . ? | 1 | P | A . ? | . S S S |
+ + + + +---+---+---+ + +---+ +---+ + + + + + + + + +---+---+---+ + +---+ +---+ + + + +
| S S S | . P 2 . A | A F ? 1 . | S S S | | S S S | . P 2 . A | A A ? 1 . | S S S |
+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+ +---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+
+11 -9
View File
@@ -1,20 +1,22 @@
# Quadrant 0 (top-left). Rotated 3x into the full board. Format: board/FORMAT.md # Quadrant 0 (top-left). Rotated 3x into the full board. Format: board/FORMAT.md
# Draft 1: north/east/west corridors, two 3x3 training rooms (rows 1-3 and rows 4-6), # Draft 2: walls as draft 1 (north/east/west corridors, two training rooms, ring around
# middle corridor, ring around the Dojo shared with the neighbouring quadrants. # the Dojo). Built for Fatica on crossing (RULES.md v0.8): two Fatica per quadrant as tolls,
# on the row-3 corridor (3,4) and the inner Dojo approach (5,5); Allenamento moved from the
# south training room onto the Dojo ring, where visitors walk.
+---+---+---+---+---+---+---+---+ +---+---+---+---+---+---+---+---+
| S S S | . 1 ? F A | | S S S | . 1 ? A A |
+ + + + +---+ +---+ + + + + + +---+ +---+ +
| S S S . | A . A | P | | S S S . | ? . A | P |
+ + + + + + + + + + + + + + + + + +
| S S S | ? | . ? . | 2 | | S S S | ? | . ? . | 2 |
+---+ +---+ + + + + + +---+ +---+ + + + + +
| . . . . A F A | . | . . . . F . A | .
+ +---+ +---+---+---+ + + + +---+ +---+---+---+ + +
| P | A . A | . ? . | F | | P | . . A | . ? . | A |
+ + + + + + +---+ + + + + + + + +---+ +
| 2 | . ? . | A | A . ? | 2 | . ? . | A | F A A
+ + + + + + + +---+ + + + + + + + +---+
| . | A . A . | K | D D | . | ? . A A | K | D D
+ +---+ +---+ + + + + + +---+ +---+ + + + +
| A 1 . F F A | D D | A 1 . A ? A | D D
+---+---+---+ +---+ + + + +---+---+---+ +---+ + + +
+44 -4
View File
@@ -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:
@@ -238,6 +240,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 +263,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 +277,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 _:
@@ -385,14 +389,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 +420,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)
+11 -3
View File
@@ -7,7 +7,7 @@ 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, BoardFiles, matrix, run
from dojo.validate import Report, validate from dojo.validate import Report, validate
@@ -68,6 +68,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)
@@ -105,10 +108,15 @@ def _simulate(args: argparse.Namespace, files: BoardFiles, parser: argparse.Argu
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
+35 -7
View File
@@ -23,6 +23,7 @@ from dojo.events import (
GameOver, GameOver,
KumiteFought, KumiteFought,
Moved, Moved,
MoveOptions,
SentToStart, SentToStart,
Source, Source,
Teleported, Teleported,
@@ -238,6 +239,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 +268,35 @@ 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] = {} 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
crossed = tuple(
None if self._costs_fatica(cell) else cell
for cell in path
if self._costs_fatica(cell) or self._fightable_at(cell, player)
)
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)
@@ -432,7 +450,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 +466,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,8 +579,15 @@ 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 self.config.fatica_on_crossing and self.board.code(square) == Square.FATICA
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:
@@ -570,7 +595,10 @@ class Game:
return None return None
def _has_unfightable_pawn(self, square: Pos, player: Player) -> bool: def _has_unfightable_pawn(self, square: Pos, player: Player) -> bool:
return any(p is not player and p.position == square and p.meditating for p in self.players) return any(
p is not player and p.position == square and (p.meditating or not self._kumite_allowed)
for p in self.players
)
def _shares_square(self, player: Player) -> bool: def _shares_square(self, player: Player) -> bool:
if self._in_safe_room(player.position): if self._in_safe_room(player.position):
+9
View File
@@ -34,6 +34,14 @@ class TurnSkipped:
player: int player: int
@dataclass(frozen=True)
class MoveOptions:
"""The squares a move could end on, as square codes, one per distinct square."""
player: int
ends: tuple[str, ...]
@dataclass(frozen=True) @dataclass(frozen=True)
class Moved: class Moved:
player: int player: int
@@ -98,6 +106,7 @@ class GameOver:
type Event = ( type Event = (
TurnStarted TurnStarted
| TurnSkipped | TurnSkipped
| MoveOptions
| Moved | Moved
| Teleported | Teleported
| KumiteFought | KumiteFought
+61 -6
View File
@@ -10,7 +10,7 @@ from collections import Counter
from collections.abc import Iterable, Sequence from collections.abc import Iterable, Sequence
from dataclasses import dataclass from dataclasses import dataclass
from dojo.board import Pos, quadrant_of from dojo.board import Pos, Square, quadrant_of
from dojo.events import ( from dojo.events import (
Event, Event,
ExamTaken, ExamTaken,
@@ -18,6 +18,7 @@ from dojo.events import (
GameOver, GameOver,
KumiteFought, KumiteFought,
Moved, Moved,
MoveOptions,
SentToStart, SentToStart,
Source, Source,
Teleported, Teleported,
@@ -31,14 +32,14 @@ from dojo.rules import RulesConfig
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, ...).
""" """
@@ -80,6 +81,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(
@@ -102,7 +112,10 @@ def measure(
kumite = upsets = expected = exam_fights = 0 kumite = upsets = expected = exam_fights = 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,6 +133,11 @@ 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):
@@ -138,8 +156,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):
@@ -193,6 +213,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,
@@ -302,6 +329,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)."""
@@ -335,9 +371,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)
@@ -348,6 +391,7 @@ def summarize(scenario: str, games: Iterable[GameMetrics]) -> Summary:
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)
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]()
@@ -406,7 +450,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())},
) )
+57 -6
View File
@@ -1,15 +1,15 @@
"""Simulation output: a plain-text summary per scenario and one CSV row per game.""" """Simulation output: a plain-text summary per scenario, and CSV files with one row per game
or one row per scenario."""
import csv import csv
import dataclasses import dataclasses
from collections.abc import Iterable from collections.abc import Iterable
from pathlib import Path from pathlib import Path
from dojo.metrics import GameMetrics, Spread, Summary from dojo.metrics import CONTENTION_BELTS, GameMetrics, SeatSummary, Spread, Summary
def format_summary(summary: Summary) -> str: def format_summary(summary: Summary) -> str:
seats = ", ".join(f"{seat} {rate:.0%}" for seat, rate in enumerate(summary.win_rate_by_seat))
bots = ", ".join(f"{bot} {rate:.0%}" for bot, rate in summary.win_rate_by_bot.items()) bots = ", ".join(f"{bot} {rate:.0%}" for bot, rate in summary.win_rate_by_bot.items())
income = ", ".join( income = ", ".join(
f"{source} +{gain:.2f}/{loss:.2f}" f"{source} +{gain:.2f}/{loss:.2f}"
@@ -35,12 +35,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 +54,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:
+8
View File
@@ -52,6 +52,12 @@ class RulesConfig:
exam_ties_to_defender: bool = False exam_ties_to_defender: bool = False
exam_rounds: int = 1 exam_rounds: int = 1
# 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
@@ -63,6 +69,8 @@ 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.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:
+7 -3
View File
@@ -14,7 +14,7 @@ from functools import cache
from pathlib import Path from pathlib import Path
from dojo.board import Board, load_board from dojo.board import Board, load_board
from dojo.bots import PROFILES, HeuristicAgent, RandomAgent from dojo.bots import PROFILES, CasualAgent, HeuristicAgent, RandomAgent
from dojo.engine import Agent, Game, RandomSource, seat_corners from dojo.engine import Agent, Game, RandomSource, seat_corners
from dojo.metrics import GameMetrics, measure from dojo.metrics import GameMetrics, measure
from dojo.rules import RulesConfig from dojo.rules import RulesConfig
@@ -29,7 +29,8 @@ EXAM_OPTIONS = {
} }
BEST_OF_ROUNDS = 3 BEST_OF_ROUNDS = 3
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)
@@ -97,8 +98,11 @@ class BoardFiles:
def make_agent(bot: str, seed: int, seat: int) -> Agent: def make_agent(bot: str, seed: int, seat: int) -> Agent:
rng = random.Random(f"bot:{seed}:{seat}")
if bot == RANDOM_BOT: if bot == RANDOM_BOT:
return RandomAgent(random.Random(f"bot:{seed}:{seat}")) return RandomAgent(rng)
if bot == CASUAL_BOT:
return CasualAgent(rng)
return HeuristicAgent(PROFILES[bot]) return HeuristicAgent(PROFILES[bot])
+5 -1
View File
@@ -96,10 +96,14 @@ def make_game(
deck: tuple[tuple[Card, int], ...] = NO_CARDS, deck: tuple[tuple[Card, int], ...] = NO_CARDS,
board: Board | None = None, board: Board | None = None,
) -> tuple[Game, list[ScriptedAgent], ScriptedDice]: ) -> tuple[Game, list[ScriptedAgent], ScriptedDice]:
"""A game on the fixture board with an empty deck unless ``deck`` is given.""" """A game on the fixture board with an empty deck unless ``deck`` is given.
It starts in the first round that allows Kumite; tests of round 1 set ``game.round``.
"""
agents = [ScriptedAgent() for _ in range(players)] agents = [ScriptedAgent() for _ in range(players)]
randomness = ScriptedDice(dice) randomness = ScriptedDice(dice)
game = Game(board or fixture_board(), replace(config, deck=deck), agents, randomness) game = Game(board or fixture_board(), replace(config, deck=deck), agents, randomness)
game.round = config.first_kumite_round
return game, agents, randomness return game, agents, randomness
+43
View File
@@ -11,6 +11,7 @@ from dojo.bots import (
BALANCED, BALANCED,
FARMER, FARMER,
PROFILES, PROFILES,
CasualAgent,
HeuristicAgent, HeuristicAgent,
Profile, Profile,
RandomAgent, RandomAgent,
@@ -57,6 +58,26 @@ 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).
choice = HeuristicAgent(AGGRESSIVE).choose_path(game.view(0), [(Pos(5, 3),), (TATAMI,)])
assert choice == (Pos(5, 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 +129,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()]
+17
View File
@@ -1,3 +1,4 @@
import csv
from pathlib import Path from pathlib import Path
import pytest import pytest
@@ -74,6 +75,22 @@ def test_sim_prints_a_summary_per_scenario_and_writes_games(
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"]
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 casual",
"exam:c p4 kiai+2 start0 casual",
]
assert {"rounds_mean", "square_gain_per_turn", "win_rate_casual"} <= rows[0].keys()
assert (rows[0]["seat3_win_rate"], rows[1]["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"]]
) )
+112 -1
View File
@@ -17,6 +17,7 @@ from dojo.events import (
GameOver, GameOver,
KumiteFought, KumiteFought,
Moved, Moved,
MoveOptions,
SentToStart, SentToStart,
Source, Source,
TrackChanged, TrackChanged,
@@ -159,6 +160,21 @@ def test_winning_attacker_continues_its_move() -> None:
] ]
def test_every_path_choice_records_the_squares_it_could_end_on() -> None:
game, agents, _ = make_game(dice=[1, 2, 6, 1])
put(game, 0, Pos(4, 2), track=3)
put(game, 1, Pos(4, 3), track=3)
turn(game, agents, 0, Pos(4, 5), Pos(4, 5))
offered = agents[0].offered_paths
assert len(offered) == 2
assert [e.ends for e in events_of(game, MoveOptions)] == [
tuple(game.board.code(end) for end in sorted({path[-1] for path in paths}))
for paths in offered
]
def test_losing_attacker_stops_even_with_steps_left() -> None: def test_losing_attacker_stops_even_with_steps_left() -> None:
game, agents, dice = make_game(dice=[1, 2, 1, 6]) game, agents, dice = make_game(dice=[1, 2, 1, 6])
put(game, 0, Pos(4, 2), track=3) put(game, 0, Pos(4, 2), track=3)
@@ -182,6 +198,59 @@ 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_kumite_round_must_be_positive() -> None:
with pytest.raises(ValueError, match="first_kumite_round"):
RulesConfig(first_kumite_round=0)
# Tecniche --------------------------------------------------------------------------------- # Tecniche ---------------------------------------------------------------------------------
@@ -289,10 +358,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 +380,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)
+16 -4
View File
@@ -10,6 +10,7 @@ from dojo.events import (
GameOver, GameOver,
KumiteFought, KumiteFought,
Moved, Moved,
MoveOptions,
Source, Source,
TrackChanged, TrackChanged,
TurnSkipped, TurnSkipped,
@@ -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,6 +68,10 @@ 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))
@@ -94,9 +101,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 +134,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