Initial commit, with rules 0.5 and basic bots
This commit is contained in:
+140
@@ -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/
|
||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
3.14
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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 |
|
||||||
|
+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
+---+---+---+ +---+ + + +
|
||||||
@@ -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"]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Il Labirinto del Dojo."""
|
||||||
@@ -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)
|
||||||
@@ -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
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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"
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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))
|
||||||
Vendored
+18
@@ -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
|
||||||
|
+ + + + + + + + +
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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))]
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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" },
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user