From 52d8af620b793d3b7d8d2973cd2191fb3359826c Mon Sep 17 00:00:00 2001 From: Federico Pasqua Date: Sun, 27 Sep 2026 17:51:13 +0200 Subject: [PATCH] Add failed-Exam loss switch; keep -3 and accept the length (6.11, D50) RulesConfig.exam_failure_loss (bots aware) with the sim axis --exam-loss. Losing 1 or 0 stripes saves 3-5 minutes but weakens comebacks, so the rules keep -3. Default games are unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 1 + RULES.md | 2 +- agents/plans/2026-09-25-dojo-board-and-sim.md | 45 ++++++++++++++++++- src/dojo/bots.py | 2 +- src/dojo/cli.py | 19 +++++++- src/dojo/engine.py | 2 +- src/dojo/rules.py | 4 ++ src/dojo/sim.py | 11 ++++- tests/test_cli.py | 14 +++--- tests/test_engine.py | 16 +++++++ tests/test_sim.py | 17 +++++-- 11 files changed, 116 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index e758416..1e711cf 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ uv run dojo sim --lineup casual balanced --summary-csv s.csv # one CSV row per s 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 --exam-loss 3 1 0 # stripes a failed Exam challenger loses (rules: 3) uv run dojo sim --lineup farmer aggressive,farmer # several lineups (comma-separated seats) uv run dojo sim --seed 42 --jobs 1 # other dice; single process ``` diff --git a/RULES.md b/RULES.md index a33d15c..7369192 100644 --- a/RULES.md +++ b/RULES.md @@ -179,7 +179,7 @@ All other cards are shown and resolved immediately. Parameters to validate by simulation: square mix; game length. Tested and set aside (simulator switches only): Exam ties to the defender; best-of-3 Exam; -Kiai +1. +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. 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 4d87d27..dfa457f 100644 --- a/agents/plans/2026-09-25-dojo-board-and-sim.md +++ b/agents/plans/2026-09-25-dojo-board-and-sim.md @@ -66,6 +66,7 @@ Deliverables: | D47 | **Kiai stays +2**: +2 and +1 are indistinguishable on every target (6.6); +2 decides more fights (5% vs 3% of Kumite in casual play). `kiai_bonus = 1` stays a simulator switch; RULES.md drops the "+1 to be tested" note | Developer, 6.6 results. | | 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. | ## Phases @@ -176,7 +177,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.11 length tuning, then 6.8. 6.2 is suspended (D43), 6.9 skipped (D44); 6.4.1 (chance square) stays optional. Order of the steps: D41. Everything up to D49 is committed; commit only when the developer asks. +**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. **Targets** (confirmed by the developer as a starting point, D32): @@ -320,7 +321,47 @@ Deliverables: - [x] 6.10.3 `movement.walks`: walks that exclude nothing but the start are cached per (board, start, steps, Dojo open) with `functools.cache` (same pattern as `bots.dojo_distance`); callers get a fresh list. ~3,200 entries per process, ~80 MB peak. - [x] 6.10.4 Parallel scaling: 8 processes running at once each take ~1.6x their solo time on this machine (8 real cores, clock and cache sharing), so ~5x on 8 workers is the hardware limit; no pool change. 16 workers (hyperthreads) add ~12%. - Result: 100 games on one process 8–12 s → 1.4–1.7 s; mixed + balanced + casual at 1,000 games on 16 workers 57 s → 8 s. All 210 fingerprinted games identical; the v0.8 baseline run prints the same metrics. -- [ ] 6.11 Length tuning (D39): at 4 players, bring every lineup to a mean of 60–90 minutes with p90 ≤ 110 (v0.8: mixed 72 / 95 and balanced 68 / 92 inside; casual 91 / 122 and all-aggressive 90 / 127 outside). Find where the time goes (climb to Brown vs Exam phase), bring candidate levers to the developer, simulate the chosen ones on at least two lineups. +- [x] 6.11 Length tuning (D39; closed by D50): at 4 players, bring every lineup to a mean of 60–90 minutes with p90 ≤ 110 (v0.8: mixed 72 / 95 and balanced 68 / 92 inside; casual 91 / 122 and all-aggressive 90 / 127 outside). Find where the time goes (climb to Brown vs Exam phase), bring candidate levers to the developer, simulate the chosen ones on at least two lineups. + - [x] 6.11.1 Where the time goes (scratch `phases.py`, 400 games per lineup; "endgame" = from the first Brown on the table to the win): + + | Lineup | Climb / endgame (min) | Winner's stripes gained (15 needed) | Drops out of Brown per game | Stripes lost per game: square / Kumite / Exam | + |---|---|---|---|---| + | mixed | 49 / 22 (31%) | 23.3 | 2.2 | 3.9 / 27.8 / 4.3 | + | balanced | 48 / 21 (30%) | 22.6 | 2.2 | 2.4 / 26.6 / 4.3 | + | casual | 56 / 36 (39%) | 24.7 | 4.0 | 11.2 / 23.1 / 4.4 | + | aggressive | 60 / 32 (35%) | 28.6 | 2.7 | 12.4 / 42.7 / 4.4 | + + Casual and aggressive are longer in both phases, most of all in the endgame (+11 to +15 min): casual players fall out of Brown twice as often (Fatica tolls, 11 stripes per game), aggressive ones trade stripes in Kumite (43 lost per game). + - [x] 6.11.2 Existing switches (`--start`, `--exam`), 1,000 games, 4 players, minutes mean / p90: + + | Setting | mixed | balanced | casual | aggressive | + |---|---|---|---|---| + | v0.8 (start 0) | 72 / 95 | 68 / 92 | 91 / 122 | 90 / 127 | + | start 1 | 68 / 92 | 65 / 87 | 88 / 122 | 88 / 124 | + | start 2 | 63 / 88 | 59 / 83 | 81 / 112 | 82 / 122 | + | start 3 | 57 / 80 | — | 77 / 110 | 74 / 111 | + | Exam defender not Black (`none`) | 60 / 77 | — | 79 / 103 | 74 / 97 | + + The start position shifts every lineup by about the same amount, so it cannot close the ~23-minute gap between balanced and casual: start 2 puts balanced under 60 while aggressive stays at p90 122. The Black-belt defender off pushes the Exam pass rate to 67–80% (target 35–55%) and the rivals at the end to 0.27–0.49: rejected again (as in D31). Other measures barely move with the start position. + - [x] 6.11.3 Candidate levers brought to the developer: L1 Fatica cannot drop a player out of Brown (**rejected by the developer**); L2 a failed challenger loses 0 or 1 stripe instead of 3 (to test); L3 accept the current length (developer: "satisfied enough"). + - [x] 6.11.4 L2 switch: `RulesConfig.exam_failure_loss` (default 3; engine and bot `_exam_score`), sim axis `Scenario.exam_loss` / `--exam-loss` (scenario names gain `fail-N`). Fingerprinted games identical at the default. Run: `uv run dojo sim --games 1000 --seed 0 --exam-loss 3 1 0 --lineup balanced,farmer,aggressive balanced casual aggressive`. + + | Lineup | Failed Exam loses | Minutes mean / p90 | Exam challengers per game | Lead changes | Halfway leader wins | Worst player out | Rivals within 1 belt | + |---|---|---|---|---|---|---|---| + | mixed | 3 | 72 / 95 | 1.68 | 6.1 | 40% | 13% | 0.84 | + | mixed | 1 | 68 / 88 | 1.48 | 5.0 | 44% | 15% | 0.94 | + | mixed | 0 | 67 / 86 | 1.45 | 4.6 | 44% | 16% | 0.87 | + | balanced | 3 | 68 / 92 | 1.69 | 6.0 | 39% | 10% | 0.81 | + | balanced | 1 | 65 / 84 | 1.50 | 5.0 | 43% | 12% | 0.97 | + | balanced | 0 | 65 / 83 | 1.46 | 4.5 | 43% | 13% | 0.90 | + | casual | 3 | 91 / 122 | 1.74 | 5.7 | 39% | 12% | 1.42 | + | casual | 1 | 87 / 116 | 1.61 | 4.9 | 42% | 13% | 1.47 | + | casual | 0 | 86 / 114 | 1.55 | 4.6 | 41% | 14% | 1.40 | + | aggressive | 3 | 90 / 127 | 1.58 | 7.3 | 41% | 21% | 0.50 | + | aggressive | 1 | 85 / 115 | 1.40 | 6.2 | 45% | 23% | 0.59 | + | aggressive | 0 | 83 / 111 | 1.32 | 5.8 | 49% | 25% | 0.54 | + + 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. diff --git a/src/dojo/bots.py b/src/dojo/bots.py index 84b8a1a..153709a 100644 --- a/src/dojo/bots.py +++ b/src/dojo/bots.py @@ -343,7 +343,7 @@ class HeuristicAgent: chance = series_probability(round_chance, view.config.exam_rounds) failed = self._value_at( view, - view.config.shifted(track, -view.config.stripes_per_belt), + view.config.shifted(track, -view.config.exam_failure_loss), start_square(view.board, me.corner), ) return chance * EXAM_WIN + (1 - chance) * self._weigh(failed - v0) diff --git a/src/dojo/cli.py b/src/dojo/cli.py index ae1e10f..7a879c7 100644 --- a/src/dojo/cli.py +++ b/src/dojo/cli.py @@ -8,7 +8,16 @@ from dojo.board import QUADRANTS, Board, BoardError, load_board from dojo.grid import GridFormatError, render_grid from dojo.metrics import summarize from dojo.report import format_summary, write_games_csv, write_summary_csv -from dojo.sim import BOTS, EXAM_OPTIONS, NO_EXAM_OPTIONS, RULES_EXAM, BoardFiles, matrix, run +from dojo.sim import ( + BOTS, + EXAM_OPTIONS, + NO_EXAM_OPTIONS, + RULES_EXAM, + RULES_EXAM_LOSS, + BoardFiles, + matrix, + run, +) from dojo.validate import Report, validate DEFAULT_QUADRANT = Path("board/quadrant.txt") @@ -58,6 +67,13 @@ def main(argv: list[str] | None = None) -> int: sim_parser.add_argument("--players", nargs="+", type=int, default=[4]) sim_parser.add_argument("--kiai", nargs="+", type=int, default=[2], help="Kiai bonus") sim_parser.add_argument("--start", nargs="+", type=int, default=[0], help="start position") + sim_parser.add_argument( + "--exam-loss", + nargs="+", + type=int, + default=[RULES_EXAM_LOSS], + help=f"stripes a failed Exam challenger loses (rules: {RULES_EXAM_LOSS})", + ) sim_parser.add_argument( "--lineup", nargs="+", @@ -104,6 +120,7 @@ def _simulate(args: argparse.Namespace, files: BoardFiles, parser: argparse.Argu args.kiai, args.start, [tuple(lineup.split(",")) for lineup in args.lineup], + args.exam_loss, ) except ValueError as error: parser.error(str(error)) diff --git a/src/dojo/engine.py b/src/dojo/engine.py index 1f32779..92f129e 100644 --- a/src/dojo/engine.py +++ b/src/dojo/engine.py @@ -442,7 +442,7 @@ class Game: if passed: self.winner = challenger.id return - self._change_track(challenger, -self.config.stripes_per_belt, Source.EXAM) + self._change_track(challenger, -self.config.exam_failure_loss, Source.EXAM) if not uke: self._send_to_start(challenger) diff --git a/src/dojo/rules.py b/src/dojo/rules.py index fb0c8d0..86e665e 100644 --- a/src/dojo/rules.py +++ b/src/dojo/rules.py @@ -51,6 +51,8 @@ class RulesConfig: exam_defender_black: bool = True exam_ties_to_defender: bool = False exam_rounds: int = 1 + # Stripes a failed challenger loses (6.11: 0 and 1 tested against the rules' 3). + exam_failure_loss: int = 3 # Fatica costs a stripe for every Fatica square walked onto, stopping included (D36); # off: only stopping on it costs. @@ -69,6 +71,8 @@ class RulesConfig: 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}") + if self.exam_failure_loss < 0: + raise ValueError(f"exam_failure_loss must be >= 0, got {self.exam_failure_loss}") if self.first_kumite_round < 1: raise ValueError(f"first_kumite_round must be positive, got {self.first_kumite_round}") diff --git a/src/dojo/sim.py b/src/dojo/sim.py index b58fde2..ab36afb 100644 --- a/src/dojo/sim.py +++ b/src/dojo/sim.py @@ -28,6 +28,7 @@ EXAM_OPTIONS = { "d": "best of 3 rounds", } BEST_OF_ROUNDS = 3 +RULES_EXAM_LOSS = RulesConfig().exam_failure_loss RANDOM_BOT = "random" CASUAL_BOT = "casual" BOTS = (RANDOM_BOT, CASUAL_BOT, *PROFILES) @@ -42,6 +43,7 @@ class Scenario: start_position: int # Bot names, repeated in order to fill the seats. lineup: tuple[str, ...] + exam_loss: int = RULES_EXAM_LOSS def __post_init__(self) -> None: options = "" if self.exam == NO_EXAM_OPTIONS else self.exam @@ -54,12 +56,13 @@ class Scenario: if unknown or not self.lineup: raise ValueError(f"unknown bots {unknown}: expected some of {list(BOTS)}") seat_corners(self.players) + _ = self.config # RulesConfig validates the remaining axes. @property def name(self) -> str: return ( f"exam:{self.exam} p{self.players} kiai+{self.kiai_bonus} start{self.start_position} " - + ",".join(self.lineup) + f"fail-{self.exam_loss} " + ",".join(self.lineup) ) @property @@ -68,6 +71,7 @@ class Scenario: 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, + exam_failure_loss=self.exam_loss, kiai_bonus=self.kiai_bonus, start_position=self.start_position, ) @@ -84,10 +88,13 @@ def matrix( kiai_bonuses: Sequence[int], start_positions: Sequence[int], lineups: Sequence[tuple[str, ...]], + exam_losses: Sequence[int] = (RULES_EXAM_LOSS,), ) -> list[Scenario]: return [ Scenario(*axes) - for axes in itertools.product(exams, players, kiai_bonuses, start_positions, lineups) + for axes in itertools.product( + exams, players, kiai_bonuses, start_positions, lineups, exam_losses + ) ] diff --git a/tests/test_cli.py b/tests/test_cli.py index 33137cb..1314e07 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -68,7 +68,7 @@ def test_sim_prints_a_summary_per_scenario_and_writes_games( assert main([*argv, "--csv", str(games)]) == EXIT_OK output = capsys.readouterr().out - assert "== exam:none p3 kiai+2 start0 balanced,farmer,aggressive (2 games" in output + assert "== exam:none p3 kiai+2 start0 fail-3 balanced,farmer,aggressive (2 games" in output assert "== exam:c p3 kiai+2 start0" in output rows = games.read_text(encoding="utf-8").splitlines() assert rows[0].startswith("scenario,seed,lineup,winner_seat") @@ -78,21 +78,25 @@ def test_sim_prints_a_summary_per_scenario_and_writes_games( def test_sim_writes_one_summary_row_per_scenario(tmp_path: Path) -> None: path = tmp_path / "summary.csv" argv = ["sim", "--games", "2", "--players", "3", "4", "--lineup", "casual", "--jobs", "1"] + argv += ["--exam-loss", "3", "0"] assert main([*argv, "--summary-csv", str(path)]) == EXIT_OK with path.open(encoding="utf-8", newline="") as file: rows = list(csv.DictReader(file)) assert [row["scenario"] for row in rows] == [ - "exam:c p3 kiai+2 start0 casual", - "exam:c p4 kiai+2 start0 casual", + "exam:c p3 kiai+2 start0 fail-3 casual", + "exam:c p3 kiai+2 start0 fail-0 casual", + "exam:c p4 kiai+2 start0 fail-3 casual", + "exam:c p4 kiai+2 start0 fail-0 casual", ] assert {"rounds_mean", "square_gain_per_turn", "win_rate_casual"} <= rows[0].keys() - assert (rows[0]["seat3_win_rate"], rows[1]["seat3_win_rate"] != "") == ("", True) + assert (rows[0]["seat3_win_rate"], rows[2]["seat3_win_rate"] != "") == ("", True) @pytest.mark.parametrize( - "bad", [["--lineup", "balanced,cheater"], ["--games", "0"], ["--exam", "b"]] + "bad", + [["--lineup", "balanced,cheater"], ["--games", "0"], ["--exam", "b"], ["--exam-loss", "-1"]], ) def test_sim_rejects_bad_arguments(bad: list[str], capsys: pytest.CaptureFixture[str]) -> None: with pytest.raises(SystemExit) as exit_info: diff --git a/tests/test_engine.py b/tests/test_engine.py index fc5a7c5..ff96803 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -677,6 +677,22 @@ def test_failing_the_exam_costs_a_belt_and_the_defender_gains_nothing() -> None: assert game.players[2].track == 12 +@pytest.mark.parametrize(("loss", "track"), [(0, 15), (1, 14)]) +def test_failed_exam_loss_is_configurable(loss: int, track: int) -> None: + config = RulesConfig(exam_failure_loss=loss) + game, agents = dojo_game([1, 1, 1, 6], config=config, p1=9, p2=12) + + turn(game, agents, 0, Pos(6, 7)) + + assert game.players[0].track == track + assert game.players[0].position == START_0 + + +def test_failed_exam_loss_cannot_be_negative() -> None: + with pytest.raises(ValueError, match="exam_failure_loss"): + RulesConfig(exam_failure_loss=-1) + + def test_uke_in_the_exam_returns_to_the_entrance() -> None: game, agents = dojo_game([1, 1, 1, 6], p1=9, p2=12) game.players[0].tecnica = Card.UKE diff --git a/tests/test_sim.py b/tests/test_sim.py index 41f5f60..d44552b 100644 --- a/tests/test_sim.py +++ b/tests/test_sim.py @@ -11,13 +11,17 @@ LINEUP = ("balanced", "random") def test_matrix_crosses_every_axis() -> None: - scenarios = matrix(["none", "acd"], [3, 4], [2, 1], [0], [LINEUP]) + scenarios = matrix(["none", "acd"], [3, 4], [2, 1], [0], [LINEUP], [3, 0]) - assert len(scenarios) == 8 - assert scenarios[0].name == "exam:none p3 kiai+2 start0 balanced,random" + assert len(scenarios) == 16 + assert scenarios[0].name == "exam:none p3 kiai+2 start0 fail-3 balanced,random" assert scenarios[0].config == RulesConfig(exam_defender_black=False) assert scenarios[-1].config == RulesConfig( - exam_ties_to_defender=True, exam_defender_black=True, exam_rounds=3, kiai_bonus=1 + exam_ties_to_defender=True, + exam_defender_black=True, + exam_rounds=3, + exam_failure_loss=0, + kiai_bonus=1, ) @@ -64,6 +68,11 @@ def test_invalid_scenarios_are_rejected( Scenario(exam, players, 2, 0, lineup) +def test_invalid_exam_loss_is_rejected() -> None: + with pytest.raises(ValueError, match="exam_failure_loss"): + Scenario("c", 4, 2, 0, LINEUP, exam_loss=-1) + + def test_runs_are_reproducible_and_independent_of_worker_count() -> None: scenarios = matrix(["none", "acd"], [3], [2], [0], [LINEUP])