From 4998931cb061ac158dd117b24e9811dad7a59b78 Mon Sep 17 00:00:00 2001 From: Federico Pasqua Date: Sat, 26 Sep 2026 17:09:42 +0200 Subject: [PATCH] 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) --- README.md | 7 +- RULES.md | 20 +- agents/plans/2026-09-25-dojo-board-and-sim.md | 84 ++++- src/dojo/bots.py | 72 +++-- src/dojo/cli.py | 15 +- src/dojo/engine.py | 37 ++- src/dojo/metrics.py | 303 +++++++++++++----- src/dojo/report.py | 56 ++-- src/dojo/rules.py | 30 +- src/dojo/sim.py | 37 ++- tests/scripted.py | 4 +- tests/test_bots.py | 27 +- tests/test_cli.py | 10 +- tests/test_engine.py | 120 +++++-- tests/test_metrics.py | 93 ++++-- tests/test_sim.py | 48 ++- 16 files changed, 707 insertions(+), 256 deletions(-) diff --git a/README.md b/README.md index c7a7cf6..41df9f6 100644 --- a/README.md +++ b/README.md @@ -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`. ```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 --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 --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 ``` diff --git a/RULES.md b/RULES.md index 7556041..775a8ba 100644 --- a/RULES.md +++ b/RULES.md @@ -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. @@ -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. - 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. ## 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 | | 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 @@ -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 from and there is no Exam this turn. - 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 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. ## Rule variants under test -| Variant | Brown requires Kumite | Higher belt beats lower | -|---|---|---| -| **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; +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. +Tested and set aside (simulator switches only): Exam ties to the defender; best-of-3 Exam. + ## FAQ **Which effects can I refuse?** diff --git a/agents/plans/2026-09-25-dojo-board-and-sim.md b/agents/plans/2026-09-25-dojo-board-and-sim.md index e5a643d..cad8a42 100644 --- a/agents/plans/2026-09-25-dojo-board-and-sim.md +++ b/agents/plans/2026-09-25-dojo-board-and-sim.md @@ -11,7 +11,7 @@ Deliverables: 2. A text-based board format, with parser, full-board generator and validator. 3. A first playable board. 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 @@ -36,7 +36,7 @@ Deliverables: | 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. | | 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). | | D22 | Tooling in Python, environment managed with **uv** | 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. | | 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. | +| 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 @@ -125,13 +131,68 @@ Deliverables: | Lead changes, final spread | 5.6, 9.3 | 3.5, 11.5 | | 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 -- [ ] 6.1 Variant A vs B. -- [ ] 6.2 Kiai +2 vs +1. -- [ ] 6.3 Player counts 3/4/6/8; start on 3 for 6+. -- [ ] 6.4 Square mix tuning; board income per turn (target ≈ 0.8 stripes/turn net, to be confirmed). -- [ ] 6.5 Update `RULES.md` with the decisions. +**Method** (applies to every step): + +- 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). +- Compared scenarios share seeds (`--seed 0`), so differences come from the rules, not the dice. +- 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) @@ -155,19 +216,24 @@ Deliverables: - Player count → focus 4, support 3–8. - 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. +- 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). ### 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.)* -- 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 - Movement enumeration: self-avoiding walks of length up to 12 can explode in open rooms. Keep rooms small; prune by outcome (destination + ordered opponents crossed + Dojo entry). - Engine speed ~1,400 turns/s (random and heuristic bots, real board). If Phase 5 needs more: precompute neighbour lists in `Board`. - Simulation conclusions depend on bot quality; compare several profiles before trusting a result. -- Board draft 1 puts 4 Allenamento in each 3x3 room next to the Safe Room: possible "home farming" (see the Phase 2 review). Measured (Phase 5 baseline): square income is +0.38/turn in the own quadrant vs +0.14/turn elsewhere; 6.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 diff --git a/src/dojo/bots.py b/src/dojo/bots.py index f059d5a..aea7ea1 100644 --- a/src/dojo/bots.py +++ b/src/dojo/bots.py @@ -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. """ +import math import random from collections import deque 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.engine import FightView, GameView, PlayerView from dojo.movement import Path -from dojo.rules import Card +from dojo.rules import BLACK, Card DIE_FACES = range(1, 7) @@ -84,12 +85,26 @@ PROFILES = {p.name: p for p in (BALANCED, FARMER, AGGRESSIVE)} @cache -def win_probability(attacker_extra: int, defender_extra: int) -> float: - """Chance the attacker wins a Kumite on 1d6 each; ties go to the attacker (Senshu).""" - wins = sum(a + attacker_extra >= d + defender_extra for a in DIE_FACES for d in DIE_FACES) +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 under Senshu.""" + 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 +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 def dojo_distance(board: Board) -> Mapping[Pos, int]: """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: 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: rank = self.profile.tecnica_rank @@ -193,8 +211,12 @@ class HeuristicAgent: else (fight.defender_total, fight.attacker_total) ) + senshu = not (fight.exam and view.config.exam_ties_to_defender) + 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): return False @@ -236,9 +258,9 @@ class HeuristicAgent: match code: case Square.ALLENAMENTO: - return at(config.shifted(track, 1, from_board=True), square) + return at(config.shifted(track, 1), square) case Square.FATICA: - return at(config.shifted(track, -1, from_board=True), square) + return at(config.shifted(track, -1), square) case Square.NEBBIA: return at(track, square, CARD_VALUE) case Square.PASSO_FALSO: @@ -278,9 +300,9 @@ class HeuristicAgent: stripes = config.stripes_per_belt mine, theirs = config.belt(track), config.belt(opponent.track) 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: - win_gain, lose_loss, their_gain = stripes, 0, config.expected_win_gain + win_gain, lose_loss, their_gain = stripes, 0, 0 else: win_gain, lose_loss, their_gain = stripes, stripes, stripes their_loss = stripes if mine <= theirs else 0 @@ -288,21 +310,19 @@ class HeuristicAgent: their_value = self._value(view, opponent) beaten = self._value_at( view, - config.shifted(opponent.track, -their_loss, from_board=False), + config.shifted(opponent.track, -their_loss), start_square(view.board, opponent.corner), ) - winner = self._value_at( - view, config.shifted(opponent.track, their_gain, from_board=False), opponent.position - ) + winner = self._value_at(view, config.shifted(opponent.track, their_gain), opponent.position) lost = self._value_at( view, - config.shifted(track, -lose_loss, from_board=False), + config.shifted(track, -lose_loss), start_square(view.board, me.corner), ) rivalry = self.profile.rivalry return _Fight( - win_chance=self._win_chance(view, me, track, opponent), - win_track=config.shifted(track, win_gain, from_board=False), + win_chance=self._win_chance(view, track, opponent), + win_track=config.shifted(track, win_gain), lose_score=self._weigh(lost - v0) - rivalry * (winner - their_value), win_rivalry=-rivalry * (beaten - their_value), ) @@ -313,21 +333,31 @@ class HeuristicAgent: if not eligible: return self._weigh(self._value_at(view, track, entrance) - v0) 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( 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), ) return chance * EXAM_WIN + (1 - chance) * self._weigh(failed - v0) def _win_chance( - self, view: GameView, me: PlayerView, track: int, opponent: PlayerView + self, view: GameView, track: int, opponent: PlayerView, *, exam: bool = False ) -> float: + """Chance of winning one Kumite I attack (one round, in the Exam).""" config = view.config 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) - 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: chance += TECNICA_EDGE if opponent.holds_tecnica: diff --git a/src/dojo/cli.py b/src/dojo/cli.py index 029b671..fec0fdb 100644 --- a/src/dojo/cli.py +++ b/src/dojo/cli.py @@ -8,7 +8,7 @@ from dojo.board import QUADRANTS, Board, BoardError, load_board from dojo.grid import GridFormatError, render_grid from dojo.metrics import summarize from dojo.report import format_summary, write_games_csv -from dojo.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 DEFAULT_QUADRANT = Path("board/quadrant.txt") @@ -45,7 +45,16 @@ def main(argv: list[str] | None = None) -> int: _add_board_arguments(sim_parser) 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("--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("--kiai", nargs="+", type=int, default=[2], help="Kiai bonus") 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") try: scenarios = matrix( - args.variant, + args.exam, args.players, args.kiai, args.start, diff --git a/src/dojo/engine.py b/src/dojo/engine.py index cedbce8..4199f40 100644 --- a/src/dojo/engine.py +++ b/src/dojo/engine.py @@ -31,7 +31,7 @@ from dojo.events import ( TurnStarted, ) from dojo.movement import Path, walks -from dojo.rules import TECNICHE, Card, RulesConfig +from dojo.rules import BLACK, TECNICHE, Card, RulesConfig MIN_PLAYERS = 2 MAX_PLAYERS = 8 @@ -308,7 +308,10 @@ class Game: def _kumite(self, attacker: Player, defender: Player, *, exam: bool = False) -> FightResult: 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) dice = [self._random.d6(), self._random.d6()] extra = [bonus if belts[0] > belts[1] else 0, bonus if belts[1] > belts[0] else 0] @@ -341,7 +344,10 @@ class Game: uke[side] = True totals = (dice[0] + extra[0], dice[1] + extra[1]) - attacker_won = totals[0] >= totals[1] # Senshu: ties go to the attacker. + 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. winner_belt, loser_belt = belts if attacker_won else belts[::-1] if winner_belt == loser_belt: kind = FightKind.EQUAL @@ -374,9 +380,7 @@ class Game: winner, loser, loser_uke = defender, attacker, result.attacker_uke stripes = self.config.stripes_per_belt - if result.kind is FightKind.EXPECTED: - self._change_track(winner, self.config.expected_win_gain, Source.KUMITE) - else: + if result.kind is not FightKind.EXPECTED: self._change_track(winner, stripes, Source.KUMITE) self._change_track(loser, -stripes, Source.KUMITE) @@ -402,13 +406,23 @@ class Game: ) defender_id = self._pick(choice, candidates, "exam defender") - result = self._kumite(challenger, self.players[defender_id], exam=True) - self._emit(ExamTaken(challenger.id, defender_id, result.attacker_won)) - if result.attacker_won: + 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) + uke = uke or result.attacker_uke + 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 return self._change_track(challenger, -self.config.stripes_per_belt, Source.EXAM) - if not result.attacker_uke: + if not uke: self._send_to_start(challenger) # Squares ------------------------------------------------------------------------------ @@ -525,8 +539,7 @@ class Game: # State helpers ------------------------------------------------------------------------ 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, from_board=from_board) + after = self.config.shifted(player.track, delta) if after != player.track: self._emit(TrackChanged(player.id, player.track, after, source)) player.track = after diff --git a/src/dojo/metrics.py b/src/dojo/metrics.py index 8be23f6..9cbf58f 100644 --- a/src/dojo/metrics.py +++ b/src/dojo/metrics.py @@ -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 from collections import Counter from collections.abc import Iterable, Sequence @@ -17,6 +22,7 @@ from dojo.events import ( Source, Teleported, TrackChanged, + TurnSkipped, TurnStarted, ) from dojo.rules import RulesConfig @@ -24,11 +30,18 @@ from dojo.rules import RulesConfig # Table-time estimate (plan, resolved questions). TURN_SECONDS = 45 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) 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 seed: int @@ -44,15 +57,28 @@ class GameMetrics: max_chain: int exam_attempts: int exam_passes: int - turns_on_14: int - longest_14_streak: int - income_square: int - income_card: int - income_kumite: int - income_exam: int + exam_challengers: int + square_gain: int + square_loss: int + card_gain: int + card_loss: int + kumite_gain: int + kumite_loss: int + exam_loss: int square_income_home: int square_income_away: 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 @@ -69,53 +95,59 @@ def measure( players = len(corners) track = [config.start_position] * players position: list[Pos | None] = [None] * players - blue_top = config.brown_position - 1 - streak = [0] * players - longest_streak = 0 - turns_on_14 = 0 - income = Counter[Source]() + history: list[Standings] = [] + gains = Counter[Source]() + losses = Counter[Source]() home = away = 0 - kumite = upsets = expected = 0 + kumite = upsets = expected = exam_fights = 0 chain = max_chain = 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 rounds = 0 winner: int | None = None - leader: int | None = None - lead_changes = 0 - active = 0 + active: int | None = None for event in events: match event: - case TurnStarted(player=player): - turns += 1 + case TurnStarted(player=player) | TurnSkipped(player=player): + if active is not None: + history.append(tuple(track)) active = player + own_turns[player] += 1 chain = 0 - if track[player] == blue_top: - turns_on_14 += 1 - streak[player] += 1 - longest_streak = max(longest_streak, streak[player]) - else: - streak[player] = 0 + turns += isinstance(event, TurnStarted) case Moved(player=player, path=path) if path: position[player] = path[-1] case Teleported(player=player, destination=destination): position[player] = destination case SentToStart(player=player, square=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 upsets += kind is FightKind.UPSET expected += kind is FightKind.EXPECTED if attacker == active: chain += 1 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_passes += passed + challengers.add(challenger) case TrackChanged(player=player, before=before, after=after, source=source): delta = after - before - income[source] += delta + if delta > 0: + gains[source] += delta + else: + losses[source] += delta square = position[player] if source is Source.SQUARE and square is not None: if quadrant_of(square) == corners[player]: @@ -123,17 +155,16 @@ def measure( else: away += delta track[player] = after - top = max(track) - leaders = [p for p in range(players) if track[p] == top] - if len(leaders) == 1 and leaders[0] != leader: - lead_changes += leader is not None - leader = leaders[0] + if reached_brown[player] is None and config.is_brown(after): + reached_brown[player] = own_turns[player] case GameOver(winner=game_winner, rounds=game_rounds): winner, rounds = game_winner, game_rounds case _: pass + if active is not None: + history.append(tuple(track)) - fights = kumite + exam_attempts + standings = _standings_metrics(history, winner, config) return GameMetrics( scenario=scenario, seed=seed, @@ -142,23 +173,115 @@ def measure( winner_bot=None if winner is None else bots[winner], rounds=rounds, 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, upsets=upsets, expected_wins=expected, max_chain=max_chain, exam_attempts=exam_attempts, exam_passes=exam_passes, - turns_on_14=turns_on_14, - longest_14_streak=longest_streak, - income_square=income[Source.SQUARE], - income_card=income[Source.CARD], - income_kumite=income[Source.KUMITE], - income_exam=income[Source.EXAM], + exam_challengers=len(challengers), + square_gain=gains[Source.SQUARE], + square_loss=losses[Source.SQUARE], + card_gain=gains[Source.CARD], + card_loss=losses[Source.CARD], + kumite_gain=gains[Source.KUMITE], + kumite_loss=losses[Source.KUMITE], + exam_loss=losses[Source.EXAM], square_income_home=home, 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), + 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 @classmethod - def of(cls, values: Sequence[float]) -> Spread: - if len(values) < 2: - value = float(values[0]) if values else 0.0 + def of(cls, values: Iterable[float | None]) -> Spread: + present = [float(v) for v in values if v is not None] + if len(present) < 2: + value = present[0] if present else 0.0 return cls(value, value, value, value) - deciles = statistics.quantiles(values, n=10, method="inclusive") - return cls(statistics.fmean(values), deciles[0], statistics.median(values), deciles[-1]) + deciles = statistics.quantiles(present, n=10, method="inclusive") + return cls(statistics.fmean(present), deciles[0], statistics.median(present), deciles[-1]) @dataclass(frozen=True) 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 games: int @@ -193,12 +317,23 @@ class Summary: max_chain: Spread exam_attempts_per_game: float exam_success: float - turns_on_14_per_game: float - longest_14_streak: int - income_per_turn: dict[Source, float] + exam_challengers: float + income_per_turn: dict[Source, tuple[float, float]] square_income_home_per_turn: float square_income_away_per_turn: float 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 win_rate_by_seat: tuple[float, ...] win_rate_by_bot: dict[str, float] @@ -208,6 +343,7 @@ def summarize(scenario: str, games: Iterable[GameMetrics]) -> Summary: rows = list(games) if not rows: raise ValueError(f"{scenario}: no games to summarize") + count = len(rows) turns = sum(r.turns for r in rows) or 1 rounds = sum(r.rounds for r in rows) or 1 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: wins[row.winner_bot] += 1 seat_wins = Counter(r.winner_seat for r in rows if r.winner_seat is not None) - incomes = { - Source.SQUARE: sum(r.income_square for r in rows), - Source.CARD: sum(r.income_card for r in rows), - Source.KUMITE: sum(r.income_kumite for r in rows), - Source.EXAM: sum(r.income_exam for r in rows), - } + finished = [r for r in rows if r.winner_seat is not None] + halfway = [r.halfway_leader_won for r in rows if r.halfway_leader_won is not None] + + def per_turn(values: Iterable[int]) -> float: + return sum(values) / turns return Summary( scenario=scenario, - games=len(rows), - finished=sum(r.winner_seat is not None for r in rows) / len(rows), - rounds=Spread.of([r.rounds for r in rows]), - minutes=Spread.of([r.minutes for r in rows]), + games=count, + finished=len(finished) / count, + rounds=Spread.of(r.rounds for r in rows), + minutes=Spread.of(r.minutes for r in rows), kumite_per_round=kumite / rounds, 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), - max_chain=Spread.of([r.max_chain for r in rows]), - exam_attempts_per_game=attempts / len(rows), + max_chain=Spread.of(r.max_chain for r in rows), + exam_attempts_per_game=attempts / count, 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), - longest_14_streak=max(r.longest_14_streak for r in rows), - income_per_turn={source: total / turns for source, total in incomes.items()}, - square_income_home_per_turn=sum(r.square_income_home for r in rows) / turns, - square_income_away_per_turn=sum(r.square_income_away for r in rows) / turns, - lead_changes=Spread.of([r.lead_changes for r in rows]), - final_spread=Spread.of([r.final_spread for r in rows]), - win_rate_by_seat=tuple(seat_wins[s] / len(rows) for s in range(seats)), + exam_challengers=sum(r.exam_challengers for r in rows) / count, + income_per_turn={ + Source.SQUARE: ( + per_turn(r.square_gain for r in rows), + per_turn(r.square_loss for r in rows), + ), + Source.CARD: (per_turn(r.card_gain for r in rows), per_turn(r.card_loss for r in rows)), + 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. - 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())}, ) diff --git a/src/dojo/report.py b/src/dojo/report.py index a9c9152..b9f3b75 100644 --- a/src/dojo/report.py +++ b/src/dojo/report.py @@ -5,34 +5,43 @@ import dataclasses from collections.abc import Iterable from pathlib import Path -from dojo.events import Source from dojo.metrics import GameMetrics, Spread, Summary 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)) 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 = [ f"== {summary.scenario} ({summary.games} games, {summary.finished:.0%} finished)", - f"Rounds {_spread(summary.rounds)}", - f"Minutes {_spread(summary.minutes)}", - f"Kumite {summary.kumite_per_round:.2f}/round; upsets {summary.upset_rate:.0%}, " - f"higher beats lower {summary.expected_rate:.0%}", - f"Longest chain {_spread(summary.max_chain)} (Kumite in one turn, per game)", - f"Exam {summary.exam_attempts_per_game:.2f} attempts/game, " - f"{summary.exam_success:.0%} passed", - f"Stuck on 14 {summary.turns_on_14_per_game:.2f} turns/game, " - f"longest streak {summary.longest_14_streak} turns", - f"Income/turn square {income[Source.SQUARE]:+.2f} " - f"(home {summary.square_income_home_per_turn:+.2f}, " - 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"Final spread {_spread(summary.final_spread)}", - f"Wins by seat {seats}", - f"Wins by bot {bots}", + f"Rounds {_spread(summary.rounds)}", + f"Minutes {_spread(summary.minutes)}", + f"Kumite {summary.kumite_per_round:.2f}/round; upsets {summary.upset_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"Exam {summary.exam_attempts_per_game:.2f} attempts/game, " + f"{summary.exam_success:.0%} passed, {summary.exam_challengers:.2f} challengers/game", + f"Income/turn {income}", + f"Square income home {summary.square_income_home_per_turn:+.2f}/turn, " + f"away {summary.square_income_away_per_turn:+.2f}/turn (net)", + f"Winner pace {_spread(summary.winner_turns_to_brown)} (own turns to reach Brown)", + 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"Wins by seat {seats}", + f"Wins by bot {bots}", ] return "\n".join(lines) @@ -51,3 +60,10 @@ def _spread(spread: Spread) -> str: f"mean {spread.mean:.1f} p10 {spread.p10:.1f} median {spread.median:.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%}" + ) diff --git a/src/dojo/rules.py b/src/dojo/rules.py index 0bdcd89..43587dc 100644 --- a/src/dojo/rules.py +++ b/src/dojo/rules.py @@ -5,6 +5,8 @@ from enum import StrEnum BELTS = ("White", "Yellow", "Orange", "Green", "Blue", "Brown") 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): @@ -44,9 +46,11 @@ DEFAULT_DECK: tuple[tuple[Card, int], ...] = ( @dataclass(frozen=True) class RulesConfig: - # Variant switches (RULES.md, "Rule variants under test"). - brown_requires_kumite: bool = False - expected_win_gain: int = 0 + # Final Exam. The defender fights as a Black belt (D31); the other two are simulator-only + # options from Phase 5 (D29), off in the rules. + exam_defender_black: bool = True + exam_ties_to_defender: bool = False + exam_rounds: int = 1 kiai_bonus: int = 2 stripes_per_belt: int = 3 @@ -56,6 +60,10 @@ class RulesConfig: # Safety stop for simulations; not a rule of the game. 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 def max_position(self) -> int: return len(BELTS) * self.stripes_per_belt - 1 @@ -70,13 +78,13 @@ class RulesConfig: def is_brown(self, position: int) -> bool: return self.belt(position) == BROWN - def shifted(self, position: int, delta: int, *, from_board: bool) -> int: - """Track position after moving ``delta``; ``from_board``: a square or card caused it.""" - after = position + delta - if from_board and self.brown_requires_kumite and position < self.brown_position: - after = min(after, self.brown_position - 1) - return max(0, min(after, self.max_position)) + def shifted(self, position: int, delta: int) -> int: + return max(0, min(position + delta, self.max_position)) + + @property + def exam_wins_needed(self) -> int: + """Rounds the challenger must win: the Exam is a best-of-``exam_rounds`` series.""" + return self.exam_rounds // 2 + 1 -VARIANT_A = RulesConfig() -VARIANT_B = RulesConfig(brown_requires_kumite=True, expected_win_gain=1) +DEFAULT_RULES = RulesConfig() diff --git a/src/dojo/sim.py b/src/dojo/sim.py index eccae57..316798e 100644 --- a/src/dojo/sim.py +++ b/src/dojo/sim.py @@ -9,7 +9,7 @@ import itertools import random from collections.abc import Sequence from concurrent.futures import ProcessPoolExecutor -from dataclasses import dataclass, replace +from dataclasses import dataclass from functools import cache from pathlib import Path @@ -17,16 +17,25 @@ from dojo.board import Board, load_board from dojo.bots import PROFILES, HeuristicAgent, RandomAgent from dojo.engine import Agent, Game, RandomSource, seat_corners 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" BOTS = (RANDOM_BOT, *PROFILES) @dataclass(frozen=True) class Scenario: - variant: str + # NO_EXAM_OPTIONS, or letters from EXAM_OPTIONS. + exam: str players: int kiai_bonus: int start_position: int @@ -34,8 +43,12 @@ class Scenario: lineup: tuple[str, ...] def __post_init__(self) -> None: - if self.variant not in VARIANTS: - raise ValueError(f"unknown variant {self.variant!r}: expected one of {list(VARIANTS)}") + options = "" if self.exam == NO_EXAM_OPTIONS else self.exam + 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] if unknown or not self.lineup: raise ValueError(f"unknown bots {unknown}: expected some of {list(BOTS)}") @@ -44,14 +57,16 @@ class Scenario: @property def name(self) -> str: 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) ) @property def config(self) -> RulesConfig: - return replace( - VARIANTS[self.variant], + return RulesConfig( + 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, start_position=self.start_position, ) @@ -63,7 +78,7 @@ class Scenario: def matrix( - variants: Sequence[str], + exams: Sequence[str], players: Sequence[int], kiai_bonuses: Sequence[int], start_positions: Sequence[int], @@ -71,7 +86,7 @@ def matrix( ) -> list[Scenario]: return [ 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) ] diff --git a/tests/scripted.py b/tests/scripted.py index 89f7769..ab3538f 100644 --- a/tests/scripted.py +++ b/tests/scripted.py @@ -9,7 +9,7 @@ from dojo.board import Board, Pos, build_board from dojo.engine import FightView, Game, GameView from dojo.grid import parse_grid 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" NO_CARDS: tuple[tuple[Card, int], ...] = () @@ -91,7 +91,7 @@ def fixture_board() -> Board: def make_game( players: int = 2, - config: RulesConfig = VARIANT_A, + config: RulesConfig = DEFAULT_RULES, dice: Iterable[int] = (), deck: tuple[tuple[Card, int], ...] = NO_CARDS, board: Board | None = None, diff --git a/tests/test_bots.py b/tests/test_bots.py index a00296f..1ad6271 100644 --- a/tests/test_bots.py +++ b/tests/test_bots.py @@ -14,10 +14,11 @@ from dojo.bots import ( HeuristicAgent, Profile, RandomAgent, + series_probability, win_probability, ) 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) 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(2, 0) == pytest.approx(30 / 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: @@ -100,7 +117,13 @@ def play(board: Board, config: RulesConfig, seed: int) -> 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: first, again = play(board, config, seed=7), play(board, config, seed=7) diff --git a/tests/test_cli.py b/tests/test_cli.py index 97223c4..709b3d4 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -62,19 +62,21 @@ def test_sim_prints_a_summary_per_scenario_and_writes_games( tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: 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 output = capsys.readouterr().out - assert "== A p3 kiai+2 start0 balanced,farmer,aggressive (2 games" in output - assert "== B p3 kiai+2 start0" in output + assert "== exam:none p3 kiai+2 start0 balanced,farmer,aggressive (2 games" in output + assert "== exam:c p3 kiai+2 start0" in output rows = games.read_text(encoding="utf-8").splitlines() assert rows[0].startswith("scenario,seed,lineup,winner_seat") 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: with pytest.raises(SystemExit) as exit_info: main(["sim", *bad]) diff --git a/tests/test_engine.py b/tests/test_engine.py index 4ec4174..180cbde 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -22,7 +22,7 @@ from dojo.events import ( TrackChanged, 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_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: 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: 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) -@pytest.mark.parametrize(("config", "gain"), [(VARIANT_A, 0), (VARIANT_B, 1)]) -def test_higher_belt_beating_lower_gains_the_variant_amount(config, gain: int) -> None: - game, agents, _ = make_game(config=config, dice=[1, 1, 1, 2]) +def test_higher_belt_beating_lower_gains_nothing() -> None: + game, agents, _ = make_game(dice=[1, 1, 1, 2]) put(game, 0, Pos(4, 3), track=6) put(game, 1, Pos(4, 5), track=0) turn(game, agents, 0, Pos(4, 5)) 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].position == START_1 @@ -311,18 +310,8 @@ def test_allenamento_and_fatica() -> None: assert game.players[0].track == 3 -@pytest.mark.parametrize(("before", "after"), [(14, 14), (13, 14), (15, 16), (17, 17)]) -def test_variant_b_squares_cannot_make_a_player_brown(before: int, after: int) -> None: - 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]) +def test_squares_can_make_a_player_brown() -> None: + game, agents, _ = make_game(dice=[1, 1]) put(game, 0, Pos(0, 3), track=14) 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 -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: game, agents, _ = make_game(dice=[1, 1, 6, 1]) 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 -------------------------------------------------------------- -def dojo_game(dice: list[int], players: int = 3, **tracks: int) -> tuple[Game, list]: - game, agents, _ = make_game(players=players, dice=dice) +def dojo_game( + 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) for name, track in tracks.items(): 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 +@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: game, agents, dice = make_game(dice=[1, 1, 3, 3, 6, 1]) 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 - 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.run() diff --git a/tests/test_metrics.py b/tests/test_metrics.py index fba641c..072bd5d 100644 --- a/tests/test_metrics.py +++ b/tests/test_metrics.py @@ -12,26 +12,28 @@ from dojo.events import ( Moved, Source, TrackChanged, + TurnSkipped, TurnStarted, ) 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_1 = Pos(0, 14) # quadrant 1 -def fought(attacker: int, defender: int, kind: FightKind, *, exam: bool = False) -> KumiteFought: - return KumiteFought(attacker, defender, 0, 0, 4, 3, True, kind, exam, cards=()) +def fought( + 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: - return measure(events, VARIANT_A, CORNERS, BOTS, scenario="test", seed=3) +def run(events: list[Event], players: int = 2) -> GameMetrics: + 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( [ TurnStarted(1, 0, 7), @@ -47,6 +49,8 @@ def test_measure_counts_fights_income_and_leads() -> None: TurnStarted(2, 0, 6), fought(1, 0, FightKind.EXPECTED), # Player 1 defends: not part of a chain. 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), ExamTaken(0, 1, passed=True), 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.max_chain == 2 - assert (metrics.exam_attempts, metrics.exam_passes) == (1, 1) - assert (metrics.income_square, metrics.income_kumite, metrics.income_card) == (2, 2, 5) + assert (metrics.exam_attempts, metrics.exam_passes, metrics.exam_challengers) == (1, 1, 1) + 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) - # 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.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( [ - TrackChanged(0, 0, 14, Source.CARD), TurnStarted(1, 0, 7), + TrackChanged(0, 0, 9, Source.CARD), # end of turn 1: (9, 0, 0), leader 0 TurnStarted(1, 1, 7), - TurnStarted(2, 0, 7), - TrackChanged(0, 14, 15, Source.KUMITE), - TurnStarted(2, 1, 7), - TurnStarted(3, 0, 7), - TrackChanged(0, 15, 14, Source.SQUARE), - TurnStarted(4, 0, 7), - GameOver(None, 4), + TrackChanged(1, 0, 3, Source.SQUARE), + TrackChanged(1, 3, 10, Source.CARD), # end of turn 2: (9, 10, 0), leader 1 + TurnStarted(1, 2, 7), + TrackChanged(2, 0, 10, Source.CARD), # end of turn 3: (9, 10, 10), no strict leader + TurnSkipped(2, 0), + TrackChanged(2, 10, 15, Source.KUMITE), # end of turn 4: (9, 10, 15), leader 2 + 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.longest_14_streak == 2 - assert metrics.winner_seat is None + assert metrics.leader_kumite_losses == 1 + assert metrics.winner_margin 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: - base = run([GameOver(None, 10)]) + base = run([TurnStarted(1, 0, 7), GameOver(None, 10)]) 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=0, winner_bot="random"), base, diff --git a/tests/test_sim.py b/tests/test_sim.py index 5928cc9..41f5f60 100644 --- a/tests/test_sim.py +++ b/tests/test_sim.py @@ -2,6 +2,7 @@ from pathlib import Path import pytest +from dojo.rules import RulesConfig from dojo.sim import BoardFiles, Scenario, matrix, run BOARD = Path(__file__).parent.parent / "board" @@ -10,16 +11,35 @@ LINEUP = ("balanced", "random") 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 scenarios[0].name == "A p3 kiai+2 start0 balanced,random" - assert scenarios[-1].config.kiai_bonus == 1 - assert scenarios[-1].config.brown_requires_kumite + assert scenarios[0].name == "exam:none p3 kiai+2 start0 balanced,random" + assert scenarios[0].config == RulesConfig(exam_defender_black=False) + 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: - scenario = Scenario("A", 3, 2, 0, LINEUP) + scenario = Scenario("none", 3, 2, 0, LINEUP) assert scenario.seating(0) == ("balanced", "random", "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( - ("variant", "players", "lineup", "message"), + ("exam", "players", "lineup", "message"), [ - ("C", 4, LINEUP, "unknown variant"), - ("A", 4, ("balanced", "cheater"), "unknown bots"), - ("A", 4, (), "unknown bots"), - ("A", 9, LINEUP, "supported range"), + ("b", 4, LINEUP, "unknown Exam options"), + ("aa", 4, LINEUP, "unknown Exam options"), + ("", 4, LINEUP, "unknown Exam options"), + ("none", 4, ("balanced", "cheater"), "unknown bots"), + ("none", 4, (), "unknown bots"), + ("none", 9, LINEUP, "supported range"), ], ) 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: 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: - 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) parallel = run(FILES, scenarios, games=2, seed=5, jobs=2)