commit 0aaf113fc3cfb109ca730a927bf1175a63074f0f Author: Federico Pasqua Date: Sat Sep 26 16:01:46 2026 +0200 Initial commit, with rules 0.5 and basic bots diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7e86cf0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,140 @@ +### Python template +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..6324d40 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.14 diff --git a/README.md b/README.md new file mode 100644 index 0000000..e6aca91 --- /dev/null +++ b/README.md @@ -0,0 +1,42 @@ +# Il Labirinto del Dojo + +A small tabletop game (1–2 h, 3–8 players, best at 4), plus the tooling used to design and +balance it. + +| Document | What it is | +|---|---| +| [`RULES.md`](RULES.md) | The rulebook (English working draft). | +| [`board/FORMAT.md`](board/FORMAT.md) | How the board is written as text. | +| [`agents/plans/`](agents/plans/) | Active work plan: decisions, phases, open questions. | + +## Setup + +Requires [uv](https://docs.astral.sh/uv/). It installs the pinned Python and all tools: + +```sh +uv sync +``` + +## Board + +The board is authored as one 8x8 quadrant in `board/quadrant.txt` (tunnel links in +`board/legend.toml`) and rotated into the full 16x16 board. + +```sh +uv run dojo board validate # check the board against the rules, print stats +uv run dojo board render # print the full 16x16 board +uv run dojo board render -o board/board.txt # regenerate the full board file (never edit it by hand) +uv run dojo board validate --quadrant other.txt --legend other.toml # check another draft +``` + +`validate` exits with `0` when the board is fine (warnings allowed), `1` on rule violations, +`2` when the file is malformed or missing. + +## Development + +```sh +uv run pytest # tests +uv run ruff check . # lint +uv run ruff format . # format +uvx pyright src tests # type check +``` diff --git a/RULES.md b/RULES.md new file mode 100644 index 0000000..7556041 --- /dev/null +++ b/RULES.md @@ -0,0 +1,207 @@ +# Il Labirinto del Dojo — Rules (draft v0.5) + +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)). + +## Goal + +Start as a White belt, climb to Brown, reach the Central Dojo and pass the Final Exam. +The first player to wear the Black Belt wins. + +## Components + +- Board: 16×16 grid with walls, rooms and tunnels; 4 corner Safe Rooms; the Central Dojo (4×4, centre). +- Up to 8 pawns. +- One progress track per player. +- 2 six-sided dice (d6). +- Nebbia del Dojo deck (22 cards). +- Legend card for square effects. + +## The progress track + +Each player has a position from **0 to 17**. Your belt is the segment you are in: + +| Position | Belt | +|---|---| +| 0–2 | White | +| 3–5 | Yellow | +| 6–8 | Orange | +| 9–11 | Green | +| 12–14 | Blue | +| 15–17 | Brown | + +- One **stripe** = 1 step on the track. One **belt** = 3 steps. +- Position never goes below 0 or above 17. +- **[VARIANT]** *Brown requires Kumite*: squares and cards cannot move a player from 14 to 15. + Once Brown, squares and cards may move the player freely within 15–17. +- A player below 15 is not Brown, whatever their previous belt was. + +## Setup + +- 3–4 players: one pawn per corner Safe Room. +- 5–8 players: two pawns per corner Safe Room. Players sharing a corner are never consecutive in turn order. +- Everyone starts on track position 0. + *Variant to test with 6+ players: start on 3 (Yellow).* +- Choose the first player at random; play proceeds clockwise. + +## Turn + +1. Roll **2d6**. +2. Move **exactly** that many steps: + - orthogonal steps only; + - never through a wall; + - never re-entering a square already visited during this move. + + If no move of the full count exists, move as far as possible. (The board is designed + without dead ends, so this should never happen; it is a safeguard.) +3. **Entering a square occupied by an opponent starts a Kumite** (you are the attacker). + This includes the last step of the move. Leaving a square never starts a Kumite. + - If you win, the defender is sent to Start and you continue with your remaining steps. + You may fight several opponents in a single move (chain). + - If you lose, you are sent to Start and your turn ends. +4. When movement ends, resolve the effect of the square you stopped on. + +## Safe areas + +**Corner Safe Rooms** (3×3, two exits on different sides): + +- No Kumite takes place inside. +- No square effects (all squares are Tatami). +- Any number of pawns may share a square. +- Pawns inside cannot be targeted by cards or square effects. +- Any player may enter any Safe Room. +- "Sent to Start" always means your own corner Safe Room. You place your pawn on any square + of the room you choose, both at setup and when sent back. + +**Central Dojo**: no pawn ever remains inside (see Final Exam). + +## Kumite + +1. Both players roll **1d6**. +2. The higher belt adds **+1 per belt of difference, maximum +2**. +3. After both dice are rolled, the attacker may play one Tecnica card, then the defender may + play one. Each decides seeing both dice; the defender also sees the attacker's card. + A Tecnica can only be played in this window (Uke included: you commit before the result). +4. Higher total wins. +5. **Senshu rule**: on a tie, the attacker wins. + +### Consequences + +| Result | Winner | Loser | +|---|---|---| +| Equal belts | +3 | −3, sent to Start | +| Lower belt wins (upset) | +3 | −3, sent to Start | +| Higher belt wins | **[VARIANT]** 0 or +1 | sent to Start | + +## Squares + +| Code | Name | Effect when you stop on it | +|---|---|---| +| `.` | Tatami | Nothing. | +| `A` | Allenamento | +1 stripe. | +| `F` | Fatica | −1 stripe. | +| `?` | Nebbia del Dojo | Draw a card. The square stays active. Reshuffle the discard pile when the deck is empty. | +| `T` | Tunnel | You **may** teleport to the paired Tunnel. Movement is already over; the exit square has no effect. If the exit is occupied, a Kumite starts (you are the attacker). An exit holding a meditating pawn cannot be used. | +| `P` | Passo Falso | Return to the square where this move started. Kumite already fought during the move stand. | +| `K` | Sfida | You **must** fight a Kumite against an opponent of your choice outside a safe area (you are the attacker). If there is none, nothing happens. | + +## Meditazione + +A meditating pawn cannot be fought or targeted. Other pawns pass through it freely. +If your move ends exactly on its square, take one extra step following the normal movement +rules (it may start a Kumite); the effect of the square you finally stop on applies. +If no legal extra step exists, stay. + +The same extra step applies whenever your move would end on any square (outside Safe Rooms) +holding a pawn you cannot fight — for example a defender who lost but was saved by Uke. + +## Nebbia del Dojo deck + +Nebbia cards are chances, not gifts: **every card must be resolved**, even when it turns out +badly for you. If a card needs a target and there is none, nothing happens. + +**Tecnica cards are secret:** + +- When you draw a Tecnica, do not show it: keep it face down in front of you. +- Everyone can see *that* you hold one, not *which* one. +- You hold **at most one**. When you draw a second, keep one and discard the other + **face down**, so nobody learns which one you kept. +- A Tecnica is revealed only when played; it then goes to the discard pile. + +All other cards are shown and resolved immediately. + +| Type | Card | Effect | Qty | +|---|---|---|---| +| Tecnica | Kiai | +2 to your Kumite total. *(+1 to be tested)* | 3 | +| Tecnica | Uke (Parata) | If you lose, you are not sent to Start (the track change still applies). A losing defender stays on its square; a losing attacker returns to the square it came from, and its turn ends with no square effect. | 2 | +| Tecnica | Kaeshi (Contrattacco) | Reroll your die. | 2 | +| Movement | Tsugi-ashi | Roll 2d6 and make another full move. That move has its own starting square (for Passo Falso). | 2 | +| Movement | Tunnel Segreto | Teleport to a Tunnel of your choice (occupied exit: Kumite, you are the attacker). You cannot choose one holding a meditating pawn. | 1 | +| Movement | Irimi (Scambio) | Swap places with an opponent of your choice outside a safe area. No Kumite, no square effects. | 2 | +| Interaction | Sfida | Fight a Kumite against an opponent of your choice outside a safe area. You are the attacker. | 2 | +| Event | Il Sensei Osserva | The player(s) lowest on the track gain +2. | 1 | +| Event | Il Peso della Cintura | The player(s) highest on the track lose 1. | 1 | +| Event | Allenamento Intensivo | +1 stripe. | 2 | +| Event | Colpo Proibito | −1 stripe. | 1 | +| Event | Passo Falso | Return to the square where this move started. | 2 | +| Event | Meditazione | Your next turn is skipped. Until the end of that skipped turn you cannot be fought or targeted. | 1 | + +## Central Dojo and Final Exam + +- Only a Brown player (position 15+) may enter. For everyone else the Dojo is a wall. +- Entering costs one step and needs no exact count: reach a square adjacent to an entrance + with at least 1 step left and step in. Movement ends immediately. +- You may enter in the same move in which you became Brown by winning a Kumite. +- The Final Exam starts at once. The **opponent highest on the track** defends + (tie: the challenger chooses among them). The defender's pawn does not move. + Meditating players cannot defend; if nobody can, you return to the square you entered + from and there is no Exam this turn. +- The exam is a Kumite with the challenger as attacker; Senshu and Tecnica cards apply. + - **Challenger wins**: they wear the Black Belt and win the game. + - **Challenger loses**: −3 and sent to Start. The defender gains nothing. + - If the challenger plays Uke and loses, they return to the square they entered from instead of Start. + +## Rule variants under test + +| Variant | Brown requires Kumite | Higher belt beats lower | +|---|---|---| +| **A** | No | winner gains 0 | +| **B** | Yes (squares/cards cannot move 14 → 15) | winner gains +1 | + +Rationale for B: with the cap, a Blue leader on 14 facing only lower belts could never +become Brown if beating lower belts gave nothing. The +1 removes that deadlock. + +Other parameters to validate by simulation: Kiai +2 vs +1; starting on 3 with 6+ players; +square mix; game length at 3/4/6/8 players. + +## FAQ + +**Which effects can I refuse?** +Only the Tunnel square ("you may teleport"). Everything else — squares and Nebbia cards — +must be resolved, including Sfida, Irimi and Tunnel Segreto. + +**I was moved without walking (Passo Falso, Irimi, Uke retreat). Does that start a Kumite +or trigger the square I land on?** +No to both. Only arriving through a Tunnel or Tunnel Segreto starts a Kumite on an occupied +square, and even then the exit square has no effect. + +**I became Brown by winning a Kumite in the middle of my move. Can I enter the Dojo with +the steps I have left?** +Yes. Your belt changes immediately, so the Dojo opens for the rest of that move. + +**My move would end on a pawn I cannot fight. What now?** +Take one extra step (see [Meditazione](#meditazione)). If none is possible, stay. + +**I am boxed in and cannot move the full count.** +Move as far as possible. This should never happen on a well-designed board. + +**My Uke saved me as the attacker. Do I get the effect of the square I return to?** +No. Losing a Kumite always ends your turn. + +**I drew Tsugi-ashi and then hit Passo Falso on the extra move. Where do I go back to?** +To the square where the extra move started, not where your turn started. + +**Can opponents know which Tecnica I hold?** +No. They only know whether you hold one. See [Nebbia del Dojo deck](#nebbia-del-dojo-deck). diff --git a/agents/plans/2026-09-25-dojo-board-and-sim.md b/agents/plans/2026-09-25-dojo-board-and-sim.md new file mode 100644 index 0000000..a63aea5 --- /dev/null +++ b/agents/plans/2026-09-25-dojo-board-and-sim.md @@ -0,0 +1,165 @@ +# Il Labirinto del Dojo — board format, rules engine, simulator + +## Goal and scope + +A small tabletop game (1–2 h, target 4 players, playable 3–8), gifted to a friend. +Logic and balance first; graphics and cosmetics are out of scope for now. + +Deliverables: + +1. `RULES.md` — the rulebook (English while iterating; Italian final edition produced separately). +2. A text-based board format, with parser, full-board generator and validator. +3. A first playable board. +4. A rules engine + bots + Monte Carlo simulator to validate balance and pacing, + and in particular to choose between rule variants A and B. + +## Decisions log + +| # | Decision | Why | +|---|---|---| +| D1 | Kumite loser loses 1 belt (−3), never below 0 | Developer requirement. | +| D2 | "Punching up": belts change only on equal-belt fights or upsets | Prevents leader farming weak players; built-in catch-up. | +| D3 | Kumite: 1d6 each, higher belt +1 per belt of gap, capped at +2 | Uncapped d6 bonus made fights predetermined (gap 5 = 100%). | +| D4 | **Senshu rule**: ties go to the attacker | WKF-inspired, one rule without exceptions, makes attacking slightly favourable so fights happen. | +| D5 | Kumite triggers on **crossing** (entering an occupied square), always forced; winning attacker continues moving; chains allowed | Developer requirement; route choice gives the agency. | +| D6 | Kumite loser is sent to own Start (corner Safe Room) | Developer requirement. | +| D7 | Corner Safe Rooms: 3×3, 2 exits, no Kumite/effects/targeting, stacking allowed | Prevents spawn camping / spawn lock. | +| D8 | Movement 2d6, exact count, orthogonal, no re-entering a square in the same move | Developer requirement + clarity. | +| D9 | Most squares carry an effect from a small vocabulary (7 codes); ~30–40% Tatami | Developer requirement; limits reading load and analysis paralysis. | +| D10 | Effects trigger only when stopping on a square | Avoids multiple triggers per move. | +| D11 | Nebbia works like Monopoly Chance: the square stays active | Developer requirement. | +| D12 | Progress track 0–17, 3 stripes per belt; squares ±1, Kumite ±3 | Fine-grained board income without breaking pacing. Physical tracking method decided later. | +| D13 | Nebbia deck is mostly movement/interaction/Tecniche, very few flat stripes | Prevents "stripe farming" that would make players avoid fights. | +| D14 | Tecnica cards: hold max 1, played after dice, attacker first | Converts Nebbia luck into a reason to fight. | +| D15 | Passo Falso returns to the start of the current move; skip-turn allowed only if rare (1 card) | Developer requirement. | +| D16 | Tunnel: when stopping on it you may teleport; movement is over; occupied exit starts a Kumite | Developer confirmed. | +| D17 | Meditazione: pass-through; ending on it forces one extra step | Developer rule. | +| D18 | Central Dojo is one logical space, several entrances, Brown only, no exact count to enter | Avoids end-game "need exactly a 3" frustration. | +| D19 | Final Exam: highest opponent defends; challenger wins → game; challenger loses → −3 and Start; defender gains nothing | Avoids the top-two flip-flop. Brown's extra stripes protect against the board, not the exam. | +| D20 | Two rule variants to be decided by simulation: **A** (no Brown cap, higher-beats-lower 0) vs **B** (squares/cards cannot move 14→15, higher-beats-lower +1) | Neither is clearly better a priori. | +| D21 | Board 16×16, 4×4 Dojo in the centre, authored as one 8×8 quadrant rotated 3× (C4 symmetry) | Fairness by construction. Small asymmetries may be wanted later (see Q2). | +| D22 | Tooling in Python, environment managed with **uv** | Developer choice. | +| D23 | Rules drafted in English; Italian final edition generated separately | Developer choice. | +| D24 | Board format: see `board/FORMAT.md`. Tunnel pairing across quadrants via `quadrant_offset` in `board/legend.toml` (cell `a` in quadrant k ↔ cell `b` in quadrant k+o) | Every tunnel cell keeps exactly one partner under rotation. | +| D25 | Only the Tunnel square is optional. Sfida (square and card), Irimi and Tunnel Segreto are mandatory; every Nebbia card must be resolved | Sfida prevents stalling; Nebbia cards are chances that can also turn out badly. | +| D26 | Tecnica cards are secret: drawn face down, others only know *whether* you hold one, a replaced Tecnica is discarded face down, revealed only when played | Hidden information and bluffing. Bots must not see opponents' Tecnica identity (Phase 4). | +| D27 | Edge rules (RULES.md v0.5, inline + FAQ): boxed-in → move as far as possible (safeguard; boards have no dead ends); Uke-saved attacker stops on its previous square, turn ends, no square effect; extra-step rule for ending on any unfightable pawn; tunnel exits with a meditating pawn unusable; meditating players cannot defend the Exam; Tecnica window after both dice, attacker first; relocations never fight or trigger squares; Tsugi-ashi has its own origin; becoming Brown mid-move opens the Dojo | Developer confirmed all ten. | + +## Phases + +### Phase 0 — Rules consolidation + +- [x] 0.1 Write `RULES.md` v0.4 with variants A/B marked. +- [x] 0.2 `RULES.md` v0.5: mandatory effects, Tecnica secrecy, edge rules and FAQ (D25–D27). + +### Phase 1 — Project scaffold and board format + +- [x] 1.1 `uv init` project (package `dojo`), Python version pinned via uv; dev deps: pytest, ruff. No runtime deps (legend in TOML, parsed with stdlib `tomllib`). +- [x] 1.2 `board/FORMAT.md` — the format specification: + - [x] ASCII grid: `+` corners, `|` vertical walls, `---` horizontal walls, 3-char cells with a centred code. + - [x] Cell codes: `S` safe room, `D` dojo, `.` `A` `F` `?` `P` `K` (Allenamento/Fatica were `+`/`-`, changed so no code looks like a wall), digits = tunnel pair ids. + - [x] Outer border must be fully walled. + - [x] Dojo entrance = open edge between a `D` cell and a non-`D` cell. + - [x] Cross-quadrant tunnel links declared in `board/legend.toml`. +- [x] 1.3 Parser: `quadrant.txt` → cell grid + wall set. +- [x] 1.4 Generator: rotate the quadrant 90°/180°/270° around the board centre → full 16×16 model; write `board/board.txt` (generated, never hand-edited). +- [x] 1.5 Graph model: cells as nodes, open edges as links. The Dojo is handled as one cell set (impassable, entrances = open D/non-D edges); treating it as a single space for movement lands in the rules engine (3.3). +- [x] 1.6 Validator: + - [x] every non-Dojo square reachable from every Safe Room; + - [x] each Safe Room has exactly 2 exits, on different sides; + - [x] no Safe Room lies on a shortest path between two other points (not a shortcut); + - [x] every tunnel id has exactly one partner; + - [x] Dojo has ≥1 entrance per quadrant; + - [x] report: square-type mix and Tatami %, shortest distance from each start to the Dojo. +- [x] 1.7 CLI: `uv run dojo board validate`, `uv run dojo board render`. +- [x] 1.8 Unit tests for parser, rotation, validator. + +### Phase 2 — First board + +- [x] 2.1 Draft the first quadrant (`board/quadrant.txt`), following D7, D9, D18. +- [x] 2.2 Validate and iterate until clean. +- [x] 2.3 Developer review of the layout (approved as draft 1). + +### Phase 3 — Rules engine + +- [x] 3.1 Game state: pawns, track positions, hands, deck/discard, meditation status, turn order. +- [x] 3.2 Rule configuration object: variant A/B, Kiai value, stripes per belt, starting position, player count. +- [x] 3.3 Movement enumeration: all legal exact-length self-avoiding walks, with Kumite on crossing, chain continuation, Dojo entry with ≥1 step left, Meditazione extra step. +- [x] 3.4 Kumite resolution: gap bonus cap, Senshu, Tecnica timing, consequences table per variant, Uke placement. +- [x] 3.5 Square effects and Nebbia cards (all 13 card types). +- [x] 3.6 Final Exam. +- [x] 3.7 Unit tests covering every edge rule in `RULES.md` (`tests/test_engine.py`, `tests/test_movement.py`; mutation-checked: breaking Senshu, bonus cap, chain continuation, Meditazione step, variant B cap, Safe Room or meditator immunity each fails tests). +- Code: `src/dojo/rules.py` (config, variants, deck), `movement.py` (walks), `engine.py` (Game, Agent protocol, Randomness), `events.py` (event log for metrics). + +### Phase 4 — Bots + +- [x] 4.0 Hidden information: agents receive a `GameView` snapshot (`Game.view(player)`), never the `Game`: opponents' Tecnica shows only as `holds_tecnica`; the event log (which records `CardDrawn`) is not visible to agents (D26). +- [x] 4.1 Random bot (baseline): `RandomAgent(rng)` in `src/dojo/bots.py`. +- [x] 4.2 Heuristic bot: `HeuristicAgent(profile)`. Value = track position; once Brown, plus `DOJO_PULL` (0.3) per step closer to the Dojo. Scores each offered path as the expected value change over the chain of Kumite (exact 1d6-vs-1d6 odds with belt bonus and Senshu, ±0.1 per Tecnica held), then the stop square's effect (Nebbia worth 0.2, Sfida = best target, Tunnel = better of staying/exit). Exam = 100 × pass chance. Tecnica played only when losing (Kiai only if it turns the fight). Known simplifications: Meditazione extra step, Passo Falso origin after a chain, opponents' replies. +- [x] 4.3 Profiles (`PROFILES`): losses weighted by `risk`, opponents' value changes by `rivalry`, Tecnica keep order. balanced (1.0, 0.5, Kiai>Kaeshi>Uke), farmer (2.5, 0.2, Uke first), aggressive (0.5, 1.0). +- Smoke run (60 seeds, real board, 1 heuristic vs 3 random): heuristic wins 58–60/60 for every profile and both variants; ~20–22 rounds; ~1,300–1,450 turns/s. Four heuristic bots: ~18.7 rounds. Finding to check in Phase 5/6: attacking a higher belt risks nothing for a non-Brown player (losing costs 0 stripes; Start is no worse than elsewhere), so every profile attacks upward. + +### Phase 5 — Simulator and metrics + +- [ ] 5.1 Monte Carlo runner over a config matrix; seeded and reproducible. +- [ ] 5.2 Metrics: + - [ ] game length in rounds and estimated minutes (turn and fight duration as parameters); + - [ ] Kumite per round, upset rate, chain length; + - [ ] win rate by seat and by bot profile; + - [ ] exam attempts per game, exam success rate; + - [ ] rounds spent stuck on 14 (variant B deadlock check); + - [ ] stripe income split: squares vs cards vs Kumite; + - [ ] lead changes per game, final spread. +- [ ] 5.3 Report output (plain text / CSV). + +### Phase 6 — Balance experiments + +- [ ] 6.1 Variant A vs B. +- [ ] 6.2 Kiai +2 vs +1. +- [ ] 6.3 Player counts 3/4/6/8; start on 3 for 6+. +- [ ] 6.4 Square mix tuning; board income per turn (target ≈ 0.8 stripes/turn net, to be confirmed). +- [ ] 6.5 Update `RULES.md` with the decisions. + +### Phase 7 — Finalisation (later) + +- [ ] 7.1 Freeze rules. +- [ ] 7.2 Italian edition. +- [ ] 7.3 Physical tracking method and components; printable board. + - [ ] Tunnel pairs need unique labels on the printed board (the text format repeats each digit in all 4 quadrants). + +## Questions + +### Resolved + +- Tie rule → Senshu (attacker wins). +- Several opponents on one square → impossible (loser is teleported); only exception handled by the Meditazione extra-step rule. +- Exam loss → −3 and Start; defender gains nothing. +- Tunnel → teleport optional on stop, movement over. +- Optional vs mandatory effects → D25. +- Engine edge rules → D27. +- Sfida squares → keep, balance via low count. +- Il Peso della Cintura → keep (1 card). +- Player count → focus 4, support 3–8. +- Tests → the agent runs the suite (`uv run pytest`), plus `ruff check`, `ruff format --check`, `pyright`. +- Minutes estimate → 45 s per turn, +30 s per Kumite. +- Safe Room placement → the owner places the pawn on any square of their room (setup and when sent to Start). + +### Open + +- 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. + +### Risks + +- Movement enumeration: self-avoiding walks of length up to 12 can explode in open rooms. Keep rooms small; prune by outcome (destination + ordered opponents crossed + Dojo entry). +- Engine speed ~1,400 turns/s (random and heuristic bots, real board). If Phase 5 needs more: precompute neighbour lists in `Board`. +- Simulation conclusions depend on bot quality; compare several profiles before trusting a result. +- Board draft 1 puts 4 Allenamento in each 3x3 room next to the Safe Room: possible "home farming" (see the Phase 2 review). Measure stripe income by region in the simulator. + +## External documentation + +- uv — https://docs.astral.sh/uv/ — project init, dependency and Python version management. +- Python `tomllib` — https://docs.python.org/3/library/tomllib.html — stdlib TOML parser for `legend.toml`. +- pytest — https://docs.pytest.org/ — unit tests. +- Ruff — https://docs.astral.sh/ruff/ — lint and format. +- WKF Kumite competition rules (wkf.net, "Rules") — source of the Senshu concept. diff --git a/board/FORMAT.md b/board/FORMAT.md new file mode 100644 index 0000000..d4a915f --- /dev/null +++ b/board/FORMAT.md @@ -0,0 +1,97 @@ +# Board format + +The board is authored as **one 8x8 quadrant** (`board/quadrant.txt`, the top-left corner of +the board). The tooling rotates it 90° clockwise three times to build the full 16x16 board, +so every corner is identical by construction. + +``` +uv run dojo board validate # check board/quadrant.txt against the rules +uv run dojo board render -o board/board.txt # write the full board (generated, never edit) +``` + +Exit codes: `0` OK (warnings allowed), `1` design errors, `2` format errors or missing file. + +## Notation + +Classic ASCII maze notation. Each cell is 3 characters wide with its code in the middle; +walls sit between cells. + +``` ++---+---+---+ +| S S | . | ++ +---+ + +| S 1 ? | ++---+---+---+ +``` + +| Element | Walled | Open | +|---|---|---| +| Corner | `+` (always) | — | +| Between two rows | `---` | three blanks | +| Between two columns | `\|` | one blank | + +- Lines starting with `#` and blank lines are ignored. +- Trailing blanks may be stripped by your editor; a missing character at the end of a line + reads as open. +- The quadrant file is 17 lines of 33 characters. + +## Cell codes + +| Code | Meaning | +|---|---| +| `S` | Safe Room | +| `D` | Central Dojo | +| `.` | Tatami | +| `A` | Allenamento | +| `F` | Fatica | +| `?` | Nebbia del Dojo | +| `P` | Passo Falso | +| `K` | Sfida | +| `1`–`9` | Tunnel; the two cells with the same digit are a pair | + +## Quadrant rules (format errors — the board cannot be built) + +- Exactly 8x8 cells, only the codes above. +- **Top and left borders** are the outer border of the board: fully walled. +- **Right and bottom borders are seams** shared with the neighbouring quadrants. Rotation maps + the bottom seam onto the right seam, so the right border at row *i* must equal the bottom + border at column *i*. +- `D` cells are exactly rows 6–7, columns 6–7 (the quadrant's share of the 4x4 Dojo). +- Each tunnel digit appears on exactly two cells. + +Rows and columns are 0-based, top-left is `(0, 0)`. On the full board, quadrants are numbered +clockwise: 0 top-left, 1 top-right, 2 bottom-right, 3 bottom-left. + +## Tunnel links — `board/legend.toml` (optional) + +The CLI reads `legend.toml` from the quadrant's folder automatically (override with +`--legend`). By default a tunnel pair links its two cells inside the same quadrant. To link across +quadrants, give the digit a `quadrant_offset`: + +```toml +[tunnels.3] +quadrant_offset = 1 +``` + +Call `a` and `b` the two cells of the digit in reading order (row by row, left to right). +With offset `o`, `a` in quadrant *k* is linked to `b` in quadrant *k + o*. Offsets are 0–3. + +## Design checks (`validate` errors and warnings) + +Errors: + +- a wall inside the Dojo; +- a quadrant without a Dojo entrance, or an entrance opening straight into a Safe Room; +- a Safe Room that does not contain its board corner, is not connected, does not have exactly + 2 exits, or has both exits on the same side; +- a Safe Room that is a shortcut (walking through it is shorter than walking around it + between its two exits); +- any square that cannot be walked to from a Safe Room (tunnels not counted, Dojo impassable). + +Warnings: + +- Safe Room size other than 3x3; +- Tatami share outside 30–40% of playable squares. + +The report also lists the square mix, number of tunnel pairs and Dojo entrances, and the +shortest walk from each Safe Room into the Dojo. diff --git a/board/board.txt b/board/board.txt new file mode 100644 index 0000000..cfa00e6 --- /dev/null +++ b/board/board.txt @@ -0,0 +1,33 @@ ++---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+ +| S S S | . 1 ? F A | A . 2 P . | S S S | ++ + + + +---+ +---+ + +---+---+---+ + + + + +| S S S . | A . A | P | 1 | A . A | . S S S | ++ + + + + + + + + + + + + + + + + +| S S S | ? | . ? . | 2 | . . ? . . | S S S | ++---+ +---+ + + + + + + + + + +---+ +---+ +| . . . . A F A | . F | A . A | . ? . . | ++ +---+ +---+---+---+ + + + +---+---+ +---+---+ + +| P | A . A | . ? . | F | F . A . | A . A | 1 | ++ + + + + + +---+ + +---+---+ + + + + + +| 2 | . ? . | A | A . ? A K A ? | F ? . ? | ++ + + + + + + +---+---+---+ + + + + + + +| . | A . A . | K | D D D D . | . A . A | F | ++ +---+ +---+ + + + + + + +---+---+---+---+ + +| A 1 . F F A | D D D D | ? F . 2 P A | ++---+---+---+ +---+ + + + + + +---+ +---+---+---+ +| A P 2 . F ? | D D D D | A F F . 1 A | ++ +---+---+---+---+ + + + + + + +---+ +---+ + +| F | A . A . | . D D D D | K | . A . A | . | ++ + + + + + +---+---+---+ + + + + + + + +| ? . ? F | ? A K A ? . A | A | . ? . | 2 | ++ + + + + +---+---+ + +---+ + + + + + + +| 1 | A . A | . A . F | F | . ? . | A . A | P | ++ +---+---+ +---+---+ + + + +---+---+---+ +---+ + +| . . ? . | A . A | F . | A F A . . . . | ++---+ +---+ + + + + + + + + + +---+ +---+ +| S S S | . . ? . . | 2 | . ? . | ? | S S S | ++ + + + + + + + + + + + + + + + + +| S S S . | A . A | 1 | P | A . A | . S S S | ++ + + + +---+---+---+ + +---+ +---+ + + + + +| S S S | . P 2 . A | A F ? 1 . | S S S | ++---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+ diff --git a/board/legend.toml b/board/legend.toml new file mode 100644 index 0000000..a1e97dd --- /dev/null +++ b/board/legend.toml @@ -0,0 +1,9 @@ +# Tunnel links across quadrants. Format: board/FORMAT.md + +# Tunnel 1: north corridor <-> west corridor of the next quadrant (clockwise). +[tunnels.1] +quadrant_offset = 1 + +# Tunnel 2: east corridor <-> west corridor of the opposite quadrant. +[tunnels.2] +quadrant_offset = 2 diff --git a/board/quadrant.txt b/board/quadrant.txt new file mode 100644 index 0000000..6b1eb89 --- /dev/null +++ b/board/quadrant.txt @@ -0,0 +1,20 @@ +# Quadrant 0 (top-left). Rotated 3x into the full board. Format: board/FORMAT.md +# Draft 1: north/east/west corridors, two 3x3 training rooms (rows 1-3 and rows 4-6), +# middle corridor, ring around the Dojo shared with the neighbouring quadrants. ++---+---+---+---+---+---+---+---+ +| S S S | . 1 ? F A | ++ + + + +---+ +---+ + +| S S S . | A . A | P | ++ + + + + + + + + +| S S S | ? | . ? . | 2 | ++---+ +---+ + + + + + +| . . . . A F A | . ++ +---+ +---+---+---+ + + +| P | A . A | . ? . | F | ++ + + + + + +---+ + +| 2 | . ? . | A | A . ? ++ + + + + + + +---+ +| . | A . A . | K | D D ++ +---+ +---+ + + + + +| A 1 . F F A | D D ++---+---+---+ +---+ + + + diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b7cbed4 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,35 @@ +[project] +name = "dojo" +version = "0.1.0" +description = "Il Labirinto del Dojo: board tooling, rules engine and balance simulator" +authors = [ + { name = "Federico Pasqua", email = "federico.pasqua.96@gmail.com" } +] +requires-python = ">=3.14" +dependencies = [] + +[project.scripts] +dojo = "dojo.cli:main" + +[build-system] +requires = ["uv_build>=0.12.10,<0.13.0"] +build-backend = "uv_build" + +[dependency-groups] +dev = [ + "pytest>=9.1.1", + "ruff>=0.16.9", +] + +[tool.ruff] +line-length = 100 + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "B", "UP", "SIM", "RUF"] + +[tool.pyright] +venvPath = "." +venv = ".venv" + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/src/dojo/__init__.py b/src/dojo/__init__.py new file mode 100644 index 0000000..78164ed --- /dev/null +++ b/src/dojo/__init__.py @@ -0,0 +1 @@ +"""Il Labirinto del Dojo.""" diff --git a/src/dojo/board.py b/src/dojo/board.py new file mode 100644 index 0000000..5a93f9f --- /dev/null +++ b/src/dojo/board.py @@ -0,0 +1,274 @@ +"""Full 16x16 board built from one 8x8 quadrant rotated around the board centre. + +Quadrants are numbered clockwise from the top-left: 0 TL, 1 TR, 2 BR, 3 BL. +Quadrant k is quadrant 0 rotated 90° clockwise k times. +""" + +import tomllib +from collections.abc import Iterator, Mapping +from dataclasses import dataclass +from enum import StrEnum +from functools import cached_property +from pathlib import Path +from typing import NamedTuple + +from dojo.grid import Grid, parse_grid + +QUADRANT_SIZE = 8 +BOARD_SIZE = 2 * QUADRANT_SIZE +QUADRANTS = 4 +QUADRANT_DOJO_CELLS = frozenset({(6, 6), (6, 7), (7, 6), (7, 7)}) +TUNNEL_IDS = frozenset("123456789") + + +class Square(StrEnum): + SAFE = "S" + DOJO = "D" + TATAMI = "." + ALLENAMENTO = "A" + FATICA = "F" + NEBBIA = "?" + PASSO_FALSO = "P" + SFIDA = "K" + + +SQUARE_CODES = frozenset(Square) + + +class Pos(NamedTuple): + row: int + col: int + + def __str__(self) -> str: + return f"({self.row}, {self.col})" + + +type Edge = tuple[Pos, Pos] + + +def edge(a: Pos, b: Pos) -> Edge: + return (a, b) if a < b else (b, a) + + +def rotate(p: Pos, times: int = 1) -> Pos: + for _ in range(times % QUADRANTS): + p = Pos(p.col, BOARD_SIZE - 1 - p.row) + return p + + +def quadrant_of(p: Pos) -> int: + top = p.row < QUADRANT_SIZE + left = p.col < QUADRANT_SIZE + if top: + return 0 if left else 1 + return 3 if left else 2 + + +def is_tunnel(code: str) -> bool: + return code in TUNNEL_IDS + + +class BoardError(ValueError): + def __init__(self, problems: list[str]) -> None: + super().__init__("\n".join(problems)) + self.problems = problems + + +@dataclass(frozen=True, eq=False) +class Board: + cells: tuple[tuple[str, ...], ...] + # Closed edges between orthogonally adjacent in-bounds cells. The outer border is + # always closed and is not stored. + walls: frozenset[Edge] + tunnels: Mapping[Pos, Pos] + + @property + def size(self) -> int: + return len(self.cells) + + def code(self, p: Pos) -> str: + return self.cells[p.row][p.col] + + def positions(self) -> Iterator[Pos]: + for r in range(self.size): + for c in range(self.size): + yield Pos(r, c) + + 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 cells_with(self, code: str) -> frozenset[Pos]: + return frozenset(p for p in self.positions() if self.code(p) == code) + + @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) + + def to_grid(self) -> Grid: + n = self.size + h_walls = tuple( + tuple(r in (0, n) or edge(Pos(r - 1, c), Pos(r, c)) in self.walls for c in range(n)) + for r in range(n + 1) + ) + v_walls = tuple( + tuple(c in (0, n) or edge(Pos(r, c - 1), Pos(r, c)) in self.walls for c in range(n + 1)) + for r in range(n) + ) + return Grid(cells=self.cells, h_walls=h_walls, v_walls=v_walls) + + +def build_board(quadrant: Grid, tunnel_offsets: Mapping[str, int] | None = None) -> Board: + """Rotate ``quadrant`` into a full board. Raises ``BoardError`` on structural problems. + + A tunnel id appears on exactly two quadrant cells, ``a`` and ``b`` in reading order. + With offset ``o``, ``a`` in quadrant k is linked to ``b`` in quadrant k + o. + """ + offsets = dict(tunnel_offsets or {}) + problems = _check_quadrant(quadrant, offsets) + if problems: + raise BoardError(problems) + + cells = [[""] * BOARD_SIZE for _ in range(BOARD_SIZE)] + for r in range(QUADRANT_SIZE): + for c in range(QUADRANT_SIZE): + for k in range(QUADRANTS): + p = rotate(Pos(r, c), k) + cells[p.row][p.col] = quadrant.cells[r][c] + + walls = frozenset( + edge(rotate(a, k), rotate(b, k)) + for a, b in _quadrant_walls(quadrant) + for k in range(QUADRANTS) + ) + + tunnels: dict[Pos, Pos] = {} + for tunnel_id, (a, b) in _tunnel_positions(quadrant).items(): + offset = offsets.get(tunnel_id, 0) + for k in range(QUADRANTS): + a_here, b_there = rotate(a, k), rotate(b, k + offset) + tunnels[a_here] = b_there + tunnels[b_there] = a_here + + return Board( + cells=tuple(tuple(row) for row in cells), + walls=walls, + tunnels=tunnels, + ) + + +def _check_quadrant(quadrant: Grid, offsets: Mapping[str, int]) -> list[str]: + n = QUADRANT_SIZE + if (quadrant.height, quadrant.width) != (n, n): + return [f"quadrant must be {n}x{n}, got {quadrant.height}x{quadrant.width}"] + + problems: list[str] = [] + for r in range(n): + for c in range(n): + code = quadrant.cells[r][c] + if code not in SQUARE_CODES and not is_tunnel(code): + problems.append(f"cell {Pos(r, c)}: unknown code '{code}'") + + if not all(quadrant.h_walls[0]): + problems.append("top border of the quadrant must be fully walled (outer border)") + if not all(row[0] for row in quadrant.v_walls): + problems.append("left border of the quadrant must be fully walled (outer border)") + + # Rotation maps the bottom seam onto the right seam, so they describe the same edges. + for i in range(n): + right_seam, bottom_seam = quadrant.v_walls[i][n], quadrant.h_walls[n][i] + if right_seam != bottom_seam: + problems.append( + f"seam mismatch: right border at row {i} is " + f"{_wall_word(right_seam)} but bottom border at column {i} is " + f"{_wall_word(bottom_seam)}; rotation requires them to be equal" + ) + + dojo = {(r, c) for r in range(n) for c in range(n) if quadrant.cells[r][c] == Square.DOJO} + if dojo != QUADRANT_DOJO_CELLS: + expected = ", ".join(str(Pos(*p)) for p in sorted(QUADRANT_DOJO_CELLS)) + problems.append(f"Dojo cells must be exactly {expected}") + + tunnel_positions = _tunnel_positions(quadrant) + for tunnel_id, positions in sorted(tunnel_positions.items()): + if len(positions) != 2: + problems.append( + f"tunnel '{tunnel_id}' must appear on exactly 2 cells, found {len(positions)}" + ) + for tunnel_id, offset in sorted(offsets.items()): + if tunnel_id not in tunnel_positions: + problems.append(f"legend links tunnel '{tunnel_id}', which is not on the quadrant") + if isinstance(offset, bool) or not isinstance(offset, int) or not 0 <= offset < QUADRANTS: + problems.append(f"tunnel '{tunnel_id}': quadrant_offset must be 0..3, got {offset!r}") + return problems + + +def _wall_word(is_wall: bool) -> str: + return "walled" if is_wall else "open" + + +def _quadrant_walls(quadrant: Grid) -> Iterator[Edge]: + """Closed edges owned by the quadrant: its interior plus the right and bottom seams.""" + n = QUADRANT_SIZE + for r in range(n): + for c in range(1, n + 1): + if quadrant.v_walls[r][c]: + yield edge(Pos(r, c - 1), Pos(r, c)) + for r in range(1, n + 1): + for c in range(n): + if quadrant.h_walls[r][c]: + yield edge(Pos(r - 1, c), Pos(r, c)) + + +def _tunnel_positions(quadrant: Grid) -> dict[str, list[Pos]]: + """Tunnel cells per id, in reading order.""" + found: dict[str, list[Pos]] = {} + for r in range(QUADRANT_SIZE): + for c in range(QUADRANT_SIZE): + code = quadrant.cells[r][c] + if is_tunnel(code): + found.setdefault(code, []).append(Pos(r, c)) + return found + + +def parse_legend(data: Mapping[str, object]) -> dict[str, int]: + """Return tunnel quadrant offsets from a parsed ``legend.toml``.""" + problems: list[str] = [] + unknown_sections = set(data) - {"tunnels"} + if unknown_sections: + problems.append(f"legend: unknown sections {sorted(unknown_sections)}") + + tunnels = data.get("tunnels", {}) + offsets: dict[str, int] = {} + if not isinstance(tunnels, Mapping): + problems.append("legend: [tunnels] must be a table") + tunnels = {} + for tunnel_id, entry in tunnels.items(): + if tunnel_id not in TUNNEL_IDS: + problems.append(f"legend: tunnel id '{tunnel_id}' must be a digit 1-9") + continue + if not isinstance(entry, Mapping) or set(entry) != {"quadrant_offset"}: + problems.append(f"legend: [tunnels.{tunnel_id}] must contain only quadrant_offset") + continue + offsets[tunnel_id] = entry["quadrant_offset"] + + if problems: + raise BoardError(problems) + return offsets + + +def load_board(quadrant_path: Path, legend_path: Path | None = None) -> Board: + quadrant = parse_grid(quadrant_path.read_text(encoding="utf-8"), source=str(quadrant_path)) + offsets: dict[str, int] = {} + if legend_path is not None: + with legend_path.open("rb") as handle: + offsets = parse_legend(tomllib.load(handle)) + return build_board(quadrant, offsets) diff --git a/src/dojo/bots.py b/src/dojo/bots.py new file mode 100644 index 0000000..f059d5a --- /dev/null +++ b/src/dojo/bots.py @@ -0,0 +1,374 @@ +"""Computer players for simulations. + +Bots only ever see a ``GameView``, so they play with the same hidden information as people. +The heuristic bot scores each option as an expected change of a value function (track +position, plus closeness to the Dojo once Brown); a ``Profile`` sets its attitude to risk. +""" + +import random +from collections import deque +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from functools import cache + +from dojo.board import Board, Pos, Square, is_tunnel +from dojo.engine import FightView, GameView, PlayerView +from dojo.movement import Path +from dojo.rules import Card + +DIE_FACES = range(1, 7) + +# Value of one step closer to the Dojo for a Brown player, in stripes. +DOJO_PULL = 0.3 +# Value of a Nebbia draw, in stripes. +CARD_VALUE = 0.2 +# Win probability adjustment for a fighter holding a Tecnica. +TECNICA_EDGE = 0.1 +# Passing the Final Exam wins the game; this dwarfs every other score. +EXAM_WIN = 100.0 + + +class RandomAgent: + """Uniformly random legal choices: the baseline every other bot must beat.""" + + def __init__(self, rng: random.Random) -> None: + self._rng = rng + + def place_pawn(self, view: GameView, squares: Sequence[Pos]) -> Pos: + return self._rng.choice(squares) + + def choose_path(self, view: GameView, paths: Sequence[Path]) -> Path: + return self._rng.choice(paths) + + def use_tunnel(self, view: GameView, exit: Pos) -> bool: + return self._rng.random() < 0.5 + + def choose_tunnel_destination(self, view: GameView, tunnels: Sequence[Pos]) -> Pos: + return self._rng.choice(tunnels) + + def choose_sfida_target(self, view: GameView, targets: Sequence[int]) -> int: + return self._rng.choice(targets) + + def choose_irimi_target(self, view: GameView, targets: Sequence[int]) -> int: + return self._rng.choice(targets) + + def choose_exam_defender(self, view: GameView, candidates: Sequence[int]) -> int: + return self._rng.choice(candidates) + + def keep_tecnica(self, view: GameView, held: Card, drawn: Card) -> Card: + return self._rng.choice((held, drawn)) + + def play_tecnica(self, view: GameView, fight: FightView) -> bool: + return self._rng.random() < 0.5 + + +@dataclass(frozen=True) +class Profile: + name: str + # Weight of expected losses against expected gains: above 1 avoids risk. + risk: float + # Weight of opponents' value changes: an opponent's loss is worth this much to me. + rivalry: float + # Tecnica to keep when holding two, best first. + tecnica_rank: tuple[Card, ...] + + +BALANCED = Profile( + "balanced", risk=1.0, rivalry=0.5, tecnica_rank=(Card.KIAI, Card.KAESHI, Card.UKE) +) +FARMER = Profile("farmer", risk=2.5, rivalry=0.2, tecnica_rank=(Card.UKE, Card.KIAI, Card.KAESHI)) +AGGRESSIVE = Profile( + "aggressive", risk=0.5, rivalry=1.0, tecnica_rank=(Card.KIAI, Card.KAESHI, Card.UKE) +) +PROFILES = {p.name: p for p in (BALANCED, FARMER, AGGRESSIVE)} + + +@cache +def win_probability(attacker_extra: int, defender_extra: int) -> float: + """Chance the attacker wins a Kumite on 1d6 each; ties go to the attacker (Senshu).""" + wins = sum(a + attacker_extra >= d + defender_extra for a in DIE_FACES for d in DIE_FACES) + return wins / len(DIE_FACES) ** 2 + + +@cache +def dojo_distance(board: Board) -> Mapping[Pos, int]: + """Walking steps from each square into the Dojo, as if the Dojo were open.""" + distance = dict.fromkeys(board.dojo, 0) + queue = deque(board.dojo) + while queue: + square = queue.popleft() + for neighbour in board.neighbours(square): + if neighbour not in distance: + distance[neighbour] = distance[square] + 1 + queue.append(neighbour) + return distance + + +@cache +def farthest_from_dojo(board: Board) -> int: + return max(dojo_distance(board).values()) + + +@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.""" + distance = dojo_distance(board) + return min(sorted(board.safe_room(corner)), key=lambda s: distance[s]) + + +@dataclass(frozen=True) +class _Fight: + """A Kumite I attack, seen from my side.""" + + win_chance: float + win_track: int + # Weighted score of the losing branch, rivalry included. + lose_score: float + # Rivalry score of the winning branch; my own gain is scored where the move ends. + win_rivalry: float + + +class HeuristicAgent: + """Picks the option with the best risk-weighted expected value change. + + Known simplifications: the Meditazione extra step, Passo Falso after a chained Kumite + (the origin is taken as the current square) and the opponents' replies are ignored. + """ + + def __init__(self, profile: Profile = BALANCED) -> None: + self.profile = profile + + # Agent protocol --------------------------------------------------------------------- + + def place_pawn(self, view: GameView, squares: Sequence[Pos]) -> Pos: + return 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)) + + def use_tunnel(self, view: GameView, exit: Pos) -> bool: + me = self._me(view) + return self._arrival_score(view, me.track, exit, self._value(view, me)) > 0 + + def choose_tunnel_destination(self, view: GameView, tunnels: Sequence[Pos]) -> Pos: + me = self._me(view) + v0 = self._value(view, me) + return max(tunnels, key=lambda t: self._arrival_score(view, me.track, t, v0)) + + def choose_sfida_target(self, view: GameView, targets: Sequence[int]) -> int: + me = self._me(view) + v0 = self._value(view, me) + return max( + targets, + key=lambda t: self._fight_score(view, me.track, me.position, view.players[t], v0), + ) + + def choose_irimi_target(self, view: GameView, targets: Sequence[int]) -> int: + me = self._me(view) + v0 = self._value(view, me) + + def score(target: int) -> float: + other = view.players[target] + mine = self._weigh(self._value_at(view, me.track, other.position) - v0) + theirs = self._value_at(view, other.track, me.position) - self._value(view, other) + return mine - self.profile.rivalry * theirs + + return max(targets, key=score) + + def choose_exam_defender(self, view: GameView, candidates: Sequence[int]) -> int: + me = self._me(view) + return max(candidates, key=lambda c: self._win_chance(view, me, me.track, view.players[c])) + + def keep_tecnica(self, view: GameView, held: Card, drawn: Card) -> Card: + rank = self.profile.tecnica_rank + return min((held, drawn), key=rank.index) + + def play_tecnica(self, view: GameView, fight: FightView) -> bool: + attacking = fight.attacker == view.me + mine, theirs = ( + (fight.attacker_total, fight.defender_total) + if attacking + else (fight.defender_total, fight.attacker_total) + ) + + def wins(total: int) -> bool: + return total >= theirs if attacking else total > theirs + + if wins(mine): + return False + if view.tecnica is Card.KIAI: + return wins(mine + view.config.kiai_bonus) + # Losing already: a reroll or a parry can only help. + return True + + # Scoring ---------------------------------------------------------------------------- + + def _path_score( + self, view: GameView, path: Path, opponents: Mapping[Pos, PlayerView], v0: float + ) -> float: + me = self._me(view) + track = me.track + alive = 1.0 + score = 0.0 + previous = me.position + for square in path: + if square in view.board.dojo: + return score + alive * self._exam_score(view, track, previous, v0) + opponent = opponents.get(square) + if opponent is not None: + fight = self._fight(view, track, opponent, v0) + score += alive * ((1 - fight.win_chance) * fight.lose_score) + alive *= fight.win_chance + score += alive * fight.win_rivalry + track = fight.win_track + previous = square + return score + alive * self._stop_score(view, track, path[-1], me.position, v0) + + def _stop_score(self, view: GameView, track: int, square: Pos, origin: Pos, v0: float) -> float: + """Score of ending the move on ``square``, including its effect.""" + config = view.config + code = view.board.code(square) + + def at(new_track: int, new_square: Pos, bonus: float = 0.0) -> float: + return self._weigh(self._value_at(view, new_track, new_square) + bonus - v0) + + match code: + case Square.ALLENAMENTO: + return at(config.shifted(track, 1, from_board=True), square) + case Square.FATICA: + return at(config.shifted(track, -1, from_board=True), square) + case Square.NEBBIA: + return at(track, square, CARD_VALUE) + case Square.PASSO_FALSO: + return at(track, origin) + case Square.SFIDA: + targets = self._fightable(view).values() + if not targets: + return at(track, square) + return max(self._fight_score(view, track, square, t, v0) for t in targets) + case _ if is_tunnel(code): + stay = at(track, square) + exit_square = view.board.tunnels[square] + if self._has_meditator(view, exit_square): + return stay + return max(stay, self._arrival_score(view, track, exit_square, v0)) + case _: + return at(track, square) + + def _arrival_score(self, view: GameView, track: int, square: Pos, v0: float) -> float: + """Score of teleporting onto ``square`` (no square effect; an opponent means Kumite).""" + opponent = self._fightable(view).get(square) + if opponent is None: + return self._weigh(self._value_at(view, track, square) - v0) + return self._fight_score(view, track, square, opponent, v0) + + def _fight_score( + self, view: GameView, track: int, square: Pos, opponent: PlayerView, v0: float + ) -> float: + """Score of a Kumite I attack after which my move is over.""" + fight = self._fight(view, track, opponent, v0) + win = fight.win_rivalry + self._weigh(self._value_at(view, fight.win_track, square) - v0) + return fight.win_chance * win + (1 - fight.win_chance) * fight.lose_score + + def _fight(self, view: GameView, track: int, opponent: PlayerView, v0: float) -> _Fight: + config = view.config + me = self._me(view) + stripes = config.stripes_per_belt + mine, theirs = config.belt(track), config.belt(opponent.track) + if mine > theirs: + win_gain, lose_loss, their_gain = config.expected_win_gain, stripes, stripes + elif mine < theirs: + win_gain, lose_loss, their_gain = stripes, 0, config.expected_win_gain + else: + win_gain, lose_loss, their_gain = stripes, stripes, stripes + their_loss = stripes if mine <= theirs else 0 + + their_value = self._value(view, opponent) + beaten = self._value_at( + view, + config.shifted(opponent.track, -their_loss, from_board=False), + start_square(view.board, opponent.corner), + ) + winner = self._value_at( + view, config.shifted(opponent.track, their_gain, from_board=False), opponent.position + ) + lost = self._value_at( + view, + config.shifted(track, -lose_loss, from_board=False), + start_square(view.board, me.corner), + ) + rivalry = self.profile.rivalry + return _Fight( + win_chance=self._win_chance(view, me, track, opponent), + win_track=config.shifted(track, win_gain, from_board=False), + lose_score=self._weigh(lost - v0) - rivalry * (winner - their_value), + win_rivalry=-rivalry * (beaten - their_value), + ) + + def _exam_score(self, view: GameView, track: int, entrance: Pos, v0: float) -> float: + me = self._me(view) + eligible = [p for p in view.players if p.id != view.me and not p.meditating] + if not eligible: + return self._weigh(self._value_at(view, track, entrance) - v0) + top = max(p.track for p in eligible) + chance = max(self._win_chance(view, me, track, p) for p in eligible if p.track == top) + failed = self._value_at( + view, + view.config.shifted(track, -view.config.stripes_per_belt, from_board=False), + start_square(view.board, me.corner), + ) + return chance * EXAM_WIN + (1 - chance) * self._weigh(failed - v0) + + def _win_chance( + self, view: GameView, me: PlayerView, track: int, opponent: PlayerView + ) -> float: + config = view.config + mine, theirs = config.belt(track), config.belt(opponent.track) + bonus = min(abs(mine - theirs), config.max_belt_bonus) + chance = win_probability(bonus if mine > theirs else 0, bonus if theirs > mine else 0) + if view.tecnica is not None: + chance += TECNICA_EDGE + if opponent.holds_tecnica: + chance -= TECNICA_EDGE + return min(1.0, max(0.0, chance)) + + # Values ----------------------------------------------------------------------------- + + def _value(self, view: GameView, player: PlayerView) -> float: + return self._value_at(view, player.track, player.position) + + def _value_at(self, view: GameView, track: int, square: Pos) -> float: + """A player's worth in stripes; once Brown, closeness to the Dojo counts too.""" + if not view.config.is_brown(track): + return track + board = view.board + return track + DOJO_PULL * (farthest_from_dojo(board) - dojo_distance(board)[square]) + + def _weigh(self, delta: float) -> float: + return delta if delta >= 0 else self.profile.risk * delta + + # View helpers ----------------------------------------------------------------------- + + def _me(self, view: GameView) -> PlayerView: + return view.players[view.me] + + def _fightable(self, view: GameView) -> dict[Pos, PlayerView]: + return { + p.position: p + for p in view.players + if p.id != view.me and not p.meditating and not self._is_safe(view, p.position) + } + + def _has_meditator(self, view: GameView, square: Pos) -> bool: + return any(p.id != view.me and p.meditating and p.position == square for p in view.players) + + @staticmethod + def _is_safe(view: GameView, square: Pos) -> bool: + return view.board.code(square) == Square.SAFE + + @staticmethod + def _nearest_to_dojo(board: Board, squares: Sequence[Pos]) -> Pos: + distance = dojo_distance(board) + return min(squares, key=lambda s: distance[s]) diff --git a/src/dojo/cli.py b/src/dojo/cli.py new file mode 100644 index 0000000..1c3c805 --- /dev/null +++ b/src/dojo/cli.py @@ -0,0 +1,108 @@ +import argparse +import sys +from pathlib import Path + +from dojo.board import QUADRANTS, Board, BoardError, load_board +from dojo.grid import GridFormatError, render_grid +from dojo.validate import Report, validate + +DEFAULT_QUADRANT = Path("board/quadrant.txt") +LEGEND_FILENAME = "legend.toml" + +EXIT_OK = 0 +EXIT_DESIGN_ERRORS = 1 +EXIT_FORMAT_ERRORS = 2 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(prog="dojo") + commands = parser.add_subparsers(dest="command", required=True) + + board_parser = commands.add_parser("board", help="board tooling") + board_commands = board_parser.add_subparsers(dest="board_command", required=True) + + validate_parser = board_commands.add_parser( + "validate", help="check the board against the rules" + ) + _add_board_arguments(validate_parser) + + render_parser = board_commands.add_parser("render", help="print the full generated board") + _add_board_arguments(render_parser) + render_parser.add_argument( + "-o", "--output", type=Path, help="write to this file instead of stdout" + ) + + args = parser.parse_args(argv) + legend = _resolve_legend(args.quadrant, args.legend) + board = _load(args.quadrant, legend) + if board is None: + return EXIT_FORMAT_ERRORS + + if args.board_command == "validate": + report = validate(board) + print(_format_report(args.quadrant, legend, report)) + return EXIT_OK if report.ok else EXIT_DESIGN_ERRORS + + text = render_grid(board.to_grid()) + if args.output is None: + sys.stdout.write(text) + else: + args.output.write_text(text, encoding="utf-8") + return EXIT_OK + + +def _add_board_arguments(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--quadrant", type=Path, default=DEFAULT_QUADRANT) + parser.add_argument( + "--legend", + type=Path, + default=None, + help=f"tunnel links (default: {LEGEND_FILENAME} next to the quadrant, if present)", + ) + + +def _resolve_legend(quadrant: Path, legend: Path | None) -> Path | None: + if legend is not None: + return legend + sibling = quadrant.with_name(LEGEND_FILENAME) + return sibling if sibling.exists() else None + + +def _load(quadrant: Path, legend: Path | None) -> Board | None: + try: + return load_board(quadrant, legend) + except OSError as error: + print(f"error: {error}", file=sys.stderr) + except (GridFormatError, BoardError) as error: + print("Board format errors:", file=sys.stderr) + for problem in error.problems: + print(f" - {problem}", file=sys.stderr) + return None + + +def _format_report(quadrant: Path, legend: Path | None, report: Report) -> str: + lines = [f"Board: {quadrant}" + (f" + {legend}" if legend else "")] + stats = report.stats + if stats is not None: + counts = ", ".join( + f"{code} {count} ({count / stats.playable_squares:.0%})" + for code, count in stats.square_counts.items() + ) + distances = ", ".join( + f"Q{quadrant} {'unreachable' if steps is None else steps}" + for quadrant, steps in zip(range(QUADRANTS), stats.dojo_distance, strict=True) + ) + lines += [ + f"Playable squares: {stats.playable_squares}", + f"Square mix: {counts}", + f"Tatami share: {stats.tatami_share:.0%}", + f"Dojo entrances: {stats.dojo_entrances}", + f"Tunnel pairs: {stats.tunnel_pairs}", + f"Shortest walk Safe Room -> Dojo: {distances}", + ] + for title, items in (("Warnings", report.warnings), ("Errors", report.errors)): + if items: + lines.append(f"{title}:") + lines += [f" - {item}" for item in items] + lines.append("OK" if report.ok else f"FAILED: {len(report.errors)} error(s)") + return "\n".join(lines) diff --git a/src/dojo/engine.py b/src/dojo/engine.py new file mode 100644 index 0000000..cedbce8 --- /dev/null +++ b/src/dojo/engine.py @@ -0,0 +1,584 @@ +"""Rules engine: one game of Il Labirinto del Dojo. + +Every choice a player makes is delegated to an ``Agent``; dice and deck order come from a +``Randomness`` source, so games are reproducible and tests can script every roll. +Rule references are to RULES.md. +""" + +import itertools +import random +from collections import deque +from collections.abc import Sequence +from dataclasses import dataclass +from enum import Enum, auto +from functools import cache +from typing import Protocol + +from dojo.board import QUADRANTS, Board, Pos, Square, is_tunnel +from dojo.events import ( + CardDrawn, + Event, + ExamTaken, + FightKind, + GameOver, + KumiteFought, + Moved, + SentToStart, + Source, + Teleported, + TrackChanged, + TurnSkipped, + TurnStarted, +) +from dojo.movement import Path, walks +from dojo.rules import TECNICHE, Card, RulesConfig + +MIN_PLAYERS = 2 +MAX_PLAYERS = 8 +SENSEI_OSSERVA_GAIN = 2 + + +class Randomness(Protocol): + def d6(self) -> int: ... + + def shuffle(self, cards: list[Card]) -> None: ... + + +class RandomSource: + def __init__(self, seed: int | None = None) -> None: + self._rng = random.Random(seed) + + def d6(self) -> int: + return self._rng.randint(1, 6) + + def shuffle(self, cards: list[Card]) -> None: + self._rng.shuffle(cards) + + +@dataclass +class Player: + id: int + corner: int + position: Pos + track: int + tecnica: Card | None = None + meditating: bool = False + + +@dataclass(frozen=True) +class FightView: + """What a fighter sees when deciding whether to play its Tecnica.""" + + attacker: int + defender: int + exam: bool + attacker_total: int + defender_total: int + played: tuple[tuple[int, Card], ...] + + +@dataclass(frozen=True) +class PlayerView: + """What everyone at the table knows about a player: whether they hold a Tecnica, not which.""" + + id: int + corner: int + position: Pos + track: int + holds_tecnica: bool + meditating: bool + + +@dataclass(frozen=True) +class GameView: + """The game as player ``me`` sees it: public state plus its own Tecnica (RULES.md, D26).""" + + board: Board + config: RulesConfig + round: int + me: int + tecnica: Card | None + players: tuple[PlayerView, ...] + + def belt(self, player: int) -> int: + return self.config.belt(self.players[player].track) + + +class Agent(Protocol): + def place_pawn(self, view: GameView, squares: Sequence[Pos]) -> Pos: ... + + def choose_path(self, view: GameView, paths: Sequence[Path]) -> Path: ... + + def use_tunnel(self, view: GameView, exit: Pos) -> bool: ... + + def choose_tunnel_destination(self, view: GameView, tunnels: Sequence[Pos]) -> Pos: ... + + def choose_sfida_target(self, view: GameView, targets: Sequence[int]) -> int: ... + + def choose_irimi_target(self, view: GameView, targets: Sequence[int]) -> int: ... + + def choose_exam_defender(self, view: GameView, candidates: Sequence[int]) -> int: ... + + def keep_tecnica(self, view: GameView, held: Card, drawn: Card) -> Card: ... + + def play_tecnica(self, view: GameView, fight: FightView) -> bool: ... + + +class Step(Enum): + MOVED = auto() + WON = auto() + LOST = auto() + EXAM = auto() + + +@dataclass(frozen=True) +class FightResult: + attacker_won: bool + kind: FightKind + attacker_uke: bool + defender_uke: bool + + +@cache +def seat_corners(players: int) -> tuple[int, ...]: + """Corner of each seat, in turn order. Corner-mates are never consecutive (cyclically).""" + if not MIN_PLAYERS <= players <= MAX_PLAYERS: + raise ValueError(f"{players} players: supported range is {MIN_PLAYERS}-{MAX_PLAYERS}") + corners = sorted([*range(min(players, QUADRANTS)), *range(max(0, players - QUADRANTS))]) + for order in itertools.permutations(corners): + if all(order[i] != order[(i + 1) % players] for i in range(players)): + return order + raise AssertionError(f"no seating for {players} players") + + +class Game: + def __init__( + self, + board: Board, + config: RulesConfig, + agents: Sequence[Agent], + randomness: Randomness, + ) -> None: + self.board = board + self.config = config + self.agents = tuple(agents) + self.events: list[Event] = [] + self.round = 1 + self.current = 0 + self.winner: int | None = None + self._random = randomness + self._dojo = board.dojo + + cards = [card for card, quantity in config.deck for _ in range(quantity)] + randomness.shuffle(cards) + self._deck: deque[Card] = deque(cards) + self._discard: list[Card] = [] + + self.players = [ + Player(seat, corner, min(board.safe_room(corner)), config.start_position) + for seat, corner in enumerate(seat_corners(len(self.agents))) + ] + for player in self.players: + self._place(player) + + @property + def finished(self) -> bool: + return self.winner is not None or self.round > self.config.max_rounds + + def belt(self, player: int) -> int: + return self.config.belt(self.players[player].track) + + def view(self, player: int) -> GameView: + me = self.players[player] + return GameView( + board=self.board, + config=self.config, + round=self.round, + me=player, + tecnica=me.tecnica, + players=tuple( + PlayerView(p.id, p.corner, p.position, p.track, p.tecnica is not None, p.meditating) + for p in self.players + ), + ) + + def run(self) -> int | None: + while not self.finished: + self.play_turn() + return self.winner + + def play_turn(self) -> None: + if self.finished: + raise RuntimeError("the game is over") + player = self.players[self.current] + if player.meditating: + player.meditating = False + self._emit(TurnSkipped(self.round, player.id)) + else: + roll = self._random.d6() + self._random.d6() + self._emit(TurnStarted(self.round, player.id, roll)) + self._move(player, roll) + + if self.winner is not None: + self._emit(GameOver(self.winner, self.round)) + return + self.current = (self.current + 1) % len(self.players) + if self.current == 0: + self.round += 1 + if self.round > self.config.max_rounds: + self._emit(GameOver(None, self.config.max_rounds)) + + # Movement ----------------------------------------------------------------------------- + + def _move(self, player: Player, steps: int) -> None: + origin = player.position + visited = {origin} + remaining = steps + while True: + paths = self._path_choices(player, remaining, visited) + if not paths: + break + path = self._pick( + self._agent(player).choose_path(self.view(player.id), paths), paths, "path" + ) + walked, step = self._walk(player, path, visited) + if step in (Step.LOST, Step.EXAM): + return + if step is not Step.WON or walked == len(path): + break + # A won Kumite may have changed the belt (and Dojo access): choose again. + remaining -= walked + if self._settle(player, visited): + self._resolve_square(player, origin) + + def _walk(self, player: Player, path: Path, visited: set[Pos]) -> tuple[int, Step]: + """Walk ``path`` until it ends or a Kumite happens. Returns (squares walked, last step).""" + step = Step.MOVED + walked = 0 + for cell in path: + visited.add(cell) + walked += 1 + step = self._step(player, cell, previous=player.position) + if step is not Step.MOVED: + break + self._emit(Moved(player.id, path[:walked])) + return walked, step + + def _path_choices(self, player: Player, steps: int, visited: set[Pos]) -> list[Path]: + """Legal walks, one per distinct outcome (end square and opponents crossed in order).""" + brown = self.config.is_brown(player.track) + choices: dict[tuple[Pos, tuple[Pos, ...]], Path] = {} + for path in walks(self.board, player.position, steps, visited, can_enter_dojo=brown): + crossed = tuple(cell for cell in path if self._fightable_at(cell, player)) + choices.setdefault((path[-1], crossed), path) + return list(choices.values()) + + def _step(self, player: Player, cell: Pos, previous: Pos) -> Step: + if cell in self._dojo: + self._exam(player, entrance=previous) + return Step.EXAM + defender = self._fightable_at(cell, player) + player.position = cell + if defender is None: + return Step.MOVED + result = self._kumite(player, defender) + self._apply_fight(player, defender, result, attacker_retreat=previous) + return Step.WON if result.attacker_won else Step.LOST + + def _settle(self, player: Player, visited: set[Pos]) -> bool: + """Meditazione rule: a move may not end on another pawn's square (outside Safe Rooms). + + Take one extra step while sharing a square; stay if no step is possible. + Returns whether the final square's effect should be resolved. + """ + while self._shares_square(player): + brown = self.config.is_brown(player.track) + options = walks(self.board, player.position, 1, visited, can_enter_dojo=brown) + if not options: + return True + path = self._pick( + self._agent(player).choose_path(self.view(player.id), options), options, "path" + ) + _, step = self._walk(player, path, visited) + if step in (Step.LOST, Step.EXAM): + return False + return True + + # Kumite ------------------------------------------------------------------------------- + + def _kumite(self, attacker: Player, defender: Player, *, exam: bool = False) -> FightResult: + config = self.config + belts = (config.belt(attacker.track), config.belt(defender.track)) + bonus = min(abs(belts[0] - belts[1]), config.max_belt_bonus) + dice = [self._random.d6(), self._random.d6()] + extra = [bonus if belts[0] > belts[1] else 0, bonus if belts[1] > belts[0] else 0] + uke = [False, False] + played: list[tuple[int, Card]] = [] + + for side, fighter in enumerate((attacker, defender)): + card = fighter.tecnica + if card is None: + continue + view = FightView( + attacker=attacker.id, + defender=defender.id, + exam=exam, + attacker_total=dice[0] + extra[0], + defender_total=dice[1] + extra[1], + played=tuple(played), + ) + if not self._agent(fighter).play_tecnica(self.view(fighter.id), view): + continue + fighter.tecnica = None + self._discard.append(card) + played.append((fighter.id, card)) + match card: + case Card.KIAI: + extra[side] += config.kiai_bonus + case Card.KAESHI: + dice[side] = self._random.d6() + case Card.UKE: + uke[side] = True + + totals = (dice[0] + extra[0], dice[1] + extra[1]) + attacker_won = totals[0] >= totals[1] # Senshu: ties go to the attacker. + winner_belt, loser_belt = belts if attacker_won else belts[::-1] + if winner_belt == loser_belt: + kind = FightKind.EQUAL + elif winner_belt < loser_belt: + kind = FightKind.UPSET + else: + kind = FightKind.EXPECTED + self._emit( + KumiteFought( + attacker=attacker.id, + defender=defender.id, + attacker_belt=belts[0], + defender_belt=belts[1], + attacker_total=totals[0], + defender_total=totals[1], + attacker_won=attacker_won, + kind=kind, + exam=exam, + cards=tuple(played), + ) + ) + return FightResult(attacker_won, kind, attacker_uke=uke[0], defender_uke=uke[1]) + + def _apply_fight( + self, attacker: Player, defender: Player, result: FightResult, attacker_retreat: Pos + ) -> None: + if result.attacker_won: + winner, loser, loser_uke = attacker, defender, result.defender_uke + else: + winner, loser, loser_uke = defender, attacker, result.attacker_uke + + stripes = self.config.stripes_per_belt + if result.kind is FightKind.EXPECTED: + self._change_track(winner, self.config.expected_win_gain, Source.KUMITE) + else: + self._change_track(winner, stripes, Source.KUMITE) + self._change_track(loser, -stripes, Source.KUMITE) + + if not loser_uke: + self._send_to_start(loser) + elif loser is attacker: + self._relocate(attacker, attacker_retreat, "uke") + + # Final Exam --------------------------------------------------------------------------- + + def _exam(self, challenger: Player, entrance: Pos) -> None: + challenger.position = entrance # No pawn ever remains inside the Dojo. + eligible = [p for p in self.players if p is not challenger and not p.meditating] + if not eligible: + return + top = max(p.track for p in eligible) + candidates = [p.id for p in eligible if p.track == top] + if len(candidates) == 1: + defender_id = candidates[0] + else: + choice = self._agent(challenger).choose_exam_defender( + self.view(challenger.id), candidates + ) + defender_id = self._pick(choice, candidates, "exam defender") + + result = self._kumite(challenger, self.players[defender_id], exam=True) + self._emit(ExamTaken(challenger.id, defender_id, result.attacker_won)) + if result.attacker_won: + self.winner = challenger.id + return + self._change_track(challenger, -self.config.stripes_per_belt, Source.EXAM) + if not result.attacker_uke: + self._send_to_start(challenger) + + # Squares ------------------------------------------------------------------------------ + + def _resolve_square(self, player: Player, origin: Pos) -> None: + code = self.board.code(player.position) + match code: + case Square.ALLENAMENTO: + self._change_track(player, 1, Source.SQUARE) + case Square.FATICA: + self._change_track(player, -1, Source.SQUARE) + case Square.NEBBIA: + self._draw(player, origin) + case Square.PASSO_FALSO: + self._relocate(player, origin, "passo falso") + case Square.SFIDA: + self._sfida(player) + case _ if is_tunnel(code): + exit_square = self.board.tunnels[player.position] + if not self._has_unfightable_pawn(exit_square, player) and self._agent( + player + ).use_tunnel(self.view(player.id), exit_square): + self._teleport(player, exit_square, "tunnel") + + def _sfida(self, player: Player) -> None: + targets = self._targets(player) + if not targets: + return + choice = self._agent(player).choose_sfida_target(self.view(player.id), targets) + defender = self.players[self._pick(choice, targets, "Sfida target")] + result = self._kumite(player, defender) + self._apply_fight(player, defender, result, attacker_retreat=player.position) + + def _teleport(self, player: Player, destination: Pos, reason: str) -> None: + """Arrive on ``destination`` without walking; an opponent there means a Kumite.""" + source = player.position + defender = self._fightable_at(destination, player) + self._relocate(player, destination, reason) + if defender is not None: + result = self._kumite(player, defender) + self._apply_fight(player, defender, result, attacker_retreat=source) + + # Nebbia del Dojo ---------------------------------------------------------------------- + + def _draw(self, player: Player, origin: Pos) -> None: + if not self._deck and self._discard: + self._random.shuffle(self._discard) + self._deck.extend(self._discard) + self._discard.clear() + if not self._deck: + return + card = self._deck.popleft() + self._emit(CardDrawn(player.id, card)) + if card in TECNICHE: + self._take_tecnica(player, card) + return + + self._discard.append(card) + match card: + case Card.TSUGI_ASHI: + self._move(player, self._random.d6() + self._random.d6()) + case Card.TUNNEL_SEGRETO: + tunnels = sorted( + p + for p in self.board.tunnels + if p != player.position and not self._has_unfightable_pawn(p, player) + ) + if tunnels: + choice = self._agent(player).choose_tunnel_destination( + self.view(player.id), tunnels + ) + self._teleport(player, self._pick(choice, tunnels, "tunnel"), "tunnel segreto") + case Card.IRIMI: + self._irimi(player) + case Card.SFIDA: + self._sfida(player) + case Card.SENSEI_OSSERVA: + lowest = min(p.track for p in self.players) + for p in [p for p in self.players if p.track == lowest]: + self._change_track(p, SENSEI_OSSERVA_GAIN, Source.CARD) + case Card.PESO_DELLA_CINTURA: + highest = max(p.track for p in self.players) + for p in [p for p in self.players if p.track == highest]: + self._change_track(p, -1, Source.CARD) + case Card.ALLENAMENTO_INTENSIVO: + self._change_track(player, 1, Source.CARD) + case Card.COLPO_PROIBITO: + self._change_track(player, -1, Source.CARD) + case Card.PASSO_FALSO: + self._relocate(player, origin, "passo falso") + case Card.MEDITAZIONE: + player.meditating = True + + def _take_tecnica(self, player: Player, drawn: Card) -> None: + held = player.tecnica + if held is None: + player.tecnica = drawn + return + choice = self._agent(player).keep_tecnica(self.view(player.id), held, drawn) + kept = self._pick(choice, (held, drawn), "Tecnica") + player.tecnica = kept + self._discard.append(drawn if kept is held else held) + + def _irimi(self, player: Player) -> None: + targets = self._targets(player) + if not targets: + return + choice = self._agent(player).choose_irimi_target(self.view(player.id), targets) + other = self.players[self._pick(choice, targets, "Irimi target")] + mine, theirs = player.position, other.position + self._relocate(player, theirs, "irimi") + self._relocate(other, mine, "irimi") + + # State helpers ------------------------------------------------------------------------ + + def _change_track(self, player: Player, delta: int, source: Source) -> None: + from_board = source in (Source.SQUARE, Source.CARD) + after = self.config.shifted(player.track, delta, from_board=from_board) + if after != player.track: + self._emit(TrackChanged(player.id, player.track, after, source)) + player.track = after + + def _place(self, player: Player) -> None: + squares = sorted(self.board.safe_room(player.corner)) + choice = self._agent(player).place_pawn(self.view(player.id), squares) + player.position = self._pick(choice, squares, "start square") + + def _send_to_start(self, player: Player) -> None: + self._place(player) + self._emit(SentToStart(player.id, player.position)) + + def _relocate(self, player: Player, destination: Pos, reason: str) -> None: + if destination != player.position: + self._emit(Teleported(player.id, player.position, destination, reason)) + player.position = destination + + def _in_safe_room(self, square: Pos) -> bool: + return self.board.code(square) == Square.SAFE + + def _fightable_at(self, square: Pos, attacker: Player) -> Player | None: + if self._in_safe_room(square): + return None + for p in self.players: + if p is not attacker and p.position == square and not p.meditating: + return p + return None + + def _has_unfightable_pawn(self, square: Pos, player: Player) -> bool: + return any(p is not player and p.position == square and p.meditating for p in self.players) + + def _shares_square(self, player: Player) -> bool: + if self._in_safe_room(player.position): + return False + return any(p is not player and p.position == player.position for p in self.players) + + def _targets(self, player: Player) -> list[int]: + return [ + p.id + for p in self.players + if p is not player and not p.meditating and not self._in_safe_room(p.position) + ] + + def _agent(self, player: Player) -> Agent: + return self.agents[player.id] + + def _emit(self, event: Event) -> None: + self.events.append(event) + + @staticmethod + def _pick[T](choice: T, options: Sequence[T], what: str) -> T: + if choice not in options: + raise ValueError(f"agent chose an illegal {what}: {choice!r}") + return choice diff --git a/src/dojo/events.py b/src/dojo/events.py new file mode 100644 index 0000000..4bae82f --- /dev/null +++ b/src/dojo/events.py @@ -0,0 +1,109 @@ +"""Game events, recorded in order for tests and simulation metrics.""" + +from dataclasses import dataclass +from enum import StrEnum + +from dojo.board import Pos +from dojo.movement import Path +from dojo.rules import Card + + +class Source(StrEnum): + SQUARE = "square" + CARD = "card" + KUMITE = "kumite" + EXAM = "exam" + + +class FightKind(StrEnum): + EQUAL = "equal" + UPSET = "upset" + EXPECTED = "expected" + + +@dataclass(frozen=True) +class TurnStarted: + round: int + player: int + roll: int + + +@dataclass(frozen=True) +class TurnSkipped: + round: int + player: int + + +@dataclass(frozen=True) +class Moved: + player: int + path: Path + + +@dataclass(frozen=True) +class Teleported: + player: int + source: Pos + destination: Pos + reason: str + + +@dataclass(frozen=True) +class KumiteFought: + attacker: int + defender: int + attacker_belt: int + defender_belt: int + attacker_total: int + defender_total: int + attacker_won: bool + kind: FightKind + exam: bool + cards: tuple[tuple[int, Card], ...] + + +@dataclass(frozen=True) +class TrackChanged: + player: int + before: int + after: int + source: Source + + +@dataclass(frozen=True) +class CardDrawn: + player: int + card: Card + + +@dataclass(frozen=True) +class SentToStart: + player: int + square: Pos + + +@dataclass(frozen=True) +class ExamTaken: + challenger: int + defender: int + passed: bool + + +@dataclass(frozen=True) +class GameOver: + winner: int | None + rounds: int + + +type Event = ( + TurnStarted + | TurnSkipped + | Moved + | Teleported + | KumiteFought + | TrackChanged + | CardDrawn + | SentToStart + | ExamTaken + | GameOver +) diff --git a/src/dojo/grid.py b/src/dojo/grid.py new file mode 100644 index 0000000..9552c7c --- /dev/null +++ b/src/dojo/grid.py @@ -0,0 +1,134 @@ +"""ASCII maze notation: a rectangular grid of one-character cells separated by walls. + +The notation is specified in ``board/FORMAT.md``. This module knows nothing about game +rules; it only turns text into cells and wall flags and back. +""" + +from dataclasses import dataclass + +WALL_CORNER = "+" +H_WALL = "---" +H_OPEN = " " +V_WALL = "|" +V_OPEN = " " +CELL_STRIDE = 4 + + +class GridFormatError(ValueError): + def __init__(self, problems: list[str]) -> None: + super().__init__("\n".join(problems)) + self.problems = problems + + +@dataclass(frozen=True) +class Grid: + cells: tuple[tuple[str, ...], ...] + # h_walls[r][c]: wall on the top edge of cell (r, c); r == height is the bottom border. + h_walls: tuple[tuple[bool, ...], ...] + # v_walls[r][c]: wall on the left edge of cell (r, c); c == width is the right border. + v_walls: tuple[tuple[bool, ...], ...] + + @property + def height(self) -> int: + return len(self.cells) + + @property + def width(self) -> int: + return len(self.cells[0]) + + +def parse_grid(text: str, source: str = "") -> Grid: + numbered = [ + (number, line.rstrip("\n")) + for number, line in enumerate(text.splitlines(), start=1) + if line.strip() and not line.lstrip().startswith("#") + ] + if not numbered: + raise GridFormatError([f"{source}: no grid lines found"]) + + first_number, first_line = numbered[0] + line_length = len(first_line.rstrip()) + width, remainder = divmod(line_length - 1, CELL_STRIDE) + if width < 1 or remainder != 0: + raise GridFormatError( + [f"{source}:{first_number}: top border length {line_length} is not 4*width+1"] + ) + if len(numbered) % 2 == 0: + raise GridFormatError( + [f"{source}: expected an odd number of lines (2*height+1), got {len(numbered)}"] + ) + + problems: list[str] = [] + cells: list[tuple[str, ...]] = [] + h_walls: list[tuple[bool, ...]] = [] + v_walls: list[tuple[bool, ...]] = [] + + for index, (number, raw) in enumerate(numbered): + # Editors strip trailing spaces, which would eat an open right border. + line = raw.rstrip() + if len(line) > line_length: + problems.append(f"{source}:{number}: line longer than the top border ({line_length})") + continue + line = line.ljust(line_length) + if index % 2 == 0: + h_walls.append(_parse_wall_line(line, width, f"{source}:{number}", problems)) + else: + row_cells, row_walls = _parse_cell_line(line, width, f"{source}:{number}", problems) + cells.append(row_cells) + v_walls.append(row_walls) + + if problems: + raise GridFormatError(problems) + return Grid(cells=tuple(cells), h_walls=tuple(h_walls), v_walls=tuple(v_walls)) + + +def _parse_wall_line(line: str, width: int, where: str, problems: list[str]) -> tuple[bool, ...]: + walls: list[bool] = [] + for c in range(width + 1): + corner_at = c * CELL_STRIDE + if line[corner_at] != WALL_CORNER: + problems.append(f"{where}:{corner_at + 1}: expected '{WALL_CORNER}'") + if c == width: + break + segment = line[corner_at + 1 : corner_at + CELL_STRIDE] + if segment not in (H_WALL, H_OPEN): + problems.append(f"{where}:{corner_at + 2}: expected '{H_WALL}' or blanks") + walls.append(segment == H_WALL) + return tuple(walls) + + +def _parse_cell_line( + line: str, width: int, where: str, problems: list[str] +) -> tuple[tuple[str, ...], tuple[bool, ...]]: + codes: list[str] = [] + walls: list[bool] = [] + for c in range(width + 1): + wall_at = c * CELL_STRIDE + separator = line[wall_at] + if separator not in (V_WALL, V_OPEN): + problems.append(f"{where}:{wall_at + 1}: expected '{V_WALL}' or blank") + walls.append(separator == V_WALL) + if c == width: + break + body = line[wall_at + 1 : wall_at + CELL_STRIDE] + code = body[1] + if body[0] != " " or body[2] != " " or code == " ": + problems.append(f"{where}:{wall_at + 2}: cell must be ' X ' with a one-character code") + codes.append(code) + return tuple(codes), tuple(walls) + + +def render_grid(grid: Grid) -> str: + lines: list[str] = [] + for r in range(grid.height + 1): + segments = (H_WALL if wall else H_OPEN for wall in grid.h_walls[r]) + lines.append(WALL_CORNER + "".join(s + WALL_CORNER for s in segments)) + if r == grid.height: + break + parts = [] + for c in range(grid.width + 1): + parts.append(V_WALL if grid.v_walls[r][c] else V_OPEN) + if c < grid.width: + parts.append(f" {grid.cells[r][c]} ") + lines.append("".join(parts).rstrip()) + return "\n".join(lines) + "\n" diff --git a/src/dojo/movement.py b/src/dojo/movement.py new file mode 100644 index 0000000..6505e86 --- /dev/null +++ b/src/dojo/movement.py @@ -0,0 +1,57 @@ +"""Legal walks on the board, ignoring pawns (the engine resolves encounters).""" + +from collections.abc import Iterator, Set + +from dojo.board import Board, Pos + +type Path = tuple[Pos, ...] + + +def walks( + board: Board, start: Pos, steps: int, visited: Set[Pos], can_enter_dojo: bool +) -> list[Path]: + """Self-avoiding orthogonal walks of exactly ``steps`` squares from ``start``. + + 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. + """ + dojo = board.dojo + seen = set(visited) | {start} + found = list(_extend(board, dojo, (start,), steps, seen, can_enter_dojo)) + into_dojo = [path for path in found if path[-1] in dojo] + full = [path for path in found if path[-1] not in dojo] + if full: + return into_dojo + full + for length in range(steps - 1, 0, -1): + longest = [ + path + for path in _extend(board, dojo, (start,), length, seen, can_enter_dojo=False) + if len(path) == length + ] + if longest: + return into_dojo + longest + return into_dojo + + +def _extend( + board: Board, + dojo: Set[Pos], + trail: tuple[Pos, ...], + remaining: int, + seen: set[Pos], + can_enter_dojo: bool, +) -> Iterator[Path]: + if remaining == 0: + yield trail[1:] + return + for nxt in board.neighbours(trail[-1]): + if nxt in seen: + continue + if nxt in dojo: + if can_enter_dojo: + yield (*trail[1:], nxt) + continue + seen.add(nxt) + yield from _extend(board, dojo, (*trail, nxt), remaining - 1, seen, can_enter_dojo) + seen.discard(nxt) diff --git a/src/dojo/rules.py b/src/dojo/rules.py new file mode 100644 index 0000000..0bdcd89 --- /dev/null +++ b/src/dojo/rules.py @@ -0,0 +1,82 @@ +"""Rule parameters and card definitions from RULES.md.""" + +from dataclasses import dataclass +from enum import StrEnum + +BELTS = ("White", "Yellow", "Orange", "Green", "Blue", "Brown") +BROWN = len(BELTS) - 1 + + +class Card(StrEnum): + KIAI = "Kiai" + UKE = "Uke" + KAESHI = "Kaeshi" + TSUGI_ASHI = "Tsugi-ashi" + TUNNEL_SEGRETO = "Tunnel Segreto" + IRIMI = "Irimi" + SFIDA = "Sfida" + SENSEI_OSSERVA = "Il Sensei Osserva" + PESO_DELLA_CINTURA = "Il Peso della Cintura" + ALLENAMENTO_INTENSIVO = "Allenamento Intensivo" + COLPO_PROIBITO = "Colpo Proibito" + PASSO_FALSO = "Passo Falso" + MEDITAZIONE = "Meditazione" + + +TECNICHE = frozenset({Card.KIAI, Card.UKE, Card.KAESHI}) + +DEFAULT_DECK: tuple[tuple[Card, int], ...] = ( + (Card.KIAI, 3), + (Card.UKE, 2), + (Card.KAESHI, 2), + (Card.TSUGI_ASHI, 2), + (Card.TUNNEL_SEGRETO, 1), + (Card.IRIMI, 2), + (Card.SFIDA, 2), + (Card.SENSEI_OSSERVA, 1), + (Card.PESO_DELLA_CINTURA, 1), + (Card.ALLENAMENTO_INTENSIVO, 2), + (Card.COLPO_PROIBITO, 1), + (Card.PASSO_FALSO, 2), + (Card.MEDITAZIONE, 1), +) + + +@dataclass(frozen=True) +class RulesConfig: + # Variant switches (RULES.md, "Rule variants under test"). + brown_requires_kumite: bool = False + expected_win_gain: int = 0 + + kiai_bonus: int = 2 + stripes_per_belt: int = 3 + max_belt_bonus: int = 2 + start_position: int = 0 + deck: tuple[tuple[Card, int], ...] = DEFAULT_DECK + # Safety stop for simulations; not a rule of the game. + max_rounds: int = 200 + + @property + def max_position(self) -> int: + return len(BELTS) * self.stripes_per_belt - 1 + + @property + def brown_position(self) -> int: + return BROWN * self.stripes_per_belt + + def belt(self, position: int) -> int: + return position // self.stripes_per_belt + + def is_brown(self, position: int) -> bool: + return self.belt(position) == BROWN + + def shifted(self, position: int, delta: int, *, from_board: bool) -> int: + """Track position after moving ``delta``; ``from_board``: a square or card caused it.""" + after = position + delta + if from_board and self.brown_requires_kumite and position < self.brown_position: + after = min(after, self.brown_position - 1) + return max(0, min(after, self.max_position)) + + +VARIANT_A = RulesConfig() +VARIANT_B = RulesConfig(brown_requires_kumite=True, expected_win_gain=1) diff --git a/src/dojo/validate.py b/src/dojo/validate.py new file mode 100644 index 0000000..a464795 --- /dev/null +++ b/src/dojo/validate.py @@ -0,0 +1,190 @@ +"""Design checks for a built board, derived from RULES.md. + +Structural problems (the board cannot be built) are raised by ``dojo.board``; this module +reports rule violations and statistics on a board that did build. +""" + +from collections import Counter, deque +from collections.abc import Callable, Iterable, Mapping +from dataclasses import dataclass, field + +from dojo.board import QUADRANTS, Board, Pos, Square, edge, is_tunnel, quadrant_of, rotate + +SAFE_ROOM_CELLS = 9 +SAFE_ROOM_EXITS = 2 +TATAMI_SHARE_RANGE = (0.30, 0.40) +TUNNEL_STAT_KEY = "T" +MAX_LISTED_POSITIONS = 5 + + +@dataclass(frozen=True) +class Stats: + playable_squares: int + square_counts: Mapping[str, int] + tatami_share: float + dojo_entrances: int + tunnel_pairs: int + # Shortest walk from a quadrant's Safe Room into the Dojo; None if unreachable. + dojo_distance: tuple[int | None, ...] + + +@dataclass +class Report: + errors: list[str] = field(default_factory=list) + warnings: list[str] = field(default_factory=list) + stats: Stats | None = None + + @property + def ok(self) -> bool: + return not self.errors + + +def validate(board: Board) -> Report: + report = Report() + dojo = board.dojo + entrances = _dojo_entrances(board, dojo) + + _check_dojo(board, dojo, entrances, report) + for quadrant in range(QUADRANTS): + _check_safe_room(board, quadrant, report) + _check_reachability(board, dojo, report) + + report.stats = _stats(board, dojo, entrances) + low, high = TATAMI_SHARE_RANGE + if not low <= report.stats.tatami_share <= high: + report.warnings.append( + f"Tatami share {report.stats.tatami_share:.0%} is outside the " + f"{low:.0%}-{high:.0%} target" + ) + return report + + +def _dojo_entrances(board: Board, dojo: frozenset[Pos]) -> list[tuple[Pos, Pos]]: + """(outside cell, dojo cell) pairs joined by an open edge.""" + return [ + (outside, inside) + for inside in sorted(dojo) + for outside in board.neighbours(inside) + if outside not in dojo + ] + + +def _check_dojo( + board: Board, + dojo: frozenset[Pos], + entrances: list[tuple[Pos, Pos]], + report: Report, +) -> None: + for a in sorted(dojo): + for b in (Pos(a.row + 1, a.col), Pos(a.row, a.col + 1)): + if b in dojo and edge(a, b) in board.walls: + report.errors.append(f"wall inside the Dojo between {a} and {b}") + + for outside, inside in entrances: + if board.code(outside) == Square.SAFE: + report.errors.append(f"Dojo entrance {inside} opens directly into a Safe Room") + + quadrants_with_entrance = {quadrant_of(outside) for outside, _ in entrances} + for quadrant in range(QUADRANTS): + if quadrant not in quadrants_with_entrance: + report.errors.append(f"quadrant {quadrant} has no Dojo entrance") + + +def _check_safe_room(board: Board, quadrant: int, report: Report) -> None: + room = board.safe_room(quadrant) + label = f"Safe Room of quadrant {quadrant}" + if not room: + report.errors.append(f"{label}: no 'S' cells") + return + + corner = rotate(Pos(0, 0), quadrant) + if corner not in room: + report.errors.append(f"{label}: does not contain the board corner {corner}") + + inside = _bfs(board, [min(room)], lambda p: p in room) + if len(inside) != len(room): + report.errors.append(f"{label}: cells are not all connected to each other") + if len(room) != SAFE_ROOM_CELLS: + report.warnings.append(f"{label}: {len(room)} cells, rules expect {SAFE_ROOM_CELLS}") + + exits = [ + (cell, out) for cell in sorted(room) for out in board.neighbours(cell) if out not in room + ] + if len(exits) != SAFE_ROOM_EXITS: + report.errors.append(f"{label}: {len(exits)} exits, rules require {SAFE_ROOM_EXITS}") + return + (cell_a, out_a), (cell_b, out_b) = exits + if _direction(cell_a, out_a) == _direction(cell_b, out_b): + report.errors.append(f"{label}: both exits face {_direction(cell_a, out_a)}") + if out_a == out_b: + return + + through_room = _bfs(board, [cell_a], lambda p: p in room)[cell_b] + 2 + outside = _bfs(board, [out_a], lambda p: board.code(p) not in (Square.SAFE, Square.DOJO)) + if out_b not in outside: + report.errors.append( + f"{label}: exits {out_a} and {out_b} are only connected through the room" + ) + elif outside[out_b] > through_room: + report.errors.append( + f"{label}: is a shortcut, {through_room} steps through the room versus " + f"{outside[out_b]} around it (between {out_a} and {out_b})" + ) + + +def _direction(src: Pos, dst: Pos) -> str: + return {(-1, 0): "N", (0, 1): "E", (1, 0): "S", (0, -1): "W"}[ + (dst.row - src.row, dst.col - src.col) + ] + + +def _check_reachability(board: Board, dojo: frozenset[Pos], report: Report) -> None: + walkable = {p for p in board.positions() if p not in dojo} + for quadrant in range(QUADRANTS): + room = board.safe_room(quadrant) + if not room: + continue + reached = _bfs(board, room, lambda p: p not in dojo) + unreached = sorted(walkable - reached.keys()) + if unreached: + listed = ", ".join(str(p) for p in unreached[:MAX_LISTED_POSITIONS]) + more = "" if len(unreached) <= MAX_LISTED_POSITIONS else ", ..." + report.errors.append( + f"{len(unreached)} squares cannot be walked to from the Safe Room of " + f"quadrant {quadrant}: {listed}{more}" + ) + + +def _stats(board: Board, dojo: frozenset[Pos], entrances: list[tuple[Pos, Pos]]) -> Stats: + playable = [p for p in board.positions() if board.code(p) not in (Square.SAFE, Square.DOJO)] + counts = Counter( + TUNNEL_STAT_KEY if is_tunnel(board.code(p)) else board.code(p) for p in playable + ) + tatami_share = counts[Square.TATAMI] / len(playable) if playable else 0.0 + + distances: list[int | None] = [] + for quadrant in range(QUADRANTS): + reached = _bfs(board, board.safe_room(quadrant), lambda p: p not in dojo) + steps = [reached[outside] + 1 for outside, _ in entrances if outside in reached] + distances.append(min(steps) if steps else None) + + return Stats( + playable_squares=len(playable), + square_counts=dict(sorted(counts.items())), + tatami_share=tatami_share, + dojo_entrances=len(entrances), + tunnel_pairs=len(board.tunnels) // 2, + dojo_distance=tuple(distances), + ) + + +def _bfs(board: Board, sources: Iterable[Pos], passable: Callable[[Pos], bool]) -> dict[Pos, int]: + distance = {p: 0 for p in sources} + queue = deque(distance) + while queue: + p = queue.popleft() + for q in board.neighbours(p): + if q not in distance and passable(q): + distance[q] = distance[p] + 1 + queue.append(q) + return distance diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..572bf4c --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,18 @@ +from pathlib import Path + +import pytest + +from dojo.board import Board, build_board +from dojo.grid import parse_grid + +FIXTURES = Path(__file__).parent / "fixtures" + + +@pytest.fixture +def quadrant_text() -> str: + return (FIXTURES / "quadrant_valid.txt").read_text(encoding="utf-8") + + +@pytest.fixture +def board(quadrant_text: str) -> Board: + return build_board(parse_grid(quadrant_text)) diff --git a/tests/fixtures/quadrant_valid.txt b/tests/fixtures/quadrant_valid.txt new file mode 100644 index 0000000..0e78f30 --- /dev/null +++ b/tests/fixtures/quadrant_valid.txt @@ -0,0 +1,18 @@ +# Minimal valid quadrant used by the tests. Mostly open, so the Tatami share is high. ++---+---+---+---+---+---+---+---+ +| S S S | . . A . . ++ + + + + + + + + +| S S S . ? . 1 . ++ + + + + + + + + +| S S S | A . . . . ++---+ +---+ + + + + + +| . . . . 1 . F . ++ + + + + + + + + +| . P . . . . . . ++ + + + + + + + + +| . . K . . . . . ++ + + + + + +---+ + +| . . . . . . | D D ++ + + + + + + + + +| . . . . . . | D D ++ + + + + + + + + diff --git a/tests/gridedit.py b/tests/gridedit.py new file mode 100644 index 0000000..39f710f --- /dev/null +++ b/tests/gridedit.py @@ -0,0 +1,36 @@ +"""Helpers to mutate a quadrant fixture at grid coordinates.""" + + +def grid_lines(text: str) -> list[str]: + """Grid lines without comments, right-padded to full width, as the parser sees them.""" + lines = [line for line in text.splitlines() if line.strip() and not line.startswith("#")] + width = len(lines[0]) + return [line.ljust(width) for line in lines] + + +def edit(text: str, line: int, column: int, replacement: str) -> str: + """Overwrite characters of grid line ``line`` (0-based, comments excluded) at ``column``.""" + lines = grid_lines(text) + target = lines[line] + lines[line] = target[:column] + replacement + target[column + len(replacement) :] + return "\n".join(lines) + "\n" + + +def cell_line(row: int) -> int: + return 2 * row + 1 + + +def wall_line_above(row: int) -> int: + return 2 * row + + +def cell_column(col: int) -> int: + return 4 * col + 2 + + +def wall_column_left_of(col: int) -> int: + return 4 * col + + +def h_segment_column(col: int) -> int: + return 4 * col + 1 diff --git a/tests/scripted.py b/tests/scripted.py new file mode 100644 index 0000000..89f7769 --- /dev/null +++ b/tests/scripted.py @@ -0,0 +1,116 @@ +"""Deterministic dice and agents for engine tests.""" + +from collections import deque +from collections.abc import Iterable, Sequence +from dataclasses import dataclass, field, replace +from pathlib import Path + +from dojo.board import Board, Pos, build_board +from dojo.engine import FightView, Game, GameView +from dojo.grid import parse_grid +from dojo.movement import Path as Walk +from dojo.rules import VARIANT_A, Card, RulesConfig + +FIXTURE = Path(__file__).parent / "fixtures" / "quadrant_valid.txt" +NO_CARDS: tuple[tuple[Card, int], ...] = () + + +class ScriptedDice: + """Rolls come from a fixed list; running out means the engine rolled more than expected.""" + + def __init__(self, rolls: Iterable[int] = ()) -> None: + self.rolls = deque(rolls) + + def d6(self) -> int: + if not self.rolls: + raise AssertionError("unexpected die roll") + return self.rolls.popleft() + + def shuffle(self, cards: list[Card]) -> None: + """Keep the given order, so tests control the deck.""" + + +@dataclass +class ScriptedAgent: + # Each entry picks the offered path ending on that square, or exactly that path. + ends: deque[Pos | Walk] = field(default_factory=deque) + start_square: Pos | None = None + use_tunnels: bool = False + tunnel_destination: Pos | None = None + sfida_target: int | None = None + irimi_target: int | None = None + exam_defender: int | None = None + keep_drawn: bool = False + plays: frozenset[Card] = frozenset() + fight_views: list[FightView] = field(default_factory=list) + offered_paths: list[Sequence[Walk]] = field(default_factory=list) + path_views: list[GameView] = field(default_factory=list) + + def place_pawn(self, view: GameView, squares: Sequence[Pos]) -> Pos: + if self.start_square is not None and self.start_square in squares: + return self.start_square + return squares[0] + + def choose_path(self, view: GameView, paths: Sequence[Walk]) -> Walk: + self.offered_paths.append(paths) + self.path_views.append(view) + if not self.ends: + return paths[0] + wanted = self.ends.popleft() + for path in paths: + if path == wanted or (isinstance(wanted, Pos) and path[-1] == wanted): + return path + raise AssertionError(f"no offered path matches {wanted}") + + def use_tunnel(self, view: GameView, exit: Pos) -> bool: + return self.use_tunnels + + def choose_tunnel_destination(self, view: GameView, tunnels: Sequence[Pos]) -> Pos: + return self.tunnel_destination if self.tunnel_destination is not None else tunnels[0] + + def choose_sfida_target(self, view: GameView, targets: Sequence[int]) -> int: + return self.sfida_target if self.sfida_target is not None else targets[0] + + def choose_irimi_target(self, view: GameView, targets: Sequence[int]) -> int: + return self.irimi_target if self.irimi_target is not None else targets[0] + + def choose_exam_defender(self, view: GameView, candidates: Sequence[int]) -> int: + return self.exam_defender if self.exam_defender is not None else candidates[0] + + def keep_tecnica(self, view: GameView, held: Card, drawn: Card) -> Card: + return drawn if self.keep_drawn else held + + def play_tecnica(self, view: GameView, fight: FightView) -> bool: + self.fight_views.append(fight) + return view.tecnica in self.plays + + +def fixture_board() -> Board: + return build_board(parse_grid(FIXTURE.read_text(encoding="utf-8"))) + + +def make_game( + players: int = 2, + config: RulesConfig = VARIANT_A, + dice: Iterable[int] = (), + deck: tuple[tuple[Card, int], ...] = NO_CARDS, + board: Board | None = None, +) -> tuple[Game, list[ScriptedAgent], ScriptedDice]: + """A game on the fixture board with an empty deck unless ``deck`` is given.""" + agents = [ScriptedAgent() for _ in range(players)] + randomness = ScriptedDice(dice) + game = Game(board or fixture_board(), replace(config, deck=deck), agents, randomness) + return game, agents, randomness + + +def put(game: Game, player: int, position: Pos, track: int | None = None) -> None: + game.players[player].position = position + if track is not None: + game.players[player].track = track + + +def turn(game: Game, agents: list[ScriptedAgent], player: int, *ends: Pos | Walk) -> None: + """Play ``player``'s turn, choosing paths by ``ends`` in order (see ``ScriptedAgent``).""" + game.current = player + agents[player].ends.extend(ends) + game.play_turn() diff --git a/tests/test_board.py b/tests/test_board.py new file mode 100644 index 0000000..6b40b7f --- /dev/null +++ b/tests/test_board.py @@ -0,0 +1,190 @@ +import pytest +from gridedit import ( + cell_column, + cell_line, + edit, + h_segment_column, + wall_column_left_of, + wall_line_above, +) + +from dojo.board import ( + BOARD_SIZE, + Board, + BoardError, + Pos, + build_board, + edge, + load_board, + parse_legend, + quadrant_of, + rotate, +) +from dojo.grid import parse_grid, render_grid + + +def build(text: str, offsets: dict[str, int] | None = None) -> Board: + return build_board(parse_grid(text), offsets) + + +def build_problems(text: str, offsets: dict[str, int] | None = None) -> list[str]: + with pytest.raises(BoardError) as raised: + build(text, offsets) + return raised.value.problems + + +def test_rotation_is_clockwise_around_the_centre() -> None: + corner = Pos(0, 0) + + assert [rotate(corner, k) for k in range(4)] == [(0, 0), (0, 15), (15, 15), (15, 0)] + assert [quadrant_of(rotate(Pos(3, 5), k)) for k in range(4)] == [0, 1, 2, 3] + assert rotate(Pos(3, 5), 4) == Pos(3, 5) + + +def test_board_is_sixteen_by_sixteen_with_central_dojo(board: Board) -> None: + assert board.size == BOARD_SIZE + assert board.dojo == {Pos(r, c) for r in range(6, 10) for c in range(6, 10)} + + +def test_every_cell_matches_its_rotated_source(board: Board, quadrant_text: str) -> None: + quadrant = parse_grid(quadrant_text) + + for r in range(8): + for c in range(8): + for k in range(4): + assert board.code(rotate(Pos(r, c), k)) == quadrant.cells[r][c] + + +def test_walls_are_rotated(board: Board) -> None: + safe_room_east_wall = edge(Pos(0, 2), Pos(0, 3)) + + for k in range(4): + a, b = safe_room_east_wall + assert edge(rotate(a, k), rotate(b, k)) in board.walls + assert edge(Pos(1, 2), Pos(1, 3)) not in board.walls + + +def test_neighbours_respect_walls_and_border(board: Board) -> None: + assert set(board.neighbours(Pos(0, 0))) == {Pos(0, 1), Pos(1, 0)} + assert Pos(0, 3) not in set(board.neighbours(Pos(0, 2))) + assert Pos(1, 3) in set(board.neighbours(Pos(1, 2))) + + +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))) + + +def test_to_grid_round_trips_through_render(board: Board) -> None: + grid = board.to_grid() + + assert parse_grid(render_grid(grid)) == grid + + +def test_tunnels_pair_within_the_quadrant_by_default(board: Board) -> None: + a, b = Pos(1, 6), Pos(3, 4) + + for k in range(4): + assert board.tunnels[rotate(a, k)] == rotate(b, k) + assert board.tunnels[rotate(b, k)] == rotate(a, k) + assert len(board.tunnels) == 8 + + +@pytest.mark.parametrize("offset", [1, 2, 3]) +def test_tunnel_offset_links_the_first_cell_to_a_later_quadrant( + quadrant_text: str, offset: int +) -> None: + board = build(quadrant_text, {"1": offset}) + a, b = Pos(1, 6), Pos(3, 4) + + for k in range(4): + assert board.tunnels[rotate(a, k)] == rotate(b, k + offset) + assert board.tunnels[rotate(b, k + offset)] == rotate(a, k) + assert all(board.tunnels[board.tunnels[p]] == p for p in board.tunnels) + + +def test_quadrant_must_be_eight_by_eight() -> None: + small = "+---+\n| D |\n+---+\n" + + assert build_problems(small) == ["quadrant must be 8x8, got 1x1"] + + +def test_unknown_cell_code(quadrant_text: str) -> None: + text = edit(quadrant_text, cell_line(4), cell_column(4), "X") + + assert "cell (4, 4): unknown code 'X'" in build_problems(text) + + +def test_outer_border_must_be_walled(quadrant_text: str) -> None: + open_top = edit(quadrant_text, wall_line_above(0), h_segment_column(4), " ") + open_left = edit(quadrant_text, cell_line(4), wall_column_left_of(0), " ") + + assert any("top border" in p for p in build_problems(open_top)) + assert any("left border" in p for p in build_problems(open_left)) + + +def test_seams_must_match(quadrant_text: str) -> None: + text = edit(quadrant_text, cell_line(2), wall_column_left_of(8), "|") + + assert any("seam mismatch: right border at row 2" in p for p in build_problems(text)) + + +def test_matching_seam_walls_are_accepted(quadrant_text: str) -> None: + text = edit(quadrant_text, cell_line(2), wall_column_left_of(8), "|") + text = edit(text, wall_line_above(8), h_segment_column(2), "---") + + board = build(text) + + assert edge(Pos(2, 7), Pos(2, 8)) in board.walls + assert edge(Pos(7, 2), Pos(8, 2)) in board.walls + + +def test_dojo_cells_are_fixed(quadrant_text: str) -> None: + text = edit(quadrant_text, cell_line(5), cell_column(7), "D") + + assert any("Dojo cells must be exactly" in p for p in build_problems(text)) + + +def test_tunnel_needs_exactly_two_cells(quadrant_text: str) -> None: + text = edit(quadrant_text, cell_line(4), cell_column(4), "1") + + assert "tunnel '1' must appear on exactly 2 cells, found 3" in build_problems(text) + + +def test_legend_offset_must_be_valid(quadrant_text: str) -> None: + problems = build_problems(quadrant_text, {"1": 4, "2": 0}) + + assert "tunnel '1': quadrant_offset must be 0..3, got 4" in problems + assert "legend links tunnel '2', which is not on the quadrant" in problems + + +def test_parse_legend() -> None: + assert parse_legend({"tunnels": {"1": {"quadrant_offset": 2}}}) == {"1": 2} + assert parse_legend({}) == {} + + +@pytest.mark.parametrize( + ("data", "message"), + [ + ({"extra": {}}, "unknown sections"), + ({"tunnels": 3}, "must be a table"), + ({"tunnels": {"a": {"quadrant_offset": 1}}}, "must be a digit"), + ({"tunnels": {"1": {"offset": 1}}}, "only quadrant_offset"), + ], +) +def test_parse_legend_errors(data: dict[str, object], message: str) -> None: + with pytest.raises(BoardError) as raised: + parse_legend(data) + + assert any(message in p for p in raised.value.problems) + + +def test_load_board_reads_legend(tmp_path, quadrant_text: str) -> None: + quadrant = tmp_path / "quadrant.txt" + legend = tmp_path / "legend.toml" + quadrant.write_text(quadrant_text, encoding="utf-8") + legend.write_text("[tunnels.1]\nquadrant_offset = 1\n", encoding="utf-8") + + board = load_board(quadrant, legend) + + assert board.tunnels[Pos(1, 6)] == rotate(Pos(3, 4), 1) diff --git a/tests/test_bots.py b/tests/test_bots.py new file mode 100644 index 0000000..a00296f --- /dev/null +++ b/tests/test_bots.py @@ -0,0 +1,108 @@ +"""Bot decisions on the fixture board (positions as in test_engine.py).""" + +import random + +import pytest +from scripted import make_game, put + +from dojo.board import Board, Pos +from dojo.bots import ( + AGGRESSIVE, + BALANCED, + FARMER, + PROFILES, + HeuristicAgent, + Profile, + RandomAgent, + win_probability, +) +from dojo.engine import Agent, FightView, Game, RandomSource +from dojo.rules import VARIANT_A, VARIANT_B, Card, RulesConfig + +ALLENAMENTO = Pos(0, 5) +FATICA = Pos(3, 6) +TATAMI = Pos(4, 4) +DOJO_ENTRANCE = Pos(5, 7) +DOJO = Pos(6, 7) + + +def test_win_probability_counts_senshu_and_bonuses() -> None: + assert win_probability(0, 0) == pytest.approx(21 / 36) + assert win_probability(2, 0) == pytest.approx(30 / 36) + assert win_probability(0, 2) == pytest.approx(10 / 36) + + +def test_heuristic_prefers_allenamento_to_fatica() -> None: + game, _, _ = make_game() + + choice = HeuristicAgent().choose_path(game.view(0), [(FATICA,), (ALLENAMENTO,)]) + + assert choice == (ALLENAMENTO,) + + +def test_brown_heuristic_takes_the_exam_when_it_can() -> None: + game, _, _ = make_game() + put(game, 0, DOJO_ENTRANCE, track=15) + + choice = HeuristicAgent().choose_path(game.view(0), [(ALLENAMENTO,), (DOJO,)]) + + assert choice == (DOJO,) + + +@pytest.mark.parametrize(("profile", "fights"), [(FARMER, False), (AGGRESSIVE, True)]) +def test_profiles_differ_on_an_even_fight(profile: Profile, fights: bool) -> None: + game, _, _ = make_game() + put(game, 0, Pos(4, 3), track=3) + put(game, 1, TATAMI, track=3) + choice = HeuristicAgent(profile).choose_path(game.view(0), [(Pos(5, 3),), (TATAMI,)]) + + assert (choice == (TATAMI,)) is fights + + +def fight(attacker_total: int, defender_total: int) -> FightView: + return FightView(0, 1, False, attacker_total, defender_total, played=()) + + +@pytest.mark.parametrize( + ("me", "card", "totals", "plays"), + [ + (0, Card.KIAI, (3, 5), True), # 5 ties 5: Senshu gives it to the attacker. + (0, Card.KIAI, (2, 5), False), # Kiai cannot turn it: keep it. + (0, Card.KIAI, (5, 5), False), # Already winning. + (1, Card.KIAI, (5, 4), True), # The defender needs to beat, not tie. + (1, Card.KIAI, (6, 4), False), + (0, Card.KAESHI, (1, 6), True), # Losing: a reroll can only help. + (1, Card.UKE, (4, 4), True), + ], +) +def test_heuristic_plays_a_tecnica_only_when_losing( + me: int, card: Card, totals: tuple[int, int], plays: bool +) -> None: + game, _, _ = make_game() + game.players[me].tecnica = card + + assert HeuristicAgent().play_tecnica(game.view(me), fight(*totals)) is plays + + +def test_heuristic_keeps_the_tecnica_its_profile_ranks_first() -> None: + game, _, _ = make_game() + + assert HeuristicAgent(BALANCED).keep_tecnica(game.view(0), Card.UKE, Card.KIAI) is Card.KIAI + assert HeuristicAgent(FARMER).keep_tecnica(game.view(0), Card.UKE, Card.KIAI) is Card.UKE + + +def play(board: Board, config: RulesConfig, seed: int) -> Game: + rng = random.Random(seed) + agents: list[Agent] = [HeuristicAgent(p) for p in PROFILES.values()] + agents.append(RandomAgent(rng)) + game = Game(board, config, agents, RandomSource(seed)) + game.run() + return game + + +@pytest.mark.parametrize("config", [VARIANT_A, VARIANT_B]) +def test_bots_play_full_games_reproducibly(board: Board, config: RulesConfig) -> None: + first, again = play(board, config, seed=7), play(board, config, seed=7) + + assert first.winner is not None + assert first.events == again.events diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..d19a899 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,58 @@ +from pathlib import Path + +import pytest + +from dojo.cli import EXIT_DESIGN_ERRORS, EXIT_FORMAT_ERRORS, EXIT_OK, main +from dojo.grid import parse_grid + +VALID = Path(__file__).parent / "fixtures" / "quadrant_valid.txt" + + +def test_validate_valid_board(capsys: pytest.CaptureFixture[str]) -> None: + assert main(["board", "validate", "--quadrant", str(VALID)]) == EXIT_OK + + output = capsys.readouterr().out + assert "Shortest walk Safe Room -> Dojo: Q0 10, Q1 10, Q2 10, Q3 10" in output + assert output.rstrip().endswith("OK") + + +def test_validate_reports_design_errors(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + no_entrance = VALID.read_text(encoding="utf-8").replace( + "+ + + + + + +---+ +", "+ + + + + + +---+---+" + ) + quadrant = tmp_path / "quadrant.txt" + quadrant.write_text(no_entrance, encoding="utf-8") + + assert main(["board", "validate", "--quadrant", str(quadrant)]) == EXIT_DESIGN_ERRORS + assert "quadrant 0 has no Dojo entrance" in capsys.readouterr().out + + +def test_format_errors_and_missing_files_exit_with_format_code( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + malformed = tmp_path / "bad.txt" + malformed.write_text("+---+\n| . |\n", encoding="utf-8") + + assert main(["board", "validate", "--quadrant", str(malformed)]) == EXIT_FORMAT_ERRORS + assert main(["board", "validate", "--quadrant", str(tmp_path / "none")]) == EXIT_FORMAT_ERRORS + assert "odd number of lines" in capsys.readouterr().err + + +def test_render_writes_a_parseable_full_board(tmp_path: Path) -> None: + output = tmp_path / "board.txt" + + assert main(["board", "render", "--quadrant", str(VALID), "-o", str(output)]) == EXIT_OK + + grid = parse_grid(output.read_text(encoding="utf-8")) + assert (grid.height, grid.width) == (16, 16) + + +def test_legend_next_to_the_quadrant_is_used_by_default( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + quadrant = tmp_path / "quadrant.txt" + quadrant.write_text(VALID.read_text(encoding="utf-8"), encoding="utf-8") + (tmp_path / "legend.toml").write_text("[tunnels.9]\nquadrant_offset = 1\n", encoding="utf-8") + + assert main(["board", "validate", "--quadrant", str(quadrant)]) == EXIT_FORMAT_ERRORS + assert "legend links tunnel '9'" in capsys.readouterr().err diff --git a/tests/test_engine.py b/tests/test_engine.py new file mode 100644 index 0000000..4ec4174 --- /dev/null +++ b/tests/test_engine.py @@ -0,0 +1,670 @@ +"""Rules engine behaviour, one RULES.md rule per test where possible. + +Positions refer to the fixture board (tests/fixtures/quadrant_valid.txt). In quadrant 0: +Allenamento (0,5); Nebbia (1,4); tunnel (1,6)<->(3,4); Fatica (3,6); Passo Falso (4,1); +Sfida (5,2); Dojo entrance (5,7)->(6,7). Player 1's Safe Room starts at (0,13). +""" + +import pytest +from scripted import make_game, put, turn + +from dojo.board import Pos +from dojo.engine import Game, RandomSource, seat_corners +from dojo.events import ( + CardDrawn, + ExamTaken, + FightKind, + GameOver, + KumiteFought, + Moved, + SentToStart, + Source, + TrackChanged, + TurnSkipped, +) +from dojo.rules import VARIANT_A, VARIANT_B, Card + +START_0 = Pos(0, 0) +START_1 = Pos(0, 13) +NEBBIA = Pos(1, 4) + + +def events_of[T](game: Game, kind: type[T]) -> list[T]: + return [e for e in game.events if isinstance(e, kind)] + + +def only_fight(game: Game) -> KumiteFought: + fights = events_of(game, KumiteFought) + assert len(fights) == 1 + return fights[0] + + +# Setup ------------------------------------------------------------------------------------ + + +@pytest.mark.parametrize("players", range(2, 9)) +def test_corner_mates_never_play_consecutively(players: int) -> None: + corners = seat_corners(players) + + assert len(corners) == players + assert all(corners[i] != corners[(i + 1) % players] for i in range(players)) + assert sorted(set(corners)) == list(range(min(players, 4))) + + +def test_seats_up_to_four_take_corners_clockwise() -> None: + assert seat_corners(4) == (0, 1, 2, 3) + + +def test_players_start_in_their_own_safe_room() -> None: + from dataclasses import replace + + game, _, _ = make_game(players=5, config=replace(VARIANT_A, start_position=3)) + + for player in game.players: + assert player.position in game.board.safe_room(player.corner) + assert player.track == 3 + + +def test_illegal_agent_choice_is_rejected() -> None: + game, agents, _ = make_game(dice=[1, 1]) + agents[0].ends.append(Pos(9, 9)) + + with pytest.raises(AssertionError, match="no offered path"): + game.play_turn() + + +# Kumite ----------------------------------------------------------------------------------- + + +def test_senshu_tie_goes_to_the_attacker_and_equal_belts_swap_a_belt() -> None: + game, agents, dice = make_game(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)) + + fight = only_fight(game) + assert fight.attacker_won and fight.kind is FightKind.EQUAL + assert (game.players[0].track, game.players[1].track) == (6, 0) + assert game.players[0].position == Pos(4, 5) + assert game.players[1].position == START_1 + assert not dice.rolls + + +def test_upset_swaps_a_belt() -> None: + game, agents, _ = make_game(dice=[1, 1, 6, 3]) + put(game, 0, Pos(4, 3), track=0) + put(game, 1, Pos(4, 5), track=6) + + turn(game, agents, 0, Pos(4, 5)) + + fight = only_fight(game) + assert fight.kind is FightKind.UPSET + assert (fight.attacker_total, fight.defender_total) == (6, 5) + assert (game.players[0].track, game.players[1].track) == (3, 3) + + +@pytest.mark.parametrize(("config", "gain"), [(VARIANT_A, 0), (VARIANT_B, 1)]) +def test_higher_belt_beating_lower_gains_the_variant_amount(config, gain: int) -> None: + game, agents, _ = make_game(config=config, dice=[1, 1, 1, 2]) + put(game, 0, Pos(4, 3), track=6) + put(game, 1, Pos(4, 5), track=0) + + turn(game, agents, 0, Pos(4, 5)) + + assert only_fight(game).kind is FightKind.EXPECTED + assert game.players[0].track == 6 + gain + assert game.players[1].track == 0 + assert game.players[1].position == START_1 + + +def test_belt_bonus_is_capped_at_two() -> None: + game, agents, _ = make_game(dice=[1, 1, 1, 4]) + put(game, 0, Pos(4, 3), track=15) + put(game, 1, Pos(4, 5), track=0) + + turn(game, agents, 0, Pos(4, 5)) + + fight = only_fight(game) + assert (fight.attacker_total, fight.defender_total) == (3, 4) + assert not fight.attacker_won and fight.kind is FightKind.UPSET + assert (game.players[0].track, game.players[1].track) == (12, 3) + assert game.players[0].position == START_0 + + +def test_losing_attacker_goes_to_start_and_skips_the_square_effect() -> None: + game, agents, _ = make_game(dice=[1, 1, 1, 2]) + put(game, 0, Pos(0, 3), track=3) + put(game, 1, Pos(0, 5), track=3) + + turn(game, agents, 0, Pos(0, 5)) + + assert game.players[0].position == START_0 + assert (game.players[0].track, game.players[1].track) == (0, 6) + assert game.players[1].position == Pos(0, 5) + assert not [e for e in events_of(game, TrackChanged) if e.source is Source.SQUARE] + + +def test_winning_attacker_continues_its_move() -> None: + game, agents, _ = make_game(dice=[1, 2, 6, 1]) + put(game, 0, Pos(4, 2), track=3) + put(game, 1, Pos(4, 3), track=3) + + turn(game, agents, 0, Pos(4, 5), Pos(4, 5)) + + assert game.players[0].position == Pos(4, 5) + assert game.players[1].position == START_1 + assert [e.path for e in events_of(game, Moved)] == [ + (Pos(4, 3),), + (Pos(4, 4), Pos(4, 5)), + ] + + +def test_losing_attacker_stops_even_with_steps_left() -> None: + game, agents, dice = make_game(dice=[1, 2, 1, 6]) + put(game, 0, Pos(4, 2), track=3) + put(game, 1, Pos(4, 3), track=3) + + turn(game, agents, 0, Pos(4, 5)) + + assert game.players[0].position == START_0 + assert game.players[1].position == Pos(4, 3) + assert not dice.rolls + + +def test_no_kumite_inside_a_safe_room() -> None: + game, agents, _ = make_game(dice=[1, 1]) + put(game, 1, Pos(1, 1)) + put(game, 0, Pos(1, 3)) + + turn(game, agents, 0, Pos(1, 1)) + + assert not events_of(game, KumiteFought) + assert game.players[0].position == game.players[1].position == Pos(1, 1) + + +# Tecniche --------------------------------------------------------------------------------- + + +def test_kiai_adds_to_the_total() -> None: + game, agents, _ = make_game(dice=[1, 1, 2, 3]) + put(game, 0, Pos(4, 3), track=3) + put(game, 1, Pos(4, 5), track=3) + game.players[0].tecnica = Card.KIAI + agents[0].plays = frozenset({Card.KIAI}) + + turn(game, agents, 0, Pos(4, 5)) + + fight = only_fight(game) + assert (fight.attacker_total, fight.defender_total) == (4, 3) + assert fight.cards == ((0, Card.KIAI),) + assert agents[0].fight_views[0].attacker_total == 2 + assert game.players[0].tecnica is None + + +def test_kaeshi_rerolls_the_die() -> None: + game, agents, dice = make_game(dice=[1, 1, 1, 4, 5]) + put(game, 0, Pos(4, 3), track=3) + put(game, 1, Pos(4, 5), track=3) + game.players[0].tecnica = Card.KAESHI + agents[0].plays = frozenset({Card.KAESHI}) + + turn(game, agents, 0, Pos(4, 5)) + + assert only_fight(game).attacker_total == 5 + assert only_fight(game).attacker_won + assert not dice.rolls + + +def test_defender_sees_the_attackers_card_before_deciding() -> None: + game, agents, _ = make_game(dice=[1, 1, 3, 3]) + put(game, 0, Pos(4, 3), track=3) + put(game, 1, Pos(4, 5), track=3) + game.players[0].tecnica = Card.KIAI + game.players[1].tecnica = Card.KIAI + agents[0].plays = agents[1].plays = frozenset({Card.KIAI}) + + turn(game, agents, 0, Pos(4, 5)) + + assert agents[1].fight_views[0].played == ((0, Card.KIAI),) + assert agents[1].fight_views[0].attacker_total == 5 + assert only_fight(game).cards == ((0, Card.KIAI), (1, Card.KIAI)) + + +def test_uke_losing_defender_stays_and_the_attacker_steps_on() -> None: + game, agents, _ = make_game(dice=[1, 1, 5, 1]) + put(game, 0, Pos(4, 3), track=3) + put(game, 1, Pos(4, 5), track=3) + game.players[1].tecnica = Card.UKE + agents[1].plays = frozenset({Card.UKE}) + + turn(game, agents, 0, Pos(4, 5), Pos(4, 6)) + + assert game.players[1].position == Pos(4, 5) + assert game.players[1].track == 0 + assert game.players[0].position == Pos(4, 6) + assert not events_of(game, SentToStart) + + +def test_uke_losing_attacker_returns_to_the_previous_square() -> None: + game, agents, _ = make_game(dice=[1, 1, 1, 6]) + put(game, 0, Pos(4, 3), track=3) + put(game, 1, Pos(4, 5), track=3) + game.players[0].tecnica = Card.UKE + agents[0].plays = frozenset({Card.UKE}) + + turn(game, agents, 0, Pos(4, 5)) + + assert game.players[0].position == Pos(4, 4) + assert (game.players[0].track, game.players[1].track) == (0, 6) + + +# Meditazione ------------------------------------------------------------------------------ + + +def test_meditating_pawn_is_passed_through_without_a_fight() -> None: + game, agents, _ = make_game(dice=[1, 1]) + put(game, 0, Pos(4, 3)) + put(game, 1, Pos(4, 4)) + game.players[1].meditating = True + + turn(game, agents, 0, Pos(4, 5)) + + assert not events_of(game, KumiteFought) + assert game.players[0].position == Pos(4, 5) + + +def test_ending_on_a_meditating_pawn_takes_one_extra_step() -> None: + game, agents, _ = make_game(dice=[1, 1]) + put(game, 0, Pos(4, 2)) + put(game, 1, Pos(4, 4)) + game.players[1].meditating = True + + turn(game, agents, 0, Pos(4, 4), Pos(4, 5)) + + assert game.players[0].position == Pos(4, 5) + extra_step_options = {path[-1] for path in agents[0].offered_paths[1]} + assert extra_step_options == {Pos(3, 4), Pos(4, 5), Pos(5, 4)} + + +def test_meditating_players_turn_is_skipped_once() -> None: + game, agents, _ = make_game() + game.players[1].meditating = True + + turn(game, agents, 1) + + assert events_of(game, TurnSkipped) == [TurnSkipped(round=1, player=1)] + assert not game.players[1].meditating + + +# Squares ---------------------------------------------------------------------------------- + + +def test_allenamento_and_fatica() -> None: + game, agents, _ = make_game(dice=[1, 1, 1, 1]) + put(game, 0, Pos(0, 3), track=3) + turn(game, agents, 0, Pos(0, 5)) + assert game.players[0].track == 4 + + put(game, 0, Pos(4, 5)) + turn(game, agents, 0, Pos(3, 6)) + assert game.players[0].track == 3 + + +@pytest.mark.parametrize(("before", "after"), [(14, 14), (13, 14), (15, 16), (17, 17)]) +def test_variant_b_squares_cannot_make_a_player_brown(before: int, after: int) -> None: + game, agents, _ = make_game(config=VARIANT_B, dice=[1, 1]) + put(game, 0, Pos(0, 3), track=before) + + turn(game, agents, 0, Pos(0, 5)) + + assert game.players[0].track == after + + +def test_variant_a_squares_can_make_a_player_brown() -> None: + game, agents, _ = make_game(config=VARIANT_A, dice=[1, 1]) + put(game, 0, Pos(0, 3), track=14) + + turn(game, agents, 0, Pos(0, 5)) + + assert game.players[0].track == 15 + + +def test_variant_b_kumite_can_make_a_player_brown() -> None: + game, agents, _ = make_game(config=VARIANT_B, dice=[1, 1, 3, 3]) + put(game, 0, Pos(4, 3), track=14) + put(game, 1, Pos(4, 5), track=12) + + turn(game, agents, 0, Pos(4, 5)) + + assert game.players[0].track == 17 + + +def test_passo_falso_returns_to_the_move_start_and_fights_stand() -> None: + game, agents, _ = make_game(dice=[1, 1, 6, 1]) + put(game, 0, Pos(4, 3), track=3) + put(game, 1, Pos(4, 2), track=3) + + turn(game, agents, 0, Pos(4, 1), Pos(4, 1)) + + assert game.players[0].position == Pos(4, 3) + assert game.players[0].track == 6 + assert game.players[1].position == START_1 + + +def test_sfida_square_fights_a_chosen_opponent_and_clamps_at_zero() -> None: + game, agents, _ = make_game(dice=[1, 1, 4, 4]) + put(game, 0, Pos(5, 4), track=0) + put(game, 1, Pos(10, 10), track=0) + agents[0].sfida_target = 1 + + turn(game, agents, 0, Pos(5, 2)) + + assert only_fight(game).attacker_won + assert (game.players[0].track, game.players[1].track) == (3, 0) + assert game.players[0].position == Pos(5, 2) + assert game.players[1].position == START_1 + + +@pytest.mark.parametrize("meditating", [False, True]) +def test_sfida_cannot_target_safe_rooms_or_meditating_players(meditating: bool) -> None: + game, agents, _ = make_game(dice=[1, 1]) + put(game, 0, Pos(5, 4)) + if meditating: + put(game, 1, Pos(10, 10)) + game.players[1].meditating = True + agents[0].sfida_target = 1 + + turn(game, agents, 0, Pos(5, 2)) + + assert not events_of(game, KumiteFought) + + +def test_tunnel_is_optional_and_ends_the_move() -> None: + game, agents, _ = make_game(dice=[1, 1, 1, 1]) + put(game, 0, NEBBIA) + turn(game, agents, 0, Pos(1, 6)) + assert game.players[0].position == Pos(1, 6) + + put(game, 0, NEBBIA) + agents[0].use_tunnels = True + turn(game, agents, 0, Pos(1, 6)) + assert game.players[0].position == Pos(3, 4) + + +def test_occupied_tunnel_exit_starts_a_kumite() -> None: + game, agents, _ = make_game(dice=[1, 1, 3, 3]) + put(game, 0, NEBBIA, track=3) + put(game, 1, Pos(3, 4), track=3) + agents[0].use_tunnels = True + + turn(game, agents, 0, Pos(1, 6)) + + assert only_fight(game).attacker_won + assert game.players[0].position == Pos(3, 4) + assert game.players[1].position == START_1 + + +def test_tunnel_exit_with_a_meditating_pawn_cannot_be_used() -> None: + game, agents, _ = make_game(dice=[1, 1]) + put(game, 0, NEBBIA) + put(game, 1, Pos(3, 4)) + game.players[1].meditating = True + agents[0].use_tunnels = True + + turn(game, agents, 0, Pos(1, 6)) + + assert game.players[0].position == Pos(1, 6) + + +# Nebbia del Dojo -------------------------------------------------------------------------- + + +def draw_on_nebbia( + card: Card, players: int = 2, dice: tuple[int, ...] = (1, 1), **tracks: int +) -> tuple[Game, list]: + game, agents, _ = make_game(players=players, dice=dice, deck=((card, 1),)) + put(game, 0, Pos(0, 3)) + for name, track in tracks.items(): + game.players[int(name[1:])].track = track + return game, agents + + +def test_card_is_drawn_when_stopping_on_nebbia() -> None: + game, agents = draw_on_nebbia(Card.ALLENAMENTO_INTENSIVO) + + turn(game, agents, 0, NEBBIA) + + assert events_of(game, CardDrawn) == [CardDrawn(0, Card.ALLENAMENTO_INTENSIVO)] + assert game.players[0].track == 1 + + +def test_discard_pile_is_reshuffled_when_the_deck_runs_out() -> None: + game, agents = draw_on_nebbia(Card.ALLENAMENTO_INTENSIVO, dice=(1, 1, 1, 1)) + turn(game, agents, 0, NEBBIA) + put(game, 0, Pos(0, 3)) + + turn(game, agents, 0, NEBBIA) + + assert game.players[0].track == 2 + + +def test_colpo_proibito() -> None: + game, agents = draw_on_nebbia(Card.COLPO_PROIBITO, p0=3) + + turn(game, agents, 0, NEBBIA) + + assert game.players[0].track == 2 + + +def test_sensei_osserva_helps_every_lowest_player() -> None: + game, agents = draw_on_nebbia(Card.SENSEI_OSSERVA, players=3, p0=3, p1=0, p2=0) + + turn(game, agents, 0, NEBBIA) + + assert [p.track for p in game.players] == [3, 2, 2] + + +def test_peso_della_cintura_hits_every_highest_player() -> None: + game, agents = draw_on_nebbia(Card.PESO_DELLA_CINTURA, players=3, p0=3, p1=6, p2=6) + + turn(game, agents, 0, NEBBIA) + + assert [p.track for p in game.players] == [3, 5, 5] + + +def test_meditazione_card() -> None: + game, agents = draw_on_nebbia(Card.MEDITAZIONE) + + turn(game, agents, 0, NEBBIA) + + assert game.players[0].meditating + + +def test_passo_falso_card() -> None: + game, agents = draw_on_nebbia(Card.PASSO_FALSO) + + turn(game, agents, 0, NEBBIA) + + assert game.players[0].position == Pos(0, 3) + + +def test_irimi_swaps_places_without_a_fight() -> None: + game, agents = draw_on_nebbia(Card.IRIMI) + put(game, 1, Pos(10, 10)) + agents[0].irimi_target = 1 + + turn(game, agents, 0, NEBBIA) + + assert game.players[0].position == Pos(10, 10) + assert game.players[1].position == NEBBIA + assert not events_of(game, KumiteFought) + + +@pytest.mark.parametrize("card", [Card.SFIDA, Card.IRIMI]) +def test_sfida_and_irimi_cards_are_mandatory(card: Card) -> None: + game, agents = draw_on_nebbia(card, dice=(1, 1, 3, 3)) + put(game, 1, Pos(10, 10)) + + turn(game, agents, 0, NEBBIA) + + if card is Card.SFIDA: + assert only_fight(game).defender == 1 + else: + assert game.players[0].position == Pos(10, 10) + + +def test_tunnel_segreto_teleports_to_a_chosen_tunnel() -> None: + game, agents = draw_on_nebbia(Card.TUNNEL_SEGRETO) + agents[0].tunnel_destination = Pos(3, 4) + + turn(game, agents, 0, NEBBIA) + + assert game.players[0].position == Pos(3, 4) + + +def test_tsugi_ashi_makes_another_full_move() -> None: + game, agents = draw_on_nebbia(Card.TSUGI_ASHI, dice=(1, 1, 1, 1)) + + turn(game, agents, 0, NEBBIA, Pos(2, 5)) + + assert game.players[0].position == Pos(2, 5) + + +@pytest.mark.parametrize(("keep_drawn", "kept"), [(True, Card.UKE), (False, Card.KIAI)]) +def test_only_one_tecnica_can_be_held(keep_drawn: bool, kept: Card) -> None: + game, agents = draw_on_nebbia(Card.UKE) + game.players[0].tecnica = Card.KIAI + agents[0].keep_drawn = keep_drawn + + turn(game, agents, 0, NEBBIA) + + assert game.players[0].tecnica is kept + + +# Central Dojo and Final Exam -------------------------------------------------------------- + + +def dojo_game(dice: list[int], players: int = 3, **tracks: int) -> tuple[Game, list]: + game, agents, _ = make_game(players=players, dice=dice) + put(game, 0, Pos(4, 7), track=15) + for name, track in tracks.items(): + game.players[int(name[1:])].track = track + return game, agents + + +def test_only_brown_players_may_enter_the_dojo() -> None: + game, agents = dojo_game([1, 1]) + game.players[0].track = 14 + + turn(game, agents, 0) + + offered = agents[0].offered_paths[0] + assert not any(game.board.dojo & set(path) for path in offered) + + +def test_passing_the_exam_wins_the_game() -> None: + game, agents = dojo_game([1, 1, 4, 1], p1=9, p2=12) + + turn(game, agents, 0, Pos(6, 7)) + + assert events_of(game, ExamTaken) == [ExamTaken(challenger=0, defender=2, passed=True)] + assert game.winner == 0 + assert game.finished + assert isinstance(game.events[-1], GameOver) + + +def test_failing_the_exam_costs_a_belt_and_the_defender_gains_nothing() -> None: + game, agents = dojo_game([1, 1, 1, 6], p1=9, p2=12) + + turn(game, agents, 0, Pos(6, 7)) + + assert game.winner is None + assert game.players[0].track == 12 + assert game.players[0].position == START_0 + assert game.players[2].track == 12 + + +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 + agents[0].plays = frozenset({Card.UKE}) + + turn(game, agents, 0, Pos(6, 7)) + + assert game.players[0].position == Pos(5, 7) + assert game.players[0].track == 12 + + +def test_challenger_picks_among_tied_defenders() -> None: + game, agents = dojo_game([1, 1, 4, 1], p1=12, p2=12) + agents[0].exam_defender = 1 + + turn(game, agents, 0, Pos(6, 7)) + + assert events_of(game, ExamTaken)[0].defender == 1 + + +def test_meditating_players_cannot_defend() -> None: + game, agents = dojo_game([1, 1, 4, 1], p1=9, p2=12) + game.players[2].meditating = True + + turn(game, agents, 0, Pos(6, 7)) + + assert events_of(game, ExamTaken)[0].defender == 1 + + +def test_kumite_won_mid_move_can_open_the_dojo() -> None: + game, agents, dice = make_game(dice=[1, 1, 3, 3, 6, 1]) + put(game, 0, Pos(4, 7), track=14) + put(game, 1, Pos(5, 7), track=12) + + turn(game, agents, 0, (Pos(5, 7), Pos(5, 6)), Pos(6, 7)) + + assert game.players[0].track == 17 + assert events_of(game, ExamTaken) == [ExamTaken(challenger=0, defender=1, passed=True)] + assert game.winner == 0 + assert not dice.rolls + + +# Whole game ------------------------------------------------------------------------------- + + +def test_a_game_runs_to_completion() -> None: + from dataclasses import replace + + from scripted import ScriptedAgent, fixture_board + + config = replace(VARIANT_A, max_rounds=30) + game = Game(fixture_board(), config, [ScriptedAgent() for _ in range(4)], RandomSource(7)) + + game.run() + + assert game.finished + assert isinstance(game.events[-1], GameOver) + with pytest.raises(RuntimeError): + game.play_turn() + + +def test_views_hide_which_tecnica_an_opponent_holds() -> None: + game, _, _ = make_game() + game.players[0].tecnica = Card.KIAI + + theirs, mine = game.view(1), game.view(0) + + assert theirs.tecnica is None + assert theirs.players[0].holds_tecnica + assert not theirs.players[1].holds_tecnica + assert mine.tecnica is Card.KIAI + + +def test_agents_decide_on_their_own_current_view() -> None: + game, agents, _ = make_game(dice=[1, 1]) + put(game, 1, Pos(4, 4), track=7) + + turn(game, agents, 1) + + view = agents[1].path_views[0] + assert view.me == 1 + assert view.players[1].position == Pos(4, 4) + assert view.belt(1) == 2 diff --git a/tests/test_grid.py b/tests/test_grid.py new file mode 100644 index 0000000..7017d11 --- /dev/null +++ b/tests/test_grid.py @@ -0,0 +1,59 @@ +import pytest + +from dojo.grid import GridFormatError, parse_grid, render_grid + +SMALL = """\ ++---+---+ +| S | . | ++ +---+ +| 1 + ++---+---+ +""" + + +def test_parse_small_grid() -> None: + grid = parse_grid(SMALL) + + assert (grid.height, grid.width) == (2, 2) + assert grid.cells == (("S", "."), ("1", "+")) + assert grid.h_walls == ((True, True), (False, True), (True, True)) + assert grid.v_walls == ((True, True, True), (True, False, False)) + + +def test_render_round_trips() -> None: + grid = parse_grid(SMALL) + + assert parse_grid(render_grid(grid)) == grid + + +def test_stripped_trailing_space_is_an_open_right_border() -> None: + grid = parse_grid(SMALL) + + assert grid.v_walls[1][2] is False + + +def test_comments_and_blank_lines_are_ignored() -> None: + commented = "# header\n\n" + SMALL.replace("+ +---+\n", "+ +---+\n# note\n") + + assert parse_grid(commented) == parse_grid(SMALL) + + +@pytest.mark.parametrize( + ("text", "message"), + [ + ("", "no grid lines"), + ("+---+--+\n", "4*width+1"), + ("+---+\n| . |\n", "odd number of lines"), + ("+---+\n| . |\n----+\n", "expected '+'"), + ("+---+\n| . |\n+-- +\n", "expected '---' or blanks"), + ("+---+\n| . !\n+---+\n", "expected '|' or blank"), + ("+---+\n| . |\n+---+\n", "longer than the top border"), + ("+---+\n|. |\n+---+\n", "' X '"), + ("+---+\n| |\n+---+\n", "' X '"), + ], +) +def test_format_errors(text: str, message: str) -> None: + with pytest.raises(GridFormatError) as raised: + parse_grid(text, source="t.txt") + + assert any(message in problem for problem in raised.value.problems) diff --git a/tests/test_movement.py b/tests/test_movement.py new file mode 100644 index 0000000..379a551 --- /dev/null +++ b/tests/test_movement.py @@ -0,0 +1,50 @@ +from scripted import fixture_board + +from dojo.board import Board, Pos, edge +from dojo.movement import walks + +BOARD = fixture_board() + + +def test_walks_have_exact_length_and_never_repeat_a_square() -> None: + start = Pos(4, 4) + + paths = walks(BOARD, start, 4, {start}, can_enter_dojo=False) + + assert paths + for path in paths: + assert len(path) == 4 + assert len({start, *path}) == 5 + for a, b in zip((start, *path), path, strict=False): + assert b in set(BOARD.neighbours(a)) + + +def test_visited_squares_are_never_entered() -> None: + start = Pos(4, 4) + visited = {start, Pos(4, 5), Pos(3, 4)} + + paths = walks(BOARD, start, 3, visited, can_enter_dojo=False) + + assert all(not visited & set(path) for path in paths) + + +def test_dojo_is_a_wall_unless_it_may_be_entered() -> None: + start = Pos(4, 7) + + closed = walks(BOARD, start, 5, {start}, can_enter_dojo=False) + opened = walks(BOARD, start, 5, {start}, can_enter_dojo=True) + + assert not any(BOARD.dojo & set(path) for path in closed) + into_dojo = [path for path in opened if path[-1] in BOARD.dojo] + assert (Pos(5, 7), Pos(6, 7)) in into_dojo + assert all(cell not in BOARD.dojo for path in into_dojo for cell in path[:-1]) + assert set(opened) - set(into_dojo) == set(closed) + + +def test_boxed_in_pawn_moves_as_far_as_possible() -> None: + walls = frozenset(edge(Pos(0, c), Pos(1, c)) for c in range(3)) + corridor = Board(cells=((".",) * 3,) * 3, walls=walls, tunnels={}) + + paths = walks(corridor, Pos(0, 0), 5, {Pos(0, 0)}, can_enter_dojo=False) + + assert paths == [(Pos(0, 1), Pos(0, 2))] diff --git a/tests/test_validate.py b/tests/test_validate.py new file mode 100644 index 0000000..a906b63 --- /dev/null +++ b/tests/test_validate.py @@ -0,0 +1,113 @@ +from gridedit import ( + cell_column, + cell_line, + edit, + h_segment_column, + wall_column_left_of, + wall_line_above, +) + +from dojo.board import Board, build_board +from dojo.grid import parse_grid +from dojo.validate import Report, validate + + +def validate_text(text: str) -> Report: + return validate(build_board(parse_grid(text))) + + +def test_fixture_board_has_no_errors(board: Board) -> None: + report = validate(board) + + assert report.errors == [] + + +def test_fixture_stats(board: Board) -> None: + stats = validate(board).stats + + assert stats is not None + assert stats.playable_squares == 4 * (64 - 9 - 4) + assert stats.square_counts == { + ".": 172, + "?": 4, + "A": 8, + "F": 4, + "K": 4, + "P": 4, + "T": 8, + } + assert stats.dojo_entrances == 4 + assert stats.tunnel_pairs == 4 + assert stats.dojo_distance == (10, 10, 10, 10) + + +def test_tatami_share_outside_target_is_a_warning(board: Board) -> None: + report = validate(board) + + assert any("Tatami share 84%" in w for w in report.warnings) + + +def test_wall_inside_dojo(quadrant_text: str) -> None: + text = edit(quadrant_text, wall_line_above(7), h_segment_column(6), "---") + + report = validate_text(text) + + assert "wall inside the Dojo between (6, 6) and (7, 6)" in report.errors + + +def test_dojo_without_entrance(quadrant_text: str) -> None: + text = edit(quadrant_text, wall_line_above(6), h_segment_column(7), "---") + + report = validate_text(text) + + assert "quadrant 0 has no Dojo entrance" in report.errors + + +def test_safe_room_with_one_exit(quadrant_text: str) -> None: + text = edit(quadrant_text, cell_line(1), wall_column_left_of(3), "|") + + report = validate_text(text) + + assert "Safe Room of quadrant 0: 1 exits, rules require 2" in report.errors + + +def test_safe_room_exits_on_the_same_side(quadrant_text: str) -> None: + text = edit(quadrant_text, cell_line(1), wall_column_left_of(3), "|") + text = edit(text, cell_line(0), wall_column_left_of(3), " ") + text = edit(text, wall_line_above(3), h_segment_column(1), "---") + text = edit(text, cell_line(2), wall_column_left_of(3), " ") + + report = validate_text(text) + + assert "Safe Room of quadrant 0: both exits face E" in report.errors + + +def test_safe_room_used_as_a_shortcut(quadrant_text: str) -> None: + text = edit(quadrant_text, cell_line(3), wall_column_left_of(2), "|") + + report = validate_text(text) + + assert any("Safe Room of quadrant 0: is a shortcut, 4 steps" in e for e in report.errors) + + +def test_safe_room_must_contain_the_corner(quadrant_text: str) -> None: + text = edit(quadrant_text, cell_line(0), cell_column(0), ".") + + report = validate_text(text) + + assert "Safe Room of quadrant 0: does not contain the board corner (0, 0)" in report.errors + assert "Safe Room of quadrant 0: 8 cells, rules expect 9" in report.warnings + + +def test_enclosed_square_is_unreachable(quadrant_text: str) -> None: + text = edit(quadrant_text, wall_line_above(4), h_segment_column(5), "---") + text = edit(text, wall_line_above(5), h_segment_column(5), "---") + text = edit(text, cell_line(4), wall_column_left_of(5), "|") + text = edit(text, cell_line(4), wall_column_left_of(6), "|") + + report = validate_text(text) + + assert any( + e.startswith("4 squares cannot be walked to from the Safe Room of quadrant 0: (4, 5)") + for e in report.errors + ) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..9133e8f --- /dev/null +++ b/uv.lock @@ -0,0 +1,108 @@ +version = 1 +revision = 3 +requires-python = ">=3.14" + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "dojo" +version = "0.1.0" +source = { editable = "." } + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=9.1.1" }, + { name = "ruff", specifier = ">=0.16.9" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/bf/c935ca98e73fe8ce65b87ef08a280c0c1e85295d569228c15e87d8fdfaf1/ruff-0.16.9.tar.gz", hash = "sha256:12b625c6cfba78d285d9f48eda5f053374f1e53cb10ef17342a383750db99161", size = 4948764, upload-time = "2026-09-24T20:37:49.416Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/26/df51322b52ee1ada7eff2d071ea09d11d5c2d1dcc9f02594f5785c4d1635/ruff-0.16.9-py3-none-linux_armv6l.whl", hash = "sha256:95e6f022090368ab3b824c36276839c53b2adf1a3f4c09fefc33dfc400f6da96", size = 10082922, upload-time = "2026-09-24T20:37:13.045Z" }, + { url = "https://files.pythonhosted.org/packages/a5/27/7bf51f5a7aa375e9f339280a303aab44ca75dc1525f1cdc5991761685b0f/ruff-0.16.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a5f27be168556594a86d2f415db0cf43f5291917849318f873c7e2791f7a8c67", size = 10236360, upload-time = "2026-09-24T20:37:16.049Z" }, + { url = "https://files.pythonhosted.org/packages/b6/63/09659283f92f02dff45809da194a70da2f688d87c55d8875c4fae3536072/ruff-0.16.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1632eb1d6197f33bd00b1acbc5b71009e89a8895c158e2d2b03a834fac964ab6", size = 9892940, upload-time = "2026-09-24T20:37:17.957Z" }, + { url = "https://files.pythonhosted.org/packages/24/58/98de1b72ec172f5f8f1731236fe21585b3998bf7dfe9fcc44ae9ba626012/ruff-0.16.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b3f951b14d865d5952c89d40a5ca07e87abe24fa5453299878411e127748fb1c", size = 10032114, upload-time = "2026-09-24T20:37:19.942Z" }, + { url = "https://files.pythonhosted.org/packages/c8/7d/f1e17c54ab59d4bad1dce8ee3e22a7a1d0ef4745240decacdcf3832b5bb2/ruff-0.16.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:447fc07e1573afff7cb02803462b12b6c8ece7cf10e2cd78565fa6d7a1c0bf8d", size = 9910227, upload-time = "2026-09-24T20:37:21.872Z" }, + { url = "https://files.pythonhosted.org/packages/34/19/436f647a65075bbd3bab2668b3bdaa5120559b294694018cdcefabbbf30b/ruff-0.16.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a3e039a6a40ed976c491722b60e0ae4a4aa1a86057f540ee7a37a5d19ae9120", size = 10547484, upload-time = "2026-09-24T20:37:24.229Z" }, + { url = "https://files.pythonhosted.org/packages/03/59/38430a6bc2f6d8095447ac39625cf8b6e9344a47e6c26225c8ba1bff3ffb/ruff-0.16.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4684dded7db60aa57cb118fa158630f5feade4af5782903b6053484bdf9bd129", size = 11412367, upload-time = "2026-09-24T20:37:26.307Z" }, + { url = "https://files.pythonhosted.org/packages/c8/bd/bbb6d7fc7f208c8b8c50dd5dc8206e4cfdb1e7a8fb852606a3adf370c880/ruff-0.16.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d29c934357e45642fda2f34c0b1f4025b4a6c01e15e4bf0016879d60078a142c", size = 10869787, upload-time = "2026-09-24T20:37:28.35Z" }, + { url = "https://files.pythonhosted.org/packages/bc/b8/9c543074918061abbefc3bd139bee22de35abedb00dde0f2d27288838962/ruff-0.16.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a21713e629d3e5bdb2f5c2def1cc7f04f47fa8e1a7eb0571b4a28e1da64bc728", size = 10406494, upload-time = "2026-09-24T20:37:30.624Z" }, + { url = "https://files.pythonhosted.org/packages/35/7a/5a8851bd146e7ccf8fd4b003f6c75c11f8fbdb0e60673b45097986b6bf41/ruff-0.16.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:7baa24ef5fc8e77aa93879e1d3f43754a01ae488e869f1ae30cf431afd4d2452", size = 10590083, upload-time = "2026-09-24T20:37:32.439Z" }, + { url = "https://files.pythonhosted.org/packages/87/f0/4c3467188f23f806960b46fa76575a7cd0514c9ba90562650b71efc96980/ruff-0.16.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a41aac6230aadfaa133bdfa1614488531ffa3e0837567ae04c0da2058a9c0f9e", size = 10119151, upload-time = "2026-09-24T20:37:34.581Z" }, + { url = "https://files.pythonhosted.org/packages/15/34/5a4def5adea572ce6aea0bb64f21f928ee317b80e0db747d7979b01d7261/ruff-0.16.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:c2529fb5896d49115b0e9aa8f887490b34bbe76baf879ec2264ac59406869ce7", size = 9911544, upload-time = "2026-09-24T20:37:36.796Z" }, + { url = "https://files.pythonhosted.org/packages/62/5d/d15ebea7499eef9373318c0ee6ca127832927c6529731f6d48e18dce7ca9/ruff-0.16.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:41e3870277694177429b56406d65dfbdb2c2802c52b715edaf6a0b829c69d4ee", size = 10269884, upload-time = "2026-09-24T20:37:38.857Z" }, + { url = "https://files.pythonhosted.org/packages/d1/56/c5d3cd119ded7a3c7aba0e961b69cb3df701c91662c98ad694467d060ce1/ruff-0.16.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8adbe4e58af167f767d7b2ba5e83c42e878350796cf78c2f5e14ab9903a92588", size = 10749366, upload-time = "2026-09-24T20:37:41.042Z" }, + { url = "https://files.pythonhosted.org/packages/ac/fe/734ec7527029ac757ecf821f143c9f3fcf69149c21f53a044a899b430f5a/ruff-0.16.9-py3-none-win32.whl", hash = "sha256:0e1dbc2073624dee6618d41d0098690a7244654af746704b64759e12b6b6b385", size = 10152355, upload-time = "2026-09-24T20:37:43.025Z" }, + { url = "https://files.pythonhosted.org/packages/14/21/26e4643629b3ebb44f0a06f9c9a53058d63d989415f63a9a3c28e2ee7f22/ruff-0.16.9-py3-none-win_amd64.whl", hash = "sha256:6bd40fec8cd4c8a3d4dd589bd8ad4e6320c13c29234159bfd959a40d529d597b", size = 10592965, upload-time = "2026-09-24T20:37:44.944Z" }, + { url = "https://files.pythonhosted.org/packages/51/60/5fb1a39dbb5ae314d5f59bc7348a63c1d5c20f3cd83914c4b5cb0be31d2d/ruff-0.16.9-py3-none-win_arm64.whl", hash = "sha256:ed1a252039200f57a59eebc063b54beabea67bfbaaca0eeaa7f54b5fbcda2284", size = 10458649, upload-time = "2026-09-24T20:37:46.882Z" }, +]