Initial commit, with rules 0.5 and basic bots

This commit is contained in:
2026-09-26 16:01:46 +02:00
commit 0aaf113fc3
32 changed files with 4206 additions and 0 deletions
+140
View File
@@ -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/
+1
View File
@@ -0,0 +1 @@
3.14
+42
View File
@@ -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
```
+207
View File
@@ -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).
@@ -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.
+97
View File
@@ -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.
+33
View File
@@ -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 |
+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+
+9
View File
@@ -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
+20
View File
@@ -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
+---+---+---+ +---+ + + +
+35
View File
@@ -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"]
+1
View File
@@ -0,0 +1 @@
"""Il Labirinto del Dojo."""
+274
View File
@@ -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)
+374
View File
@@ -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])
+108
View File
@@ -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)
+584
View File
@@ -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
+109
View File
@@ -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
)
+134
View File
@@ -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>") -> 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"
+57
View File
@@ -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)
+82
View File
@@ -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)
+190
View File
@@ -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
+18
View File
@@ -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))
+18
View File
@@ -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
+ + + + + + + + +
+36
View File
@@ -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
+116
View File
@@ -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()
+190
View File
@@ -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)
+108
View File
@@ -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
+58
View File
@@ -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
+670
View File
@@ -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
+59
View File
@@ -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)
+50
View File
@@ -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))]
+113
View File
@@ -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
)
Generated
+108
View File
@@ -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" },
]