diff --git a/README.md b/README.md index cb7e2a2..e758416 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ rotates one seat per game. Bots: `random`, `casual`, `balanced`, `farmer`, `aggr ```sh 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 --lineup casual random --summary-csv s.csv # one CSV row per scenario +uv run dojo sim --lineup casual balanced --summary-csv s.csv # one CSV row per scenario 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 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 409e480..d2b9390 100644 --- a/agents/plans/2026-09-25-dojo-board-and-sim.md +++ b/agents/plans/2026-09-25-dojo-board-and-sim.md @@ -60,6 +60,9 @@ Deliverables: | D41 | Phase 6 order: board and Fatica (6.4 with 6.5) → seat order (6.2) → catch-up (6.9) → style dynamics (6.3) → Kiai (6.6), player counts (6.7), length → consolidation (6.8). Structural fixes before tuning | 6.1 review. | | D42 | **Board draft 2 adopted** as `board/quadrant.txt` (`board/board.txt` regenerated; draft 1 is in git history, commit 4998931) | 6.4.0 results. | | D43 | **6.2 seat order suspended**: measuring it needs 4,000+ games per scenario for an effect of a few points; D35 (no Kumite in round 1) stays in the rules. Seat rates are still reported by every run and watched in 6.8 | Developer, after the 4,000-game runs were started. | +| D44 | **6.9 catch-up skipped**: the v0.8 baseline already has 3–4% of player-turns out of contention (worst player 10–13%), so candidate A has little to fix | Developer, after the v0.8 baseline metrics. | +| D45 | The **`random` lineup is no longer run** in experiments: it never finishes a game and cost ~80% of a run's time. The bot stays in the code and the tests | Developer, 6.10. | +| D46 | Performance work must leave **every game identical** (same events for the same seed), so every result recorded in this file stays valid | Developer, 6.10. | ## Phases @@ -170,7 +173,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.9 catch-up candidate A, when the developer says to continue. 6.2 is suspended (D43); 6.4.1 (chance square) stays optional. Order of the steps: D41. Everything up to D43 and RULES.md v0.8 is committed; commit only when the developer asks. 6.0, 6.1, D35 and D36 are not committed yet; commit only when the developer asks. +**Next action:** 6.3 style dynamics, 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 D43 and RULES.md v0.8 is committed (bbc95b6); 6.10 and D44–D46 are not; commit only when the developer asks. **Targets** (confirmed by the developer as a starting point, D32): @@ -266,8 +269,14 @@ Deliverables: - [x] 6.5 Home farming: covered by board draft 2 (6.4.0): Allenamento moved from the south training room onto the Dojo ring; own-quadrant share of square income 74% → 62–67%, short of the 60% aim (players cross their own quadrant for most of the game); draft 2 adopted as is (D42). Original step: 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.9 Catch-up candidate A (D38), next in the order (D41; 6.2 suspended, D43): `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 with all lineups, 2,000 games each; every target checked; `RULES.md` v0.8 with the decisions; open items moved to Phase 7. +- [x] 6.10 Simulation speed (before 6.3; D46: games unchanged). Profile: ~95% of the time was listing moves (`movement._extend`, `Board.neighbours`, the outcome keys in `Engine._path_choices`). + - [x] 6.10.1 Guard: `agents/scripts/fingerprint_games.py` hashes every event of 210 games (mixed / casual at 4 players, 6 players, Fatica on stop with Kumite in round 1, random bots, 60-round cap); `write` before an engine change, `check` after. A tool for refactoring and speed work, not a test (rule changes are expected to differ). Timings came from a scratch benchmark. + - [x] 6.10.2 `Board`: neighbour table, squares per code and Safe Rooms computed once (`cached_property`); neighbour order N, E, S, W kept (the random and casual bots pick by position in the offered list). `Engine`: Fatica squares as a set (`_tolls`), opponents' squares looked up once per move (`_fightable_squares`), and paths crossing no opponent keyed by their Fatica count with C-level set operations. + - [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.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 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. ### Phase 7 — Finalisation (later) @@ -306,9 +315,9 @@ Deliverables: ### Risks - Movement enumeration: self-avoiding walks of length up to 12 can explode in open rooms. Keep rooms small; prune by outcome (destination + ordered opponents crossed + Dojo entry). -- Engine speed ~1,400 turns/s (random and heuristic bots, real board). If Phase 5 needs more: precompute neighbour lists in `Board`. +- Engine speed: after 6.10, ~65 games/s per process (heuristic and casual bots). Further gains would need changing the move enumeration itself (e.g. grouping walks by outcome), not attempted. - 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.5 decides whether that is a problem. Board draft 2 (D42) moved them to the Dojo ring: 62–67%. +- Home farming: board draft 2 (D42) moved the Allenamento onto the Dojo ring; the own-quadrant share of square income is 62–67% (draft 1: 74%), above the 60% aim because players cross their own quadrant for most of the game. Accepted with draft 2 (6.5). ## External documentation diff --git a/agents/scripts/fingerprint_games.py b/agents/scripts/fingerprint_games.py new file mode 100644 index 0000000..3f66298 --- /dev/null +++ b/agents/scripts/fingerprint_games.py @@ -0,0 +1,69 @@ +"""Check that an engine change leaves every game identical (events hashed per game). + +Record before the change, check after (a rule change is expected to differ): + + uv run python agents/scripts/fingerprint_games.py write /tmp/fingerprints.json + uv run python agents/scripts/fingerprint_games.py check /tmp/fingerprints.json +""" + +import hashlib +import json +import sys +from concurrent.futures import ProcessPoolExecutor +from pathlib import Path + +from dojo.board import load_board +from dojo.engine import Game, RandomSource +from dojo.rules import RulesConfig +from dojo.sim import make_agent + +BOARD = Path(__file__).resolve().parents[2] / "board" +GAMES = 60 +CASES = [ + ("mixed4", RulesConfig(), ("balanced", "farmer", "aggressive", "balanced"), GAMES), + ("casual4", RulesConfig(), ("casual",) * 4, GAMES), + ( + "mixed6", + RulesConfig(), + ("casual", "farmer", "aggressive", "balanced", "casual", "farmer"), + 30, + ), + ( + "stop-r1", + RulesConfig(fatica_on_crossing=False, first_kumite_round=1), + ("casual", "balanced", "farmer"), + 30, + ), + ("random", RulesConfig(max_rounds=60), ("random", "casual", "random", "balanced"), 30), +] + + +def play(case: int, seed: int) -> tuple[str, int, str]: + name, config, bots, _ = CASES[case] + board = load_board(BOARD / "quadrant.txt", BOARD / "legend.toml") + agents = [make_agent(bot, seed, seat) for seat, bot in enumerate(bots)] + game = Game(board, config, agents, RandomSource(seed)) + game.run() + digest = hashlib.sha256("\n".join(map(repr, game.events)).encode()).hexdigest() + return name, seed, digest + + +def main() -> None: + mode, path = sys.argv[1], Path(sys.argv[2]) + tasks = [(i, seed) for i, case in enumerate(CASES) for seed in range(case[3])] + with ProcessPoolExecutor(8) as pool: + results = {f"{n}:{s}": d for n, s, d in pool.map(play, *zip(*tasks, strict=True))} + if mode == "write": + path.write_text(json.dumps(results, indent=0)) + print(f"wrote {len(results)} games") + return + expected = json.loads(path.read_text()) + bad = [k for k in expected if results.get(k) != expected[k]] + print(f"{len(expected) - len(bad)}/{len(expected)} games identical") + if bad: + print("differ:", bad[:10]) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/src/dojo/board.py b/src/dojo/board.py index 5a93f9f..e419b0f 100644 --- a/src/dojo/board.py +++ b/src/dojo/board.py @@ -97,21 +97,50 @@ class Board: def in_bounds(self, p: Pos) -> bool: return 0 <= p.row < self.size and 0 <= p.col < self.size - def neighbours(self, p: Pos) -> Iterator[Pos]: - for dr, dc in ((-1, 0), (0, 1), (1, 0), (0, -1)): - q = Pos(p.row + dr, p.col + dc) - if self.in_bounds(q) and edge(p, q) not in self.walls: - yield q + def neighbours(self, p: Pos) -> tuple[Pos, ...]: + """Squares one open step from ``p`` (in bounds), in the order N, E, S, W.""" + return self._adjacency[p] + + @cached_property + def _adjacency(self) -> dict[Pos, tuple[Pos, ...]]: + return { + p: tuple( + q + for q in ( + Pos(p.row - 1, p.col), + Pos(p.row, p.col + 1), + Pos(p.row + 1, p.col), + Pos(p.row, p.col - 1), + ) + if self.in_bounds(q) and edge(p, q) not in self.walls + ) + for p in self.positions() + } def cells_with(self, code: str) -> frozenset[Pos]: - return frozenset(p for p in self.positions() if self.code(p) == code) + return self._cells_by_code.get(code, frozenset()) + + @cached_property + def _cells_by_code(self) -> dict[str, frozenset[Pos]]: + by_code: dict[str, set[Pos]] = {} + for p in self.positions(): + by_code.setdefault(self.code(p), set()).add(p) + return {code: frozenset(cells) for code, cells in by_code.items()} @cached_property def dojo(self) -> frozenset[Pos]: return self.cells_with(Square.DOJO) def safe_room(self, quadrant: int) -> frozenset[Pos]: - return frozenset(p for p in self.cells_with(Square.SAFE) if quadrant_of(p) == quadrant) + return self._safe_rooms.get(quadrant, frozenset()) + + @cached_property + def _safe_rooms(self) -> dict[int, frozenset[Pos]]: + safe = self.cells_with(Square.SAFE) + return { + quadrant: frozenset(p for p in safe if quadrant_of(p) == quadrant) + for quadrant in range(QUADRANTS) + } def to_grid(self) -> Grid: n = self.size diff --git a/src/dojo/engine.py b/src/dojo/engine.py index 297641b..f4de4d8 100644 --- a/src/dojo/engine.py +++ b/src/dojo/engine.py @@ -169,6 +169,7 @@ class Game: self.winner: int | None = None self._random = randomness self._dojo = board.dojo + self._tolls = board.cells_with(Square.FATICA) if config.fatica_on_crossing else frozenset() cards = [card for card, quantity in config.deck for _ in range(quantity)] randomness.shuffle(cards) @@ -271,15 +272,20 @@ class Game: """Legal walks, one per distinct outcome: the end square, and in order the opponents crossed and the Fatica squares that cost a stripe (``None``).""" brown = self.config.is_brown(player.track) + tolls = self._tolls + opponents = self._fightable_squares(player) choices: dict[tuple[Pos, tuple[Pos | None, ...]], Path] = {} for path in walks(self.board, player.position, steps, visited, can_enter_dojo=brown): if path[-1] in self._dojo and not self._still_brown_at_dojo(player, path): continue - crossed = tuple( - None if self._costs_fatica(cell) else cell - for cell in path - if self._costs_fatica(cell) or self._fightable_at(cell, player) - ) + if opponents.isdisjoint(path): + crossed = (None,) * len(tolls.intersection(path)) + else: + crossed = tuple( + None if cell in tolls else cell + for cell in path + if cell in tolls or cell in opponents + ) choices.setdefault((path[-1], crossed), path) return list(choices.values()) @@ -584,7 +590,7 @@ class Game: return self.round >= self.config.first_kumite_round def _costs_fatica(self, square: Pos) -> bool: - return self.config.fatica_on_crossing and self.board.code(square) == Square.FATICA + return square in self._tolls def _fightable_at(self, square: Pos, attacker: Player) -> Player | None: if self._in_safe_room(square) or not self._kumite_allowed: @@ -594,6 +600,16 @@ class Game: return p return None + def _fightable_squares(self, attacker: Player) -> set[Pos]: + """Squares where ``_fightable_at`` finds a defender for ``attacker``.""" + if not self._kumite_allowed: + return set() + return { + p.position + for p in self.players + if p is not attacker and not p.meditating and not self._in_safe_room(p.position) + } + def _has_unfightable_pawn(self, square: Pos, player: Player) -> bool: return any( p is not player and p.position == square and (p.meditating or not self._kumite_allowed) diff --git a/src/dojo/movement.py b/src/dojo/movement.py index 6505e86..0a7098d 100644 --- a/src/dojo/movement.py +++ b/src/dojo/movement.py @@ -1,6 +1,7 @@ """Legal walks on the board, ignoring pawns (the engine resolves encounters).""" from collections.abc import Iterator, Set +from functools import cache from dojo.board import Board, Pos @@ -15,7 +16,21 @@ def walks( Squares in ``visited`` are never entered. The Dojo is a wall unless ``can_enter_dojo``; entering it ends the walk early, so Dojo walks may be shorter than ``steps``. If no walk of full length exists, the longest possible walks are returned instead. + Walks that exclude nothing but ``start`` are computed once per board and reused. """ + if visited <= {start}: + return list(_open_walks(board, start, steps, can_enter_dojo)) + return _walks(board, start, steps, visited, can_enter_dojo) + + +@cache +def _open_walks(board: Board, start: Pos, steps: int, can_enter_dojo: bool) -> tuple[Path, ...]: + return tuple(_walks(board, start, steps, frozenset(), can_enter_dojo)) + + +def _walks( + board: Board, start: Pos, steps: int, visited: Set[Pos], can_enter_dojo: bool +) -> list[Path]: dojo = board.dojo seen = set(visited) | {start} found = list(_extend(board, dojo, (start,), steps, seen, can_enter_dojo)) diff --git a/tests/test_board.py b/tests/test_board.py index 6b40b7f..745d626 100644 --- a/tests/test_board.py +++ b/tests/test_board.py @@ -70,6 +70,18 @@ def test_neighbours_respect_walls_and_border(board: Board) -> None: assert Pos(1, 3) in set(board.neighbours(Pos(1, 2))) +def test_neighbours_come_north_east_south_west(board: Board) -> None: + square = Pos(1, 2) + compass = [Pos(0, 2), Pos(1, 3), Pos(2, 2), Pos(1, 1)] + + assert list(board.neighbours(square)) == [q for q in compass if q in board.neighbours(square)] + + +def test_square_lookups_without_matches_are_empty(board: Board) -> None: + assert board.cells_with("unknown") == frozenset() + assert board.safe_room(4) == frozenset() + + def test_seams_join_neighbouring_quadrants(board: Board) -> None: assert Pos(0, 8) in set(board.neighbours(Pos(0, 7))) assert Pos(8, 0) in set(board.neighbours(Pos(7, 0))) diff --git a/tests/test_movement.py b/tests/test_movement.py index 379a551..658f1ca 100644 --- a/tests/test_movement.py +++ b/tests/test_movement.py @@ -48,3 +48,14 @@ def test_boxed_in_pawn_moves_as_far_as_possible() -> None: paths = walks(corridor, Pos(0, 0), 5, {Pos(0, 0)}, can_enter_dojo=False) assert paths == [(Pos(0, 1), Pos(0, 2))] + + +def test_reused_walks_match_a_fresh_search_and_are_independent_copies() -> None: + start = Pos(4, 4) + off_board = Pos(-1, -1) + + walks(BOARD, start, 6, {start}, can_enter_dojo=True).clear() + reused = walks(BOARD, start, 6, {start}, can_enter_dojo=True) + + assert reused + assert reused == walks(BOARD, start, 6, {start, off_board}, can_enter_dojo=True)