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