Drop variant B, adopt Black-belt Exam defender, add one-sidedness metrics

- Remove variant B (Brown cap, +1 for higher beating lower) from rules,
  engine, bots and simulator; a higher belt beating a lower one gains 0.
- Final Exam: the defender fights as a Black belt (RULES.md v0.7). Ties to
  the defender and best-of-3 stay as simulator switches (`--exam`).
- Report stripe income per source as gains and losses.
- Add ten one-sidedness measures sampled at turn ends (lead gap, winner
  margin and lead share, halfway leader, last lead change, contention,
  rank swaps, leader Kumite losses, Exam challengers, winner pace).
- Plan: decisions D28-D33 and the detailed Phase 6.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-26 17:09:42 +02:00
parent 7b76b10175
commit 4998931cb0
16 changed files with 707 additions and 256 deletions
+4 -3
View File
@@ -39,11 +39,12 @@ listed values is a scenario; game `i` of each scenario uses seed `--seed + i`, a
rotates one seat per game. Bots: `random`, `balanced`, `farmer`, `aggressive`. rotates one seat per game. Bots: `random`, `balanced`, `farmer`, `aggressive`.
```sh ```sh
uv run dojo sim # variants A and B, 4 players, 200 games each 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 --variant B --players 3 4 6 8 --start 0 3 # player counts x start position uv run dojo sim --exam c none a ac # Final Exam options in force (rules: c), combinable
uv run dojo sim --players 3 4 6 8 --start 0 3 # player counts x start position
uv run dojo sim --kiai 2 1 # Kiai +2 vs +1 uv run dojo sim --kiai 2 1 # Kiai +2 vs +1
uv run dojo sim --lineup balanced,random balanced # several lineups (comma-separated seats) uv run dojo sim --lineup farmer aggressive,farmer # several lineups (comma-separated seats)
uv run dojo sim --seed 42 --jobs 1 # other dice; single process uv run dojo sim --seed 42 --jobs 1 # other dice; single process
``` ```
+7 -13
View File
@@ -1,4 +1,4 @@
# Il Labirinto del Dojo — Rules (draft v0.5) # Il Labirinto del Dojo — Rules (draft v0.7)
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.
@@ -34,8 +34,6 @@ Each player has a position from **0 to 17**. Your belt is the segment you are in
- One **stripe** = 1 step on the track. One **belt** = 3 steps. - One **stripe** = 1 step on the track. One **belt** = 3 steps.
- Position never goes below 0 or above 17. - Position never goes below 0 or above 17.
- **[VARIANT]** *Brown requires Kumite*: squares and cards cannot move a player from 14 to 15.
Once Brown, squares and cards may move the player freely within 15–17.
- A player below 15 is not Brown, whatever their previous belt was. - A player below 15 is not Brown, whatever their previous belt was.
## Setup ## Setup
@@ -93,7 +91,7 @@ Each player has a position from **0 to 17**. Your belt is the segment you are in
|---|---|---| |---|---|---|
| Equal belts | +3 | −3, sent to Start | | Equal belts | +3 | −3, sent to Start |
| Lower belt wins (upset) | +3 | −3, sent to Start | | Lower belt wins (upset) | +3 | −3, sent to Start |
| Higher belt wins | **[VARIANT]** 0 or +1 | sent to Start | | Higher belt wins | nothing | sent to Start |
## Squares ## Squares
@@ -159,23 +157,19 @@ All other cards are shown and resolved immediately.
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
from and there is no Exam this turn. from and there is no Exam this turn.
- The exam is a Kumite with the challenger as attacker; Senshu and Tecnica cards apply. - The exam is a Kumite with the challenger as attacker; Senshu and Tecnica cards apply.
- **The defender fights as a Black belt**, whatever their own belt: the Brown challenger is
one belt lower, so the defender adds +1.
- **Challenger wins**: they wear the Black Belt and win the game. - **Challenger wins**: they wear the Black Belt and win the game.
- **Challenger loses**: −3 and sent to Start. The defender gains nothing. - **Challenger loses**: −3 and sent to Start. The defender gains nothing.
- If the challenger plays Uke and loses, they return to the square they entered from instead of Start. - If the challenger plays Uke and loses, they return to the square they entered from instead of Start.
## Rule variants under test ## Rule variants under test
| Variant | Brown requires Kumite | Higher belt beats lower | Parameters to validate by simulation: Kiai +2 vs +1; starting on 3 with 6+ players;
|---|---|---|
| **A** | No | winner gains 0 |
| **B** | Yes (squares/cards cannot move 14 → 15) | winner gains +1 |
Rationale for B: with the cap, a Blue leader on 14 facing only lower belts could never
become Brown if beating lower belts gave nothing. The +1 removes that deadlock.
Other 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.
Tested and set aside (simulator switches only): Exam ties to the defender; best-of-3 Exam.
## FAQ ## FAQ
**Which effects can I refuse?** **Which effects can I refuse?**
+75 -9
View File
@@ -11,7 +11,7 @@ Deliverables:
2. A text-based board format, with parser, full-board generator and validator. 2. A text-based board format, with parser, full-board generator and validator.
3. A first playable board. 3. A first playable board.
4. A rules engine + bots + Monte Carlo simulator to validate balance and pacing, 4. A rules engine + bots + Monte Carlo simulator to validate balance and pacing,
and in particular to choose between rule variants A and B. and in particular to choose between rule variants A and B (B dropped, D28; now the Final Exam options, D29).
## Decisions log ## Decisions log
@@ -36,7 +36,7 @@ Deliverables:
| D17 | Meditazione: pass-through; ending on it forces one extra step | Developer rule. | | D17 | Meditazione: pass-through; ending on it forces one extra step | Developer rule. |
| D18 | Central Dojo is one logical space, several entrances, Brown only, no exact count to enter | Avoids end-game "need exactly a 3" frustration. | | D18 | Central Dojo is one logical space, several entrances, Brown only, no exact count to enter | Avoids end-game "need exactly a 3" frustration. |
| D19 | Final Exam: highest opponent defends; challenger wins → game; challenger loses → −3 and Start; defender gains nothing | Avoids the top-two flip-flop. Brown's extra stripes protect against the board, not the exam. | | D19 | Final Exam: highest opponent defends; challenger wins → game; challenger loses → −3 and Start; defender gains nothing | Avoids the top-two flip-flop. Brown's extra stripes protect against the board, not the exam. |
| D20 | Two rule variants to be decided by simulation: **A** (no Brown cap, higher-beats-lower 0) vs **B** (squares/cards cannot move 14→15, higher-beats-lower +1) | Neither is clearly better a priori. | | D20 | Two rule variants to be decided by simulation: **A** (no Brown cap, higher-beats-lower 0) vs **B** (squares/cards cannot move 14→15, higher-beats-lower +1) | Neither is clearly better a priori. *Superseded by D28.* |
| D21 | Board 16×16, 4×4 Dojo in the centre, authored as one 8×8 quadrant rotated 3× (C4 symmetry) | Fairness by construction. Small asymmetries may be wanted later (see Q2). | | D21 | Board 16×16, 4×4 Dojo in the centre, authored as one 8×8 quadrant rotated 3× (C4 symmetry) | Fairness by construction. Small asymmetries may be wanted later (see Q2). |
| D22 | Tooling in Python, environment managed with **uv** | Developer choice. | | D22 | Tooling in Python, environment managed with **uv** | Developer choice. |
| D23 | Rules drafted in English; Italian final edition generated separately | Developer choice. | | D23 | Rules drafted in English; Italian final edition generated separately | Developer choice. |
@@ -44,6 +44,12 @@ Deliverables:
| D25 | Only the Tunnel square is optional. Sfida (square and card), Irimi and Tunnel Segreto are mandatory; every Nebbia card must be resolved | Sfida prevents stalling; Nebbia cards are chances that can also turn out badly. | | D25 | Only the Tunnel square is optional. Sfida (square and card), Irimi and Tunnel Segreto are mandatory; every Nebbia card must be resolved | Sfida prevents stalling; Nebbia cards are chances that can also turn out badly. |
| D26 | Tecnica cards are secret: drawn face down, others only know *whether* you hold one, a replaced Tecnica is discarded face down, revealed only when played | Hidden information and bluffing. Bots must not see opponents' Tecnica identity (Phase 4). | | D26 | Tecnica cards are secret: drawn face down, others only know *whether* you hold one, a replaced Tecnica is discarded face down, revealed only when played | Hidden information and bluffing. Bots must not see opponents' Tecnica identity (Phase 4). |
| D27 | Edge rules (RULES.md v0.5, inline + FAQ): boxed-in → move as far as possible (safeguard; boards have no dead ends); Uke-saved attacker stops on its previous square, turn ends, no square effect; extra-step rule for ending on any unfightable pawn; tunnel exits with a meditating pawn unusable; meditating players cannot defend the Exam; Tecnica window after both dice, attacker first; relocations never fight or trigger squares; Tsugi-ashi has its own origin; becoming Brown mid-move opens the Dojo | Developer confirmed all ten. | | D27 | Edge rules (RULES.md v0.5, inline + FAQ): boxed-in → move as far as possible (safeguard; boards have no dead ends); Uke-saved attacker stops on its previous square, turn ends, no square effect; extra-step rule for ending on any unfightable pawn; tunnel exits with a meditating pawn unusable; meditating players cannot defend the Exam; Tecnica window after both dice, attacker first; relocations never fight or trigger squares; Tsugi-ashi has its own origin; becoming Brown mid-move opens the Dojo | Developer confirmed all ten. |
| D28 | Variant B dropped (developer, after the Phase 5 baseline): no Brown cap; a higher belt beating a lower one gains nothing. Removed from engine, bots, simulator, RULES.md (v0.6) | B made games shorter (13 vs 18 rounds) instead of adding an endgame challenge; the cap barely bit and the +1 applied to every such win. Too complex for what it gave. |
| D29 | Final Exam options under test: **a** ties go to the defender; **c** the defender fights as a Black belt (defender +1 vs a Brown challenger); **d** best of 3. Combinable (`--exam ac`). Option b (no belt bonus in the Exam) not pursued. Developer favours c | Baseline pass rate ~80%: the defender is usually 1–2 belts below the Brown challenger, who also gets Senshu. |
| D30 | Simulator reporting: income per source as gains and losses separately (Kumite is zero-sum, so its net hid the flow); standings sampled at the end of every turn; ten one-sidedness measures (see 5.4) | Developer request after the Phase 5 baseline. |
| D31 | Final Exam rule: **the defender fights as a Black belt** (option c; RULES.md v0.7). Options a (ties to the defender) and d (best of 3) stay as simulator switches only; `--exam` lists the options in force and defaults to `c` | c gave a 44% pass rate, more distinct challengers (1.58/game) and closer endings; d alone raised the pass rate to 91%. |
| D32 | Phase 6 targets confirmed by the developer as a starting point (table in Phase 6) | Q5. |
| D33 | Fatica fix: test **option 2 first** — a chance square (stop on it, roll 1d6: 1–2 lose a stripe, 3–6 gain one) replacing Fatica and some Allenamento; then **option 1 must also be tested** — Fatica costs a stripe when walked through as well as when stopped on (breaks D10 for that square only). Options 3 (fewer paths: board rework) and 4 (fewer / once-per-room Allenamento) kept in reserve. The casual bot (6.0.1) is built regardless | Q6. Any on-stop penalty is avoidable when a move reaches ~24 end squares; the chance outcome cannot be dodged. Developer likes 1 and 2. |
## Phases ## Phases
@@ -125,13 +131,68 @@ Deliverables:
| Lead changes, final spread | 5.6, 9.3 | 3.5, 11.5 | | Lead changes, final spread | 5.6, 9.3 | 3.5, 11.5 |
| Wins by seat | 23–27% | 23–27% | | Wins by seat | 23–27% | 23–27% |
- [x] 5.4 Follow-up to the baseline review (D28–D30):
- [x] remove variant B; `--variant` replaced by `--exam` (Exam options, `RulesConfig.exam_ties_to_defender / exam_defender_black / exam_rounds`); "turns on 14" metric removed with it;
- [x] income per source as gains / losses;
- [x] one-sidedness measures, sampled at turn ends: (1) gap 1st–2nd (mean per game) and winner margin over the best rival at the end; (2) share of turns the winner led; (3) halfway leader wins; (4) point of the game of the last lead change; (5) rivals within one belt of the winner at the end; (6) share of player-turns more than two belts behind the leader, and the worst player's share; (7) rank swaps between any two players; (8) Kumite lost by the leader; (9) distinct Exam challengers; (10) winner's own turns to reach Brown.
- After 5.4 (300 games per option, 4 players, lineup balanced/farmer/aggressive, seed 0):
| Metric | none | a | c | d |
|---|---|---|---|---|
| Rounds mean (p10–p90) | 18.5 (14–23) | 19.3 (14–25) | 21.9 (16–29) | 18.0 (14–22) |
| Minutes mean | 67 | 70 | 80 | 66 |
| Exam pass rate, attempts per game | 81%, 1.24 | 67%, 1.50 | 44%, 2.28 | 91%, 1.10 |
| Distinct challengers per game | 1.13 | 1.24 | 1.58 | 1.06 |
| Winner margin at the end | 4.7 | 4.6 | 3.9 | 4.8 |
| Halfway leader wins | 40% | 41% | 40% | 41% |
| Rivals within one belt at the end | 0.32 | 0.37 | 0.62 | 0.30 |
| Player-turns out of contention | 22% | 22% | 21% | 22% |
Also measured: Kumite moves +0.44 / −0.40 stripes per turn; heuristic bots almost never stop on Fatica (square losses 0.0002 per turn vs 0.08 for random bots): with 2d6 and many paths an Allenamento is nearly always reachable. Seat 3 (last in turn order) won 28–31% in every option.
### Phase 6 — Balance experiments ### Phase 6 — Balance experiments
- [ ] 6.1 Variant A vs B. **Method** (applies to every step):
- [ ] 6.2 Kiai +2 vs +1.
- [ ] 6.3 Player counts 3/4/6/8; start on 3 for 6+. - Runs use `uv run dojo sim`. Default 1,000 games per scenario (a 25% win rate is then known to about ±1.4 pp); 4,000 games where seat effects are measured (about ±0.7 pp).
- [ ] 6.4 Square mix tuning; board income per turn (target ≈ 0.8 stripes/turn net, to be confirmed). - Compared scenarios share seeds (`--seed 0`), so differences come from the rules, not the dice.
- [ ] 6.5 Update `RULES.md` with the decisions. - Every conclusion is checked on at least two lineups (mixed `balanced,farmer,aggressive`, and one same-style lineup), because results depend on bot quality.
- One change at a time. When a change is adopted, re-run the 6.1 baseline so later steps start from the current rules, and log the decision (D-number) here and in `RULES.md`.
- Results go in this file as a table per step (command line, scenario, key measures).
**Next action:** 6.0 (tooling), then 6.1. Everything up to D33 and RULES.md v0.7 is committed; commit only when the developer asks.
**Targets** (confirmed by the developer as a starting point, D32):
| Measure | Target |
|---|---|
| Minutes, 4 players | mean 60–90, p90 ≤ 110 |
| Exam pass rate | 35–55% |
| Halfway leader wins (4 players) | 35–55% |
| Player-turns out of contention | ≤ 20%; worst player mean ≤ 35% |
| Rivals within one belt of the winner at the end | ≥ 0.5 |
| Win rate by seat, 4 players | each 25% ± 3 pp |
| Win rate by bot style (mixed lineup) | spread ≤ 6 pp |
| Games finished within 200 rounds | 100%, every lineup |
**Steps:**
- [ ] 6.0 Tooling needed by the experiments
- [ ] 6.0.1 "Casual" bot: the heuristic bot scoring only a small random sample of the offered moves (sample size is a parameter, default 6), as a stand-in for a human who cannot compare ~24 end squares every move. Added to `BOTS` as `casual`.
- [ ] 6.0.2 `--summary-csv PATH`: one row per scenario with the summary measures, to compare scenarios side by side.
- [ ] 6.0.3 Seat breakdown in the summary: per seat, mean final track, Exam attempts, and share of games where that seat defended the Exam (for 6.2).
- [ ] 6.0.4 Square reachability measure: share of moves where an Allenamento / Fatica end square is reachable, and mean distinct end squares per move (the Phase 6 probe, made a metric; for 6.4).
- [ ] 6.1 Baseline on rules v0.7 (Exam c): 4 players, 1,000 games, lineups mixed / `balanced` / `casual` / `random`. Record the table; check it against the targets.
- [ ] 6.2 Seat order (monitor; developer concerned): 4,000 games, 4 players, lineups mixed and `random`; 2,000 games at 3 and 6 players. If a seat is outside 25% ± 3 pp, diagnose with the 6.0.3 breakdown (hypotheses: moving last means seeing everyone else move first; Exam defender selection; Sensei / Peso cards) and bring candidate fixes to the developer. No fix without agreement.
- [ ] 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.1 Option 2, chance square: new code (proposal `C`, name to choose with the developer, e.g. "Sorte" / "Randori") — on stop roll 1d6, 1–2 → −1 stripe, 3–6 → +1. Draft: replace every Fatica and some Allenamento with it (start: all F plus about a third of A, keep the square mix inside the validator's Tatami range). Bot: value = expected change (+1/3 stripe, risk-weighted: gain 2/3, loss 1/3). Try variations of the split (e.g. 1–3 / 4–6) if income is off.
- [ ] 6.4.2 Option 1, Fatica on crossing: `RulesConfig.fatica_on_crossing` — each Fatica square entered during a move costs 1 stripe, **1 in total per square whether or not the move stops there** (developer, D33); stopping on it adds nothing more. Engine: apply in `_walk` per square entered (not on teleports / relocations, which never walk). Bot: count Fatica crossed in `_path_score`. Likely needs fewer Fatica squares; test on the current board and on a draft with fewer F.
- [ ] 6.4.3 Compare 6.4.1, 6.4.2 and both together; bring the table to the developer for a decision (then `RULES.md` and D-number).
- [ ] 6.5 Home farming: after 6.4, if the own-quadrant share of square income is still above 60% (baseline: 71%), move Allenamento squares away from the Safe Rooms in a board draft (`dojo sim --quadrant board/drafts/...`) and compare. The board must still pass `dojo board validate`.
- [ ] 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.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)
@@ -155,19 +216,24 @@ Deliverables:
- Player count → focus 4, support 3–8. - Player count → focus 4, support 3–8.
- Tests → the agent runs the suite (`uv run pytest`), plus `ruff check`, `ruff format --check`, `pyright`. - Tests → the agent runs the suite (`uv run pytest`), plus `ruff check`, `ruff format --check`, `pyright`.
- Minutes estimate → 45 s per turn, +30 s per Kumite. - Minutes estimate → 45 s per turn, +30 s per Kumite.
- Q4 Target net income per turn → current pace is fine: A already runs ~66 min (p90 ~85); the winner's pace is measured instead of a table average.
- Variant A vs B → B dropped (D28).
- Final Exam → the defender fights as a Black belt (D31).
- Q5 Phase 6 targets → confirmed as a starting point (D32).
- Q6 Fatica avoidable (bots stop on it once in ~4,000 turns; ~24 distinct end squares per move; Allenamento reachable on 79% of moves, Fatica never forced) → test the chance square first, then Fatica on crossing (D33).
- 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.)*
- Q4 Target net income per turn — derived from 1–2 h and ~15–20 turns per player; confirm with first simulations. Baseline measured net income ≈ 0.56 (A) / 0.75 (B) stripes per turn.
### Risks ### Risks
- Movement enumeration: self-avoiding walks of length up to 12 can explode in open rooms. Keep rooms small; prune by outcome (destination + ordered opponents crossed + Dojo entry). - Movement enumeration: self-avoiding walks of length up to 12 can explode in open rooms. Keep rooms small; prune by outcome (destination + ordered opponents crossed + Dojo entry).
- Engine speed ~1,400 turns/s (random and heuristic bots, real board). If Phase 5 needs more: precompute neighbour lists in `Board`. - Engine speed ~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.4 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.
## External documentation ## External documentation
+51 -21
View File
@@ -5,6 +5,7 @@ The heuristic bot scores each option as an expected change of a value function (
position, plus closeness to the Dojo once Brown); a ``Profile`` sets its attitude to risk. position, plus closeness to the Dojo once Brown); a ``Profile`` sets its attitude to risk.
""" """
import math
import random import random
from collections import deque from collections import deque
from collections.abc import Mapping, Sequence from collections.abc import Mapping, Sequence
@@ -14,7 +15,7 @@ from functools import cache
from dojo.board import Board, Pos, Square, is_tunnel from dojo.board import Board, Pos, Square, is_tunnel
from dojo.engine import FightView, GameView, PlayerView from dojo.engine import FightView, GameView, PlayerView
from dojo.movement import Path from dojo.movement import Path
from dojo.rules import Card from dojo.rules import BLACK, Card
DIE_FACES = range(1, 7) DIE_FACES = range(1, 7)
@@ -84,12 +85,26 @@ PROFILES = {p.name: p for p in (BALANCED, FARMER, AGGRESSIVE)}
@cache @cache
def win_probability(attacker_extra: int, defender_extra: int) -> float: def win_probability(attacker_extra: int, defender_extra: int, *, senshu: bool = True) -> float:
"""Chance the attacker wins a Kumite on 1d6 each; ties go to the attacker (Senshu).""" """Chance the attacker wins a Kumite on 1d6 each; ties go to the attacker under Senshu."""
wins = sum(a + attacker_extra >= d + defender_extra for a in DIE_FACES for d in DIE_FACES) wins = sum(
a + attacker_extra > d + defender_extra
or (senshu and a + attacker_extra == d + defender_extra)
for a in DIE_FACES
for d in DIE_FACES
)
return wins / len(DIE_FACES) ** 2 return wins / len(DIE_FACES) ** 2
def series_probability(round_chance: float, rounds: int) -> float:
"""Chance of winning a best-of-``rounds`` series, each round won with ``round_chance``."""
needed = rounds // 2 + 1
return sum(
math.comb(rounds, won) * round_chance**won * (1 - round_chance) ** (rounds - won)
for won in range(needed, rounds + 1)
)
@cache @cache
def dojo_distance(board: Board) -> Mapping[Pos, int]: def dojo_distance(board: Board) -> Mapping[Pos, int]:
"""Walking steps from each square into the Dojo, as if the Dojo were open.""" """Walking steps from each square into the Dojo, as if the Dojo were open."""
@@ -179,7 +194,10 @@ class HeuristicAgent:
def choose_exam_defender(self, view: GameView, candidates: Sequence[int]) -> int: def choose_exam_defender(self, view: GameView, candidates: Sequence[int]) -> int:
me = self._me(view) me = self._me(view)
return max(candidates, key=lambda c: self._win_chance(view, me, me.track, view.players[c])) return max(
candidates,
key=lambda c: self._win_chance(view, me.track, view.players[c], exam=True),
)
def keep_tecnica(self, view: GameView, held: Card, drawn: Card) -> Card: def keep_tecnica(self, view: GameView, held: Card, drawn: Card) -> Card:
rank = self.profile.tecnica_rank rank = self.profile.tecnica_rank
@@ -193,8 +211,12 @@ class HeuristicAgent:
else (fight.defender_total, fight.attacker_total) else (fight.defender_total, fight.attacker_total)
) )
senshu = not (fight.exam and view.config.exam_ties_to_defender)
def wins(total: int) -> bool: def wins(total: int) -> bool:
return total >= theirs if attacking else total > theirs if attacking:
return total > theirs or (senshu and total == theirs)
return total > theirs or (not senshu and total == theirs)
if wins(mine): if wins(mine):
return False return False
@@ -236,9 +258,9 @@ class HeuristicAgent:
match code: match code:
case Square.ALLENAMENTO: case Square.ALLENAMENTO:
return at(config.shifted(track, 1, from_board=True), square) return at(config.shifted(track, 1), square)
case Square.FATICA: case Square.FATICA:
return at(config.shifted(track, -1, from_board=True), 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)
case Square.PASSO_FALSO: case Square.PASSO_FALSO:
@@ -278,9 +300,9 @@ class HeuristicAgent:
stripes = config.stripes_per_belt stripes = config.stripes_per_belt
mine, theirs = config.belt(track), config.belt(opponent.track) mine, theirs = config.belt(track), config.belt(opponent.track)
if mine > theirs: if mine > theirs:
win_gain, lose_loss, their_gain = config.expected_win_gain, stripes, stripes win_gain, lose_loss, their_gain = 0, stripes, stripes
elif mine < theirs: elif mine < theirs:
win_gain, lose_loss, their_gain = stripes, 0, config.expected_win_gain win_gain, lose_loss, their_gain = stripes, 0, 0
else: else:
win_gain, lose_loss, their_gain = stripes, stripes, stripes win_gain, lose_loss, their_gain = stripes, stripes, stripes
their_loss = stripes if mine <= theirs else 0 their_loss = stripes if mine <= theirs else 0
@@ -288,21 +310,19 @@ class HeuristicAgent:
their_value = self._value(view, opponent) their_value = self._value(view, opponent)
beaten = self._value_at( beaten = self._value_at(
view, view,
config.shifted(opponent.track, -their_loss, from_board=False), config.shifted(opponent.track, -their_loss),
start_square(view.board, opponent.corner), start_square(view.board, opponent.corner),
) )
winner = self._value_at( winner = self._value_at(view, config.shifted(opponent.track, their_gain), opponent.position)
view, config.shifted(opponent.track, their_gain, from_board=False), opponent.position
)
lost = self._value_at( lost = self._value_at(
view, view,
config.shifted(track, -lose_loss, from_board=False), config.shifted(track, -lose_loss),
start_square(view.board, me.corner), start_square(view.board, me.corner),
) )
rivalry = self.profile.rivalry rivalry = self.profile.rivalry
return _Fight( return _Fight(
win_chance=self._win_chance(view, me, track, opponent), win_chance=self._win_chance(view, track, opponent),
win_track=config.shifted(track, win_gain, from_board=False), win_track=config.shifted(track, win_gain),
lose_score=self._weigh(lost - v0) - rivalry * (winner - their_value), lose_score=self._weigh(lost - v0) - rivalry * (winner - their_value),
win_rivalry=-rivalry * (beaten - their_value), win_rivalry=-rivalry * (beaten - their_value),
) )
@@ -313,21 +333,31 @@ class HeuristicAgent:
if not eligible: if not eligible:
return self._weigh(self._value_at(view, track, entrance) - v0) return self._weigh(self._value_at(view, track, entrance) - v0)
top = max(p.track for p in eligible) top = max(p.track for p in eligible)
chance = max(self._win_chance(view, me, track, p) for p in eligible if p.track == top) round_chance = max(
self._win_chance(view, track, p, exam=True) for p in eligible if p.track == top
)
chance = series_probability(round_chance, view.config.exam_rounds)
failed = self._value_at( failed = self._value_at(
view, view,
view.config.shifted(track, -view.config.stripes_per_belt, from_board=False), view.config.shifted(track, -view.config.stripes_per_belt),
start_square(view.board, me.corner), start_square(view.board, me.corner),
) )
return chance * EXAM_WIN + (1 - chance) * self._weigh(failed - v0) return chance * EXAM_WIN + (1 - chance) * self._weigh(failed - v0)
def _win_chance( def _win_chance(
self, view: GameView, me: PlayerView, track: int, opponent: PlayerView self, view: GameView, track: int, opponent: PlayerView, *, exam: bool = False
) -> float: ) -> float:
"""Chance of winning one Kumite I attack (one round, in the Exam)."""
config = view.config config = view.config
mine, theirs = config.belt(track), config.belt(opponent.track) mine, theirs = config.belt(track), config.belt(opponent.track)
if exam and config.exam_defender_black:
theirs = BLACK
bonus = min(abs(mine - theirs), config.max_belt_bonus) bonus = min(abs(mine - theirs), config.max_belt_bonus)
chance = win_probability(bonus if mine > theirs else 0, bonus if theirs > mine else 0) chance = win_probability(
bonus if mine > theirs else 0,
bonus if theirs > mine else 0,
senshu=not (exam and config.exam_ties_to_defender),
)
if view.tecnica is not None: if view.tecnica is not None:
chance += TECNICA_EDGE chance += TECNICA_EDGE
if opponent.holds_tecnica: if opponent.holds_tecnica:
+12 -3
View File
@@ -8,7 +8,7 @@ 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
from dojo.sim import BOTS, VARIANTS, 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
DEFAULT_QUADRANT = Path("board/quadrant.txt") DEFAULT_QUADRANT = Path("board/quadrant.txt")
@@ -45,7 +45,16 @@ def main(argv: list[str] | None = None) -> int:
_add_board_arguments(sim_parser) _add_board_arguments(sim_parser)
sim_parser.add_argument("--games", type=int, default=200, help="games per scenario") sim_parser.add_argument("--games", type=int, default=200, help="games per scenario")
sim_parser.add_argument("--seed", type=int, default=0, help="seed of the first game") sim_parser.add_argument("--seed", type=int, default=0, help="seed of the first game")
sim_parser.add_argument("--variant", nargs="+", choices=list(VARIANTS), default=["A", "B"]) exam_options = "; ".join(f"{key}: {text}" for key, text in EXAM_OPTIONS.items())
sim_parser.add_argument(
"--exam",
nargs="+",
default=[RULES_EXAM],
help=(
f"Final Exam options in force, combinable (e.g. ac), or {NO_EXAM_OPTIONS}; "
f"the rules use {RULES_EXAM}; {exam_options}"
),
)
sim_parser.add_argument("--players", nargs="+", type=int, default=[4]) sim_parser.add_argument("--players", nargs="+", type=int, default=[4])
sim_parser.add_argument("--kiai", nargs="+", type=int, default=[2], help="Kiai bonus") sim_parser.add_argument("--kiai", nargs="+", type=int, default=[2], help="Kiai bonus")
sim_parser.add_argument("--start", nargs="+", type=int, default=[0], help="start position") sim_parser.add_argument("--start", nargs="+", type=int, default=[0], help="start position")
@@ -87,7 +96,7 @@ def _simulate(args: argparse.Namespace, files: BoardFiles, parser: argparse.Argu
parser.error("--games must be at least 1") parser.error("--games must be at least 1")
try: try:
scenarios = matrix( scenarios = matrix(
args.variant, args.exam,
args.players, args.players,
args.kiai, args.kiai,
args.start, args.start,
+22 -9
View File
@@ -31,7 +31,7 @@ from dojo.events import (
TurnStarted, TurnStarted,
) )
from dojo.movement import Path, walks from dojo.movement import Path, walks
from dojo.rules import TECNICHE, Card, RulesConfig from dojo.rules import BLACK, TECNICHE, Card, RulesConfig
MIN_PLAYERS = 2 MIN_PLAYERS = 2
MAX_PLAYERS = 8 MAX_PLAYERS = 8
@@ -308,7 +308,10 @@ class Game:
def _kumite(self, attacker: Player, defender: Player, *, exam: bool = False) -> FightResult: def _kumite(self, attacker: Player, defender: Player, *, exam: bool = False) -> FightResult:
config = self.config config = self.config
belts = (config.belt(attacker.track), config.belt(defender.track)) defender_belt = config.belt(defender.track)
if exam and config.exam_defender_black:
defender_belt = BLACK
belts = (config.belt(attacker.track), defender_belt)
bonus = min(abs(belts[0] - belts[1]), config.max_belt_bonus) bonus = min(abs(belts[0] - belts[1]), config.max_belt_bonus)
dice = [self._random.d6(), self._random.d6()] dice = [self._random.d6(), self._random.d6()]
extra = [bonus if belts[0] > belts[1] else 0, bonus if belts[1] > belts[0] else 0] extra = [bonus if belts[0] > belts[1] else 0, bonus if belts[1] > belts[0] else 0]
@@ -341,6 +344,9 @@ class Game:
uke[side] = True uke[side] = True
totals = (dice[0] + extra[0], dice[1] + extra[1]) totals = (dice[0] + extra[0], dice[1] + extra[1])
if exam and config.exam_ties_to_defender:
attacker_won = totals[0] > totals[1]
else:
attacker_won = totals[0] >= totals[1] # Senshu: ties go to the attacker. attacker_won = totals[0] >= totals[1] # Senshu: ties go to the attacker.
winner_belt, loser_belt = belts if attacker_won else belts[::-1] winner_belt, loser_belt = belts if attacker_won else belts[::-1]
if winner_belt == loser_belt: if winner_belt == loser_belt:
@@ -374,9 +380,7 @@ class Game:
winner, loser, loser_uke = defender, attacker, result.attacker_uke winner, loser, loser_uke = defender, attacker, result.attacker_uke
stripes = self.config.stripes_per_belt stripes = self.config.stripes_per_belt
if result.kind is FightKind.EXPECTED: if result.kind is not FightKind.EXPECTED:
self._change_track(winner, self.config.expected_win_gain, Source.KUMITE)
else:
self._change_track(winner, stripes, Source.KUMITE) self._change_track(winner, stripes, Source.KUMITE)
self._change_track(loser, -stripes, Source.KUMITE) self._change_track(loser, -stripes, Source.KUMITE)
@@ -402,13 +406,23 @@ class Game:
) )
defender_id = self._pick(choice, candidates, "exam defender") defender_id = self._pick(choice, candidates, "exam defender")
needed = self.config.exam_wins_needed
won = lost = 0
uke = False
while won < needed and lost < needed:
result = self._kumite(challenger, self.players[defender_id], exam=True) result = self._kumite(challenger, self.players[defender_id], exam=True)
self._emit(ExamTaken(challenger.id, defender_id, result.attacker_won)) uke = uke or result.attacker_uke
if result.attacker_won: if result.attacker_won:
won += 1
else:
lost += 1
passed = won == needed
self._emit(ExamTaken(challenger.id, defender_id, passed))
if passed:
self.winner = challenger.id self.winner = challenger.id
return return
self._change_track(challenger, -self.config.stripes_per_belt, Source.EXAM) self._change_track(challenger, -self.config.stripes_per_belt, Source.EXAM)
if not result.attacker_uke: if not uke:
self._send_to_start(challenger) self._send_to_start(challenger)
# Squares ------------------------------------------------------------------------------ # Squares ------------------------------------------------------------------------------
@@ -525,8 +539,7 @@ class Game:
# State helpers ------------------------------------------------------------------------ # State helpers ------------------------------------------------------------------------
def _change_track(self, player: Player, delta: int, source: Source) -> None: def _change_track(self, player: Player, delta: int, source: Source) -> None:
from_board = source in (Source.SQUARE, Source.CARD) after = self.config.shifted(player.track, delta)
after = self.config.shifted(player.track, delta, from_board=from_board)
if after != player.track: if after != player.track:
self._emit(TrackChanged(player.id, player.track, after, source)) self._emit(TrackChanged(player.id, player.track, after, source))
player.track = after player.track = after
+231 -72
View File
@@ -1,5 +1,10 @@
"""Per-game measurements taken from the event log, and their aggregation over many games.""" """Per-game measurements taken from the event log, and their aggregation over many games.
Standings (leader, gaps, ranks, contention) are sampled at the end of every turn, so the
back-and-forth inside a single Kumite never counts as a change of lead.
"""
import itertools
import statistics import statistics
from collections import Counter from collections import Counter
from collections.abc import Iterable, Sequence from collections.abc import Iterable, Sequence
@@ -17,6 +22,7 @@ from dojo.events import (
Source, Source,
Teleported, Teleported,
TrackChanged, TrackChanged,
TurnSkipped,
TurnStarted, TurnStarted,
) )
from dojo.rules import RulesConfig from dojo.rules import RulesConfig
@@ -24,11 +30,18 @@ from dojo.rules import RulesConfig
# Table-time estimate (plan, resolved questions). # Table-time estimate (plan, resolved questions).
TURN_SECONDS = 45 TURN_SECONDS = 45
KUMITE_SECONDS = 30 KUMITE_SECONDS = 30
# A player more than this many belts behind the leader is out of contention.
CONTENTION_BELTS = 2
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, flat so it can be written as a CSV row. Kumite counts exclude the Exam.
Optional fields are None when they do not apply (no winner, no lead change, ...).
"""
scenario: str scenario: str
seed: int seed: int
@@ -44,15 +57,28 @@ class GameMetrics:
max_chain: int max_chain: int
exam_attempts: int exam_attempts: int
exam_passes: int exam_passes: int
turns_on_14: int exam_challengers: int
longest_14_streak: int square_gain: int
income_square: int square_loss: int
income_card: int card_gain: int
income_kumite: int card_loss: int
income_exam: int kumite_gain: int
kumite_loss: int
exam_loss: int
square_income_home: int square_income_home: int
square_income_away: int square_income_away: int
lead_changes: int lead_changes: int
last_lead_change: float | None
lead_gap_mean: float
winner_margin: int | None
winner_lead_share: float | None
halfway_leader_won: bool | None
contenders_at_end: int
out_of_contention_share: float
worst_out_of_contention_share: float
rank_swaps: int
leader_kumite_losses: int
winner_turns_to_brown: int | None
final_spread: int final_spread: int
@@ -69,53 +95,59 @@ def measure(
players = len(corners) players = len(corners)
track = [config.start_position] * players track = [config.start_position] * players
position: list[Pos | None] = [None] * players position: list[Pos | None] = [None] * players
blue_top = config.brown_position - 1 history: list[Standings] = []
streak = [0] * players gains = Counter[Source]()
longest_streak = 0 losses = Counter[Source]()
turns_on_14 = 0
income = Counter[Source]()
home = away = 0 home = away = 0
kumite = upsets = expected = 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
challengers: set[int] = set()
own_turns = [0] * players
reached_brown: list[int | None] = [None] * players
leader_losses = 0
turns = 0 turns = 0
rounds = 0 rounds = 0
winner: int | None = None winner: int | None = None
leader: int | None = None active: int | None = None
lead_changes = 0
active = 0
for event in events: for event in events:
match event: match event:
case TurnStarted(player=player): case TurnStarted(player=player) | TurnSkipped(player=player):
turns += 1 if active is not None:
history.append(tuple(track))
active = player active = player
own_turns[player] += 1
chain = 0 chain = 0
if track[player] == blue_top: turns += isinstance(event, TurnStarted)
turns_on_14 += 1
streak[player] += 1
longest_streak = max(longest_streak, streak[player])
else:
streak[player] = 0
case Moved(player=player, path=path) if path: case Moved(player=player, path=path) if path:
position[player] = path[-1] position[player] = path[-1]
case Teleported(player=player, destination=destination): case Teleported(player=player, destination=destination):
position[player] = destination position[player] = destination
case SentToStart(player=player, square=square): case SentToStart(player=player, square=square):
position[player] = square position[player] = square
case KumiteFought(exam=False, attacker=attacker, kind=kind): case KumiteFought(exam=True):
exam_fights += 1
case KumiteFought(attacker=attacker, defender=defender, attacker_won=won, kind=kind):
kumite += 1 kumite += 1
upsets += kind is FightKind.UPSET upsets += kind is FightKind.UPSET
expected += kind is FightKind.EXPECTED expected += kind is FightKind.EXPECTED
if attacker == active: if attacker == active:
chain += 1 chain += 1
max_chain = max(max_chain, chain) max_chain = max(max_chain, chain)
case ExamTaken(passed=passed): loser = defender if won else attacker
if history and _leader(history[-1]) == loser:
leader_losses += 1
case ExamTaken(challenger=challenger, passed=passed):
exam_attempts += 1 exam_attempts += 1
exam_passes += passed exam_passes += passed
challengers.add(challenger)
case TrackChanged(player=player, before=before, after=after, source=source): case TrackChanged(player=player, before=before, after=after, source=source):
delta = after - before delta = after - before
income[source] += delta if delta > 0:
gains[source] += delta
else:
losses[source] += delta
square = position[player] square = position[player]
if source is Source.SQUARE and square is not None: if source is Source.SQUARE and square is not None:
if quadrant_of(square) == corners[player]: if quadrant_of(square) == corners[player]:
@@ -123,17 +155,16 @@ def measure(
else: else:
away += delta away += delta
track[player] = after track[player] = after
top = max(track) if reached_brown[player] is None and config.is_brown(after):
leaders = [p for p in range(players) if track[p] == top] reached_brown[player] = own_turns[player]
if len(leaders) == 1 and leaders[0] != leader:
lead_changes += leader is not None
leader = leaders[0]
case GameOver(winner=game_winner, rounds=game_rounds): case GameOver(winner=game_winner, rounds=game_rounds):
winner, rounds = game_winner, game_rounds winner, rounds = game_winner, game_rounds
case _: case _:
pass pass
if active is not None:
history.append(tuple(track))
fights = kumite + exam_attempts standings = _standings_metrics(history, winner, config)
return GameMetrics( return GameMetrics(
scenario=scenario, scenario=scenario,
seed=seed, seed=seed,
@@ -142,23 +173,115 @@ def measure(
winner_bot=None if winner is None else bots[winner], winner_bot=None if winner is None else bots[winner],
rounds=rounds, rounds=rounds,
turns=turns, turns=turns,
minutes=round((turns * TURN_SECONDS + fights * KUMITE_SECONDS) / 60, 1), minutes=round((turns * TURN_SECONDS + (kumite + exam_fights) * KUMITE_SECONDS) / 60, 1),
kumite=kumite, kumite=kumite,
upsets=upsets, upsets=upsets,
expected_wins=expected, expected_wins=expected,
max_chain=max_chain, max_chain=max_chain,
exam_attempts=exam_attempts, exam_attempts=exam_attempts,
exam_passes=exam_passes, exam_passes=exam_passes,
turns_on_14=turns_on_14, exam_challengers=len(challengers),
longest_14_streak=longest_streak, square_gain=gains[Source.SQUARE],
income_square=income[Source.SQUARE], square_loss=losses[Source.SQUARE],
income_card=income[Source.CARD], card_gain=gains[Source.CARD],
income_kumite=income[Source.KUMITE], card_loss=losses[Source.CARD],
income_exam=income[Source.EXAM], kumite_gain=gains[Source.KUMITE],
kumite_loss=losses[Source.KUMITE],
exam_loss=losses[Source.EXAM],
square_income_home=home, square_income_home=home,
square_income_away=away, square_income_away=away,
lead_changes=lead_changes, leader_kumite_losses=leader_losses,
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),
lead_changes=standings.lead_changes,
last_lead_change=standings.last_lead_change,
lead_gap_mean=standings.lead_gap_mean,
winner_margin=standings.winner_margin,
winner_lead_share=standings.winner_lead_share,
halfway_leader_won=standings.halfway_leader_won,
contenders_at_end=standings.contenders_at_end,
out_of_contention_share=standings.out_of_contention_share,
worst_out_of_contention_share=standings.worst_out_of_contention_share,
rank_swaps=standings.rank_swaps,
)
def _leader(standings: Standings) -> int | None:
top = max(standings)
leaders = [p for p, t in enumerate(standings) if t == top]
return leaders[0] if len(leaders) == 1 else None
@dataclass(frozen=True)
class _StandingsMetrics:
lead_changes: int
last_lead_change: float | None
lead_gap_mean: float
winner_margin: int | None
winner_lead_share: float | None
halfway_leader_won: bool | None
contenders_at_end: int
out_of_contention_share: float
worst_out_of_contention_share: float
rank_swaps: int
def _standings_metrics(
history: Sequence[Standings], winner: int | None, config: RulesConfig
) -> _StandingsMetrics:
if not history:
history = [(config.start_position,)]
turns = len(history)
players = len(history[0])
leaders = [_leader(s) for s in history]
lead_changes = 0
last_change: int | None = None
current: int | None = None
for index, leader in enumerate(leaders):
if leader is None or leader == current:
continue
if current is not None:
lead_changes += 1
last_change = index
current = leader
gaps = [sorted(s, reverse=True) for s in history]
lead_gap_mean = statistics.fmean(g[0] - g[1] if players > 1 else 0 for g in gaps)
behind = CONTENTION_BELTS * config.stripes_per_belt
out = [0] * players
for standings in history:
top = max(standings)
for player, t in enumerate(standings):
out[player] += t < top - behind
swaps = 0
order: dict[tuple[int, int], int] = {}
for standings in history:
for a, b in itertools.combinations(range(players), 2):
sign = (standings[a] > standings[b]) - (standings[a] < standings[b])
if sign == 0:
continue
swaps += order.get((a, b), sign) != sign
order[(a, b)] = sign
final = history[-1]
reference = winner if winner is not None else final.index(max(final))
rivals = [t for p, t in enumerate(final) if p != reference]
halfway = leaders[(turns - 1) // 2]
return _StandingsMetrics(
lead_changes=lead_changes,
last_lead_change=None if last_change is None else round((last_change + 1) / turns, 3),
lead_gap_mean=round(lead_gap_mean, 2),
winner_margin=None if winner is None else final[winner] - max(rivals, default=0),
winner_lead_share=None if winner is None else round(leaders.count(winner) / turns, 3),
halfway_leader_won=None if winner is None or halfway is None else halfway == winner,
contenders_at_end=sum(t >= final[reference] - config.stripes_per_belt for t in rivals),
out_of_contention_share=round(sum(out) / (players * turns), 3),
worst_out_of_contention_share=round(max(out) / turns, 3),
rank_swaps=swaps,
) )
@@ -170,17 +293,18 @@ class Spread:
p90: float p90: float
@classmethod @classmethod
def of(cls, values: Sequence[float]) -> Spread: def of(cls, values: Iterable[float | None]) -> Spread:
if len(values) < 2: present = [float(v) for v in values if v is not None]
value = float(values[0]) if values else 0.0 if len(present) < 2:
value = present[0] if present else 0.0
return cls(value, value, value, value) return cls(value, value, value, value)
deciles = statistics.quantiles(values, n=10, method="inclusive") deciles = statistics.quantiles(present, n=10, method="inclusive")
return cls(statistics.fmean(values), deciles[0], statistics.median(values), deciles[-1]) return cls(statistics.fmean(present), deciles[0], statistics.median(present), deciles[-1])
@dataclass(frozen=True) @dataclass(frozen=True)
class Summary: class Summary:
"""Aggregate of one scenario. Rates are per turn unless named otherwise.""" """Aggregate of one scenario. Income is in stripes per turn, as (gains, losses)."""
scenario: str scenario: str
games: int games: int
@@ -193,12 +317,23 @@ class Summary:
max_chain: Spread max_chain: Spread
exam_attempts_per_game: float exam_attempts_per_game: float
exam_success: float exam_success: float
turns_on_14_per_game: float exam_challengers: float
longest_14_streak: int income_per_turn: dict[Source, tuple[float, float]]
income_per_turn: dict[Source, float]
square_income_home_per_turn: float square_income_home_per_turn: float
square_income_away_per_turn: float square_income_away_per_turn: float
lead_changes: Spread lead_changes: Spread
last_lead_change: Spread
lead_gap: Spread
winner_margin: Spread
winner_lead_share: float
halfway_leader_wins: float
halfway_games: int
contenders_at_end: float
out_of_contention: float
worst_out_of_contention: Spread
rank_swaps: Spread
leader_kumite_losses: float
winner_turns_to_brown: Spread
final_spread: Spread final_spread: Spread
win_rate_by_seat: tuple[float, ...] win_rate_by_seat: tuple[float, ...]
win_rate_by_bot: dict[str, float] win_rate_by_bot: dict[str, float]
@@ -208,6 +343,7 @@ def summarize(scenario: str, games: Iterable[GameMetrics]) -> Summary:
rows = list(games) rows = list(games)
if not rows: if not rows:
raise ValueError(f"{scenario}: no games to summarize") raise ValueError(f"{scenario}: no games to summarize")
count = len(rows)
turns = sum(r.turns for r in rows) or 1 turns = sum(r.turns for r in rows) or 1
rounds = sum(r.rounds for r in rows) or 1 rounds = sum(r.rounds for r in rows) or 1
kumite = sum(r.kumite for r in rows) kumite = sum(r.kumite for r in rows)
@@ -221,33 +357,56 @@ def summarize(scenario: str, games: Iterable[GameMetrics]) -> Summary:
if row.winner_bot is not None: if row.winner_bot is not None:
wins[row.winner_bot] += 1 wins[row.winner_bot] += 1
seat_wins = Counter(r.winner_seat for r in rows if r.winner_seat is not None) seat_wins = Counter(r.winner_seat for r in rows if r.winner_seat is not None)
incomes = { finished = [r for r in rows if r.winner_seat is not None]
Source.SQUARE: sum(r.income_square for r in rows), halfway = [r.halfway_leader_won for r in rows if r.halfway_leader_won is not None]
Source.CARD: sum(r.income_card for r in rows),
Source.KUMITE: sum(r.income_kumite for r in rows), def per_turn(values: Iterable[int]) -> float:
Source.EXAM: sum(r.income_exam for r in rows), return sum(values) / turns
}
return Summary( return Summary(
scenario=scenario, scenario=scenario,
games=len(rows), games=count,
finished=sum(r.winner_seat is not None for r in rows) / len(rows), finished=len(finished) / count,
rounds=Spread.of([r.rounds for r in rows]), rounds=Spread.of(r.rounds for r in rows),
minutes=Spread.of([r.minutes for r in rows]), minutes=Spread.of(r.minutes for r in rows),
kumite_per_round=kumite / rounds, kumite_per_round=kumite / rounds,
upset_rate=sum(r.upsets for r in rows) / (kumite or 1), upset_rate=sum(r.upsets for r in rows) / (kumite or 1),
expected_rate=sum(r.expected_wins for r in rows) / (kumite or 1), expected_rate=sum(r.expected_wins for r in rows) / (kumite or 1),
max_chain=Spread.of([r.max_chain for r in rows]), max_chain=Spread.of(r.max_chain for r in rows),
exam_attempts_per_game=attempts / len(rows), exam_attempts_per_game=attempts / count,
exam_success=sum(r.exam_passes for r in rows) / (attempts or 1), exam_success=sum(r.exam_passes for r in rows) / (attempts or 1),
turns_on_14_per_game=sum(r.turns_on_14 for r in rows) / len(rows), exam_challengers=sum(r.exam_challengers for r in rows) / count,
longest_14_streak=max(r.longest_14_streak for r in rows), income_per_turn={
income_per_turn={source: total / turns for source, total in incomes.items()}, Source.SQUARE: (
square_income_home_per_turn=sum(r.square_income_home for r in rows) / turns, per_turn(r.square_gain for r in rows),
square_income_away_per_turn=sum(r.square_income_away for r in rows) / turns, per_turn(r.square_loss for r in rows),
lead_changes=Spread.of([r.lead_changes for r in rows]), ),
final_spread=Spread.of([r.final_spread for r in rows]), Source.CARD: (per_turn(r.card_gain for r in rows), per_turn(r.card_loss for r in rows)),
win_rate_by_seat=tuple(seat_wins[s] / len(rows) for s in range(seats)), Source.KUMITE: (
per_turn(r.kumite_gain for r in rows),
per_turn(r.kumite_loss for r in rows),
),
Source.EXAM: (0.0, per_turn(r.exam_loss for r in rows)),
},
square_income_home_per_turn=per_turn(r.square_income_home for r in rows),
square_income_away_per_turn=per_turn(r.square_income_away for r in rows),
lead_changes=Spread.of(r.lead_changes for r in rows),
last_lead_change=Spread.of(r.last_lead_change for r in rows),
lead_gap=Spread.of(r.lead_gap_mean for r in rows),
winner_margin=Spread.of(r.winner_margin for r in rows),
winner_lead_share=statistics.fmean(r.winner_lead_share or 0.0 for r in finished)
if finished
else 0.0,
halfway_leader_wins=sum(halfway) / len(halfway) if halfway else 0.0,
halfway_games=len(halfway),
contenders_at_end=sum(r.contenders_at_end for r in rows) / count,
out_of_contention=statistics.fmean(r.out_of_contention_share for r in rows),
worst_out_of_contention=Spread.of(r.worst_out_of_contention_share for r in rows),
rank_swaps=Spread.of(r.rank_swaps for r in rows),
leader_kumite_losses=sum(r.leader_kumite_losses for r in rows) / count,
winner_turns_to_brown=Spread.of(r.winner_turns_to_brown for r in rows),
final_spread=Spread.of(r.final_spread for r in rows),
win_rate_by_seat=tuple(seat_wins[s] / count for s in range(seats)),
# 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] / count for bot, count in sorted(appearances.items())}, win_rate_by_bot={bot: wins[bot] / seated for bot, seated in sorted(appearances.items())},
) )
+27 -11
View File
@@ -5,31 +5,40 @@ import dataclasses
from collections.abc import Iterable from collections.abc import Iterable
from pathlib import Path from pathlib import Path
from dojo.events import Source
from dojo.metrics import GameMetrics, Spread, Summary from dojo.metrics import GameMetrics, Spread, Summary
def format_summary(summary: Summary) -> str: def format_summary(summary: Summary) -> str:
income = summary.income_per_turn
seats = ", ".join(f"{seat} {rate:.0%}" for seat, rate in enumerate(summary.win_rate_by_seat)) 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(
f"{source} +{gain:.2f}/{loss:.2f}"
for source, (gain, loss) in summary.income_per_turn.items()
)
lines = [ lines = [
f"== {summary.scenario} ({summary.games} games, {summary.finished:.0%} finished)", f"== {summary.scenario} ({summary.games} games, {summary.finished:.0%} finished)",
f"Rounds {_spread(summary.rounds)}", f"Rounds {_spread(summary.rounds)}",
f"Minutes {_spread(summary.minutes)}", f"Minutes {_spread(summary.minutes)}",
f"Kumite {summary.kumite_per_round:.2f}/round; upsets {summary.upset_rate:.0%}, " f"Kumite {summary.kumite_per_round:.2f}/round; upsets {summary.upset_rate:.0%}, "
f"higher beats lower {summary.expected_rate:.0%}", f"higher beats lower (no stripes change) {summary.expected_rate:.0%}",
f"Longest chain {_spread(summary.max_chain)} (Kumite in one turn, per game)", f"Longest chain {_spread(summary.max_chain)} (Kumite in one turn, per game)",
f"Exam {summary.exam_attempts_per_game:.2f} attempts/game, " f"Exam {summary.exam_attempts_per_game:.2f} attempts/game, "
f"{summary.exam_success:.0%} passed", f"{summary.exam_success:.0%} passed, {summary.exam_challengers:.2f} challengers/game",
f"Stuck on 14 {summary.turns_on_14_per_game:.2f} turns/game, " f"Income/turn {income}",
f"longest streak {summary.longest_14_streak} turns", f"Square income home {summary.square_income_home_per_turn:+.2f}/turn, "
f"Income/turn square {income[Source.SQUARE]:+.2f} " f"away {summary.square_income_away_per_turn:+.2f}/turn (net)",
f"(home {summary.square_income_home_per_turn:+.2f}, " f"Winner pace {_spread(summary.winner_turns_to_brown)} (own turns to reach Brown)",
f"away {summary.square_income_away_per_turn:+.2f}), "
f"card {income[Source.CARD]:+.2f}, kumite {income[Source.KUMITE]:+.2f}, "
f"exam {income[Source.EXAM]:+.2f}",
f"Lead changes {_spread(summary.lead_changes)}", f"Lead changes {_spread(summary.lead_changes)}",
f"Last change {_shares(summary.last_lead_change)} (point of the game)",
f"Gap 1st-2nd {_spread(summary.lead_gap)} (per-game mean, stripes)",
f"Winner margin {_spread(summary.winner_margin)} (over best rival at the end)",
f"Winner led {summary.winner_lead_share:.0%} of turns; halfway leader won "
f"{summary.halfway_leader_wins:.0%} of {summary.halfway_games} games",
f"Contention {summary.contenders_at_end:.2f} rivals within one belt at the end; "
f"{summary.out_of_contention:.0%} of player-turns out (>2 belts behind)",
f"Worst player {_shares(summary.worst_out_of_contention)} (share of turns out)",
f"Rank swaps {_spread(summary.rank_swaps)}",
f"Leader beaten {summary.leader_kumite_losses:.2f} Kumite lost by the leader per game",
f"Final spread {_spread(summary.final_spread)}", f"Final spread {_spread(summary.final_spread)}",
f"Wins by seat {seats}", f"Wins by seat {seats}",
f"Wins by bot {bots}", f"Wins by bot {bots}",
@@ -51,3 +60,10 @@ def _spread(spread: Spread) -> str:
f"mean {spread.mean:.1f} p10 {spread.p10:.1f} median {spread.median:.1f} " f"mean {spread.mean:.1f} p10 {spread.p10:.1f} median {spread.median:.1f} "
f"p90 {spread.p90:.1f}" f"p90 {spread.p90:.1f}"
) )
def _shares(spread: Spread) -> str:
return (
f"mean {spread.mean:.0%} p10 {spread.p10:.0%} median {spread.median:.0%} "
f"p90 {spread.p90:.0%}"
)
+19 -11
View File
@@ -5,6 +5,8 @@ from enum import StrEnum
BELTS = ("White", "Yellow", "Orange", "Green", "Blue", "Brown") BELTS = ("White", "Yellow", "Orange", "Green", "Blue", "Brown")
BROWN = len(BELTS) - 1 BROWN = len(BELTS) - 1
# The belt an Exam defender fights with under the "defender is a Black belt" option.
BLACK = len(BELTS)
class Card(StrEnum): class Card(StrEnum):
@@ -44,9 +46,11 @@ DEFAULT_DECK: tuple[tuple[Card, int], ...] = (
@dataclass(frozen=True) @dataclass(frozen=True)
class RulesConfig: class RulesConfig:
# Variant switches (RULES.md, "Rule variants under test"). # Final Exam. The defender fights as a Black belt (D31); the other two are simulator-only
brown_requires_kumite: bool = False # options from Phase 5 (D29), off in the rules.
expected_win_gain: int = 0 exam_defender_black: bool = True
exam_ties_to_defender: bool = False
exam_rounds: int = 1
kiai_bonus: int = 2 kiai_bonus: int = 2
stripes_per_belt: int = 3 stripes_per_belt: int = 3
@@ -56,6 +60,10 @@ class RulesConfig:
# Safety stop for simulations; not a rule of the game. # Safety stop for simulations; not a rule of the game.
max_rounds: int = 200 max_rounds: int = 200
def __post_init__(self) -> None:
if self.exam_rounds < 1 or self.exam_rounds % 2 == 0:
raise ValueError(f"exam_rounds must be odd and positive, got {self.exam_rounds}")
@property @property
def max_position(self) -> int: def max_position(self) -> int:
return len(BELTS) * self.stripes_per_belt - 1 return len(BELTS) * self.stripes_per_belt - 1
@@ -70,13 +78,13 @@ class RulesConfig:
def is_brown(self, position: int) -> bool: def is_brown(self, position: int) -> bool:
return self.belt(position) == BROWN return self.belt(position) == BROWN
def shifted(self, position: int, delta: int, *, from_board: bool) -> int: def shifted(self, position: int, delta: int) -> int:
"""Track position after moving ``delta``; ``from_board``: a square or card caused it.""" return max(0, min(position + delta, self.max_position))
after = position + delta
if from_board and self.brown_requires_kumite and position < self.brown_position: @property
after = min(after, self.brown_position - 1) def exam_wins_needed(self) -> int:
return max(0, min(after, self.max_position)) """Rounds the challenger must win: the Exam is a best-of-``exam_rounds`` series."""
return self.exam_rounds // 2 + 1
VARIANT_A = RulesConfig() DEFAULT_RULES = RulesConfig()
VARIANT_B = RulesConfig(brown_requires_kumite=True, expected_win_gain=1)
+26 -11
View File
@@ -9,7 +9,7 @@ import itertools
import random import random
from collections.abc import Sequence from collections.abc import Sequence
from concurrent.futures import ProcessPoolExecutor from concurrent.futures import ProcessPoolExecutor
from dataclasses import dataclass, replace from dataclasses import dataclass
from functools import cache from functools import cache
from pathlib import Path from pathlib import Path
@@ -17,16 +17,25 @@ from dojo.board import Board, load_board
from dojo.bots import PROFILES, HeuristicAgent, RandomAgent from dojo.bots import PROFILES, 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 VARIANT_A, VARIANT_B, RulesConfig from dojo.rules import RulesConfig
VARIANTS = {"A": VARIANT_A, "B": VARIANT_B} NO_EXAM_OPTIONS = "none"
# Final Exam options; a scenario lists the ones in force, e.g. "ac". The rules use "c".
RULES_EXAM = "c"
EXAM_OPTIONS = {
"a": "the defender wins ties",
"c": "the defender fights as a Black belt",
"d": "best of 3 rounds",
}
BEST_OF_ROUNDS = 3
RANDOM_BOT = "random" RANDOM_BOT = "random"
BOTS = (RANDOM_BOT, *PROFILES) BOTS = (RANDOM_BOT, *PROFILES)
@dataclass(frozen=True) @dataclass(frozen=True)
class Scenario: class Scenario:
variant: str # NO_EXAM_OPTIONS, or letters from EXAM_OPTIONS.
exam: str
players: int players: int
kiai_bonus: int kiai_bonus: int
start_position: int start_position: int
@@ -34,8 +43,12 @@ class Scenario:
lineup: tuple[str, ...] lineup: tuple[str, ...]
def __post_init__(self) -> None: def __post_init__(self) -> None:
if self.variant not in VARIANTS: options = "" if self.exam == NO_EXAM_OPTIONS else self.exam
raise ValueError(f"unknown variant {self.variant!r}: expected one of {list(VARIANTS)}") if not self.exam or len(set(options)) != len(options) or set(options) - set(EXAM_OPTIONS):
raise ValueError(
f"unknown Exam options {self.exam!r}: expected {NO_EXAM_OPTIONS!r} "
f"or letters from {''.join(EXAM_OPTIONS)}"
)
unknown = [bot for bot in self.lineup if bot not in BOTS] unknown = [bot for bot in self.lineup if bot not in BOTS]
if unknown or not self.lineup: if unknown or not self.lineup:
raise ValueError(f"unknown bots {unknown}: expected some of {list(BOTS)}") raise ValueError(f"unknown bots {unknown}: expected some of {list(BOTS)}")
@@ -44,14 +57,16 @@ class Scenario:
@property @property
def name(self) -> str: def name(self) -> str:
return ( return (
f"{self.variant} p{self.players} kiai+{self.kiai_bonus} start{self.start_position} " f"exam:{self.exam} p{self.players} kiai+{self.kiai_bonus} start{self.start_position} "
+ ",".join(self.lineup) + ",".join(self.lineup)
) )
@property @property
def config(self) -> RulesConfig: def config(self) -> RulesConfig:
return replace( return RulesConfig(
VARIANTS[self.variant], exam_ties_to_defender="a" in self.exam,
exam_defender_black="c" in self.exam,
exam_rounds=BEST_OF_ROUNDS if "d" in self.exam else 1,
kiai_bonus=self.kiai_bonus, kiai_bonus=self.kiai_bonus,
start_position=self.start_position, start_position=self.start_position,
) )
@@ -63,7 +78,7 @@ class Scenario:
def matrix( def matrix(
variants: Sequence[str], exams: Sequence[str],
players: Sequence[int], players: Sequence[int],
kiai_bonuses: Sequence[int], kiai_bonuses: Sequence[int],
start_positions: Sequence[int], start_positions: Sequence[int],
@@ -71,7 +86,7 @@ def matrix(
) -> list[Scenario]: ) -> list[Scenario]:
return [ return [
Scenario(*axes) Scenario(*axes)
for axes in itertools.product(variants, players, kiai_bonuses, start_positions, lineups) for axes in itertools.product(exams, players, kiai_bonuses, start_positions, lineups)
] ]
+2 -2
View File
@@ -9,7 +9,7 @@ from dojo.board import Board, Pos, build_board
from dojo.engine import FightView, Game, GameView from dojo.engine import FightView, Game, GameView
from dojo.grid import parse_grid from dojo.grid import parse_grid
from dojo.movement import Path as Walk from dojo.movement import Path as Walk
from dojo.rules import VARIANT_A, Card, RulesConfig from dojo.rules import DEFAULT_RULES, Card, RulesConfig
FIXTURE = Path(__file__).parent / "fixtures" / "quadrant_valid.txt" FIXTURE = Path(__file__).parent / "fixtures" / "quadrant_valid.txt"
NO_CARDS: tuple[tuple[Card, int], ...] = () NO_CARDS: tuple[tuple[Card, int], ...] = ()
@@ -91,7 +91,7 @@ def fixture_board() -> Board:
def make_game( def make_game(
players: int = 2, players: int = 2,
config: RulesConfig = VARIANT_A, config: RulesConfig = DEFAULT_RULES,
dice: Iterable[int] = (), dice: Iterable[int] = (),
deck: tuple[tuple[Card, int], ...] = NO_CARDS, deck: tuple[tuple[Card, int], ...] = NO_CARDS,
board: Board | None = None, board: Board | None = None,
+25 -2
View File
@@ -14,10 +14,11 @@ from dojo.bots import (
HeuristicAgent, HeuristicAgent,
Profile, Profile,
RandomAgent, RandomAgent,
series_probability,
win_probability, win_probability,
) )
from dojo.engine import Agent, FightView, Game, RandomSource from dojo.engine import Agent, FightView, Game, RandomSource
from dojo.rules import VARIANT_A, VARIANT_B, Card, RulesConfig from dojo.rules import DEFAULT_RULES, Card, RulesConfig
ALLENAMENTO = Pos(0, 5) ALLENAMENTO = Pos(0, 5)
FATICA = Pos(3, 6) FATICA = Pos(3, 6)
@@ -30,6 +31,22 @@ def test_win_probability_counts_senshu_and_bonuses() -> None:
assert win_probability(0, 0) == pytest.approx(21 / 36) assert win_probability(0, 0) == pytest.approx(21 / 36)
assert win_probability(2, 0) == pytest.approx(30 / 36) assert win_probability(2, 0) == pytest.approx(30 / 36)
assert win_probability(0, 2) == pytest.approx(10 / 36) assert win_probability(0, 2) == pytest.approx(10 / 36)
assert win_probability(0, 0, senshu=False) == pytest.approx(15 / 36)
def test_series_probability_is_a_best_of_n() -> None:
assert series_probability(0.3, 1) == pytest.approx(0.3)
assert series_probability(0.5, 3) == pytest.approx(0.5)
assert series_probability(0.6, 3) == pytest.approx(0.6**2 * (3 - 2 * 0.6))
def test_kiai_that_only_ties_the_exam_is_kept_when_ties_go_to_the_defender() -> None:
game, _, _ = make_game(config=RulesConfig(exam_ties_to_defender=True))
game.players[0].tecnica = Card.KIAI
exam = FightView(0, 1, True, 3, 5, played=())
assert not HeuristicAgent().play_tecnica(game.view(0), exam)
assert HeuristicAgent().play_tecnica(game.view(0), fight(3, 5))
def test_heuristic_prefers_allenamento_to_fatica() -> None: def test_heuristic_prefers_allenamento_to_fatica() -> None:
@@ -100,7 +117,13 @@ def play(board: Board, config: RulesConfig, seed: int) -> Game:
return game return game
@pytest.mark.parametrize("config", [VARIANT_A, VARIANT_B]) @pytest.mark.parametrize(
"config",
[
DEFAULT_RULES,
RulesConfig(exam_ties_to_defender=True, exam_defender_black=True, exam_rounds=3),
],
)
def test_bots_play_full_games_reproducibly(board: Board, config: RulesConfig) -> None: def test_bots_play_full_games_reproducibly(board: Board, config: RulesConfig) -> None:
first, again = play(board, config, seed=7), play(board, config, seed=7) first, again = play(board, config, seed=7), play(board, config, seed=7)
+6 -4
View File
@@ -62,19 +62,21 @@ def test_sim_prints_a_summary_per_scenario_and_writes_games(
tmp_path: Path, capsys: pytest.CaptureFixture[str] tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None: ) -> None:
games = tmp_path / "games.csv" games = tmp_path / "games.csv"
argv = ["sim", "--games", "2", "--variant", "A", "B", "--players", "3", "--jobs", "1"] argv = ["sim", "--games", "2", "--exam", "c", "none", "--players", "3", "--jobs", "1"]
assert main([*argv, "--csv", str(games)]) == EXIT_OK assert main([*argv, "--csv", str(games)]) == EXIT_OK
output = capsys.readouterr().out output = capsys.readouterr().out
assert "== A p3 kiai+2 start0 balanced,farmer,aggressive (2 games" in output assert "== exam:none p3 kiai+2 start0 balanced,farmer,aggressive (2 games" in output
assert "== B p3 kiai+2 start0" in output assert "== exam:c p3 kiai+2 start0" in output
rows = games.read_text(encoding="utf-8").splitlines() rows = games.read_text(encoding="utf-8").splitlines()
assert rows[0].startswith("scenario,seed,lineup,winner_seat") assert rows[0].startswith("scenario,seed,lineup,winner_seat")
assert len(rows) == 1 + 4 assert len(rows) == 1 + 4
@pytest.mark.parametrize("bad", [["--lineup", "balanced,cheater"], ["--games", "0"]]) @pytest.mark.parametrize(
"bad", [["--lineup", "balanced,cheater"], ["--games", "0"], ["--exam", "b"]]
)
def test_sim_rejects_bad_arguments(bad: list[str], capsys: pytest.CaptureFixture[str]) -> None: def test_sim_rejects_bad_arguments(bad: list[str], capsys: pytest.CaptureFixture[str]) -> None:
with pytest.raises(SystemExit) as exit_info: with pytest.raises(SystemExit) as exit_info:
main(["sim", *bad]) main(["sim", *bad])
+89 -31
View File
@@ -22,7 +22,7 @@ from dojo.events import (
TrackChanged, TrackChanged,
TurnSkipped, TurnSkipped,
) )
from dojo.rules import VARIANT_A, VARIANT_B, Card from dojo.rules import BLACK, DEFAULT_RULES, Card, RulesConfig
START_0 = Pos(0, 0) START_0 = Pos(0, 0)
START_1 = Pos(0, 13) START_1 = Pos(0, 13)
@@ -58,7 +58,7 @@ def test_seats_up_to_four_take_corners_clockwise() -> None:
def test_players_start_in_their_own_safe_room() -> None: def test_players_start_in_their_own_safe_room() -> None:
from dataclasses import replace from dataclasses import replace
game, _, _ = make_game(players=5, config=replace(VARIANT_A, start_position=3)) game, _, _ = make_game(players=5, config=replace(DEFAULT_RULES, start_position=3))
for player in game.players: for player in game.players:
assert player.position in game.board.safe_room(player.corner) assert player.position in game.board.safe_room(player.corner)
@@ -104,16 +104,15 @@ def test_upset_swaps_a_belt() -> None:
assert (game.players[0].track, game.players[1].track) == (3, 3) assert (game.players[0].track, game.players[1].track) == (3, 3)
@pytest.mark.parametrize(("config", "gain"), [(VARIANT_A, 0), (VARIANT_B, 1)]) def test_higher_belt_beating_lower_gains_nothing() -> None:
def test_higher_belt_beating_lower_gains_the_variant_amount(config, gain: int) -> None: game, agents, _ = make_game(dice=[1, 1, 1, 2])
game, agents, _ = make_game(config=config, dice=[1, 1, 1, 2])
put(game, 0, Pos(4, 3), track=6) put(game, 0, Pos(4, 3), track=6)
put(game, 1, Pos(4, 5), track=0) put(game, 1, Pos(4, 5), track=0)
turn(game, agents, 0, Pos(4, 5)) turn(game, agents, 0, Pos(4, 5))
assert only_fight(game).kind is FightKind.EXPECTED assert only_fight(game).kind is FightKind.EXPECTED
assert game.players[0].track == 6 + gain assert game.players[0].track == 6
assert game.players[1].track == 0 assert game.players[1].track == 0
assert game.players[1].position == START_1 assert game.players[1].position == START_1
@@ -311,18 +310,8 @@ def test_allenamento_and_fatica() -> None:
assert game.players[0].track == 3 assert game.players[0].track == 3
@pytest.mark.parametrize(("before", "after"), [(14, 14), (13, 14), (15, 16), (17, 17)]) def test_squares_can_make_a_player_brown() -> None:
def test_variant_b_squares_cannot_make_a_player_brown(before: int, after: int) -> None: game, agents, _ = make_game(dice=[1, 1])
game, agents, _ = make_game(config=VARIANT_B, dice=[1, 1])
put(game, 0, Pos(0, 3), track=before)
turn(game, agents, 0, Pos(0, 5))
assert game.players[0].track == after
def test_variant_a_squares_can_make_a_player_brown() -> None:
game, agents, _ = make_game(config=VARIANT_A, dice=[1, 1])
put(game, 0, Pos(0, 3), track=14) put(game, 0, Pos(0, 3), track=14)
turn(game, agents, 0, Pos(0, 5)) turn(game, agents, 0, Pos(0, 5))
@@ -330,16 +319,6 @@ def test_variant_a_squares_can_make_a_player_brown() -> None:
assert game.players[0].track == 15 assert game.players[0].track == 15
def test_variant_b_kumite_can_make_a_player_brown() -> None:
game, agents, _ = make_game(config=VARIANT_B, dice=[1, 1, 3, 3])
put(game, 0, Pos(4, 3), track=14)
put(game, 1, Pos(4, 5), track=12)
turn(game, agents, 0, Pos(4, 5))
assert game.players[0].track == 17
def test_passo_falso_returns_to_the_move_start_and_fights_stand() -> None: def test_passo_falso_returns_to_the_move_start_and_fights_stand() -> None:
game, agents, _ = make_game(dice=[1, 1, 6, 1]) game, agents, _ = make_game(dice=[1, 1, 6, 1])
put(game, 0, Pos(4, 3), track=3) put(game, 0, Pos(4, 3), track=3)
@@ -545,8 +524,10 @@ def test_only_one_tecnica_can_be_held(keep_drawn: bool, kept: Card) -> None:
# Central Dojo and Final Exam -------------------------------------------------------------- # Central Dojo and Final Exam --------------------------------------------------------------
def dojo_game(dice: list[int], players: int = 3, **tracks: int) -> tuple[Game, list]: def dojo_game(
game, agents, _ = make_game(players=players, dice=dice) dice: list[int], players: int = 3, config: RulesConfig = DEFAULT_RULES, **tracks: int
) -> tuple[Game, list]:
game, agents, _ = make_game(players=players, config=config, dice=dice)
put(game, 0, Pos(4, 7), track=15) put(game, 0, Pos(4, 7), track=15)
for name, track in tracks.items(): for name, track in tracks.items():
game.players[int(name[1:])].track = track game.players[int(name[1:])].track = track
@@ -614,6 +595,83 @@ def test_meditating_players_cannot_defend() -> None:
assert events_of(game, ExamTaken)[0].defender == 1 assert events_of(game, ExamTaken)[0].defender == 1
@pytest.mark.parametrize(("ties_to_defender", "passed"), [(False, True), (True, False)])
def test_exam_ties_can_go_to_the_defender(ties_to_defender: bool, passed: bool) -> None:
config = RulesConfig(exam_ties_to_defender=ties_to_defender)
# Black belt defender: +1, so 4 vs 3 is a tie.
game, agents = dojo_game([1, 1, 4, 3], config=config, p1=9, p2=15)
turn(game, agents, 0, Pos(6, 7))
assert events_of(game, ExamTaken)[0].passed is passed
def test_exam_ties_go_to_the_defender_only_in_the_exam() -> None:
game, agents, _ = make_game(config=RulesConfig(exam_ties_to_defender=True), dice=[1, 1, 3, 3])
put(game, 0, Pos(4, 3), track=3)
put(game, 1, Pos(4, 5), track=3)
turn(game, agents, 0, Pos(4, 5))
assert only_fight(game).attacker_won
@pytest.mark.parametrize(("black", "passed"), [(False, True), (True, False)])
def test_exam_defender_may_fight_as_a_black_belt(black: bool, passed: bool) -> None:
config = RulesConfig(exam_defender_black=black)
game, agents = dojo_game([1, 1, 4, 4], config=config, p1=9, p2=12)
turn(game, agents, 0, Pos(6, 7))
fight = only_fight(game)
assert fight.defender_belt == (BLACK if black else 4)
assert (fight.attacker_total, fight.defender_total) == ((4, 5) if black else (5, 4))
assert events_of(game, ExamTaken)[0].passed is passed
def test_best_of_three_exam_is_passed_on_two_rounds() -> None:
game, agents = dojo_game(
[1, 1, 6, 1, 1, 6, 6, 1], config=RulesConfig(exam_rounds=3), p1=9, p2=15
)
turn(game, agents, 0, Pos(6, 7))
rounds = events_of(game, KumiteFought)
assert [r.attacker_won for r in rounds] == [True, False, True]
assert all(r.exam for r in rounds)
assert events_of(game, ExamTaken) == [ExamTaken(challenger=0, defender=2, passed=True)]
assert game.winner == 0
def test_best_of_three_exam_stops_once_decided() -> None:
game, agents = dojo_game([1, 1, 1, 6, 1, 6], config=RulesConfig(exam_rounds=3), p1=9, p2=15)
turn(game, agents, 0, Pos(6, 7))
assert len(events_of(game, KumiteFought)) == 2
assert game.winner is None
assert game.players[0].track == 12
assert game.players[0].position == START_0
def test_uke_played_in_any_exam_round_saves_the_challenger() -> None:
game, agents = dojo_game([1, 1, 1, 6, 1, 6], config=RulesConfig(exam_rounds=3), p1=9, p2=15)
game.players[0].tecnica = Card.UKE
agents[0].plays = frozenset({Card.UKE})
turn(game, agents, 0, Pos(6, 7))
assert events_of(game, KumiteFought)[0].cards == ((0, Card.UKE),)
assert game.players[0].position == Pos(5, 7)
assert game.players[0].track == 12
@pytest.mark.parametrize("rounds", [0, 2])
def test_exam_rounds_must_be_odd(rounds: int) -> None:
with pytest.raises(ValueError, match="odd"):
RulesConfig(exam_rounds=rounds)
def test_kumite_won_mid_move_can_open_the_dojo() -> None: def test_kumite_won_mid_move_can_open_the_dojo() -> None:
game, agents, dice = make_game(dice=[1, 1, 3, 3, 6, 1]) game, agents, dice = make_game(dice=[1, 1, 3, 3, 6, 1])
put(game, 0, Pos(4, 7), track=14) put(game, 0, Pos(4, 7), track=14)
@@ -635,7 +693,7 @@ def test_a_game_runs_to_completion() -> None:
from scripted import ScriptedAgent, fixture_board from scripted import ScriptedAgent, fixture_board
config = replace(VARIANT_A, max_rounds=30) config = replace(DEFAULT_RULES, max_rounds=30)
game = Game(fixture_board(), config, [ScriptedAgent() for _ in range(4)], RandomSource(7)) game = Game(fixture_board(), config, [ScriptedAgent() for _ in range(4)], RandomSource(7))
game.run() game.run()
+64 -29
View File
@@ -12,26 +12,28 @@ from dojo.events import (
Moved, Moved,
Source, Source,
TrackChanged, TrackChanged,
TurnSkipped,
TurnStarted, TurnStarted,
) )
from dojo.metrics import KUMITE_SECONDS, TURN_SECONDS, GameMetrics, measure, summarize from dojo.metrics import KUMITE_SECONDS, TURN_SECONDS, GameMetrics, measure, summarize
from dojo.rules import VARIANT_A from dojo.rules import DEFAULT_RULES
CORNERS = (0, 1)
BOTS = ("balanced", "random")
HOME_0 = Pos(0, 1) # quadrant 0 HOME_0 = Pos(0, 1) # quadrant 0
HOME_1 = Pos(0, 14) # quadrant 1
def fought(attacker: int, defender: int, kind: FightKind, *, exam: bool = False) -> KumiteFought: def fought(
return KumiteFought(attacker, defender, 0, 0, 4, 3, True, kind, exam, cards=()) attacker: int, defender: int, kind: FightKind, *, won: bool = True, exam: bool = False
) -> KumiteFought:
return KumiteFought(attacker, defender, 0, 0, 4, 3, won, kind, exam, cards=())
def run(events: list[Event]) -> GameMetrics: def run(events: list[Event], players: int = 2) -> GameMetrics:
return measure(events, VARIANT_A, CORNERS, BOTS, scenario="test", seed=3) corners = tuple(range(players))
bots = ("balanced", "random", "farmer")[:players]
return measure(events, DEFAULT_RULES, corners, bots, scenario="test", seed=3)
def test_measure_counts_fights_income_and_leads() -> None: def test_measure_counts_fights_income_and_exams() -> None:
metrics = run( metrics = run(
[ [
TurnStarted(1, 0, 7), TurnStarted(1, 0, 7),
@@ -47,6 +49,8 @@ def test_measure_counts_fights_income_and_leads() -> None:
TurnStarted(2, 0, 6), TurnStarted(2, 0, 6),
fought(1, 0, FightKind.EXPECTED), # Player 1 defends: not part of a chain. fought(1, 0, FightKind.EXPECTED), # Player 1 defends: not part of a chain.
TrackChanged(0, 0, 5, Source.CARD), TrackChanged(0, 0, 5, Source.CARD),
TrackChanged(0, 5, 4, Source.CARD),
fought(0, 1, FightKind.EQUAL, won=False, exam=True),
fought(0, 1, FightKind.EQUAL, exam=True), fought(0, 1, FightKind.EQUAL, exam=True),
ExamTaken(0, 1, passed=True), ExamTaken(0, 1, passed=True),
GameOver(0, 2), GameOver(0, 2),
@@ -55,41 +59,72 @@ def test_measure_counts_fights_income_and_leads() -> None:
assert (metrics.kumite, metrics.upsets, metrics.expected_wins) == (3, 1, 1) assert (metrics.kumite, metrics.upsets, metrics.expected_wins) == (3, 1, 1)
assert metrics.max_chain == 2 assert metrics.max_chain == 2
assert (metrics.exam_attempts, metrics.exam_passes) == (1, 1) assert (metrics.exam_attempts, metrics.exam_passes, metrics.exam_challengers) == (1, 1, 1)
assert (metrics.income_square, metrics.income_kumite, metrics.income_card) == (2, 2, 5) assert (metrics.square_gain, metrics.square_loss) == (2, 0)
assert (metrics.kumite_gain, metrics.kumite_loss) == (3, -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)
# Leader: 0 (1-0), tie (1-1) keeps it, 1 (0-4), 0 (5-4).
assert metrics.lead_changes == 2
assert metrics.final_spread == 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.minutes == pytest.approx(round((3 * TURN_SECONDS + 4 * KUMITE_SECONDS) / 60, 1)) # Both Exam rounds count as fights for the time estimate.
assert metrics.minutes == pytest.approx(round((3 * TURN_SECONDS + 5 * KUMITE_SECONDS) / 60, 1))
def test_measure_tracks_turns_started_on_14() -> None: def test_standings_are_sampled_at_turn_ends() -> None:
metrics = run( metrics = run(
[ [
TrackChanged(0, 0, 14, Source.CARD),
TurnStarted(1, 0, 7), TurnStarted(1, 0, 7),
TrackChanged(0, 0, 9, Source.CARD), # end of turn 1: (9, 0, 0), leader 0
TurnStarted(1, 1, 7), TurnStarted(1, 1, 7),
TurnStarted(2, 0, 7), TrackChanged(1, 0, 3, Source.SQUARE),
TrackChanged(0, 14, 15, Source.KUMITE), TrackChanged(1, 3, 10, Source.CARD), # end of turn 2: (9, 10, 0), leader 1
TurnStarted(2, 1, 7), TurnStarted(1, 2, 7),
TurnStarted(3, 0, 7), TrackChanged(2, 0, 10, Source.CARD), # end of turn 3: (9, 10, 10), no strict leader
TrackChanged(0, 15, 14, Source.SQUARE), TurnSkipped(2, 0),
TurnStarted(4, 0, 7), TrackChanged(2, 10, 15, Source.KUMITE), # end of turn 4: (9, 10, 15), leader 2
GameOver(None, 4), GameOver(2, 2),
],
players=3,
)
assert metrics.lead_changes == 2
assert metrics.last_lead_change == pytest.approx(4 / 4)
assert metrics.lead_gap_mean == pytest.approx((9 + 1 + 0 + 5) / 4)
assert metrics.winner_margin == 5
assert metrics.winner_lead_share == pytest.approx(1 / 4)
# Halfway is the end of turn 2, led by player 1.
assert metrics.halfway_leader_won is False
assert metrics.contenders_at_end == 0
# Out (more than 6 behind): players 1 and 2 after turn 1, player 2 after turn 2.
assert metrics.out_of_contention_share == pytest.approx(3 / 12)
assert metrics.worst_out_of_contention_share == pytest.approx(0.5)
# 0>1 becomes 1>0; 0>2 becomes 2>0; 1>2 (ties skipped) becomes 2>1.
assert metrics.rank_swaps == 3
assert metrics.winner_turns_to_brown == 1
assert metrics.final_spread == 6
def test_leader_losing_a_kumite_is_counted() -> None:
metrics = run(
[
TurnStarted(1, 0, 7),
TrackChanged(0, 0, 3, Source.CARD),
TurnStarted(1, 1, 7),
fought(1, 0, FightKind.EQUAL), # Leader 0 loses.
fought(1, 0, FightKind.EQUAL, won=False), # The attacker loses: 0 wins.
GameOver(None, 1),
] ]
) )
assert metrics.turns_on_14 == 3 assert metrics.leader_kumite_losses == 1
assert metrics.longest_14_streak == 2 assert metrics.winner_margin is None
assert metrics.winner_seat is None assert metrics.halfway_leader_won is None
assert metrics.last_lead_change is None
def test_summarize_splits_wins_by_seat_and_bot() -> None: def test_summarize_splits_wins_by_seat_and_bot() -> None:
base = run([GameOver(None, 10)]) base = run([TurnStarted(1, 0, 7), GameOver(None, 10)])
rows = [ rows = [
replace(base, 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, base,
+35 -13
View File
@@ -2,6 +2,7 @@ from pathlib import Path
import pytest import pytest
from dojo.rules import RulesConfig
from dojo.sim import BoardFiles, Scenario, matrix, run from dojo.sim import BoardFiles, Scenario, matrix, run
BOARD = Path(__file__).parent.parent / "board" BOARD = Path(__file__).parent.parent / "board"
@@ -10,16 +11,35 @@ LINEUP = ("balanced", "random")
def test_matrix_crosses_every_axis() -> None: def test_matrix_crosses_every_axis() -> None:
scenarios = matrix(["A", "B"], [3, 4], [2, 1], [0], [LINEUP]) scenarios = matrix(["none", "acd"], [3, 4], [2, 1], [0], [LINEUP])
assert len(scenarios) == 8 assert len(scenarios) == 8
assert scenarios[0].name == "A p3 kiai+2 start0 balanced,random" assert scenarios[0].name == "exam:none p3 kiai+2 start0 balanced,random"
assert scenarios[-1].config.kiai_bonus == 1 assert scenarios[0].config == RulesConfig(exam_defender_black=False)
assert scenarios[-1].config.brown_requires_kumite assert scenarios[-1].config == RulesConfig(
exam_ties_to_defender=True, exam_defender_black=True, exam_rounds=3, kiai_bonus=1
)
@pytest.mark.parametrize(
("exam", "ties", "black", "rounds"),
[
("none", False, False, 1),
("a", True, False, 1),
("c", False, True, 1),
("d", False, False, 3),
("cd", False, True, 3),
],
)
def test_exam_options_map_to_rules(exam: str, ties: bool, black: bool, rounds: int) -> None:
config = Scenario(exam, 4, 2, 0, LINEUP).config
assert (config.exam_ties_to_defender, config.exam_defender_black) == (ties, black)
assert config.exam_rounds == rounds
def test_lineup_fills_the_seats_and_rotates_each_game() -> None: def test_lineup_fills_the_seats_and_rotates_each_game() -> None:
scenario = Scenario("A", 3, 2, 0, LINEUP) scenario = Scenario("none", 3, 2, 0, LINEUP)
assert scenario.seating(0) == ("balanced", "random", "balanced") assert scenario.seating(0) == ("balanced", "random", "balanced")
assert scenario.seating(1) == ("random", "balanced", "balanced") assert scenario.seating(1) == ("random", "balanced", "balanced")
@@ -27,23 +47,25 @@ def test_lineup_fills_the_seats_and_rotates_each_game() -> None:
@pytest.mark.parametrize( @pytest.mark.parametrize(
("variant", "players", "lineup", "message"), ("exam", "players", "lineup", "message"),
[ [
("C", 4, LINEUP, "unknown variant"), ("b", 4, LINEUP, "unknown Exam options"),
("A", 4, ("balanced", "cheater"), "unknown bots"), ("aa", 4, LINEUP, "unknown Exam options"),
("A", 4, (), "unknown bots"), ("", 4, LINEUP, "unknown Exam options"),
("A", 9, LINEUP, "supported range"), ("none", 4, ("balanced", "cheater"), "unknown bots"),
("none", 4, (), "unknown bots"),
("none", 9, LINEUP, "supported range"),
], ],
) )
def test_invalid_scenarios_are_rejected( def test_invalid_scenarios_are_rejected(
variant: str, players: int, lineup: tuple[str, ...], message: str exam: str, players: int, lineup: tuple[str, ...], message: str
) -> None: ) -> None:
with pytest.raises(ValueError, match=message): with pytest.raises(ValueError, match=message):
Scenario(variant, players, 2, 0, lineup) Scenario(exam, players, 2, 0, lineup)
def test_runs_are_reproducible_and_independent_of_worker_count() -> None: def test_runs_are_reproducible_and_independent_of_worker_count() -> None:
scenarios = matrix(["A", "B"], [3], [2], [0], [LINEUP]) scenarios = matrix(["none", "acd"], [3], [2], [0], [LINEUP])
serial = run(FILES, scenarios, games=2, seed=5, jobs=1) serial = run(FILES, scenarios, games=2, seed=5, jobs=1)
parallel = run(FILES, scenarios, games=2, seed=5, jobs=2) parallel = run(FILES, scenarios, games=2, seed=5, jobs=2)