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:
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -1,33 +1,33 @@
|
|||||||
+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+
|
+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+
|
||||||
| S S S | . 1 ? F A | A . 2 P . | S S S |
|
| S S S | . 1 ? A A | A . 2 P . | S S S |
|
||||||
+ + + + +---+ +---+ + +---+---+---+ + + + +
|
+ + + + +---+ +---+ + +---+---+---+ + + + +
|
||||||
| S S S . | A . A | P | 1 | A . A | . S S S |
|
| S S S . | ? . A | P | 1 | ? . . | . S S S |
|
||||||
+ + + + + + + + + + + + + + + + +
|
+ + + + + + + + + + + + + + + + +
|
||||||
| S S S | ? | . ? . | 2 | . . ? . . | S S S |
|
| S S S | ? | . ? . | 2 | . . ? . . | S S S |
|
||||||
+---+ +---+ + + + + + + + + + +---+ +---+
|
+---+ +---+ + + + + + + + + + +---+ +---+
|
||||||
| . . . . A F A | . F | A . A | . ? . . |
|
| . . . . F . A | . A | A . A | . ? . . |
|
||||||
+ +---+ +---+---+---+ + + + +---+---+ +---+---+ +
|
+ +---+ +---+---+---+ + + + +---+---+ +---+---+ +
|
||||||
| P | A . A | . ? . | F | F . A . | A . A | 1 |
|
| P | . . A | . ? . | A | ? A A . | F . ? | 1 |
|
||||||
+ + + + + + +---+ + +---+---+ + + + + +
|
+ + + + + + +---+ + +---+---+ + + + + +
|
||||||
| 2 | . ? . | A | A . ? A K A ? | F ? . ? |
|
| 2 | . ? . | A | F A A A K F ? | . ? . ? |
|
||||||
+ + + + + + + +---+---+---+ + + + + + +
|
+ + + + + + + +---+---+---+ + + + + + +
|
||||||
| . | A . A . | K | D D D D . | . A . A | F |
|
| . | ? . A A | K | D D D D A | . A . A | A |
|
||||||
+ +---+ +---+ + + + + + + +---+---+---+---+ +
|
+ +---+ +---+ + + + + + + +---+---+---+---+ +
|
||||||
| A 1 . F F A | D D D D | ? F . 2 P A |
|
| A 1 . A ? A | D D D D | A A . 2 P A |
|
||||||
+---+---+---+ +---+ + + + + + +---+ +---+---+---+
|
+---+---+---+ +---+ + + + + + +---+ +---+---+---+
|
||||||
| A P 2 . F ? | D D D D | A F F . 1 A |
|
| A P 2 . A A | D D D D | A ? A . 1 A |
|
||||||
+ +---+---+---+---+ + + + + + + +---+ +---+ +
|
+ +---+---+---+---+ + + + + + + +---+ +---+ +
|
||||||
| F | A . A . | . D D D D | K | . A . A | . |
|
| A | A . A . | A D D D D | K | A A . ? | . |
|
||||||
+ + + + + + +---+---+---+ + + + + + + +
|
+ + + + + + +---+---+---+ + + + + + + +
|
||||||
| ? . ? F | ? A K A ? . A | A | . ? . | 2 |
|
| ? . ? . | ? F K A A A F | A | . ? . | 2 |
|
||||||
+ + + + + +---+---+ + +---+ + + + + + +
|
+ + + + + +---+---+ + +---+ + + + + + +
|
||||||
| 1 | A . A | . A . F | F | . ? . | A . A | P |
|
| 1 | ? . F | . A A ? | A | . ? . | A . . | P |
|
||||||
+ +---+---+ +---+---+ + + + +---+---+---+ +---+ +
|
+ +---+---+ +---+---+ + + + +---+---+---+ +---+ +
|
||||||
| . . ? . | A . A | F . | A F A . . . . |
|
| . . ? . | A . A | A . | A . F . . . . |
|
||||||
+---+ +---+ + + + + + + + + + +---+ +---+
|
+---+ +---+ + + + + + + + + + +---+ +---+
|
||||||
| S S S | . . ? . . | 2 | . ? . | ? | S S S |
|
| S S S | . . ? . . | 2 | . ? . | ? | S S S |
|
||||||
+ + + + + + + + + + + + + + + + +
|
+ + + + + + + + + + + + + + + + +
|
||||||
| S S S . | A . A | 1 | P | A . A | . S S S |
|
| S S S . | . . ? | 1 | P | A . ? | . S S S |
|
||||||
+ + + + +---+---+---+ + +---+ +---+ + + + +
|
+ + + + +---+---+---+ + +---+ +---+ + + + +
|
||||||
| S S S | . P 2 . A | A F ? 1 . | S S S |
|
| S S S | . P 2 . A | A A ? 1 . | S S S |
|
||||||
+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+
|
+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+
|
||||||
|
|||||||
+11
-9
@@ -1,20 +1,22 @@
|
|||||||
# Quadrant 0 (top-left). Rotated 3x into the full board. Format: board/FORMAT.md
|
# Quadrant 0 (top-left). Rotated 3x into the full board. Format: board/FORMAT.md
|
||||||
# Draft 1: north/east/west corridors, two 3x3 training rooms (rows 1-3 and rows 4-6),
|
# Draft 2: walls as draft 1 (north/east/west corridors, two training rooms, ring around
|
||||||
# middle corridor, ring around the Dojo shared with the neighbouring quadrants.
|
# the Dojo). Built for Fatica on crossing (RULES.md v0.8): two Fatica per quadrant as tolls,
|
||||||
|
# on the row-3 corridor (3,4) and the inner Dojo approach (5,5); Allenamento moved from the
|
||||||
|
# south training room onto the Dojo ring, where visitors walk.
|
||||||
+---+---+---+---+---+---+---+---+
|
+---+---+---+---+---+---+---+---+
|
||||||
| S S S | . 1 ? F A |
|
| S S S | . 1 ? A A |
|
||||||
+ + + + +---+ +---+ +
|
+ + + + +---+ +---+ +
|
||||||
| S S S . | A . A | P |
|
| S S S . | ? . A | P |
|
||||||
+ + + + + + + + +
|
+ + + + + + + + +
|
||||||
| S S S | ? | . ? . | 2 |
|
| S S S | ? | . ? . | 2 |
|
||||||
+---+ +---+ + + + + +
|
+---+ +---+ + + + + +
|
||||||
| . . . . A F A | .
|
| . . . . F . A | .
|
||||||
+ +---+ +---+---+---+ + +
|
+ +---+ +---+---+---+ + +
|
||||||
| P | A . A | . ? . | F |
|
| P | . . A | . ? . | A |
|
||||||
+ + + + + + +---+ +
|
+ + + + + + +---+ +
|
||||||
| 2 | . ? . | A | A . ?
|
| 2 | . ? . | A | F A A
|
||||||
+ + + + + + + +---+
|
+ + + + + + + +---+
|
||||||
| . | A . A . | K | D D
|
| . | ? . A A | K | D D
|
||||||
+ +---+ +---+ + + + +
|
+ +---+ +---+ + + + +
|
||||||
| A 1 . F F A | D D
|
| A 1 . A ? A | D D
|
||||||
+---+---+---+ +---+ + + +
|
+---+---+---+ +---+ + + +
|
||||||
|
|||||||
+44
-4
@@ -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
@@ -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
@@ -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):
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()]
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user