diff --git a/RULES.md b/RULES.md index 7369192..e0cba5d 100644 --- a/RULES.md +++ b/RULES.md @@ -1,9 +1,9 @@ -# Il Labirinto del Dojo — Rules (draft v0.8) +# Il Labirinto del Dojo — Rules (draft v0.9) Working language: English. The final gift edition will be produced separately in Italian. -Items marked **[VARIANT]** are unresolved and will be decided by simulation (see -[Rule variants under test](#rule-variants-under-test)). +Every rule has been checked by simulation; alternatives that were tried and set aside are +listed under [Tested alternatives](#tested-alternatives). ## Goal @@ -174,14 +174,16 @@ All other cards are shown and resolved immediately. - **Challenger loses**: −3 and sent to Start. The defender gains nothing. - If the challenger plays Uke and loses, they return to the square they entered from instead of Start. -## Rule variants under test +## Tested alternatives -Parameters to validate by simulation: square mix; game length. +Tried in simulation and set aside; the simulator keeps them as switches: -Tested and set aside (simulator switches only): Exam ties to the defender; best-of-3 Exam; -Kiai +1; a failed Exam costing 0 or 1 stripe instead of 3. -Earlier rules kept as simulator switches: Fatica costing only when you stop on it; Kumite -allowed in the first round. +- Final Exam: ties to the defender; best of 3; the defender fighting with their own belt. +- Kiai +1 instead of +2. +- A failed Exam costing 0 or 1 stripe instead of 3. +- Fatica costing a stripe only when you stop on it. +- Kumite allowed in the first round. +- One extra starting stripe for the first player. ## FAQ diff --git a/agents/plans/2026-09-25-dojo-board-and-sim.md b/agents/plans/2026-09-25-dojo-board-and-sim.md index dfa457f..37cc998 100644 --- a/agents/plans/2026-09-25-dojo-board-and-sim.md +++ b/agents/plans/2026-09-25-dojo-board-and-sim.md @@ -67,6 +67,8 @@ Deliverables: | D48 | **6+ players start on 3 (Yellow)**; any player count may start on 3 as a "shorter game" option (saves 15–20% of the time, 6.7). 8 players stays long (107 / 142 min mixed / casual from Yellow) and is accepted: it will rarely be played. Balancing simulations stay at start 0 at every count (developer); `--start 3` reproduces the rules' 6+ setup or the shorter game | Developer, 6.7 results. | | D49 | **Chance square parked** (6.4.1 and 6.4.3): Fatica on crossing (D36) on board draft 2 (D42) already makes squares matter; 6.4 closed | Developer. | | D50 | **Length accepted as is; a failed Exam still costs 3 stripes.** L1 (Brown floor against Fatica) rejected; L2 (failed Exam −1 / 0) saves only 3–5 min on the mean and weakens comebacks (6.11.4). `exam_failure_loss` stays a simulator switch. At 4 players mixed and balanced are inside the target; casual (91 / 122) and all-aggressive (90 / 127) stay slightly outside, within the "1–2 h" promise; the shorter-game start on Yellow (D48) is the players' lever | Developer, 6.11 results. | +| D51 | **Bots decide alike in every corner** (start square and tied paths broken in the player's own frame), overriding D40 for this bias: the old tie-break by absolute board order made corners 1 and 2 win 25–28% and corners 0 and 3 23% | Developer, 6.2.1 diagnosis. | +| D52 | **No first-player compensation**: the seat order stays as it is. At 4 players every seat is inside 25% ± 3 pp; +1 stripe for the first player gains about 1 point (6.2.4), not worth an extra rule. `first_player_bonus` stays a simulator switch (no CLI axis) | Developer, 6.2.4 results. | ## Phases @@ -177,7 +179,7 @@ Deliverables: - One change at a time. When a change is adopted, re-run the 6.1 baseline so later steps start from the current rules, and log the decision (D-number) here and in `RULES.md`. - Results go in this file as a table per step (command line, scenario, key measures). -**Next action:** 6.8 consolidation, when the developer says to continue. 6.2 is suspended (D43), 6.9 skipped (D44); 6.4.1 (chance square) stays optional. Order of the steps: D41. Everything up to D50 is committed; commit only when the developer asks. +**Next action:** redo the 6.8 run with the fixed bots (D51), then 6.8.5 and Phase 7. 6.2 is suspended (D43), 6.9 skipped (D44), 6.4.1 parked (D49). Everything up to D52 is committed; commit only when the developer asks. **Targets** (confirmed by the developer as a starting point, D32): @@ -249,7 +251,43 @@ Deliverables: Fix found while planning Dojo tolls: a Brown player (15) walking a Fatica drops to 14 and must not enter the Dojo on that move (the belt changes at once, FAQ). `_path_choices` no longer offers such Dojo paths (`_still_brown_at_dojo`; a Kumite won on the way re-offers paths). The first run without the fix differed by ≤ 3 minutes and ≤ 2 pp per seat. Findings: (1) at 3 belts the contention targets are met with a wide margin (5% of player-turns, worst player 10–15%). (2) Fatica on crossing on the current board (5 F per quadrant, 20 in all, 10% of the squares) barely touches the heuristic bots (−0.03 to −0.05 per turn) but costs the casual bot −0.12 per turn and adds ~44 minutes (127 mean, p90 174): the board has too many Fatica on ordinary routes for players who do not plan around them. (3) No Kumite in round 1 alone moves seat 3 by about 1 pp (within the ±1.4 pp noise of 1,000 games); 6.2 measures it on 4,000 games. (4) Mixed v0.8 shows seat 0 at 30% and seat 2 at 20%: not seen in the same-bot lineups; to confirm in 6.2 before reading anything into it. -- [ ] 6.2 Seat order — **suspended (D43)**; runs stopped before any result (monitor; developer concerned): 4,000 games, 4 players, lineups mixed and `random`; 2,000 games at 3 and 6 players. If a seat is outside 25% ± 3 pp, diagnose with the 6.0.3 breakdown (hypotheses: moving last means seeing everyone else move first; Exam defender selection; Sensei / Peso cards) and bring candidate fixes to the developer. No fix without agreement. +- [x] 6.2 Seat order (closed: D51, D52) — **reopened by the developer after 6.8.4** (was suspended, D43); runs stopped before any result (monitor; developer concerned): 4,000 games, 4 players, lineups mixed and `random`; 2,000 games at 3 and 6 players. If a seat is outside 25% ± 3 pp, diagnose with the 6.0.3 breakdown (hypotheses: moving last means seeing everyone else move first; Exam defender selection; Sensei / Peso cards) and bring candidate fixes to the developer. No fix without agreement. + - [x] 6.2.1 Diagnosis, 4 players, mixed lineup, 4,000 games per run (scratch `seats.py`). Seat and corner are the same at 4 players, so the run was repeated with the corners rotated by 1, 2, 3 seats and averaged, separating turn order from board position: + + | Averaged over | Win rate 0 / 1 / 2 / 3 | First to Brown | Track after round 1 | Net Kumite stripes per game | Attacked per game in rounds 2–3 | + |---|---|---|---|---|---| + | seats (turn order) | 23.9 / 24.2 / 25.5 / 26.4 | 23.9 / 24.0 / 25.7 / 26.4 | 0.74 / 0.76 / 0.76 / 0.76 | +0.08 / +0.44 / +0.79 / +1.12 | 0.95 / 0.78 / 0.54 / 0.41 | + | corners (board position) | 23.3 / 27.6 / 25.5 / 23.6 | 22.6 / 30.4 / 25.4 / 21.6 | 0.70 / 0.81 / 0.81 / 0.69 | +0.56 / +0.54 / +0.49 / +0.84 | 0.64 / 0.76 / 0.70 / 0.57 | + + - Corner effect (the larger one, 23–28%) is a **bot artifact**: `bots.start_square` breaks the tie between equally distant Safe Room squares by absolute (row, col), so in the quadrant's own frame corners 0 and 3 start on (1, 2) and corners 1 and 2 on (2, 1), which is the better square. The board itself is symmetric. It also shows that the Safe Room squares are not equivalent (a player picks where to start, RULES.md "Safe areas"). + - Turn-order effect (smaller, 24 → 26%, within the 25% ± 3 pp target): later seats are attacked less early in the game (rounds 2–3: 0.95 vs 0.41 times) and end up +1 stripe from Kumite over a game. The earlier seats walk into the open first and the later ones answer. + - Other bot tie-breaks (`max` over paths offered in N, E, S, W order) are also orientation-dependent; not measured yet. + - [x] 6.2.2 Bot fix (developer, D51): `bots.start_square` breaks ties in the Safe Room's own frame (every corner starts from (2, 1) in the quadrant frame, the better square); `HeuristicAgent.place_pawn` uses it; `HeuristicAgent.choose_path` breaks score ties by the path in the player's own frame (`bots.best_of`, `bots.own_frame`). Tests: same start square from every corner, tied paths chosen alike from every corner. Every game changes (fingerprints must be re-recorded; earlier tables stay as recorded, measured with the biased bots). + - Re-run of 6.2.1 with the fix (4 × 4,000 games): corners 24.0 / 25.3 / 25.4 / 25.4 (was 23.3–27.6), track after round 1 equal (0.80–0.81); seats 24.3 / 24.6 / 25.4 / 25.7. Attacked in rounds 2–3 still 0.81 / 0.59 / 0.39 / 0.27 by seat. + - [x] 6.2.3 Seat order with the fixed bots (`dojo sim`, start 0; 4,000 games at 4 players, 2,000 at 3 / 6 / 8): + + | Players | Fair share | mixed, seats in turn order | casual, seats in turn order | + |---|---|---|---| + | 3 | 33.3 | 30.6 / 33.7 / 35.7 | 34.0 / 32.0 / 34.0 | + | 4 | 25.0 | 23.2 / 25.5 / 25.5 / 25.8 | 24.0 / 26.0 / 25.7 / 24.3 | + | 6 | 16.7 | 13.4 / 17.3 / 16.4 / 15.4 / 17.9 / 19.6 | 15.2 / 16.1 / 16.2 / 16.2 / 19.0 / 17.2 | + | 8 | 12.5 | 10.1 / 11.5 / 12.0 / 13.5 / 12.0 / 12.5 / 13.8 / 14.6 | 10.8 / 12.6 / 11.9 / 13.1 / 12.2 / 13.9 / 11.7 / 14.0 | + + Mixed 4 players now takes 68 min (72 with the biased bots). Reading: the first seat is 1–3 points under its fair share at every count (more with heuristic than casual bots); at 4 players every seat is inside 25% ± 3 pp. At 6 players the two seats alone in a corner (seats 4 and 5, `seat_corners(6)` = 0, 1, 0, 1, 2, 3) win 17–20%, the four sharing a Safe Room 13–17%. + - [x] 6.2.4 First-player compensation, +1 stripe (developer's choice to test): `RulesConfig.first_player_bonus` (default 0; `RulesConfig.start_track(seat)` used by the engine and the metrics). No sim axis yet (scratch `bonus.py`, same seeds as 6.2.3; the bonus-0 rows reproduce 6.2.3 exactly). + + | Players | Lineup | First seat, bonus 0 → 1 (fair share) | All seats with bonus 1 | Minutes 0 → 1 | + |---|---|---|---|---| + | 3 | mixed | 30.6 → 33.2 (33.3) | 33.2 / 32.5 / 34.2 | 53 → 52 | + | 3 | casual | 34.0 → 33.8 | 33.8 / 30.9 / 35.4 | 70 → 70 | + | 4 | mixed | 23.2 → 22.6 (25.0) | 22.6 / 25.2 / 26.0 / 26.2 | 68 → 68 | + | 4 | casual | 24.0 → 25.4 | 25.4 / 25.9 / 24.4 / 24.3 | 90 → 90 | + | 6 | mixed | 13.3 → 14.8 (16.7) | 14.8 / 17.3 / 14.8 / 14.9 / 18.1 / 20.1 | 101 → 100 | + | 6 | casual | 15.2 → 16.1 | 16.1 / 16.8 / 15.4 / 16.2 / 19.3 / 16.1 | 130 → 131 | + | 8 | mixed | 10.1 → 10.7 (12.5) | 10.7 / 12.3 / 10.8 / 12.6 / 12.3 / 14.4 / 13.8 / 13.1 | 130 → 128 | + | 8 | casual | 10.8 → 12.0 | 12.0 / 12.1 / 12.6 / 13.1 / 12.2 / 12.0 / 13.6 / 12.4 | 168 → 168 | + + Reading: +1 stripe lifts the first seat by about 1 point on average (−0.6 to +2.6; the noise is ±0.7–1 point), not enough to close the gap at 6–8 players with heuristic bots, and nothing at 4 players mixed. No other seat is hurt systematically; length unchanged. - [x] 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. - Run: `uv run dojo sim --games 1000 --seed 0 --lineup farmer aggressive balanced casual aggressive,farmer,farmer,farmer farmer,aggressive,aggressive,aggressive balanced,farmer,farmer,farmer` (rules v0.8, board draft 2, 4 players; 17 s on 16 workers). @@ -364,6 +402,39 @@ Deliverables: Exam pass rate unchanged (40–44%). Reading: −1 saves 3–5 minutes on the mean and 6–12 on the p90; 0 adds only 1–2 more. The price is the comeback: the first Brown keeps retrying instead of dropping back (fewer different challengers), lead changes fall by ~1 per game, the halfway leader wins 3–8 points more, the worst player is out a little longer. No setting brings casual under p90 110. - [ ] 6.9 Catch-up candidate A — **skipped (D44)**. Was: `RulesConfig` switch, Allenamento +2 for a player 2+ belts behind the leader (bot aware of it); compare on the contention, lead and length measures and on the style win rates. - [ ] 6.8 Consolidation: the final configuration at 3 / 4 / 6 / 8 players (start 0 as for balancing, plus start 3 at 6 and 8 as in the rules, D48) with all lineups except `random` (D45), 2,000 games each; every target checked; `RULES.md` updated to the next version with the decisions; open items moved to Phase 7. + - [x] 6.8.1 Run (rules v0.9 = v0.8 + D47–D50, board draft 2; 2,000 games per scenario, 3 min 27 s): `uv run dojo sim --games 2000 --seed 0 --players 3 4 6 8 --start 0 --lineup balanced,farmer,aggressive balanced casual aggressive`, and the same with `--players 6 8 --start 3`. All games finished; Exam pass rate 41–44% everywhere; win rate by bot style (mixed) within 4 pp at every count. + + | Players | Start | Lineup | Minutes mean / p90 | Exam passed | Halfway leader wins | Out of contention; worst player | Rivals within 1 belt | Win rate by seat | + |---|---|---|---|---|---|---|---|---| + | 3 | 0 | mixed | 54 / 75 | 42% | 50% | 3%; 9% | 0.60 | 29 / 37 / 34 | + | 3 | 0 | balanced | 53 / 72 | 42% | 46% | 3%; 7% | 0.59 | 33 / 34 / 34 | + | 3 | 0 | casual | 71 / 98 | 44% | 45% | 2%; 6% | 1.04 | 33 / 33 / 34 | + | 3 | 0 | aggressive | 64 / 90 | 42% | 52% | 6%; 14% | 0.36 | 29 / 34 / 37 | + | 4 | 0 | mixed | 72 / 98 | 41% | 40% | 4%; 13% | 0.86 | 24 / 27 / 26 / 24 | + | 4 | 0 | balanced | 68 / 92 | 42% | 40% | 3%; 11% | 0.82 | 22 / 26 / 26 / 26 | + | 4 | 0 | casual | 91 / 123 | 43% | 38% | 4%; 12% | 1.41 | 22 / 27 / 24 / 26 | + | 4 | 0 | aggressive | 90 / 128 | 42% | 43% | 8%; 21% | 0.51 | 21 / 26 / 29 / 24 | + | 6 | 0 | mixed | 106 / 144 | 42% | 31% | 9%; 29% | 1.17 | 12 / 16 / 15 / 18 / 20 / 20 | + | 6 | 0 | balanced | 100 / 135 | 42% | 30% | 8%; 28% | 1.07 | 13 / 17 / 13 / 18 / 19 / 20 | + | 6 | 0 | casual | 135 / 182 | 42% | 27% | 8%; 27% | 1.90 | 16 / 15 / 15 / 16 / 20 / 18 | + | 6 | 0 | aggressive | 131 / 183 | 42% | 29% | 12%; 35% | 0.78 | 13 / 19 / 14 / 17 / 20 / 17 | + | 8 | 0 | mixed | 133 / 183 | 42% | 27% | 11%; 37% | 1.44 | 9 / 12 / 11 / 12 / 13 / 13 / 14 / 15 | + | 8 | 0 | balanced | 127 / 172 | 43% | 25% | 11%; 38% | 1.30 | 9 / 11 / 11 / 13 / 13 / 13 / 14 / 15 | + | 8 | 0 | casual | 170 / 231 | 44% | 23% | 11%; 38% | 2.15 | 12 / 12 / 12 / 13 / 14 / 12 / 14 / 12 | + | 8 | 0 | aggressive | 167 / 234 | 43% | 25% | 16%; 43% | 1.01 | 9 / 13 / 11 / 14 / 14 / 12 / 15 / 13 | + | 6 | 3 | mixed | 85 / 123 | 42% | 31% | 8%; 28% | 1.16 | 13 / 17 / 13 / 17 / 20 / 20 | + | 6 | 3 | balanced | 81 / 115 | 42% | 29% | 7%; 28% | 1.17 | 15 / 15 / 15 / 17 / 21 / 18 | + | 6 | 3 | casual | 113 / 160 | 42% | 30% | 8%; 28% | 1.92 | 14 / 16 / 15 / 16 / 19 / 20 | + | 6 | 3 | aggressive | 106 / 158 | 42% | 32% | 12%; 38% | 0.81 | 11 / 18 / 14 / 18 / 22 / 18 | + | 8 | 3 | mixed | 107 / 150 | 42% | 27% | 10%; 38% | 1.51 | 10 / 12 / 10 / 13 / 14 / 13 / 14 / 13 | + | 8 | 3 | balanced | 101 / 145 | 42% | 28% | 11%; 40% | 1.35 | 10 / 12 / 11 / 12 / 15 / 14 / 13 / 13 | + | 8 | 3 | casual | 144 / 203 | 42% | 24% | 11%; 40% | 2.33 | 11 / 12 / 11 / 13 / 13 / 13 / 14 / 13 | + | 8 | 3 | aggressive | 135 / 202 | 41% | 27% | 17%; 49% | 1.05 | 10 / 12 / 10 / 14 / 14 / 14 / 14 / 13 | + + - [x] 6.8.2 Targets at 4 players (start 0): inside for mixed and balanced on every measure. Accepted misses (D50): casual 91 / 123 and aggressive 90 / 128 minutes; aggressive seats 21–29% and rivals 0.51 (edge). 3 players: short (53–71 min), halfway leader wins 45–52% (edge of 35–55%), rivals 0.36–0.60 with heuristic bots. 6–8 players: long (D48 accepted), worst player out 27–49% of turns, halfway leader wins 23–32%. + - [x] 6.8.3 `RULES.md` v0.9: D47–D50 in the text; "Rule variants under test" replaced by "Tested alternatives" (the simulator switches). + - [x] 6.8.4 Seat order (finding; studied in 6.2, closed by D51 and D52): the first seat is behind at every count, and more so with more players — 4 players 21–24% (fair 25%), 6 players 11–16% (fair 17%), 8 players 9–12% (fair 12.5%); later seats rise to 20% (6) and 15% (8). At 6 players seats 4 and 5 are the two players alone in a corner (`seat_corners` gives 0, 1, 0, 1, 2, 3) and win most (17–22%), so sharing a Safe Room costs too. 6.2 (suspended, D43) would be the step to study it. + - [ ] 6.8.5 Open items moved to Phase 7. ### Phase 7 — Finalisation (later) diff --git a/src/dojo/bots.py b/src/dojo/bots.py index 153709a..a7aebd7 100644 --- a/src/dojo/bots.py +++ b/src/dojo/bots.py @@ -8,11 +8,11 @@ position, plus closeness to the Dojo once Brown); a ``Profile`` sets its attitud import math import random from collections import deque -from collections.abc import Mapping, Sequence +from collections.abc import Callable, Mapping, Sequence from dataclasses import dataclass from functools import cache -from dojo.board import Board, Pos, Square, is_tunnel +from dojo.board import Board, Pos, Square, is_tunnel, rotate from dojo.engine import FightView, GameView, PlayerView from dojo.movement import Path from dojo.rules import BLACK, Card @@ -128,9 +128,33 @@ def farthest_from_dojo(board: Board) -> int: @cache def start_square(board: Board, corner: int) -> Pos: - """The square of a Safe Room a bot starts from: the one nearest to the Dojo.""" + """The square of a Safe Room a bot starts from: the one nearest to the Dojo. + + Ties go to the square furthest down, then right, in the room's own frame, so every corner + starts alike; on board draft 2 that is the better of the two (plan 6.2.1). + """ distance = dojo_distance(board) - return min(sorted(board.safe_room(corner)), key=lambda s: distance[s]) + return min(board.safe_room(corner), key=lambda s: (distance[s], _negated(own_frame(s, corner)))) + + +def own_frame(square: Pos, corner: int) -> Pos: + """``square`` as seen from quadrant 0, i.e. rotated back from ``corner``'s quadrant.""" + return rotate(square, -corner) + + +def _negated(p: Pos) -> tuple[int, int]: + return (-p.row, -p.col) + + +def best_of[T]( + options: Sequence[T], score: Callable[[T], float], frame: Callable[[T], tuple[Pos, ...]] +) -> T: + """The highest-scoring option; ties go to the lowest ``frame`` key, so that a bot decides + alike in every corner instead of by absolute board order.""" + scored = [(score(option), option) for option in options] + top = max(value for value, _ in scored) + tied = [option for value, option in scored if value == top] + return tied[0] if len(tied) == 1 else min(tied, key=frame) @dataclass(frozen=True) @@ -158,12 +182,18 @@ class HeuristicAgent: # Agent protocol --------------------------------------------------------------------- def place_pawn(self, view: GameView, squares: Sequence[Pos]) -> Pos: - return self._nearest_to_dojo(view.board, squares) + square = start_square(view.board, self._me(view).corner) + return square if square in squares else self._nearest_to_dojo(view.board, squares) def choose_path(self, view: GameView, paths: Sequence[Path]) -> Path: v0 = self._value(view, self._me(view)) opponents = self._fightable(view) - return max(paths, key=lambda path: self._path_score(view, path, opponents, v0)) + corner = self._me(view).corner + return best_of( + paths, + lambda path: self._path_score(view, path, opponents, v0), + lambda path: tuple(own_frame(cell, corner) for cell in path), + ) def use_tunnel(self, view: GameView, exit: Pos) -> bool: me = self._me(view) diff --git a/src/dojo/engine.py b/src/dojo/engine.py index 92f129e..81469b2 100644 --- a/src/dojo/engine.py +++ b/src/dojo/engine.py @@ -177,7 +177,7 @@ class Game: self._discard: list[Card] = [] self.players = [ - Player(seat, corner, min(board.safe_room(corner)), config.start_position) + Player(seat, corner, min(board.safe_room(corner)), config.start_track(seat)) for seat, corner in enumerate(seat_corners(len(self.agents))) ] for player in self.players: diff --git a/src/dojo/metrics.py b/src/dojo/metrics.py index 6e4fe02..cbb6929 100644 --- a/src/dojo/metrics.py +++ b/src/dojo/metrics.py @@ -109,7 +109,7 @@ def measure( ) -> GameMetrics: """Replay ``events`` of one game. ``corners[seat]`` and ``bots[seat]`` describe each seat.""" players = len(corners) - track = [config.start_position] * players + track = [config.start_track(seat) for seat in range(players)] position: list[Pos | None] = [None] * players history: list[Standings] = [] gains = Counter[Source]() diff --git a/src/dojo/rules.py b/src/dojo/rules.py index 86e665e..203194f 100644 --- a/src/dojo/rules.py +++ b/src/dojo/rules.py @@ -64,6 +64,8 @@ class RulesConfig: stripes_per_belt: int = 3 max_belt_bonus: int = 2 start_position: int = 0 + # Extra stripes for the first player in turn order (6.2: compensation under test). + first_player_bonus: int = 0 deck: tuple[tuple[Card, int], ...] = DEFAULT_DECK # Safety stop for simulations; not a rule of the game. max_rounds: int = 200 @@ -73,6 +75,8 @@ class RulesConfig: raise ValueError(f"exam_rounds must be odd and positive, got {self.exam_rounds}") if self.exam_failure_loss < 0: raise ValueError(f"exam_failure_loss must be >= 0, got {self.exam_failure_loss}") + if self.first_player_bonus < 0: + raise ValueError(f"first_player_bonus must be >= 0, got {self.first_player_bonus}") if self.first_kumite_round < 1: raise ValueError(f"first_kumite_round must be positive, got {self.first_kumite_round}") @@ -99,6 +103,10 @@ class RulesConfig: return attacker_total > defender_total return attacker_total >= defender_total + def start_track(self, seat: int) -> int: + bonus = self.first_player_bonus if seat == 0 else 0 + return self.shifted(self.start_position, bonus) + @property def exam_wins_needed(self) -> int: """Rounds the challenger must win: the Exam is a best-of-``exam_rounds`` series.""" diff --git a/tests/test_bots.py b/tests/test_bots.py index 57dab0d..0134de0 100644 --- a/tests/test_bots.py +++ b/tests/test_bots.py @@ -3,9 +3,9 @@ import random import pytest -from scripted import make_game, put +from scripted import fixture_board, make_game, put -from dojo.board import Board, Pos +from dojo.board import Board, Pos, rotate from dojo.bots import ( AGGRESSIVE, BALANCED, @@ -15,7 +15,9 @@ from dojo.bots import ( HeuristicAgent, Profile, RandomAgent, + own_frame, series_probability, + start_square, win_probability, ) from dojo.engine import Agent, FightView, Game, RandomSource @@ -73,9 +75,29 @@ def test_heuristic_sees_no_opponent_to_fight_in_round_one() -> None: put(game, 1, TATAMI, track=3) # In round 2 the aggressive bot picks the fight (test_profiles_differ_on_an_even_fight). - choice = HeuristicAgent(AGGRESSIVE).choose_path(game.view(0), [(Pos(5, 3),), (TATAMI,)]) + # Two plain Tatami tie; the tie goes to the lower square in the bot's frame, (4, 2). + choice = HeuristicAgent(AGGRESSIVE).choose_path(game.view(0), [(TATAMI,), (Pos(4, 2),)]) - assert choice == (Pos(5, 3),) + assert choice == (Pos(4, 2),) + + +def test_bots_start_from_the_same_square_of_every_safe_room() -> None: + board = fixture_board() + + assert len({own_frame(start_square(board, corner), corner) for corner in range(4)}) == 1 + + +def test_tied_paths_are_chosen_alike_from_every_corner() -> None: + game, _, _ = make_game(players=4) + agent = HeuristicAgent(BALANCED) + chosen = set() + for seat, player in enumerate(game.players): + player.position = rotate(Pos(4, 3), player.corner) + tatami = [(rotate(square, player.corner),) for square in (Pos(5, 3), Pos(3, 3), Pos(4, 2))] + choice = agent.choose_path(game.view(seat), tatami) + chosen.add(own_frame(choice[0], player.corner)) + + assert chosen == {Pos(3, 3)} def test_brown_heuristic_takes_the_exam_when_it_can() -> None: diff --git a/tests/test_engine.py b/tests/test_engine.py index ff96803..b2a552f 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -246,6 +246,17 @@ def test_no_kumite_in_round_one_an_occupied_tunnel_exit_cannot_be_used() -> None assert game.players[0].position == Pos(1, 6) +def test_first_player_bonus_raises_only_the_first_seat() -> None: + game, _, _ = make_game(players=3, config=RulesConfig(start_position=3, first_player_bonus=1)) + + assert [p.track for p in game.players] == [4, 3, 3] + + +def test_first_player_bonus_cannot_be_negative() -> None: + with pytest.raises(ValueError, match="first_player_bonus"): + RulesConfig(first_player_bonus=-1) + + def test_first_kumite_round_must_be_positive() -> None: with pytest.raises(ValueError, match="first_kumite_round"): RulesConfig(first_kumite_round=0) diff --git a/tests/test_metrics.py b/tests/test_metrics.py index 5f045f3..1a5b83f 100644 --- a/tests/test_metrics.py +++ b/tests/test_metrics.py @@ -17,7 +17,7 @@ from dojo.events import ( TurnStarted, ) from dojo.metrics import KUMITE_SECONDS, TURN_SECONDS, GameMetrics, measure, summarize -from dojo.rules import DEFAULT_RULES, Card +from dojo.rules import DEFAULT_RULES, Card, RulesConfig HOME_0 = Pos(0, 1) # quadrant 0 @@ -106,6 +106,16 @@ def test_kiai_decides_a_fight_only_when_the_result_flips_without_it( assert summary.exam_kiai_decided_rate == decided +def test_first_player_bonus_is_counted_from_the_start() -> None: + config = RulesConfig(first_player_bonus=1) + + metrics = measure( + [GameOver(None, 1)], config, (0, 1), ("balanced", "farmer"), scenario="t", seed=0 + ) + + assert metrics.final_track == (1, 0) + + def test_standings_are_sampled_at_turn_ends() -> None: metrics = run( [