diff --git a/README.md b/README.md index 41df9f6..cb7e2a2 100644 --- a/README.md +++ b/README.md @@ -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 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 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 --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 --players 3 4 6 8 --start 0 3 # player counts x start position uv run dojo sim --kiai 2 1 # Kiai +2 vs +1 diff --git a/RULES.md b/RULES.md index 775a8ba..d80c0e6 100644 --- a/RULES.md +++ b/RULES.md @@ -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. @@ -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. You may fight several opponents in a single move (chain). - 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 @@ -77,6 +79,11 @@ Each player has a position from **0 to 17**. Your belt is the segment you are in ## 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**. 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 @@ -99,7 +106,7 @@ Each player has a position from **0 to 17**. Your belt is the segment you are in |---|---|---| | `.` | Tatami | Nothing. | | `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. | | `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. | @@ -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 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. +- 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 (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 @@ -166,9 +175,12 @@ All other cards are shown and resolved immediately. ## Rule variants under test 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. +Earlier rules kept as simulator switches: Fatica costing only when you stop on it; Kumite +allowed in the first round. ## 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 or trigger the square I land on?** -No to both. Only arriving through a Tunnel or Tunnel Segreto starts a Kumite on an occupied -square, and even then the exit square has no effect. +No to both (a Fatica costs a stripe only when you walk onto it). Only arriving through a +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 the steps I have left?** 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?** Take one extra step (see [Meditazione](#meditazione)). If none is possible, stay. diff --git a/agents/plans/2026-09-25-dojo-board-and-sim.md b/agents/plans/2026-09-25-dojo-board-and-sim.md index cad8a42..409e480 100644 --- a/agents/plans/2026-09-25-dojo-board-and-sim.md +++ b/agents/plans/2026-09-25-dojo-board-and-sim.md @@ -26,7 +26,7 @@ Deliverables: | 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. | | 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. | | 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. | @@ -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%. | | 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. | +| 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 @@ -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`. - 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): @@ -177,21 +187,86 @@ Deliverables: **Steps:** -- [ ] 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`. - - [ ] 6.0.2 `--summary-csv PATH`: one row per scenario with the summary measures, to compare scenarios side by side. - - [ ] 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). - - [ ] 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). -- [ ] 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. +- [x] 6.0 Tooling needed by the experiments + - [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). + - [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 `_mean/_p10/_median/_p90`, income `_gain/loss_per_turn`, seats `seat_`, bots `win_rate_`; columns missing in a scenario are empty. + - [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. + - [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`. +- [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. + - 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.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.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. - - [ ] 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.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.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 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`. - [ ] 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.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. ### Phase 7 — Finalisation (later) @@ -221,11 +296,11 @@ Deliverables: - Final Exam → the defender fights as a Black belt (D31). - 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). +- 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). ### 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.)* ### 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). - 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. -- 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 diff --git a/board/board.txt b/board/board.txt index cfa00e6..43179dc 100644 --- a/board/board.txt +++ b/board/board.txt @@ -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 | +---+ +---+ + + + + + + + + + +---+ +---+ -| . . . . 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 . | 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 | +---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+ diff --git a/board/quadrant.txt b/board/quadrant.txt index 6b1eb89..3b3b114 100644 --- a/board/quadrant.txt +++ b/board/quadrant.txt @@ -1,20 +1,22 @@ # 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), -# middle corridor, ring around the Dojo shared with the neighbouring quadrants. +# Draft 2: walls as draft 1 (north/east/west corridors, two training rooms, ring around +# 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 | +---+ +---+ + + + + + -| . . . . 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 +---+---+---+ +---+ + + + diff --git a/src/dojo/bots.py b/src/dojo/bots.py index aea7ea1..84b8a1a 100644 --- a/src/dojo/bots.py +++ b/src/dojo/bots.py @@ -27,6 +27,8 @@ CARD_VALUE = 0.2 TECNICA_EDGE = 0.1 # Passing the Final Exam wins the game; this dwarfs every other score. EXAM_WIN = 100.0 +# Moves a casual player compares (Q7: a guess at human play). +CASUAL_SAMPLE = 6 class RandomAgent: @@ -238,6 +240,8 @@ class HeuristicAgent: for square in path: if square in view.board.dojo: 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) if opponent is not None: fight = self._fight(view, track, opponent, v0) @@ -259,7 +263,7 @@ class HeuristicAgent: match code: case Square.ALLENAMENTO: 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) case Square.NEBBIA: return at(track, square, CARD_VALUE) @@ -273,7 +277,7 @@ class HeuristicAgent: case _ if is_tunnel(code): stay = at(track, 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 max(stay, self._arrival_score(view, track, exit_square, v0)) case _: @@ -385,14 +389,28 @@ class HeuristicAgent: return view.players[view.me] def _fightable(self, view: GameView) -> dict[Pos, PlayerView]: + if not self._kumite_allowed(view): + return {} return { p.position: p for p in view.players 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: - return any(p.id != view.me and p.meditating and p.position == square for p in view.players) + def _has_unfightable_pawn(self, view: GameView, square: Pos) -> bool: + 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 def _is_safe(view: GameView, square: Pos) -> bool: @@ -402,3 +420,25 @@ class HeuristicAgent: def _nearest_to_dojo(board: Board, squares: Sequence[Pos]) -> Pos: distance = dojo_distance(board) 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) diff --git a/src/dojo/cli.py b/src/dojo/cli.py index fec0fdb..ae1e10f 100644 --- a/src/dojo/cli.py +++ b/src/dojo/cli.py @@ -7,7 +7,7 @@ from pathlib import Path from dojo.board import QUADRANTS, Board, BoardError, load_board from dojo.grid import GridFormatError, render_grid 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.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" ) 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) 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: parser.error(str(error)) 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): - print(format_summary(summarize(scenario, rows)), end="\n\n") + summaries = [ + 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: write_games_csv(args.csv, games) + if args.summary_csv is not None: + write_summary_csv(args.summary_csv, summaries) return EXIT_OK diff --git a/src/dojo/engine.py b/src/dojo/engine.py index 4199f40..297641b 100644 --- a/src/dojo/engine.py +++ b/src/dojo/engine.py @@ -23,6 +23,7 @@ from dojo.events import ( GameOver, KumiteFought, Moved, + MoveOptions, SentToStart, Source, Teleported, @@ -238,6 +239,8 @@ class Game: paths = self._path_choices(player, remaining, visited) if not paths: 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( self._agent(player).choose_path(self.view(player.id), paths), paths, "path" ) @@ -265,20 +268,35 @@ class Game: return walked, step 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) - 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): - 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) 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: if cell in self._dojo: self._exam(player, entrance=previous) return Step.EXAM defender = self._fightable_at(cell, player) player.position = cell + if self._costs_fatica(cell): + self._change_track(player, -1, Source.SQUARE) if defender is None: return Step.MOVED result = self._kumite(player, defender) @@ -432,7 +450,7 @@ class Game: match code: case Square.ALLENAMENTO: 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) case Square.NEBBIA: self._draw(player, origin) @@ -448,7 +466,7 @@ class Game: self._teleport(player, exit_square, "tunnel") def _sfida(self, player: Player) -> None: - targets = self._targets(player) + targets = self._targets(player) if self._kumite_allowed else [] if not targets: return 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: 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: - if self._in_safe_room(square): + if self._in_safe_room(square) or not self._kumite_allowed: return None for p in self.players: if p is not attacker and p.position == square and not p.meditating: @@ -570,7 +595,10 @@ class Game: return None 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: if self._in_safe_room(player.position): diff --git a/src/dojo/events.py b/src/dojo/events.py index 4bae82f..382b98b 100644 --- a/src/dojo/events.py +++ b/src/dojo/events.py @@ -34,6 +34,14 @@ class TurnSkipped: 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) class Moved: player: int @@ -98,6 +106,7 @@ class GameOver: type Event = ( TurnStarted | TurnSkipped + | MoveOptions | Moved | Teleported | KumiteFought diff --git a/src/dojo/metrics.py b/src/dojo/metrics.py index 9cbf58f..a76985d 100644 --- a/src/dojo/metrics.py +++ b/src/dojo/metrics.py @@ -10,7 +10,7 @@ from collections import Counter from collections.abc import Iterable, Sequence from dataclasses import dataclass -from dojo.board import Pos, quadrant_of +from dojo.board import Pos, Square, quadrant_of from dojo.events import ( Event, ExamTaken, @@ -18,6 +18,7 @@ from dojo.events import ( GameOver, KumiteFought, Moved, + MoveOptions, SentToStart, Source, Teleported, @@ -31,14 +32,14 @@ from dojo.rules import RulesConfig TURN_SECONDS = 45 KUMITE_SECONDS = 30 # A player more than this many belts behind the leader is out of contention. -CONTENTION_BELTS = 2 +CONTENTION_BELTS = 3 type Standings = tuple[int, ...] @dataclass(frozen=True) 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, ...). """ @@ -80,6 +81,15 @@ class GameMetrics: leader_kumite_losses: int winner_turns_to_brown: int | None 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( @@ -102,7 +112,10 @@ def measure( kumite = upsets = expected = exam_fights = 0 chain = max_chain = 0 exam_attempts = exam_passes = 0 + attempts_by_seat = [0] * players + defences_by_seat = [0] * players challengers: set[int] = set() + move_choices = end_squares = allenamento = fatica = 0 own_turns = [0] * players reached_brown: list[int | None] = [None] * players leader_losses = 0 @@ -120,6 +133,11 @@ def measure( own_turns[player] += 1 chain = 0 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: position[player] = path[-1] case Teleported(player=player, destination=destination): @@ -138,8 +156,10 @@ def measure( loser = defender if won else attacker if history and _leader(history[-1]) == loser: leader_losses += 1 - case ExamTaken(challenger=challenger, passed=passed): + case ExamTaken(challenger=challenger, defender=defender, passed=passed): exam_attempts += 1 + attempts_by_seat[challenger] += 1 + defences_by_seat[defender] += 1 exam_passes += passed challengers.add(challenger) case TrackChanged(player=player, before=before, after=after, source=source): @@ -193,6 +213,13 @@ def measure( leader_kumite_losses=leader_losses, winner_turns_to_brown=None if winner is None else reached_brown[winner], 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, last_lead_change=standings.last_lead_change, 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]) +@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) class Summary: """Aggregate of one scenario. Income is in stripes per turn, as (gains, losses).""" @@ -335,9 +371,16 @@ class Summary: leader_kumite_losses: float winner_turns_to_brown: 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] + @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: 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 kumite = sum(r.kumite 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(",")) 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, 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), - 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. win_rate_by_bot={bot: wins[bot] / seated for bot, seated in sorted(appearances.items())}, ) diff --git a/src/dojo/report.py b/src/dojo/report.py index b9f3b75..7841f7b 100644 --- a/src/dojo/report.py +++ b/src/dojo/report.py @@ -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 dataclasses from collections.abc import Iterable 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: - 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()) income = ", ".join( 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"{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"{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"Rank swaps {_spread(summary.rank_swaps)}", f"Leader beaten {summary.leader_kumite_losses:.2f} Kumite lost by the leader per game", 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}", ] 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.writeheader() 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: diff --git a/src/dojo/rules.py b/src/dojo/rules.py index 43587dc..d4d3c44 100644 --- a/src/dojo/rules.py +++ b/src/dojo/rules.py @@ -52,6 +52,12 @@ class RulesConfig: exam_ties_to_defender: bool = False 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 stripes_per_belt: int = 3 max_belt_bonus: int = 2 @@ -63,6 +69,8 @@ class RulesConfig: def __post_init__(self) -> None: 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}") + if self.first_kumite_round < 1: + raise ValueError(f"first_kumite_round must be positive, got {self.first_kumite_round}") @property def max_position(self) -> int: diff --git a/src/dojo/sim.py b/src/dojo/sim.py index 316798e..b58fde2 100644 --- a/src/dojo/sim.py +++ b/src/dojo/sim.py @@ -14,7 +14,7 @@ from functools import cache from pathlib import Path 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.metrics import GameMetrics, measure from dojo.rules import RulesConfig @@ -29,7 +29,8 @@ EXAM_OPTIONS = { } BEST_OF_ROUNDS = 3 RANDOM_BOT = "random" -BOTS = (RANDOM_BOT, *PROFILES) +CASUAL_BOT = "casual" +BOTS = (RANDOM_BOT, CASUAL_BOT, *PROFILES) @dataclass(frozen=True) @@ -97,8 +98,11 @@ class BoardFiles: def make_agent(bot: str, seed: int, seat: int) -> Agent: + rng = random.Random(f"bot:{seed}:{seat}") 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]) diff --git a/tests/scripted.py b/tests/scripted.py index ab3538f..bfa10c4 100644 --- a/tests/scripted.py +++ b/tests/scripted.py @@ -96,10 +96,14 @@ def make_game( deck: tuple[tuple[Card, int], ...] = NO_CARDS, board: Board | None = None, ) -> 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)] randomness = ScriptedDice(dice) game = Game(board or fixture_board(), replace(config, deck=deck), agents, randomness) + game.round = config.first_kumite_round return game, agents, randomness diff --git a/tests/test_bots.py b/tests/test_bots.py index 1ad6271..57dab0d 100644 --- a/tests/test_bots.py +++ b/tests/test_bots.py @@ -11,6 +11,7 @@ from dojo.bots import ( BALANCED, FARMER, PROFILES, + CasualAgent, HeuristicAgent, Profile, RandomAgent, @@ -57,6 +58,26 @@ def test_heuristic_prefers_allenamento_to_fatica() -> None: 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: game, _, _ = make_game() 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 +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: rng = random.Random(seed) agents: list[Agent] = [HeuristicAgent(p) for p in PROFILES.values()] diff --git a/tests/test_cli.py b/tests/test_cli.py index 709b3d4..33137cb 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,3 +1,4 @@ +import csv from pathlib import Path import pytest @@ -74,6 +75,22 @@ def test_sim_prints_a_summary_per_scenario_and_writes_games( 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( "bad", [["--lineup", "balanced,cheater"], ["--games", "0"], ["--exam", "b"]] ) diff --git a/tests/test_engine.py b/tests/test_engine.py index 180cbde..fc5a7c5 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -17,6 +17,7 @@ from dojo.events import ( GameOver, KumiteFought, Moved, + MoveOptions, SentToStart, Source, 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: game, agents, dice = make_game(dice=[1, 2, 1, 6]) 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) +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 --------------------------------------------------------------------------------- @@ -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: game, agents, _ = make_game() game.players[1].meditating = True + start = game.round 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 @@ -310,6 +380,47 @@ def test_allenamento_and_fatica() -> None: 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: game, agents, _ = make_game(dice=[1, 1]) put(game, 0, Pos(0, 3), track=14) diff --git a/tests/test_metrics.py b/tests/test_metrics.py index 072bd5d..114a2da 100644 --- a/tests/test_metrics.py +++ b/tests/test_metrics.py @@ -10,6 +10,7 @@ from dojo.events import ( GameOver, KumiteFought, Moved, + MoveOptions, Source, TrackChanged, TurnSkipped, @@ -37,9 +38,11 @@ def test_measure_counts_fights_income_and_exams() -> None: metrics = run( [ TurnStarted(1, 0, 7), + MoveOptions(0, ("A", "F", ".")), Moved(0, (HOME_0,)), TrackChanged(0, 0, 1, Source.SQUARE), TurnStarted(1, 1, 5), + MoveOptions(1, ("A", "?")), Moved(1, (HOME_0,)), TrackChanged(1, 0, 1, Source.SQUARE), 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.square_income_home, metrics.square_income_away) == (1, 1) 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. 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. assert metrics.halfway_leader_won is False assert metrics.contenders_at_end == 0 - # Out (more than 6 behind): players 1 and 2 after turn 1, player 2 after turn 2. - assert metrics.out_of_contention_share == pytest.approx(3 / 12) - assert metrics.worst_out_of_contention_share == pytest.approx(0.5) + # Out (more than 9 behind): player 2 after turn 2 only. + assert metrics.out_of_contention_share == round(1 / 12, 3) + 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. assert metrics.rank_swaps == 3 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="random,balanced", winner_seat=1, winner_bot="balanced"), 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) assert summary.finished == 0.75 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.rounds.median == 10