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`.
```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
```
+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.
@@ -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?**
+75 -9
View File
@@ -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
+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.
"""
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:
+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.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,
+22 -9
View File
@@ -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,6 +344,9 @@ class Game:
uke[side] = True
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.
winner_belt, loser_belt = belts if attacker_won else belts[::-1]
if winner_belt == loser_belt:
@@ -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")
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)
self._emit(ExamTaken(challenger.id, defender_id, result.attacker_won))
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
+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
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())},
)
+27 -11
View File
@@ -5,31 +5,40 @@ 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"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",
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"{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}",
@@ -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%}"
)
+19 -11
View File
@@ -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()
+26 -11
View File
@@ -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)
]
+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.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,
+25 -2
View File
@@ -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)
+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]
) -> 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])
+89 -31
View File
@@ -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()
+64 -29
View File
@@ -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,
+35 -13
View File
@@ -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)