Compare commits

...

20 commits

Author SHA1 Message Date
dbeac32b0f Trim docs/PLAN.md now that core work is shipped 2026-04-17 22:42:27 +02:00
2d2375e569 Polish transformer visuals with copper flash and new cargo colors
CellView gains FlashTransform: bright copper pulse + brief scale punch
(0.45s) on CargoConvertedEvent, distinct from the warmer golden
FlashProduce used by productions. EventAnimator routes transformer
conversions through the new phase so the flash reads as a conversion
moment rather than a second production tick.

PieceView picks up cargo colors for Tools (copper), Arms (deep red),
and Gold, matching the cargo-slide particle palette — pieces carrying
those transformed cargoes now render the correct indicator instead of
defaulting to white.
2026-04-17 22:42:03 +02:00
8a377c2e41 Add recurring-demand mode with shortage tracking
DemandDef gains optional ConsumptionPerTurn and SustainTurns. When
ConsumptionPerTurn > 0 the demand maintains a buffer filled by
deliveries and drained each turn. Shortage fires the first turn the
buffer can't cover consumption; it clears when the buffer refills.
SustainedTurns counts consecutive non-shortage turns, and IsSatisfied
flips to true once it meets SustainTurns — so the victory condition
becomes "no shortage for N consecutive turns" as soon as a mission opts
in. Classic demands (ConsumptionPerTurn = 0) behave exactly as before.

TurnExecutor runs the consumption sub-phase after transfers. Two new
events (DemandShortageStarted / DemandShortageCleared) let the
presentation surface the state later. BoardSnapshot + CampaignLoader
carry the new fields; no existing mission opts in yet, so
campaign_01.json is unaffected.
2026-04-17 22:39:28 +02:00
e3eb10570b Extend campaign_01 to 9 missions with a finale cathedral
Renames mission 7 from "Le Couronnement" to "Le Comptoir" (it only sets
up the tools→gold chain — the coronation is now the final mission) and
adds:

  - Mission 8 "L'Expansion Finale" (12×10): a Forge Est (wood→tools) and
    an Armurerie Est (stone→arms) on new rightmost columns, plus walls
    that force pieces to route around them. An Entrepôt Est demand on
    (11,9) gives the mission its own goal without depending on the old
    demands.
  - Mission 9 "Le Couronnement" (12×12): the Cathédrale occupies row 11
    as three adjacent demands — outils, armes, and or — so the player
    must keep all three transformation chains running simultaneously to
    complete the campaign.

Existing file tests updated for the new count and rename; new
Campaign01Tests asserts structure and non-regressive terrain across all
nine missions.
2026-04-17 22:34:11 +02:00
480c783bd6 Add bidirectional arrows and idle pulsation on piece trajectories
TrajectView now draws an arrow at each endpoint (since pieces oscillate
between start and end, both directions are relevant) and runs a looped
width/alpha tween that breathes between 0.3 and 0.75 over ~2.2s. The
pulse makes stationary relays visually distinct from static board art
without competing with active cargo/particle animations.
2026-04-17 22:31:56 +02:00
1d0999a78e Add cinematic on mission transition (cell pop-in, camera pan, title overlay)
OnMissionAdvanced now plays a three-track cinematic when the auto-advance
fires between missions:
  - Every cell in the rebuilt board pops in with a scale+fade tween
    (0.45s, back ease) so terrain expansion reads as "appearing".
  - The camera smooth-pans + zooms back to the new board center over
    0.7s, undoing any collision zoom that was active.
  - A full-size "Mission N\nName" overlay fades in, holds for ~1.4s,
    then fades out and frees itself.

The per-cell animation doubles as the expansion cue; a dedicated
new-cells-only tween can be layered later if needed. No new engine
surface — hooks onto the existing MissionStartedEvent path.
2026-04-17 22:30:15 +02:00
6c28665c38 Add Delete/Backspace shortcut to remove the selected piece
DetailPanel exposes the currently-shown piece id; Main's key handler
delegates Delete or Backspace to the same path as the Retirer button
when a piece is selected. Harness gains "delete" under key() so UI
tests can exercise the shortcut directly.
2026-04-17 22:23:38 +02:00
c4f6ecbf44 Add collision camera pan/zoom and toast notification
EventAnimator now emits CollisionOccurred at the end of the collision
phase, carrying the struck cell and victim/destroyer identity. Main pans
and zooms the camera onto the cell over 0.45s and shows a fading toast
("Pion détruit par Tour — retourné au stock", or "collision mutuelle"
for same-status ties). The toast fades out after 3s and leaves the
camera framing the collision so the player can inspect the aftermath
before resuming.
2026-04-17 22:21:36 +02:00
1522b70398 Add drag & drop to relocate placed pieces
InputMapper tracks a mouse-down over a placed piece and promotes it to
drag mode once the cursor travels past an 8px threshold. Legal drop
cells (those where the piece's start→end vector still fits a legal
placement) are highlighted in green. Releasing on a legal cell emits a
RelocateRequested signal; Main feeds it to MovePieceCommand, which is
already undoable via the existing history stack.

Escape or releasing on an invalid cell cancels. The harness gains a
relocate() helper so UI tests can script drag-and-drop moves without
synthesizing motion events.
2026-04-17 22:18:50 +02:00
97bca7d7df Add Undo (Ctrl+Z) backed by the WorldSave checkpoint mechanism
GameSim snapshots the state before each undoable command
(PlacePiece / RemovePiece / MovePiece) into a bounded LinkedList stack
(max 32). Undo() pops the last checkpoint and emits StateRestoredEvent,
reusing the presentation rebuild path already wired for QuickLoad.

Ctrl+Z in Main triggers the engine method; the harness exposes undo()
for tests. QuickLoad clears the stack (fresh timeline). Seven unit tests
cover empty stack, place/remove/move undo, reverse-order multiple undos,
rejected commands not checkpointing, and post-simulation rewind.
2026-04-17 22:14:06 +02:00
2537bfe828 Add QuickSave/QuickLoad with full state restore and visual rebuild
BoardState.CaptureSave/RestoreFromSave deep-copy every mutable field
(grid, pieces, demands, transformers, buffers, stock, campaign progress)
into a WorldSave slot. GameSim.QuickSave/QuickLoad expose slotted saves
and emit StateSavedEvent / StateRestoredEvent — the latter carries a
fresh BoardSnapshot so the presentation can rebuild board, pieces,
trajectories, objectives, stock, camera, and control bar in one pass.

F5/F9 trigger it in Main; harness gains quick_save/quick_load commands so
UI tests can checkpoint a scenario and resume without replaying from
scratch. Seven xUnit tests cover the roundtrip (including independence
from post-save mutations, campaign state, and multi-slot isolation).
2026-04-17 22:10:06 +02:00
bd1763f372 Consolidate plans into docs/PLAN.md and document dev loop
Delete autonomous_plan.md (fully shipped), PLAN_playtest.md (all P1-P7
done), PLAN_missions.md and PLAN_leveldesign.md (partial — engine done,
UI polish + 3 final missions + recurring demands + transformer visuals
remain). The surviving TODO list lives in docs/PLAN.md.

Also add the 6-step dev loop to CLAUDE.md (take next topic → implement →
tests → UI test → docs → commit).
2026-04-17 22:09:53 +02:00
eba81400a8 Skip unresolvable domains in firewall allowlist instead of aborting
If DNS resolution transiently fails for one domain, the whole postStart
hook used to exit 1 and leave the container without a firewall. Warn and
continue so the remaining rules still get installed.
2026-04-17 21:13:06 +02:00
3077b2d669 Teach Claude the autonomous devcontainer test recipe in CLAUDE.md
Covers toolchain sanity check, the root-owned .godot permissions pitfall,
build -> smoke -> Harness loop, Python snippet, visual validation loop,
and non-obvious state shape details. Also fixes the stale Windows-only
Godot path mention.
2026-04-17 21:13:05 +02:00
Samuel Bouchet
5146798f5c Document --dangerously-skip-permissions (YOLO) mode in README
Explains why YOLO is reasonable inside this specific container (bind-mount
scope, firewall allow-list, non-root user, no host secrets mounted) and
flags the residual risks worth knowing: uncommitted work in /workspace,
exfiltration via GitHub / Anthropic, no CPU/RAM limits. Key advice: commit
before long autonomous runs.
2026-04-17 20:50:38 +02:00
Samuel Bouchet
c451a50349 Headless Linux dev container: Godot + .NET + Xvfb for autonomous testing
Claude Code running inside the project's dev container can now build the
game, launch a real Godot instance under Xvfb, and drive the automation
harness end-to-end — no Windows dependency.

Dockerfile adds (as root, before USER node):
- X11 / Mesa software GL / audio runtime deps + python3
- .NET SDK 9.0 via upstream dot.net install script -> /usr/local/dotnet
- Godot 4.6.2-stable mono Linux x86_64 -> /opt/godot/godot
- /usr/local/bin/godot-xvfb wrapper: auto-wraps invocations in
  xvfb-run -a --server-args="-screen 0 1280x720x24 ..."

harness.py picks GODOT_BIN from env, defaults to /opt/godot/godot on
Linux, and auto-wraps the subprocess in xvfb-run when DISPLAY is unset.
Windows code path unchanged.

init-firewall.sh adds api.nuget.org to the allowlist so dotnet restore
works post-boot. Godot + .NET SDK are fetched at image build time, before
the firewall exists.

New docs:
- autonomous_plan.md: design rationale, alternatives considered
- README.md: launch instructions for Windows terminal / Docker Desktop /
  VS Code Dev Containers / WSL2 natif
- CLAUDE.md already documents the harness (done in previous commit)

Validation: docker build succeeds; inside the container, dotnet --version
=9.0.313, godot --version=4.6.2.stable.mono, dotnet test=102/102,
python3 tools/automation/smoke.py passes end-to-end with 14 non-black
1280x720 PNGs. Mission 1 screenshot is visually identical to the Windows
build, and Xvfb determinism is a bonus (det_a.png ≡ det_b.png bytewise).
2026-04-17 16:57:56 +02:00
Samuel Bouchet
8f3b1b39e7 Document automation harness usage in CLAUDE.md 2026-04-16 22:37:20 +02:00
Samuel Bouchet
62a208934c Untrack accidentally committed __pycache__; ignore .pyc 2026-04-16 22:35:18 +02:00
Samuel Bouchet
f86b9abecd Add file-IPC automation harness for autonomous game testing
Launching Godot with --automation=<dir> activates an AutomationHarness
node that polls <dir>/inbox/ for JSON command files, executes them via
a thin facade over existing public surfaces (GameSim, InputMapper,
EventAnimator, ControlBar, PieceStockPanel), and writes results plus
screenshots back to disk. The black-box simulation boundary is not
crossed — every command routes through the same signals/methods a real
player would trigger.

A stdlib-only Python helper (tools/automation/harness.py) wraps the
protocol for test scripts and interactive REPLs. Smoke test passes
end-to-end: load mission, place a piece, step 10 turns, capture 14
1280x720 PNGs, handle rejections, quit cleanly. Existing 102 engine
unit tests still green.
2026-04-16 22:34:56 +02:00
Samuel Bouchet
2d1aea0a7a Snapshot campaign system progress before automation harness
Bundles in-flight work on the campaign/missions system (CampaignDef,
MissionDef, TerrainPatch, TransformerDef, MissionChecker, CampaignLoader,
FlavorBanner, transformer rules), plan files, and matching tests. Baseline
commit so the upcoming automation testing harness lands on a clean tree.
2026-04-16 21:22:49 +02:00
98 changed files with 6710 additions and 956 deletions

View file

@ -25,8 +25,80 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
jq \
nano \
vim \
ca-certificates \
curl \
wget \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
# -----------------------------------------------------------------------------
# Chessistics: headless Godot + .NET SDK
# -----------------------------------------------------------------------------
# 1. Xvfb + Mesa software GL + X/audio runtime deps for Godot's GL-compatibility renderer
RUN apt-get update && apt-get install -y --no-install-recommends \
xvfb \
xauth \
x11-utils \
libx11-6 \
libxcursor1 \
libxinerama1 \
libxrandr2 \
libxi6 \
libxext6 \
libxrender1 \
libxfixes3 \
libxss1 \
libxkbcommon0 \
libxkbcommon-x11-0 \
libgl1 \
libglx-mesa0 \
libgl1-mesa-dri \
libglu1-mesa \
libegl1 \
libgles2 \
libasound2 \
libpulse0 \
libfontconfig1 \
libfreetype6 \
libdbus-1-3 \
libudev1 \
fonts-dejavu-core \
python3 \
python3-pip \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
# 2. .NET SDK 9.0 via the upstream install script (arch-agnostic, no apt repo needed)
RUN curl -fsSL https://dot.net/v1/dotnet-install.sh -o /tmp/dotnet-install.sh \
&& bash /tmp/dotnet-install.sh --channel 9.0 --install-dir /usr/local/dotnet \
&& ln -s /usr/local/dotnet/dotnet /usr/local/bin/dotnet \
&& rm /tmp/dotnet-install.sh
ENV DOTNET_ROOT=/usr/local/dotnet
ENV DOTNET_CLI_TELEMETRY_OPTOUT=1
ENV DOTNET_NOLOGO=1
# 3. Godot 4.6.2-stable Mono for Linux x86_64
# The zip contains a directory like "Godot_v..._mono_linux_x86_64/" with
# an executable whose exact filename has varied across releases
# ("Godot_v..._mono_linux.x86_64" on 4.x). Locate it dynamically.
ARG GODOT_VERSION=4.6.2-stable
RUN mkdir -p /opt/godot \
&& cd /tmp \
&& wget -q "https://github.com/godotengine/godot/releases/download/${GODOT_VERSION}/Godot_v${GODOT_VERSION}_mono_linux_x86_64.zip" \
&& unzip -q "Godot_v${GODOT_VERSION}_mono_linux_x86_64.zip" -d /opt/godot \
&& GODOT_EXE="$(find /opt/godot -maxdepth 3 -type f \( -name 'Godot_v*mono_linux*x86_64' -o -name 'Godot_v*mono_linux*x86_64' \) | head -1)" \
&& if [ -z "$GODOT_EXE" ]; then echo "Godot executable not found in zip" && ls -R /opt/godot && exit 1; fi \
&& chmod +x "$GODOT_EXE" \
&& ln -sf "$GODOT_EXE" /opt/godot/godot \
&& rm "Godot_v${GODOT_VERSION}_mono_linux_x86_64.zip"
ENV GODOT_BIN=/opt/godot/godot
ENV PATH=$PATH:/opt/godot:/usr/local/dotnet
# 4. xvfb wrapper — any Godot invocation gets its own virtual 1280x720x24 display.
# Usage: `godot-xvfb --path /workspace ...` or let tools/automation/harness.py
# invoke it automatically on Linux.
COPY godot-xvfb.sh /usr/local/bin/godot-xvfb
RUN chmod +x /usr/local/bin/godot-xvfb
# Ensure default node user has access to /usr/local/share
RUN mkdir -p /usr/local/share/npm-global && \
chown -R node:node /usr/local/share

View file

@ -0,0 +1,15 @@
#!/bin/bash
# Wrap a Godot invocation in a fresh Xvfb 1280x720x24 display so the GL
# renderer has something to draw into. If DISPLAY is already set (real
# display / nested X server), skip xvfb-run and exec Godot directly.
set -euo pipefail
: "${GODOT_BIN:=/opt/godot/godot}"
if [[ -n "${DISPLAY:-}" ]]; then
exec "$GODOT_BIN" "$@"
fi
exec xvfb-run -a \
--server-args="-screen 0 1280x720x24 -ac +extension GLX +render -noreset" \
"$GODOT_BIN" "$@"

View file

@ -69,12 +69,13 @@ for domain in \
"api.anthropic.com" \
"sentry.io" \
"statsig.anthropic.com" \
"statsig.com"; do
"statsig.com" \
"api.nuget.org"; do
echo "Resolving $domain..."
ips=$(dig +noall +answer A "$domain" | awk '$4 == "A" {print $5}')
if [ -z "$ips" ]; then
echo "ERROR: Failed to resolve $domain"
exit 1
echo "WARN: Failed to resolve $domain - skipping"
continue
fi
while read -r ip; do

5
.gitignore vendored
View file

@ -21,3 +21,8 @@ Thumbs.db
# Claude Code
.claude/
.idea
# Automation harness run outputs
.automation_runs/
**/__pycache__/
*.pyc

177
CLAUDE.md
View file

@ -29,4 +29,179 @@ Tout `Control` (ColorRect, Label…) a `MouseFilter = Stop` par defaut. Quand un
### Plans
Les fichiers de plan doivent etre rediges a la racine du workspace (ex: `/workspace/PLAN_juice.md`), **pas** dans `.claude/plans/` car ce dossier a une taille limitee.
Les plans vivants (travail restant, features a faire) vont dans
[`docs/PLAN.md`](docs/PLAN.md). Pour un brouillon ad-hoc, ecrire a la racine
du workspace (ex: `/workspace/PLAN_<sujet>.md`), **pas** dans
`.claude/plans/` (taille limitee). Une fois implemente, supprimer le
fichier ; si partiel, consolider le restant dans `docs/PLAN.md`.
### Boucle de developpement
Pour chaque sujet pris dans `docs/PLAN.md` :
1. **Prendre le sujet suivant** dans le plan (ordre de priorite).
2. **Implementer** (moteur + presentation selon le cas).
3. **Ajouter des tests unitaires** si applicable (`chessistics-tests/`).
4. **Tester l'UI/UX** de la fonctionnalite dans le jeu si applicable
(harness + quick save/load pour reprendre un checkpoint).
5. **Mettre a jour la documentation** (README, CLAUDE.md, GDD) si
necessaire et **retirer le sujet du plan** (ou annoter ce qui reste).
6. **Commit** (un commit par sujet, message en anglais, sans co-author
Claude).
## Harnais d'automatisation (Claude peut jouer tout seul)
Le jeu peut etre pilote de maniere autonome via le flag `--automation=<dir>`. Un
`AutomationHarness` (`Scripts/Automation/`) s'active alors comme noeud au root de la
scene, lit des commandes JSON dans `<dir>/inbox/`, ecrit les resultats dans
`<dir>/outbox/`, et place les captures d'ecran dans `<dir>/screens/`. Sans le flag,
comportement normal — overhead zero.
Cote agent, un wrapper Python stdlib (`tools/automation/harness.py`) expose une API
simple. Le binaire Godot est detecte via `GODOT_BIN` (fallback Windows
`C:\Apps\godot\Godot_v4.6.2-stable_mono_win64_console.exe`, Linux
`/opt/godot/godot`). Sous Linux sans `DISPLAY`, la commande Godot est auto-wrappee
dans `xvfb-run` (framebuffer virtuel 1280x720).
### Build + utilisation
```bash
dotnet build Chessistics.csproj # compiler avant tout lancement
python tools/automation/smoke.py # smoke test end-to-end
python tools/automation/run_game.py # REPL interactif
```
### API Python
```python
from tools.automation.harness import Harness
with Harness.launch() as h:
h.load_mission("campaign_01", 0) # charge la campagne + mission 0
state = h.state() # snapshot complet (dict)
h.screenshot("before") # -> .automation_runs/<ts>/screens/before.png
h.place("Pawn", (0, 0), (0, 1)) # pose une piece
h.step() # un tour (auto-wait animation)
h.screenshot("after")
h.set_speed(0.1); h.play() # auto-play rapide
```
Methodes : `screenshot`, `state`, `select`, `place`, `click_cell`, `key`, `play`,
`pause`, `step`, `wait_idle`, `set_speed`, `load_mission`, `back_to_menu`, `quit`.
Toutes les commandes non-query attendent `EventAnimator.IsAnimating == false` avant
de retourner -> appels en serie toujours vus par le prochain `state()`.
### Validation visuelle par Claude
Les PNG 1280x720 ecrites dans `.automation_runs/<run>/screens/` peuvent etre lues
directement par l'outil `Read` de Claude. Workflow type pour valider l'UI :
1. `h.load_mission("campaign_01", N)` + `h.screenshot("mission_N_start")`
2. Lire le PNG -> verifier titre, flavor banner, board, panneau objectifs, stock
3. Placer des pieces via `h.place(...)` et re-screenshot
4. `h.step()` en boucle + screenshot a chaque etape
5. Attendre `phase == "MissionComplete"` dans le snapshot
Cette boucle permet de valider que :
- Les demandes affichent les bons compteurs
- Les pieces bougent comme prevu
- Le stock se met a jour
- L'ecran `MissionComplete` apparait quand attendu
### Details importants
- `Place` passe par le signal `PlacementRequested` (meme chemin qu'un vrai clic) --
ne pas appeler `GameSim.ProcessCommand(PlacePieceCommand)` directement dans le
dispatcher, ca mute deux fois.
- Les captures d'ecran sont prises apres `RenderingServer.frame_post_draw` -> le
frame reflete l'etat final, animations incluses.
- La facade (`AutomationFacade`) est la **seule** surface exposee au dispatcher.
Elle ne touche que des methodes/signals publics de `GameSim`, `InputMapper`,
`EventAnimator`, `ControlBar`, `PieceStockPanel`. La separation black-box tient.
- Les fichiers IPC sont ecrits `.tmp` puis renommes (atomique sur Windows).
- La campagne se charge via `load_mission("campaign_01", 0)`. Passer a une mission
> 0 n'est pas supporte directement (il faut passer par `MissionComplete` reel).
## Mode autonome dans le devcontainer
Claude tourne dans un devcontainer Linux (`.devcontainer/`) qui embarque deja
`.NET 9 SDK`, `Godot 4.6.2 mono Linux`, `Xvfb`, `Python 3`. Tout le workflow
ci-dessous est executable sans sortir du container, sans display physique.
### Sanity check toolchain (une fois par session)
```bash
dotnet --version # 9.0.x
godot --version # 4.6.2.stable.mono.official.*
which godot-xvfb # /usr/local/bin/godot-xvfb (auto xvfb-run wrapper)
```
### Piege permissions `.godot/`
Si une etape precedente (build Docker, editeur…) a laisse `.godot/` detenu par
`root`, le build dotnet echoue avec `MSB3374: Access to the path '...Up2Date'
is denied`. Les perms sont a 777 -> **supprimer le cache suffit**, pas besoin de
`sudo` :
```bash
rm -rf /workspace/.godot
dotnet build Chessistics.csproj
```
### Recette pour verifier que Claude peut jouer tout seul
```bash
dotnet build Chessistics.csproj # doit etre vert
python3 tools/automation/smoke.py # charge mission 1, screenshots, determinisme
ls .automation_runs/smoke/screens/ # PNG non-noirs
```
Si `smoke.py` passe, tout le pipeline marche : Godot boot -> IPC inbox/outbox
-> screenshots lisibles via l'outil `Read`.
### Driver le jeu en Python depuis Claude
```python
import sys, time
sys.path.insert(0, '/workspace')
from tools.automation.harness import Harness
with Harness.launch(run_name="claude_drive") as h:
h.load_mission("campaign_01", 0)
s = h.state() # dict: phase, turn, width, height,
# grid, productions, demands,
# transformers, pieces, remainingStock
h.screenshot("01_loaded")
h.place("Pawn", (0, 0), (0, 1))
h.screenshot("02_placed")
h.set_speed(0.05); h.play()
time.sleep(2); h.pause()
h.screenshot("03_after_play")
```
Les PNG atterrissent dans `/workspace/.automation_runs/<run_name>/screens/`.
Claude les lit directement via `Read` (multimodal).
### Boucle typique de validation visuelle
1. `h.load_mission("campaign_01", N)` -> `h.screenshot(f"m{N}_start")` -> `Read`
2. Verifier sur le PNG : titre, bandeau flavor, board, panneau OBJECTIFS,
compteurs PIECES.
3. Poser des pieces via `h.place(...)`, relire l'etat (`remainingStock`
diminue, `pieces` grandit).
4. `h.play()` + `sleep` + `h.pause()` (ou `h.step()` en boucle), screenshot
a chaque palier.
5. Boucler jusqu'a `state()["phase"] == "MissionComplete"` ou un objectif
`demands[i].satisfied == True`.
### Details qui surprennent
- Le snapshot `state()` expose `width`/`height` au niveau racine (pas
`board.width`).
- `remainingStock` est un dict `{"Pawn": 4, ...}` ; verifier qu'il decremente
apres un `place()` confirme que la commande a bien ete appliquee.
- Les logs Godot affichent des warnings ALSA (pas de carte son) et V-Sync —
inoffensifs en headless, les filtrer avant d'afficher a l'utilisateur.
- Le firewall du container bloque tout sauf l'allowlist ; le runtime Godot
n'a besoin d'aucun reseau, donc aucun probleme.

View file

@ -0,0 +1,312 @@
{
"name": "La Quête du Roi",
"initialWidth": 4,
"initialHeight": 4,
"missions": [
{
"id": 1,
"name": "Premier Convoi",
"description": "Les pions découvrent une scierie. Acheminez le bois au dépôt.",
"flavor": "« Allez les gars, on porte ce bois ! Premier jour, on y croit ! » — Pion enthousiaste",
"terrainPatch": {
"newWidth": 4,
"newHeight": 4,
"cells": [
{ "col": 0, "row": 0, "type": "production", "production": { "name": "Scierie", "cargo": "wood", "amount": 4 } },
{ "col": 3, "row": 0, "type": "demand", "demand": { "name": "Dépôt Royal", "cargo": "wood", "amount": 3 } }
]
},
"unlockedPieces": ["pawn"],
"unlockedLevels": [{ "kind": "pawn", "level": 1 }],
"stock": [
{ "kind": "pawn", "count": 4 }
]
},
{
"id": 2,
"name": "Forger les Tours",
"description": "Les pions forgent des Tours. Le territoire s'étend et une carrière apparaît.",
"flavor": "« Des Tours ! Enfin des collègues qui bossent en ligne droite. » — Pion impressionné",
"terrainPatch": {
"newWidth": 6,
"newHeight": 6,
"cells": [
{ "col": 4, "row": 0, "type": "empty" },
{ "col": 4, "row": 1, "type": "empty" },
{ "col": 4, "row": 2, "type": "empty" },
{ "col": 4, "row": 3, "type": "empty" },
{ "col": 4, "row": 4, "type": "empty" },
{ "col": 4, "row": 5, "type": "empty" },
{ "col": 5, "row": 0, "type": "demand", "demand": { "name": "Caserne", "cargo": "wood", "amount": 4 } },
{ "col": 5, "row": 1, "type": "empty" },
{ "col": 5, "row": 2, "type": "empty" },
{ "col": 5, "row": 3, "type": "empty" },
{ "col": 5, "row": 4, "type": "empty" },
{ "col": 5, "row": 5, "type": "production", "production": { "name": "Carrière", "cargo": "stone", "amount": 4 } },
{ "col": 0, "row": 4, "type": "empty" },
{ "col": 0, "row": 5, "type": "demand", "demand": { "name": "Entrepôt de Pierre", "cargo": "stone", "amount": 4 } },
{ "col": 1, "row": 4, "type": "empty" },
{ "col": 1, "row": 5, "type": "empty" },
{ "col": 2, "row": 4, "type": "empty" },
{ "col": 2, "row": 5, "type": "empty" },
{ "col": 3, "row": 4, "type": "empty" },
{ "col": 3, "row": 5, "type": "empty" }
]
},
"unlockedPieces": ["rook"],
"unlockedLevels": [{ "kind": "rook", "level": 1 }],
"stock": [
{ "kind": "pawn", "count": 2 },
{ "kind": "rook", "count": 4 }
]
},
{
"id": 3,
"name": "Le Col",
"description": "Un mur bloque le passage. Les Cavaliers sautent par-dessus les obstacles.",
"flavor": "« Moi, les murs, je les enjambe. C'est ça, la classe. » — Cavalier fanfaron",
"terrainPatch": {
"newWidth": 8,
"newHeight": 6,
"cells": [
{ "col": 6, "row": 0, "type": "wall" },
{ "col": 6, "row": 1, "type": "wall" },
{ "col": 6, "row": 2, "type": "wall" },
{ "col": 6, "row": 3, "type": "empty" },
{ "col": 6, "row": 4, "type": "empty" },
{ "col": 6, "row": 5, "type": "empty" },
{ "col": 7, "row": 0, "type": "demand", "demand": { "name": "Avant-Poste du Col", "cargo": "wood", "amount": 4 } },
{ "col": 7, "row": 1, "type": "empty" },
{ "col": 7, "row": 2, "type": "empty" },
{ "col": 7, "row": 3, "type": "empty" },
{ "col": 7, "row": 4, "type": "empty" },
{ "col": 7, "row": 5, "type": "empty" }
]
},
"unlockedPieces": ["knight"],
"unlockedLevels": [{ "kind": "knight", "level": 1 }],
"stock": [
{ "kind": "rook", "count": 2 },
{ "kind": "knight", "count": 2 }
]
},
{
"id": 4,
"name": "Le Carrefour",
"description": "Le territoire s'agrandit. Un carrefour central rend les routes plus complexes.",
"flavor": "« Diagonales, mes amies ! En avant toute ! » — Fou enthousiaste",
"terrainPatch": {
"newWidth": 8,
"newHeight": 8,
"cells": [
{ "col": 0, "row": 6, "type": "empty" },
{ "col": 0, "row": 7, "type": "demand", "demand": { "name": "Fort du Sud", "cargo": "stone", "amount": 3 } },
{ "col": 1, "row": 6, "type": "empty" },
{ "col": 1, "row": 7, "type": "empty" },
{ "col": 2, "row": 6, "type": "empty" },
{ "col": 2, "row": 7, "type": "empty" },
{ "col": 3, "row": 6, "type": "wall" },
{ "col": 3, "row": 7, "type": "empty" },
{ "col": 4, "row": 6, "type": "wall" },
{ "col": 4, "row": 7, "type": "empty" },
{ "col": 5, "row": 6, "type": "empty" },
{ "col": 5, "row": 7, "type": "empty" },
{ "col": 6, "row": 6, "type": "empty" },
{ "col": 6, "row": 7, "type": "empty" },
{ "col": 7, "row": 6, "type": "production", "production": { "name": "Carrière Est", "cargo": "stone", "amount": 4 } },
{ "col": 7, "row": 7, "type": "empty" },
{ "col": 5, "row": 7, "type": "demand", "demand": { "name": "Château", "cargo": "wood", "amount": 2 } }
]
},
"unlockedPieces": ["bishop"],
"unlockedLevels": [{ "kind": "bishop", "level": 1 }],
"stock": [
{ "kind": "pawn", "count": 4 },
{ "kind": "rook", "count": 2 },
{ "kind": "bishop", "count": 2 },
{ "kind": "knight", "count": 1 }
]
},
{
"id": 5,
"name": "La Forge",
"description": "La Forge transforme le bois en outils. La Dame entre en jeu pour les longues distances.",
"flavor": "« Du bois qui rentre, des outils qui sortent. La magie de l'industrie ! » — Tour pragmatique",
"terrainPatch": {
"newWidth": 8,
"newHeight": 8,
"cells": [
{ "col": 6, "row": 3, "type": "transformer", "transformer": { "name": "Forge", "inputCargo": "wood", "inputRequired": 2, "outputCargo": "tools", "outputAmount": 1 } },
{ "col": 7, "row": 5, "type": "demand", "demand": { "name": "Atelier", "cargo": "tools", "amount": 3 } }
]
},
"unlockedPieces": ["queen"],
"unlockedLevels": [{ "kind": "queen", "level": 1 }],
"stock": [
{ "kind": "rook", "count": 4 },
{ "kind": "queen", "count": 1 }
]
},
{
"id": 6,
"name": "L'Armurerie",
"description": "Le territoire s'étend. L'Armurerie transforme la pierre en armes pour la Garnison.",
"flavor": "« De la pierre brute aux lames acérées… on ne rigole plus ! » — Cavalier guerrier",
"terrainPatch": {
"newWidth": 10,
"newHeight": 10,
"cells": [
{ "col": 8, "row": 0, "type": "empty" },
{ "col": 8, "row": 1, "type": "transformer", "transformer": { "name": "Armurerie", "inputCargo": "stone", "inputRequired": 2, "outputCargo": "arms", "outputAmount": 1 } },
{ "col": 8, "row": 2, "type": "empty" },
{ "col": 8, "row": 3, "type": "wall" },
{ "col": 8, "row": 4, "type": "empty" },
{ "col": 8, "row": 5, "type": "empty" },
{ "col": 8, "row": 6, "type": "empty" },
{ "col": 8, "row": 7, "type": "empty" },
{ "col": 8, "row": 8, "type": "empty" },
{ "col": 8, "row": 9, "type": "empty" },
{ "col": 9, "row": 0, "type": "empty" },
{ "col": 9, "row": 1, "type": "empty" },
{ "col": 9, "row": 2, "type": "demand", "demand": { "name": "Garnison", "cargo": "arms", "amount": 3 } },
{ "col": 9, "row": 3, "type": "wall" },
{ "col": 9, "row": 4, "type": "empty" },
{ "col": 9, "row": 5, "type": "empty" },
{ "col": 9, "row": 6, "type": "empty" },
{ "col": 9, "row": 7, "type": "empty" },
{ "col": 9, "row": 8, "type": "empty" },
{ "col": 9, "row": 9, "type": "empty" },
{ "col": 0, "row": 8, "type": "empty" },
{ "col": 0, "row": 9, "type": "empty" },
{ "col": 1, "row": 8, "type": "empty" },
{ "col": 1, "row": 9, "type": "empty" },
{ "col": 2, "row": 8, "type": "empty" },
{ "col": 2, "row": 9, "type": "empty" },
{ "col": 3, "row": 8, "type": "empty" },
{ "col": 3, "row": 9, "type": "empty" },
{ "col": 4, "row": 8, "type": "wall" },
{ "col": 4, "row": 9, "type": "empty" },
{ "col": 5, "row": 8, "type": "wall" },
{ "col": 5, "row": 9, "type": "empty" },
{ "col": 6, "row": 8, "type": "empty" },
{ "col": 6, "row": 9, "type": "empty" },
{ "col": 7, "row": 8, "type": "empty" },
{ "col": 7, "row": 9, "type": "empty" }
]
},
"unlockedPieces": [],
"unlockedLevels": [],
"stock": [
{ "kind": "rook", "count": 4 },
{ "kind": "knight", "count": 2 },
{ "kind": "pawn", "count": 4 }
]
},
{
"id": 7,
"name": "Le Comptoir",
"description": "Le Comptoir transforme les outils en or. Livrez l'or au Trésor Royal — bientôt le Roi.",
"flavor": "« De l'or ! Le Roi sera content. Enfin… s'il reste du budget pour nous payer. » — Dame sceptique",
"terrainPatch": {
"newWidth": 10,
"newHeight": 10,
"cells": [
{ "col": 4, "row": 9, "type": "transformer", "transformer": { "name": "Comptoir", "inputCargo": "tools", "inputRequired": 2, "outputCargo": "gold", "outputAmount": 1 } },
{ "col": 9, "row": 9, "type": "demand", "demand": { "name": "Trésor Royal", "cargo": "gold", "amount": 2 } }
]
},
"unlockedPieces": [],
"unlockedLevels": [],
"stock": [
{ "kind": "queen", "count": 1 },
{ "kind": "rook", "count": 4 },
{ "kind": "bishop", "count": 2 }
]
},
{
"id": 8,
"name": "L'Expansion Finale",
"description": "Deux nouveaux transformateurs s'ajoutent pour soutenir la production. Les routes existantes doivent tenir.",
"flavor": "« On double l'équipe, on double la paie ? Non. D'accord, double le boulot alors. » — Pion pragmatique",
"terrainPatch": {
"newWidth": 12,
"newHeight": 10,
"cells": [
{ "col": 10, "row": 0, "type": "empty" },
{ "col": 10, "row": 1, "type": "empty" },
{ "col": 10, "row": 2, "type": "empty" },
{ "col": 10, "row": 3, "type": "empty" },
{ "col": 10, "row": 4, "type": "empty" },
{ "col": 10, "row": 5, "type": "transformer", "transformer": { "name": "Forge Est", "inputCargo": "wood", "inputRequired": 2, "outputCargo": "tools", "outputAmount": 1 } },
{ "col": 10, "row": 6, "type": "wall" },
{ "col": 10, "row": 7, "type": "empty" },
{ "col": 10, "row": 8, "type": "empty" },
{ "col": 10, "row": 9, "type": "empty" },
{ "col": 11, "row": 0, "type": "empty" },
{ "col": 11, "row": 1, "type": "empty" },
{ "col": 11, "row": 2, "type": "empty" },
{ "col": 11, "row": 3, "type": "transformer", "transformer": { "name": "Armurerie Est", "inputCargo": "stone", "inputRequired": 2, "outputCargo": "arms", "outputAmount": 1 } },
{ "col": 11, "row": 4, "type": "empty" },
{ "col": 11, "row": 5, "type": "empty" },
{ "col": 11, "row": 6, "type": "empty" },
{ "col": 11, "row": 7, "type": "wall" },
{ "col": 11, "row": 8, "type": "empty" },
{ "col": 11, "row": 9, "type": "demand", "demand": { "name": "Entrepôt Est", "cargo": "tools", "amount": 2 } }
]
},
"unlockedPieces": [],
"unlockedLevels": [],
"stock": [
{ "kind": "rook", "count": 4 },
{ "kind": "bishop", "count": 2 },
{ "kind": "knight", "count": 2 },
{ "kind": "pawn", "count": 4 }
]
},
{
"id": 9,
"name": "Le Couronnement",
"description": "La Cathédrale est la dernière étape. Elle réclame outils, armes et or simultanément pour couronner le Roi.",
"flavor": "« Trois offrandes, un Roi. Et après, c'est lui qui nous couronne ? Ou qui nous exécute ? » — Cavalier inquiet",
"terrainPatch": {
"newWidth": 12,
"newHeight": 12,
"cells": [
{ "col": 0, "row": 10, "type": "empty" },
{ "col": 0, "row": 11, "type": "empty" },
{ "col": 1, "row": 10, "type": "empty" },
{ "col": 1, "row": 11, "type": "empty" },
{ "col": 2, "row": 10, "type": "empty" },
{ "col": 2, "row": 11, "type": "empty" },
{ "col": 3, "row": 10, "type": "empty" },
{ "col": 3, "row": 11, "type": "empty" },
{ "col": 4, "row": 10, "type": "empty" },
{ "col": 4, "row": 11, "type": "empty" },
{ "col": 5, "row": 10, "type": "empty" },
{ "col": 5, "row": 11, "type": "demand", "demand": { "name": "Cathédrale (Outils)", "cargo": "tools", "amount": 2 } },
{ "col": 6, "row": 10, "type": "empty" },
{ "col": 6, "row": 11, "type": "demand", "demand": { "name": "Cathédrale (Armes)", "cargo": "arms", "amount": 2 } },
{ "col": 7, "row": 10, "type": "empty" },
{ "col": 7, "row": 11, "type": "demand", "demand": { "name": "Cathédrale (Or)", "cargo": "gold", "amount": 2 } },
{ "col": 8, "row": 10, "type": "empty" },
{ "col": 8, "row": 11, "type": "empty" },
{ "col": 9, "row": 10, "type": "empty" },
{ "col": 9, "row": 11, "type": "empty" },
{ "col": 10, "row": 10, "type": "empty" },
{ "col": 10, "row": 11, "type": "empty" },
{ "col": 11, "row": 10, "type": "empty" },
{ "col": 11, "row": 11, "type": "empty" }
]
},
"unlockedPieces": [],
"unlockedLevels": [],
"stock": [
{ "kind": "queen", "count": 2 },
{ "kind": "rook", "count": 4 },
{ "kind": "bishop", "count": 2 },
{ "kind": "knight", "count": 2 },
{ "kind": "pawn", "count": 4 }
]
}
]
}

55
PLAN.md
View file

@ -1,55 +0,0 @@
# Chessistics — Prototype Roadmap
## Phase 1: Core solvability (DONE)
- Black Box Sim pattern: commands self-apply via `DoApply()`/`AssertApplicationConditions()`
- 3 piece types: Rook (orthogonal range 2), Bishop (diagonal range 2), Knight (L-jump)
- Relay chain mechanic with shared relay points (collision-free alternating)
- Transfer system: production → pieces → demands, 4-adjacency, participation tracking
- Victory/defeat: all demands met vs deadline expired
## Phase 2: Cargo-type aware transfers (DONE)
- `CargoFilter` property on `PieceState`: optional `CargoType?` restricting accepted cargo
- Auto-assigned at placement via relay chain tracing (adjacency to production, then shared
relay points with filtered pieces)
- `TransferResolver` enforces filter: receivers with mismatched `CargoFilter` are skipped
- Forward-direction sorting uses cargo-type-aware `MinDistanceToProduction` to avoid
wrong sorting when multiple productions exist
- Level 3 restored to dual-cargo (Wood+Stone) with 10R+2K stock
- GDD stock corrections: Level 2 = 6R+1B, Level 3 = 10R+2K
- 60 tests passing including 2 new CargoFilter tests
## Phase 3: Pion, surplus stock, levels 4-6 (DONE)
- Pion: orthogonal range 1, status 1 (lowest), cheap relay maillon
- Surplus stock on all levels (more pieces than minimum solution)
- Levels 4-6: Le Carrefour (8x8), Le Labyrinthe (8x6), Trois Royaumes (10x8)
- Production interval removed: all productions fire every turn
- GDD updated with Pion, 6 levels
## Phase 5: Dame, network levels, juice pass (DONE)
- Dame (Queen): 8 directions, range 2, status 7 (highest)
- Levels 7-8: La Dame Blanche (10x10), Le Grand Reseau (12x10)
- Procedural SFX, particles, polished animations, fade transitions
- UI bugfixes: stop reset, piece selection visuals, back-to-menu button
- Trajectory preview on piece click
## Phase 6: Godot integration (DONE)
- Board renderer, piece placement, step/play/pause controls
- Event visualization with simultaneous animations per phase
- Victory/defeat screens with animated metrics
- Production flash, cargo slide trails, destruction particles, confetti
## Phase 7: Zoom, scroll wheel, and camera polish
- Mouse scroll wheel to zoom in/out on board
- Zoom limits (min/max) to prevent getting lost
- Double-click to center on a piece
## Phase 8: Level editor (future)
- Player-designed levels via JSON export
- In-game editing of board size, walls, productions, demands, stock

217
README.md Normal file
View file

@ -0,0 +1,217 @@
# Chessistics
Jeu de logistique sur échiquier en Godot 4 / C#. Le joueur place des pièces
d'échecs sur un plateau ; elles se déplacent automatiquement et transportent
des ressources entre des productions et des demandes.
Voir [`CLAUDE.md`](CLAUDE.md) pour l'architecture (black-box simulation) et
les conventions internes.
## Arborescence
```
Chessistics/
├─ Scripts/ # Code Godot (présentation)
│ ├─ Automation/ # Harness autonome (CLI --automation=<dir>)
│ ├─ Board/ Input/ UI/ Pieces/ Presentation/
│ └─ Main.cs
├─ chessistics-engine/ # Moteur pur .NET, sans Godot
├─ chessistics-tests/ # Tests unitaires xUnit (sans Godot)
├─ Data/
│ ├─ campaigns/ # campaign_01.json (7 missions)
│ └─ levels/ # niveaux legacy
├─ tools/automation/ # Harness Python stdlib pour piloter Godot
├─ .devcontainer/ # Image Docker + firewall + Xvfb + Godot Linux
├─ Scenes/ icon.svg project.godot …
```
## Lancer le jeu (poste Windows direct)
Prérequis : Godot 4.6 mono + .NET 9 SDK installés localement.
```powershell
dotnet build Chessistics.csproj
"C:\Apps\godot\Godot_v4.6.2-stable_mono_win64.exe" --path .
```
Tests headless du moteur :
```powershell
dotnet test chessistics-tests/
```
Smoke test du harness d'automatisation :
```powershell
python tools/automation/smoke.py
```
## Dev container autonome (recommandé pour Claude Code)
Le dossier `.devcontainer/` contient une image Docker qui embarque :
- **.NET SDK 9.0** (build + tests)
- **Godot 4.6.2-stable mono Linux x86_64** sous `/opt/godot/godot`
- **Xvfb** + Mesa software GL pour un framebuffer virtuel 1280×720
- **Python 3** pour le harness d'automatisation
- **Claude Code** installé automatiquement (`@anthropic-ai/claude-code`)
- Un firewall `iptables` qui restreint les sorties réseau à une allow-list
(GitHub, npm, Anthropic, NuGet, Sentry, Statsig)
Claude Code, lancé à l'intérieur, peut donc compiler le projet, exécuter
les tests, **démarrer une vraie instance de Godot en headless** et lire les
captures PNG produites — sans dépendance Windows.
### Option 1 — Docker Desktop + terminal Windows (le plus simple)
1. Installer [Docker Desktop pour Windows](https://www.docker.com/products/docker-desktop/).
Docker Desktop utilise WSL2 en coulisses ; aucune configuration WSL
manuelle n'est nécessaire.
2. Installer la CLI devcontainers :
```powershell
npm install -g @devcontainers/cli
```
3. Depuis PowerShell ou Terminal Windows, à la racine du repo :
```powershell
cd C:\Projets\Chessistics
devcontainer up --workspace-folder .
devcontainer exec --workspace-folder . zsh
```
La première commande construit l'image (long le premier coup : Godot +
.NET à télécharger). La seconde ouvre un shell interactif dans le
container.
4. Dans le container :
```bash
dotnet build Chessistics.csproj
python3 tools/automation/smoke.py
claude # lance Claude Code inside the container
```
### Option 2 — VS Code + extension Dev Containers
1. Installer Docker Desktop + [VS Code](https://code.visualstudio.com/) +
l'extension **Dev Containers**.
2. Ouvrir le dossier du repo dans VS Code.
3. Palette de commandes → `Dev Containers: Reopen in Container`.
4. Le terminal intégré est déjà dans le container. Lancer `claude` pour
démarrer Claude Code.
### Option 3 — WSL2 natif (plus performant si Docker Desktop rame)
Les bind-mounts Docker Desktop sur `C:\` sont plus lents qu'un fichier
stocké directement dans le système de fichiers WSL2. Pour un gros projet
c'est négligeable, mais si la lenteur devient visible :
1. Installer WSL2 + Ubuntu depuis le Microsoft Store.
2. Cloner le repo **à l'intérieur** de WSL2 (pas sur `/mnt/c/`) :
```bash
cd ~ && git clone <url> chessistics && cd chessistics
```
3. Installer Docker dans WSL2 (via `docker.io` apt ou Docker Desktop avec
intégration WSL2 activée).
4. Installer la CLI devcontainers :
```bash
npm install -g @devcontainers/cli
devcontainer up --workspace-folder .
devcontainer exec --workspace-folder . zsh
```
Avantage : I/O disque natif Linux. Inconvénient : pas d'accès Explorer
direct aux fichiers (`\\wsl$\Ubuntu\home\…`).
### Vérifier que tout fonctionne dans le container
```bash
dotnet --version # -> 9.0.x
godot --version # -> 4.6.2.stable.mono.official.*
dotnet test chessistics-tests/ # 102/102
python3 tools/automation/smoke.py # Godot boots, PNG screenshots written
ls .automation_runs/smoke/screens/ # 01_loaded.png, 02_placed.png, ...
```
Le harness Python détecte automatiquement Linux et enveloppe Godot dans
`xvfb-run`. Aucune variable d'environnement à positionner.
### Mode YOLO (`--dangerously-skip-permissions`)
Dans le container, tu peux lancer Claude Code sans prompt de confirmation à
chaque action :
```bash
claude --dangerously-skip-permissions
```
Avec ce flag Claude **n'affiche plus de "Allow this tool use? (y/n)"** avant
chaque appel d'outil (Bash, Edit, Write, etc.). Il agit en continu.
Pourquoi c'est raisonnable **dans ce container précis** :
| Frontière | Protection |
|-----------|-----------|
| Ton OS Windows, `C:\Users\…`, `~/.ssh`, autres projets | Inaccessibles — seul `/workspace` est bind-mounté |
| Réseau sortant | `iptables` en DROP par défaut ; allow-list : GitHub, npm, Anthropic, NuGet, Sentry, Statsig. Le reste est REJECTé |
| Privilèges | Claude tourne en user `node` (UID 1000), pas root. `sudo` whitelisté **uniquement** pour `init-firewall.sh` |
| Secrets | Aucun `~/.ssh`, `~/.aws`, cookies navigateur ou `.env` système montés |
Risques résiduels à garder en tête :
- **Perte de travail non committé dans `/workspace`.** Le mount est
read-write, donc un `rm -rf` ou un `git reset --hard` écrase tes fichiers
locaux. **Commit fréquemment** — c'est la seule garantie contre la perte.
- **Exfiltration via les domaines autorisés.** GitHub reste joignable : un
Claude compromis pourrait créer un gist public ou pousser sur un fork. Si
tu veux réduire ce vecteur, ne fais pas `gh auth login` dans le container,
ou utilise un PAT fine-grained limité à ce seul repo.
- **Capabilities `NET_ADMIN`/`NET_RAW`.** Actives pour le container (requis
par iptables). Exploitables uniquement via root, qui n'est pas accessible
à Claude en utilisation normale.
- **Pas de limites CPU/RAM.** Un process qui part en vrille peut saturer ta
machine jusqu'au prochain `docker stop`. Pas dramatique, juste gênant.
Ce que tu **ne risques pas** même en YOLO :
- Perte de données en dehors du projet
- Accès à tes autres dépôts, credentials personnelles, réseau domestique
- Modification de ton OS Windows
En pratique, le scénario à éviter : tu as des modifs locales importantes
non-pushées et Claude fait un `git reset --hard HEAD`. Donc : `git commit`
avant de lancer un long run autonome.
### Personnaliser la version de Godot
L'image par défaut pose Godot 4.6.2-stable. Pour changer, modifier
l'argument de build :
```jsonc
// .devcontainer/devcontainer.json
"build": {
"args": {
"GODOT_VERSION": "4.7.0-stable"
}
}
```
Puis `devcontainer up --workspace-folder . --build-no-cache`.
## Dépannage
| Symptôme | Cause probable | Fix |
|----------|----------------|-----|
| `godot --version` donne *no such file* | PATH non mis à jour | `source /etc/profile` ou relancer le shell |
| Screenshot tout noir | Aucun DISPLAY + pas d'xvfb | Vérifier `which xvfb-run` ; utiliser `godot-xvfb` au lieu de `godot` directement |
| `dotnet restore` bloque | Firewall bloque `api.nuget.org` | Vérifier que `init-firewall.sh` s'est bien exécuté avec les changements récents |
| Build Docker échoue au download de Godot | Réseau restreint côté hôte | Retry, ou installer Godot manuellement et commenter les lignes correspondantes |
| Claude Code demande confirmation à chaque action | Comportement normal hors YOLO | Voir la section *Mode YOLO* ci-dessus |
## Licence
Voir les fichiers du repo.

View file

@ -0,0 +1,72 @@
using System;
using Chessistics.Engine.Simulation;
using Chessistics.Scripts.Input;
using Chessistics.Scripts.Presentation;
using Chessistics.Scripts.UI;
namespace Chessistics.Scripts.Automation;
/// <summary>
/// Thin pass-through from the automation harness to the runtime objects it needs.
/// The harness never talks to Main directly — only through this facade — so the
/// surface to audit stays tiny.
/// </summary>
internal class AutomationFacade
{
public Func<GameSim?> Sim { get; }
public InputMapper Input { get; }
public EventAnimator Animator { get; }
public PieceStockPanel Stock { get; }
public ControlBar ControlBar { get; }
public Action<string, int> LoadMission { get; }
public Action Play { get; }
public Action Pause { get; }
public Action Step { get; }
public Action TogglePlayPause { get; }
public Action BackToMenu { get; }
public Action<float> SetSpeed { get; }
public Action Quit { get; }
public Action QuickSave { get; }
public Action QuickLoad { get; }
public Action Undo { get; }
public Action DeleteSelected { get; }
public AutomationFacade(
Func<GameSim?> sim,
InputMapper input,
EventAnimator animator,
PieceStockPanel stock,
ControlBar controlBar,
Action<string, int> loadMission,
Action play,
Action pause,
Action step,
Action togglePlayPause,
Action backToMenu,
Action<float> setSpeed,
Action quit,
Action quickSave,
Action quickLoad,
Action undo,
Action deleteSelected)
{
Sim = sim;
Input = input;
Animator = animator;
Stock = stock;
ControlBar = controlBar;
LoadMission = loadMission;
Play = play;
Pause = pause;
Step = step;
TogglePlayPause = togglePlayPause;
BackToMenu = backToMenu;
SetSpeed = setSpeed;
Quit = quit;
QuickSave = quickSave;
QuickLoad = quickLoad;
Undo = undo;
DeleteSelected = deleteSelected;
}
}

View file

@ -0,0 +1,127 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text.Json.Nodes;
using System.Threading.Tasks;
using Godot;
namespace Chessistics.Scripts.Automation;
/// <summary>
/// Lives at the root of the scene tree when the game is launched with
/// --automation=&lt;dir&gt;. Polls &lt;dir&gt;/inbox/ each frame for JSON command
/// files, dispatches them, and writes results to &lt;dir&gt;/outbox/.
/// </summary>
public partial class AutomationHarness : Node
{
public string Root { get; }
private readonly AutomationFacade _facade;
private CommandDispatcher _dispatcher = null!;
private bool _busy;
private readonly HashSet<string> _processed = new(StringComparer.Ordinal);
internal AutomationHarness(string root, AutomationFacade facade)
{
Root = root;
_facade = facade;
Name = "AutomationHarness";
}
public override void _Ready()
{
IpcFiles.EnsureDirs(Root);
_dispatcher = new CommandDispatcher(this, _facade);
GD.Print($"[Automation] Harness ready at {Root}");
var ready = new JsonObject
{
["ready"] = true,
["pid"] = OS.GetProcessId(),
["godotVersion"] = (string)Godot.Engine.GetVersionInfo()["string"],
["viewportWidth"] = GetViewport().GetVisibleRect().Size.X,
["viewportHeight"] = GetViewport().GetVisibleRect().Size.Y,
};
IpcFiles.AtomicWrite(Path.Combine(Root, IpcFiles.ReadyFile), ready.ToJsonString());
}
public override void _Process(double delta)
{
if (_busy) return;
var next = IpcFiles.NextInbox(Root, _processed);
if (next == null) return;
_processed.Add(next);
_busy = true;
_ = ProcessCommandAsync(next);
}
private async Task ProcessCommandAsync(string inboxPath)
{
string id = Path.GetFileNameWithoutExtension(inboxPath);
JsonObject envelope;
try
{
var text = await ReadAllTextRetry(inboxPath);
envelope = (JsonObject)JsonNode.Parse(text)!;
id = envelope["id"]?.GetValue<string>() ?? id;
}
catch (Exception ex)
{
GD.PrintErr($"[Automation] Cannot parse {inboxPath}: {ex.Message}");
_busy = false;
return;
}
var response = new JsonObject { ["id"] = id };
try
{
var cmd = envelope["cmd"]!.GetValue<string>();
var args = envelope["args"]?.AsObject() ?? new JsonObject();
GD.Print($"[Automation] → {cmd}");
var result = await _dispatcher.Dispatch(cmd, args);
response["ok"] = true;
response["result"] = result;
}
catch (Exception ex)
{
GD.PrintErr($"[Automation] Error on {inboxPath}: {ex}");
response["ok"] = false;
response["error"] = ex.Message;
}
try
{
IpcFiles.AtomicWrite(IpcFiles.OutboxPath(Root, id), response.ToJsonString());
}
catch (Exception ex)
{
GD.PrintErr($"[Automation] Cannot write outbox for {id}: {ex}");
}
try
{
if (File.Exists(inboxPath)) File.Delete(inboxPath);
}
catch { /* best effort */ }
_busy = false;
}
/// <summary>Awaiting a signal from C#; wrapper so dispatcher can use it.</summary>
internal SignalAwaiter ToSignalAsync(GodotObject source, string signal) => ToSignal(source, signal);
/// <summary>Called via CallDeferred by the quit command.</summary>
public void RequestQuit() => GetTree().Quit();
private static async Task<string> ReadAllTextRetry(string path)
{
// The agent writes .tmp→rename atomically, but a brief race can still occur.
for (int i = 0; i < 5; i++)
{
try { return File.ReadAllText(path); }
catch (IOException) { await Task.Delay(20); }
}
return File.ReadAllText(path);
}
}

View file

@ -0,0 +1,332 @@
using System;
using System.IO;
using System.Threading.Tasks;
using System.Text.Json.Nodes;
using Godot;
using System.Linq;
using Chessistics.Engine.Model;
using Chessistics.Scripts.Input;
namespace Chessistics.Scripts.Automation;
/// <summary>
/// Maps command strings → handlers. Each handler returns the "result" payload;
/// the harness wraps it in the envelope.
/// </summary>
internal class CommandDispatcher
{
private readonly AutomationHarness _harness;
private readonly AutomationFacade _facade;
public CommandDispatcher(AutomationHarness harness, AutomationFacade facade)
{
_harness = harness;
_facade = facade;
}
public async Task<JsonNode?> Dispatch(string cmd, JsonObject args)
{
return cmd switch
{
"screenshot" => await Screenshot(args),
"get_state" => GetState(),
"select_piece" => SelectPiece(args),
"place" => Place(args),
"click_cell" => ClickCell(args),
"key" => Key(args),
"play" => Play(),
"pause" => Pause(),
"step" => await Step(),
"wait_idle" => await WaitIdle(args),
"set_speed" => SetSpeed(args),
"load_mission" => LoadMission(args),
"back_to_menu" => BackToMenu(),
"relocate" => Relocate(args),
"quick_save" => QuickSave(),
"quick_load" => QuickLoad(),
"undo" => Undo(),
"quit" => Quit(),
_ => throw new InvalidOperationException($"Unknown command: {cmd}"),
};
}
// --- handlers ---
private async Task<JsonNode?> Screenshot(JsonObject args)
{
var name = args["name"]?.GetValue<string>() ?? "screenshot";
await _harness.ToSignalAsync(RenderingServer.Singleton, "frame_post_draw");
var image = _harness.GetViewport().GetTexture().GetImage();
var path = IpcFiles.ScreenshotPath(_harness.Root, name);
var err = image.SavePng(path);
if (err != Error.Ok)
throw new InvalidOperationException($"SavePng failed: {err}");
var result = new JsonObject
{
["path"] = Path.GetRelativePath(_harness.Root, path).Replace('\\', '/'),
["abs_path"] = path,
["width"] = image.GetWidth(),
["height"] = image.GetHeight(),
};
return result;
}
private JsonNode? GetState()
{
var sim = _facade.Sim();
if (sim == null) return new JsonObject { ["loaded"] = false };
var snap = sim.GetSnapshot();
var obj = SnapshotSerializer.Serialize(snap);
obj["loaded"] = true;
obj["animating"] = _facade.Animator.IsAnimating;
return obj;
}
private JsonNode? SelectPiece(JsonObject args)
{
var kind = ParsePieceKind(args["kind"]);
_facade.Stock.SimulateSelect(kind);
return new JsonObject
{
["phase"] = _facade.Input.CurrentPhase.ToString(),
["kind"] = kind.ToString(),
};
}
private JsonNode? Place(JsonObject args)
{
var sim = _facade.Sim();
if (sim == null) throw new InvalidOperationException("No simulation loaded.");
var kind = ParsePieceKind(args["kind"]);
var start = ParseCoords(args["start"]);
var end = ParseCoords(args["end"]);
// Snapshot ids before so we can identify the new piece after.
var before = sim.GetSnapshot();
var idsBefore = new HashSet<int>(before.Pieces.Select(p => p.Id));
// Route through the UI signal — this mutates the sim exactly once and
// runs the same path a human click would: HandleEditEvents, stock refresh,
// SetSnapshot on InputMapper.
_facade.Input.EmitSignal(
InputMapper.SignalName.PlacementRequested,
(int)kind, start.Col, start.Row, end.Col, end.Row);
var after = sim.GetSnapshot();
var newPiece = after.Pieces.FirstOrDefault(p => !idsBefore.Contains(p.Id));
var placed = newPiece != null;
return new JsonObject
{
["placed"] = placed,
["pieceId"] = newPiece?.Id,
["reason"] = placed ? null : "Placement rejected (check Godot console log for PlacementRejectedEvent).",
};
}
private JsonNode? ClickCell(JsonObject args)
{
var col = args["col"]!.GetValue<int>();
var row = args["row"]!.GetValue<int>();
var button = args["button"]?.GetValue<string>()?.ToLowerInvariant() ?? "left";
var mouseBtn = button switch
{
"right" => MouseButton.Right,
_ => MouseButton.Left,
};
_facade.Input.SimulateClick(new Coords(col, row), mouseBtn);
return new JsonObject
{
["phase"] = _facade.Input.CurrentPhase.ToString(),
};
}
private JsonNode? Key(JsonObject args)
{
var key = args["key"]!.GetValue<string>();
switch (key.ToLowerInvariant())
{
case "space":
_facade.TogglePlayPause();
break;
case "escape":
case "esc":
_facade.Input.Cancel();
break;
case "delete":
case "del":
_facade.DeleteSelected();
break;
default:
throw new InvalidOperationException($"Unsupported key: {key}");
}
return new JsonObject();
}
private JsonNode? Play()
{
_facade.Play();
return PhaseInfo();
}
private JsonNode? Pause()
{
_facade.Pause();
return PhaseInfo();
}
private async Task<JsonNode?> Step()
{
_facade.Step();
// Dispatcher auto-waits for idle before writing the result.
await WaitIdleInternal(timeoutMs: 10000);
return PhaseInfo();
}
private async Task<JsonNode?> WaitIdle(JsonObject args)
{
var timeout = args["timeoutMs"]?.GetValue<int>() ?? 10000;
var reached = await WaitIdleInternal(timeout);
var info = PhaseInfo()!.AsObject();
info["idle"] = reached;
return info;
}
internal async Task<bool> WaitIdleInternal(int timeoutMs)
{
var elapsed = 0.0;
while (_facade.Animator.IsAnimating)
{
await _harness.ToSignalAsync(_harness.GetTree(), "process_frame");
elapsed += 1.0 / 60.0 * 1000.0;
if (elapsed > timeoutMs) return false;
}
return true;
}
private JsonNode? SetSpeed(JsonObject args)
{
var interval = (float)args["interval"]!.GetValue<double>();
_facade.SetSpeed(interval);
return new JsonObject { ["interval"] = interval };
}
private JsonNode? LoadMission(JsonObject args)
{
var campaign = args["campaign"]?.GetValue<string>() ?? "campaign_01";
var index = args["missionIndex"]?.GetValue<int>() ?? 0;
_facade.LoadMission(campaign, index);
var sim = _facade.Sim();
if (sim == null) return new JsonObject { ["loaded"] = false };
return SnapshotSerializer.Serialize(sim.GetSnapshot());
}
private JsonNode? BackToMenu()
{
_facade.BackToMenu();
return new JsonObject();
}
private JsonNode? QuickSave()
{
_facade.QuickSave();
var sim = _facade.Sim();
return new JsonObject
{
["saved"] = sim != null,
["turn"] = sim?.GetSnapshot().TurnNumber
};
}
private JsonNode? Relocate(JsonObject args)
{
var pieceId = args["pieceId"]!.GetValue<int>();
var newStart = ParseCoords(args["newStart"]);
var newEnd = ParseCoords(args["newEnd"]);
_facade.Input.EmitSignal(InputMapper.SignalName.RelocateRequested,
pieceId, newStart.Col, newStart.Row, newEnd.Col, newEnd.Row);
var sim = _facade.Sim();
if (sim == null) return new JsonObject { ["relocated"] = false };
var piece = sim.GetSnapshot().Pieces.FirstOrDefault(p => p.Id == pieceId);
return new JsonObject
{
["relocated"] = piece != null && piece.StartCell == newStart && piece.EndCell == newEnd,
["pieceId"] = pieceId
};
}
private JsonNode? Undo()
{
var sim = _facade.Sim();
var hadUndo = sim?.CanUndo ?? false;
_facade.Undo();
if (sim == null) return new JsonObject { ["undone"] = false };
var snap = sim.GetSnapshot();
return new JsonObject
{
["undone"] = hadUndo,
["turn"] = snap.TurnNumber,
["canUndo"] = sim.CanUndo
};
}
private JsonNode? QuickLoad()
{
_facade.QuickLoad();
var sim = _facade.Sim();
if (sim == null) return new JsonObject { ["loaded"] = false };
var snap = sim.GetSnapshot();
return new JsonObject
{
["loaded"] = true,
["turn"] = snap.TurnNumber,
["phase"] = snap.Phase.ToString(),
["missionIndex"] = snap.Campaign?.CurrentMissionIndex
};
}
private JsonNode? Quit()
{
// Defer so we can write the result first.
_harness.CallDeferred(nameof(AutomationHarness.RequestQuit));
return new JsonObject { ["quitting"] = true };
}
// --- helpers ---
private JsonNode PhaseInfo()
{
var sim = _facade.Sim();
var obj = new JsonObject();
if (sim != null)
{
var snap = sim.GetSnapshot();
obj["phase"] = snap.Phase.ToString();
obj["turn"] = snap.TurnNumber;
obj["missionIndex"] = snap.Campaign?.CurrentMissionIndex;
}
obj["animating"] = _facade.Animator.IsAnimating;
return obj;
}
private static PieceKind ParsePieceKind(JsonNode? node)
{
if (node == null) throw new InvalidOperationException("Missing 'kind'.");
var val = node.AsValue();
if (val.TryGetValue<int>(out var i)) return (PieceKind)i;
var s = val.GetValue<string>();
if (Enum.TryParse<PieceKind>(s, ignoreCase: true, out var k)) return k;
throw new InvalidOperationException($"Unknown piece kind: {s}");
}
private static Coords ParseCoords(JsonNode? node)
{
if (node is JsonArray arr && arr.Count == 2)
return new Coords(arr[0]!.GetValue<int>(), arr[1]!.GetValue<int>());
if (node is JsonObject obj && obj.ContainsKey("col") && obj.ContainsKey("row"))
return new Coords(obj["col"]!.GetValue<int>(), obj["row"]!.GetValue<int>());
throw new InvalidOperationException("Coords must be [col,row] or {col,row}.");
}
}

View file

@ -0,0 +1,65 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
namespace Chessistics.Scripts.Automation;
/// <summary>
/// Tiny helpers for the file-based IPC. All writes are atomic: write .tmp then rename.
/// </summary>
internal static class IpcFiles
{
public const string InboxDir = "inbox";
public const string OutboxDir = "outbox";
public const string ScreensDir = "screens";
public const string ReadyFile = "ready.json";
public const string LogFile = "harness.log";
public static void EnsureDirs(string root)
{
Directory.CreateDirectory(root);
Directory.CreateDirectory(Path.Combine(root, InboxDir));
Directory.CreateDirectory(Path.Combine(root, OutboxDir));
Directory.CreateDirectory(Path.Combine(root, ScreensDir));
}
public static void AtomicWrite(string path, string contents)
{
var tmp = path + ".tmp";
File.WriteAllText(tmp, contents);
if (File.Exists(path)) File.Delete(path);
File.Move(tmp, path);
}
/// <summary>
/// Return the oldest inbox file whose id hasn't been processed yet, or null.
/// Sorted by filename — agents should name files with a monotonic prefix for ordering.
/// </summary>
public static string? NextInbox(string root, HashSet<string> processed)
{
var inbox = Path.Combine(root, InboxDir);
if (!Directory.Exists(inbox)) return null;
var files = Directory.GetFiles(inbox, "*.json")
.Where(f => !f.EndsWith(".tmp.json", StringComparison.Ordinal))
.OrderBy(f => Path.GetFileName(f), StringComparer.Ordinal)
.ToList();
foreach (var f in files)
{
if (!processed.Contains(f))
return f;
}
return null;
}
public static string OutboxPath(string root, string id) => Path.Combine(root, OutboxDir, id + ".json");
public static string ScreenshotPath(string root, string name)
{
var safe = name.Replace('/', '_').Replace('\\', '_').Replace(':', '_');
if (!safe.EndsWith(".png", StringComparison.OrdinalIgnoreCase)) safe += ".png";
return Path.Combine(root, ScreensDir, safe);
}
}

View file

@ -0,0 +1,123 @@
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Nodes;
using Chessistics.Engine.Model;
namespace Chessistics.Scripts.Automation;
/// <summary>
/// Serializes BoardSnapshot into plain JSON so the agent can reason about game state.
/// </summary>
internal static class SnapshotSerializer
{
public static JsonObject Serialize(BoardSnapshot snap)
{
var root = new JsonObject
{
["phase"] = snap.Phase.ToString(),
["turn"] = snap.TurnNumber,
["width"] = snap.Width,
["height"] = snap.Height,
["grid"] = SerializeGrid(snap.Grid, snap.Width, snap.Height),
["productions"] = new JsonArray(snap.Productions.Select(SerializeProd).ToArray<JsonNode?>()),
["demands"] = new JsonArray(snap.Demands.Select(SerializeDemand).ToArray<JsonNode?>()),
["transformers"] = new JsonArray(snap.Transformers.Select(SerializeTransformer).ToArray<JsonNode?>()),
["pieces"] = new JsonArray(snap.Pieces.Select(SerializePiece).ToArray<JsonNode?>()),
["remainingStock"] = SerializeStock(snap.RemainingStock),
};
if (snap.Campaign != null)
{
root["campaign"] = new JsonObject
{
["name"] = snap.Campaign.Name,
["currentMissionIndex"] = snap.Campaign.CurrentMissionIndex,
["completedMissions"] = new JsonArray(snap.Campaign.CompletedMissions.Select(i => (JsonNode?)JsonValue.Create(i)).ToArray()),
["availablePieceKinds"] = new JsonArray(snap.Campaign.AvailablePieceKinds.Select(k => (JsonNode?)JsonValue.Create(k.ToString())).ToArray()),
["availableLevels"] = new JsonArray(snap.Campaign.AvailableLevels
.Select(u => (JsonNode?)new JsonObject { ["kind"] = u.Kind.ToString(), ["level"] = u.Level })
.ToArray()),
};
}
else
{
root["campaign"] = null;
}
return root;
}
private static JsonArray SerializeGrid(CellType[,] grid, int w, int h)
{
var rows = new JsonArray();
for (int r = 0; r < h; r++)
{
var row = new JsonArray();
for (int c = 0; c < w; c++)
row.Add(grid[c, r].ToString());
rows.Add(row);
}
return rows;
}
private static JsonObject SerializeProd(ProductionSnapshot p) => new()
{
["pos"] = CoordsJson(p.Position),
["name"] = p.Name,
["cargo"] = p.Cargo.ToString(),
["amount"] = p.Amount,
["buffer"] = p.BufferCount,
};
private static JsonObject SerializeDemand(DemandSnapshot d) => new()
{
["pos"] = CoordsJson(d.Position),
["name"] = d.Name,
["cargo"] = d.Cargo.ToString(),
["required"] = d.Required,
["deadline"] = d.Deadline,
["received"] = d.ReceivedCount,
["satisfied"] = d.IsSatisfied,
["missionIndex"] = d.MissionIndex,
};
private static JsonObject SerializeTransformer(TransformerSnapshot t) => new()
{
["pos"] = CoordsJson(t.Position),
["name"] = t.Name,
["inputCargo"] = t.InputCargo.ToString(),
["inputRequired"] = t.InputRequired,
["outputCargo"] = t.OutputCargo.ToString(),
["outputAmount"] = t.OutputAmount,
["inputBuffer"] = t.InputBufferCount,
["outputBuffer"] = t.OutputBufferCount,
};
private static JsonObject SerializePiece(PieceSnapshot p) => new()
{
["id"] = p.Id,
["kind"] = p.Kind.ToString(),
["level"] = p.Level,
["start"] = CoordsJson(p.StartCell),
["end"] = CoordsJson(p.EndCell),
["current"] = CoordsJson(p.CurrentCell),
["cargo"] = p.Cargo?.ToString(),
["cargoFilter"] = p.CargoFilter?.ToString(),
["socialStatus"] = p.SocialStatus,
};
private static JsonObject SerializeStock(IReadOnlyDictionary<PieceKind, int> stock)
{
var obj = new JsonObject();
foreach (var (k, v) in stock) obj[k.ToString()] = v;
return obj;
}
private static JsonArray CoordsJson(Coords c)
{
var arr = new JsonArray();
arr.Add(c.Col);
arr.Add(c.Row);
return arr;
}
}

View file

@ -50,6 +50,48 @@ public partial class BoardView : Node2D
}
}
public void BuildBoardFromSnapshot(BoardSnapshot snap)
{
// Clear existing children
foreach (var child in GetChildren())
child.QueueFree();
_cells.Clear();
_width = snap.Width;
_height = snap.Height;
for (int col = 0; col < snap.Width; col++)
{
for (int row = 0; row < snap.Height; row++)
{
var coords = new Coords(col, row);
var cellView = new CellView();
cellView.Setup(coords, snap.Grid[col, row], CellSize);
AddChild(cellView);
_cells[coords] = cellView;
}
}
// Label productions and demands
foreach (var prod in snap.Productions)
{
if (_cells.TryGetValue(prod.Position, out var cell))
cell.SetLabel(prod.Name);
}
foreach (var demand in snap.Demands)
{
if (_cells.TryGetValue(demand.Position, out var cell))
cell.SetLabel(demand.Name);
}
foreach (var transformer in snap.Transformers)
{
if (_cells.TryGetValue(transformer.Position, out var cell))
cell.SetLabel(transformer.Name);
}
}
public Coords? PixelToCoords(Vector2 localPos)
{
int col = Mathf.FloorToInt(localPos.X / CellSize);

View file

@ -24,6 +24,7 @@ public partial class CellView : Node2D
private static readonly Color WallColor = new("#3A3A3A"); // charcoal
private static readonly Color ProductionColor = new("#4A6E3A"); // deep forest
private static readonly Color DemandColor = new("#B8942A"); // aged gold
private static readonly Color TransformerColor = new("#8B4513"); // copper brown
private static readonly Color HighlightColor = new("#44FF4444");
private static readonly Color HoverOutlineColor = new("#FFFFFF88");
@ -47,6 +48,7 @@ public partial class CellView : Node2D
CellType.Wall => WallColor,
CellType.Production => ProductionColor,
CellType.Demand => DemandColor,
CellType.Transformer => TransformerColor,
_ => baseColor
};
AddChild(_background);
@ -132,4 +134,24 @@ public partial class CellView : Node2D
.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Cubic);
tween.TweenCallback(Callable.From(() => _highlight.Visible = false));
}
/// <summary>
/// Transformer conversion flash: bright copper pulse + scale punch.
/// Uses a distinct color from production so the two phases read apart.
/// </summary>
public void FlashTransform(float duration = 0.45f)
{
_highlight.Color = new Color(1f, 0.5f, 0.15f, 0.7f); // bright copper
_highlight.Visible = true;
var tween = CreateTween();
tween.SetParallel(true);
tween.TweenProperty(_highlight, "color", new Color(1f, 0.5f, 0.15f, 0f), duration)
.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Cubic);
tween.TweenProperty(_background, "scale", new Vector2(1.08f, 1.08f), duration * 0.45f)
.SetEase(Tween.EaseType.Out);
tween.Chain().TweenProperty(_background, "scale", Vector2.One, duration * 0.55f)
.SetEase(Tween.EaseType.InOut);
tween.Chain().TweenCallback(Callable.From(() => _highlight.Visible = false));
}
}

View file

@ -16,9 +16,13 @@ public partial class InputMapper : Node
public delegate void CellClickedEventHandler(int col, int row);
[Signal]
public delegate void CancelledEventHandler();
[Signal]
public delegate void RelocateRequestedEventHandler(int pieceId, int newStartCol, int newStartRow, int newEndCol, int newEndRow);
public enum PlacementPhase { None, SelectingStart, SelectingEnd }
private const float DragThreshold = 8f;
private BoardView _boardView = null!;
private PieceKind? _selectedKind;
private Coords? _selectedStart;
@ -26,6 +30,11 @@ public partial class InputMapper : Node
private BoardSnapshot? _snapshot;
private Coords? _hoverCoords;
// Drag & drop of a placed piece
private int? _dragPieceId;
private Vector2 _dragMouseStart;
private bool _dragging;
public PlacementPhase CurrentPhase => _phase;
public void Initialize(BoardView boardView)
@ -72,28 +81,140 @@ public partial class InputMapper : Node
public override void _UnhandledInput(InputEvent @event)
{
if (@event is InputEventMouseButton mouseEvent && mouseEvent.Pressed)
{
if (mouseEvent.ButtonIndex == MouseButton.Right)
{
Cancel();
return;
}
if (mouseEvent.ButtonIndex == MouseButton.Left)
if (@event is InputEventMouseButton mouseEvent && mouseEvent.ButtonIndex == MouseButton.Left)
{
var localPos = _boardView.GetLocalMousePosition();
GD.Print($"[InputMapper] LEFT CLICK — localPos={localPos}, phase={_phase}");
HandleLeftClick();
if (mouseEvent.Pressed)
HandleLeftPress(localPos);
else
HandleLeftRelease(localPos);
}
if (@event is InputEventMouseMotion && _dragPieceId != null)
{
var localPos = _boardView.GetLocalMousePosition();
UpdateDrag(localPos);
}
if (@event is InputEventKey keyEvent && keyEvent.Pressed && keyEvent.Keycode == Key.Escape)
{
CancelDrag();
Cancel();
}
}
private void HandleLeftPress(Vector2 localPos)
{
// In placement mode, ignore drag behavior — click advances placement
if (_phase != PlacementPhase.None) return;
var coords = _boardView.PixelToCoords(localPos);
if (coords == null || _snapshot == null) return;
var piece = _snapshot.Pieces.FirstOrDefault(
p => p.StartCell == coords.Value || p.EndCell == coords.Value);
if (piece != null)
{
_dragPieceId = piece.Id;
_dragMouseStart = localPos;
_dragging = false;
}
}
private void UpdateDrag(Vector2 localPos)
{
if (_dragPieceId == null) return;
if (!_dragging && (localPos - _dragMouseStart).Length() > DragThreshold)
{
_dragging = true;
HighlightLegalDropsFor(_dragPieceId.Value);
}
}
private void HandleLeftRelease(Vector2 localPos)
{
if (_dragging && _dragPieceId != null)
{
var dropCoords = _boardView.PixelToCoords(localPos);
TryRelocate(_dragPieceId.Value, dropCoords);
CancelDrag();
return;
}
// Normal click flow
CancelDrag();
HandleLeftClick();
}
private void CancelDrag()
{
_dragPieceId = null;
_dragging = false;
_boardView.ClearHighlights();
}
private void HighlightLegalDropsFor(int pieceId)
{
var legal = ComputeLegalDrops(pieceId);
_boardView.ClearHighlights();
_boardView.HighlightCells(legal, new Color("#44FF88AA"));
}
private List<Coords> ComputeLegalDrops(int pieceId)
{
var result = new List<Coords>();
if (_snapshot == null) return result;
var piece = _snapshot.Pieces.FirstOrDefault(p => p.Id == pieceId);
if (piece == null) return result;
var dc = piece.EndCell.Col - piece.StartCell.Col;
var dr = piece.EndCell.Row - piece.StartCell.Row;
var boardState = GetBoardStateForValidation();
if (boardState == null) return result;
for (int c = 0; c < _snapshot.Width; c++)
{
for (int r = 0; r < _snapshot.Height; r++)
{
var newStart = new Coords(c, r);
var newEnd = new Coords(c + dc, r + dr);
if (!newEnd.IsOnBoard(_snapshot.Width, _snapshot.Height)) continue;
if (_snapshot.Grid[newStart.Col, newStart.Row] == CellType.Wall) continue;
if (_snapshot.Grid[newEnd.Col, newEnd.Row] == CellType.Wall) continue;
if (!MoveValidator.IsLegalPlacement(piece.Kind, newStart, newEnd, boardState)) continue;
result.Add(newStart);
}
}
return result;
}
private void TryRelocate(int pieceId, Coords? dropCoords)
{
if (_snapshot == null || dropCoords == null) return;
var piece = _snapshot.Pieces.FirstOrDefault(p => p.Id == pieceId);
if (piece == null) return;
var dc = piece.EndCell.Col - piece.StartCell.Col;
var dr = piece.EndCell.Row - piece.StartCell.Row;
var newStart = dropCoords.Value;
var newEnd = new Coords(newStart.Col + dc, newStart.Row + dr);
if (!newEnd.IsOnBoard(_snapshot.Width, _snapshot.Height)) return;
if (_snapshot.Grid[newStart.Col, newStart.Row] == CellType.Wall) return;
if (_snapshot.Grid[newEnd.Col, newEnd.Row] == CellType.Wall) return;
var boardState = GetBoardStateForValidation();
if (boardState == null) return;
if (!MoveValidator.IsLegalPlacement(piece.Kind, newStart, newEnd, boardState)) return;
EmitSignal(SignalName.RelocateRequested, pieceId,
newStart.Col, newStart.Row, newEnd.Col, newEnd.Row);
}
private void HandleLeftClick()
{
var localPos = _boardView.GetLocalMousePosition();
@ -107,22 +228,41 @@ public partial class InputMapper : Node
return;
}
HandleClickAt(coords.Value);
}
private void HandleClickAt(Coords coords)
{
switch (_phase)
{
case PlacementPhase.SelectingStart:
OnStartSelected(coords.Value);
OnStartSelected(coords);
break;
case PlacementPhase.SelectingEnd:
OnEndSelected(coords.Value);
OnEndSelected(coords);
break;
default:
EmitSignal(SignalName.CellClicked, coords.Value.Col, coords.Value.Row);
EmitSignal(SignalName.CellClicked, coords.Col, coords.Row);
break;
}
}
/// <summary>
/// Same effect as a left/right click on a board cell, for automation.
/// Runs the exact branch HandleLeftClick runs (no InputEvent synthesis).
/// </summary>
public void SimulateClick(Coords coords, MouseButton button)
{
if (button == MouseButton.Right)
{
Cancel();
return;
}
HandleClickAt(coords);
}
private void OnStartSelected(Coords start)
{
if (_selectedKind == null || _snapshot == null)

File diff suppressed because it is too large Load diff

View file

@ -25,6 +25,9 @@ public partial class PieceView : Node2D
private static readonly Color QueenColor = new("#8E3D5A"); // deep burgundy
private static readonly Color WoodCargoColor = new("#A67C32");
private static readonly Color StoneCargoColor = new("#7A7A7A");
private static readonly Color ToolsCargoColor = new("#C87533");
private static readonly Color ArmsCargoColor = new("#8B0000");
private static readonly Color GoldCargoColor = new("#FFD700");
private static readonly Color ShadowColor = new Color(0, 0, 0, 0.18f);
public void Setup(int pieceId, PieceKind kind, Coords startCell, Coords endCell, BoardView boardView)
@ -136,6 +139,9 @@ public partial class PieceView : Node2D
{
CargoType.Wood => WoodCargoColor,
CargoType.Stone => StoneCargoColor,
CargoType.Tools => ToolsCargoColor,
CargoType.Arms => ArmsCargoColor,
CargoType.Gold => GoldCargoColor,
_ => Colors.White
};

View file

@ -5,34 +5,54 @@ namespace Chessistics.Scripts.Pieces;
public partial class TrajectView : Line2D
{
public int PieceId { get; private set; }
private Polygon2D? _arrow;
private Polygon2D? _arrowEnd;
private Polygon2D? _arrowStart;
private Tween? _pulseTween;
private Color _baseColor;
public void Setup(int pieceId, Vector2 from, Vector2 to, Color color)
{
PieceId = pieceId;
Width = 2.5f;
DefaultColor = new Color(color, 0.35f);
_baseColor = color;
Width = 3f;
DefaultColor = new Color(color, 0.4f);
Antialiased = true;
ClearPoints();
AddPoint(from);
AddPoint(to);
ZIndex = -1;
// Arrowhead at the end point
_arrowEnd = BuildArrow(from, to, color);
_arrowStart = BuildArrow(to, from, color);
AddChild(_arrowEnd);
AddChild(_arrowStart);
StartPulse();
}
private static Polygon2D BuildArrow(Vector2 from, Vector2 to, Color color)
{
var dir = (to - from).Normalized();
var perp = new Vector2(-dir.Y, dir.X);
float arrowSize = 8f;
var tip = to - dir * 4f; // slightly inset from end
const float arrowSize = 9f;
var tip = to - dir * 4f;
var baseL = tip - dir * arrowSize + perp * arrowSize * 0.5f;
var baseR = tip - dir * arrowSize - perp * arrowSize * 0.5f;
_arrow = new Polygon2D
return new Polygon2D
{
Polygon = [tip - Position, baseL - Position, baseR - Position],
Color = new Color(color, 0.4f),
Position = Vector2.Zero
Polygon = [tip, baseL, baseR],
Color = new Color(color, 0.5f)
};
// Position relative to parent, not this Line2D
AddChild(_arrow);
}
private void StartPulse()
{
_pulseTween?.Kill();
_pulseTween = CreateTween();
_pulseTween.SetLoops();
_pulseTween.TweenProperty(this, "default_color:a", 0.75f, 1.1f)
.SetEase(Tween.EaseType.InOut).SetTrans(Tween.TransitionType.Sine);
_pulseTween.TweenProperty(this, "default_color:a", 0.3f, 1.1f)
.SetEase(Tween.EaseType.InOut).SetTrans(Tween.TransitionType.Sine);
}
}

View file

@ -25,6 +25,9 @@ public partial class EventAnimator : Node
private static readonly Color WoodCargoColor = new("#A67C32");
private static readonly Color StoneCargoColor = new("#7A7A7A");
private static readonly Color ToolsCargoColor = new("#C87533");
private static readonly Color ArmsCargoColor = new("#8B0000");
private static readonly Color GoldCargoColor = new("#FFD700");
private const float ProduceDuration = 0.35f;
private const float TransferDuration = 0.28f;
@ -36,6 +39,10 @@ public partial class EventAnimator : Node
public delegate void TurnAnimationCompletedEventHandler();
[Signal]
public delegate void VictoryReachedEventHandler();
[Signal]
public delegate void MissionAdvancedEventHandler();
[Signal]
public delegate void CollisionOccurredEventHandler(int col, int row, string victimKind, int destroyerId);
public void Initialize(BoardView boardView, ObjectivePanel objectivePanel,
ControlBar controlBar, MetricsOverlay metricsOverlay)
@ -73,16 +80,20 @@ public partial class EventAnimator : Node
tween.SetParallel(false);
var produceEvents = new List<CargoProducedEvent>();
var transformerEvents = new List<CargoConvertedEvent>();
var transferEvents = new List<IWorldEvent>();
var moveEvents = new List<PieceMovedEvent>();
var collisionEvents = new List<PieceDestroyedEvent>();
var collisionEvents = new List<PieceReturnedToStockEvent>();
// Pre-scan: if MissionStartedEvent follows MissionCompleteEvent, it's an auto-advance (not last mission)
bool hasAutoAdvance = events.Any(e => e is MissionStartedEvent);
foreach (var evt in events)
{
switch (evt)
{
case TurnStartedEvent ts:
FlushPhases(tween, produceEvents, transferEvents, moveEvents, collisionEvents);
FlushPhases(tween, produceEvents, transformerEvents, transferEvents, moveEvents, collisionEvents);
tween.TweenCallback(Callable.From(() => _controlBar.UpdateTurn(ts.TurnNumber)));
break;
@ -90,6 +101,10 @@ public partial class EventAnimator : Node
produceEvents.Add(produced);
break;
case CargoConvertedEvent converted:
transformerEvents.Add(converted);
break;
case CargoTransferredEvent:
case DemandProgressEvent:
transferEvents.Add(evt);
@ -99,23 +114,35 @@ public partial class EventAnimator : Node
moveEvents.Add(moved);
break;
case PieceDestroyedEvent destroyed:
collisionEvents.Add(destroyed);
case PieceReturnedToStockEvent returned:
collisionEvents.Add(returned);
break;
case VictoryEvent victory:
FlushPhases(tween, produceEvents, transferEvents, moveEvents, collisionEvents);
case MissionCompleteEvent:
FlushPhases(tween, produceEvents, transformerEvents, transferEvents, moveEvents, collisionEvents);
tween.TweenCallback(Callable.From(() =>
{
SfxManager.Instance?.PlayVictory();
SpawnConfetti();
_metricsOverlay.ShowMetrics(victory.Metrics);
if (!hasAutoAdvance)
EmitSignal(SignalName.VictoryReached);
}));
break;
case MissionStartedEvent:
FlushPhases(tween, produceEvents, transformerEvents, transferEvents, moveEvents, collisionEvents);
tween.TweenCallback(Callable.From(() =>
{
EmitSignal(SignalName.MissionAdvanced);
}));
break;
case SimulationPausedEvent:
// Auto-pause from collision — handled by FlushPhases
break;
case TurnEndedEvent:
FlushPhases(tween, produceEvents, transferEvents, moveEvents, collisionEvents);
FlushPhases(tween, produceEvents, transformerEvents, transferEvents, moveEvents, collisionEvents);
break;
default:
@ -123,7 +150,7 @@ public partial class EventAnimator : Node
}
}
FlushPhases(tween, produceEvents, transferEvents, moveEvents, collisionEvents);
FlushPhases(tween, produceEvents, transformerEvents, transferEvents, moveEvents, collisionEvents);
tween.TweenCallback(Callable.From(() =>
{
@ -135,10 +162,29 @@ public partial class EventAnimator : Node
private void FlushPhases(
Tween tween,
List<CargoProducedEvent> produceEvents,
List<CargoConvertedEvent> transformerEvents,
List<IWorldEvent> transferEvents,
List<PieceMovedEvent> moveEvents,
List<PieceDestroyedEvent> collisionEvents)
List<PieceReturnedToStockEvent> collisionEvents)
{
// Phase 1a: Transformer conversions — copper flash, distinct from production
if (transformerEvents.Count > 0)
{
var captured = transformerEvents.ToList();
tween.TweenCallback(Callable.From(() =>
{
SfxManager.Instance?.PlayProduce();
foreach (var evt in captured)
{
var cell = _boardView.GetCellView(evt.TransformerCell);
cell?.FlashTransform(0.45f);
SpawnProduceParticles(evt.TransformerCell, evt.OutputCargo);
}
}));
tween.TweenInterval(0.45f);
transformerEvents.Clear();
}
// Phase 1: Produce — warm golden flash + particle burst
if (produceEvents.Count > 0)
{
@ -222,16 +268,16 @@ public partial class EventAnimator : Node
moveEvents.Clear();
}
// Phase 4: Collision/Destruction — shrink + spin + particles
// Phase 4: Collision — piece returned to stock (shrink + spin + particles)
if (collisionEvents.Count > 0)
{
var captured = collisionEvents.ToList();
tween.TweenCallback(Callable.From(() =>
{
SfxManager.Instance?.PlayDestroy();
foreach (var destroyed in captured)
foreach (var returned in captured)
{
if (_pieceViews.TryGetValue(destroyed.PieceId, out var pv))
if (_pieceViews.TryGetValue(returned.PieceId, out var pv))
{
SpawnDestroyParticles(pv.Position);
@ -243,12 +289,18 @@ public partial class EventAnimator : Node
dt.TweenProperty(pv, "modulate:a", 0f, DestroyDuration);
}
}
// Emit signal for Main to pan camera + show toast
var first = captured[0];
EmitSignal(SignalName.CollisionOccurred,
first.Cell.Col, first.Cell.Row, first.Kind.ToString(),
first.DestroyerPieceId ?? -1);
}));
tween.TweenInterval(DestroyDuration);
tween.TweenCallback(Callable.From(() =>
{
foreach (var destroyed in captured)
UnregisterPiece(destroyed.PieceId);
foreach (var returned in captured)
UnregisterPiece(returned.PieceId);
}));
collisionEvents.Clear();
}
@ -423,6 +475,9 @@ public partial class EventAnimator : Node
{
CargoType.Wood => WoodCargoColor,
CargoType.Stone => StoneCargoColor,
CargoType.Tools => ToolsCargoColor,
CargoType.Arms => ArmsCargoColor,
CargoType.Gold => GoldCargoColor,
_ => Colors.White
};

View file

@ -12,15 +12,14 @@ public partial class ControlBar : HBoxContainer
public delegate void PausePressedEventHandler();
[Signal]
public delegate void StepPressedEventHandler();
[Signal]
public delegate void StopPressedEventHandler();
// Stop removed in campaign mode
[Signal]
public delegate void SpeedChangedEventHandler(float speed);
private Button _playButton = null!;
private Button _pauseButton = null!;
private Button _stepButton = null!;
private Button _stopButton = null!;
// _stopButton removed
private OptionButton _speedSelect = null!;
private Label _turnLabel = null!;
@ -46,9 +45,7 @@ public partial class ControlBar : HBoxContainer
_stepButton.Pressed += () => EmitSignal(SignalName.StepPressed);
AddChild(_stepButton);
_stopButton = CreateStyledButton("STOP");
_stopButton.Pressed += () => EmitSignal(SignalName.StopPressed);
AddChild(_stopButton);
// Stop button removed in campaign mode
// Spacer
AddChild(new Control { CustomMinimumSize = new Vector2(12, 0) });
@ -68,7 +65,7 @@ public partial class ControlBar : HBoxContainer
_turnLabel.AddThemeColorOverride("font_color", new Color("#999999"));
AddChild(_turnLabel);
UpdateForPhase(SimPhase.Edit);
UpdateForPhase(SimPhase.Paused);
}
private static Button CreateStyledButton(string text)
@ -76,7 +73,8 @@ public partial class ControlBar : HBoxContainer
var btn = new Button
{
Text = text,
CustomMinimumSize = new Vector2(70, 30)
CustomMinimumSize = new Vector2(70, 30),
FocusMode = FocusModeEnum.None
};
btn.AddThemeFontSizeOverride("font_size", 11);
@ -124,10 +122,9 @@ public partial class ControlBar : HBoxContainer
public void UpdateForPhase(SimPhase phase)
{
_playButton.Disabled = phase != SimPhase.Edit && phase != SimPhase.Paused;
_playButton.Disabled = phase != SimPhase.Paused && phase != SimPhase.MissionComplete;
_pauseButton.Disabled = phase != SimPhase.Running;
_stepButton.Disabled = phase == SimPhase.Running || phase == SimPhase.Victory || phase == SimPhase.Defeat;
_stopButton.Disabled = phase == SimPhase.Edit;
_stepButton.Disabled = phase == SimPhase.Running;
}
public void UpdateTurn(int turn)

View file

@ -79,4 +79,6 @@ public partial class DetailPanel : PanelContainer
}
public new void Hide() => Visible = false;
public int? CurrentPieceId => Visible ? _currentPieceId : null;
}

View file

@ -0,0 +1,73 @@
using Godot;
namespace Chessistics.Scripts.UI;
/// <summary>
/// Displays a one-line narrative blurb at the top of the screen when a mission starts.
/// Auto-fades out after a few seconds.
/// </summary>
public partial class FlavorBanner : PanelContainer
{
private Label _label = null!;
private Tween? _activeTween;
private static readonly Color BannerBg = new(0.12f, 0.10f, 0.08f, 0.92f);
private static readonly Color BorderColor = new("#B8942A");
private static readonly Color TextColor = new("#E8D4A0");
public override void _Ready()
{
// Style the panel
var style = new StyleBoxFlat
{
BgColor = BannerBg,
BorderColor = BorderColor,
BorderWidthBottom = 2,
ContentMarginLeft = 24,
ContentMarginRight = 24,
ContentMarginTop = 10,
ContentMarginBottom = 10,
CornerRadiusBottomLeft = 6,
CornerRadiusBottomRight = 6
};
AddThemeStyleboxOverride("panel", style);
_label = new Label
{
HorizontalAlignment = HorizontalAlignment.Center,
AutowrapMode = TextServer.AutowrapMode.WordSmart
};
_label.AddThemeFontSizeOverride("font_size", 13);
_label.AddThemeColorOverride("font_color", TextColor);
AddChild(_label);
MouseFilter = MouseFilterEnum.Ignore;
Visible = false;
}
public void ShowFlavor(string text)
{
if (string.IsNullOrWhiteSpace(text))
{
Visible = false;
return;
}
_activeTween?.Kill();
_label.Text = text;
Visible = true;
Modulate = new Color(1, 1, 1, 0);
_activeTween = CreateTween();
// Fade in
_activeTween.TweenProperty(this, "modulate:a", 1f, 0.4f)
.SetEase(Tween.EaseType.Out);
// Hold
_activeTween.TweenInterval(5.0f);
// Fade out
_activeTween.TweenProperty(this, "modulate:a", 0f, 1.0f)
.SetEase(Tween.EaseType.In);
_activeTween.TweenCallback(Callable.From(() => Visible = false));
}
}

View file

@ -0,0 +1 @@
uid://bcapogap6qff2

View file

@ -6,19 +6,7 @@ namespace Chessistics.Scripts.UI;
public partial class LevelSelectScreen : Control
{
[Signal]
public delegate void LevelSelectedEventHandler(int levelIndex);
private readonly (string name, string desc)[] _levels =
[
("Premier Convoi", "Acheminez du bois de la scierie au depot."),
("Deux Clients", "Fournissez deux destinations depuis une seule scierie."),
("Le Col", "Franchissez le mur et gerez deux types de cargaison."),
("Le Carrefour", "Deux productions, deux demandes, et un carrefour au centre."),
("Le Labyrinthe", "Un couloir etroit serpente a travers les murs."),
("Trois Royaumes", "Trois productions, trois demandes. Gerez un reseau complet."),
("La Dame Blanche", "La Dame entre en jeu. Portee supreme sur 8 directions."),
("Le Grand Reseau", "Quatre productions, quatre demandes. Reseau complet.")
];
public delegate void StartCampaignPressedEventHandler();
public override void _Ready()
{
@ -30,199 +18,79 @@ public partial class LevelSelectScreen : Control
bg.MouseFilter = MouseFilterEnum.Ignore;
AddChild(bg);
// Outer margin
var margin = new MarginContainer();
margin.SetAnchorsPreset(LayoutPreset.FullRect);
margin.AddThemeConstantOverride("margin_left", 80);
margin.AddThemeConstantOverride("margin_right", 80);
margin.AddThemeConstantOverride("margin_top", 60);
margin.AddThemeConstantOverride("margin_bottom", 60);
margin.MouseFilter = MouseFilterEnum.Ignore;
// Center content
var center = new CenterContainer();
center.SetAnchorsPreset(LayoutPreset.FullRect);
center.MouseFilter = MouseFilterEnum.Ignore;
var outerVBox = new VBoxContainer();
outerVBox.AddThemeConstantOverride("separation", 0);
outerVBox.MouseFilter = MouseFilterEnum.Ignore;
// --- Header section ---
var headerBox = new VBoxContainer();
headerBox.AddThemeConstantOverride("separation", 4);
headerBox.MouseFilter = MouseFilterEnum.Ignore;
var vbox = new VBoxContainer { Alignment = BoxContainer.AlignmentMode.Center };
vbox.AddThemeConstantOverride("separation", 24);
vbox.MouseFilter = MouseFilterEnum.Ignore;
// Title
var title = new Label
{
Text = "CHESSISTICS",
HorizontalAlignment = HorizontalAlignment.Center
};
title.AddThemeFontSizeOverride("font_size", 48);
title.AddThemeFontSizeOverride("font_size", 56);
title.AddThemeColorOverride("font_color", new Color("#FFD700"));
headerBox.AddChild(title);
vbox.AddChild(title);
// Subtitle
var subtitle = new Label
{
Text = "Selectionnez un niveau",
Text = "La Quête du Roi",
HorizontalAlignment = HorizontalAlignment.Center
};
subtitle.AddThemeFontSizeOverride("font_size", 15);
subtitle.AddThemeColorOverride("font_color", new Color("#777777"));
headerBox.AddChild(subtitle);
outerVBox.AddChild(headerBox);
subtitle.AddThemeFontSizeOverride("font_size", 18);
subtitle.AddThemeColorOverride("font_color", new Color("#999999"));
vbox.AddChild(subtitle);
// Spacer
outerVBox.AddChild(new Control { CustomMinimumSize = new Vector2(0, 48) });
vbox.AddChild(new Control { CustomMinimumSize = new Vector2(0, 32) });
// --- Level cards in a scrollable grid ---
var scroll = new ScrollContainer
// Start button
var startBtn = new Button
{
SizeFlagsVertical = SizeFlags.ExpandFill,
HorizontalScrollMode = ScrollContainer.ScrollMode.Disabled
};
var grid = new GridContainer
{
Columns = 3,
SizeFlagsHorizontal = SizeFlags.ShrinkCenter,
MouseFilter = MouseFilterEnum.Ignore
};
grid.AddThemeConstantOverride("h_separation", 28);
grid.AddThemeConstantOverride("v_separation", 28);
for (int i = 0; i < _levels.Length; i++)
{
var (name, desc) = _levels[i];
grid.AddChild(CreateLevelCard(i, name, desc));
}
scroll.AddChild(grid);
outerVBox.AddChild(scroll);
margin.AddChild(outerVBox);
AddChild(margin);
}
private Control CreateLevelCard(int index, string name, string description)
{
var card = new PanelContainer
{
CustomMinimumSize = new Vector2(300, 240),
SizeFlagsVertical = SizeFlags.ShrinkCenter
};
var cardStyle = new StyleBoxFlat
{
BgColor = new Color(0.17f, 0.17f, 0.19f),
BorderColor = new Color(0.28f, 0.28f, 0.32f),
BorderWidthBottom = 1,
BorderWidthTop = 1,
BorderWidthLeft = 1,
BorderWidthRight = 1,
CornerRadiusTopLeft = 8,
CornerRadiusTopRight = 8,
CornerRadiusBottomLeft = 8,
CornerRadiusBottomRight = 8,
ContentMarginLeft = 24,
ContentMarginRight = 24,
ContentMarginTop = 24,
ContentMarginBottom = 24
};
card.AddThemeStyleboxOverride("panel", cardStyle);
var vbox = new VBoxContainer();
vbox.AddThemeConstantOverride("separation", 10);
// Level number
var numLabel = new Label
{
Text = $"Niveau {index + 1}",
HorizontalAlignment = HorizontalAlignment.Center
};
numLabel.AddThemeFontSizeOverride("font_size", 12);
numLabel.AddThemeColorOverride("font_color", new Color("#666666"));
vbox.AddChild(numLabel);
// Level name
var nameLabel = new Label
{
Text = name,
HorizontalAlignment = HorizontalAlignment.Center
};
nameLabel.AddThemeFontSizeOverride("font_size", 22);
nameLabel.AddThemeColorOverride("font_color", new Color("#EEEEEE"));
vbox.AddChild(nameLabel);
// Thin separator
var sep = new HSeparator { CustomMinimumSize = new Vector2(0, 4) };
vbox.AddChild(sep);
// Description
var descLabel = new Label
{
Text = description,
HorizontalAlignment = HorizontalAlignment.Center,
AutowrapMode = TextServer.AutowrapMode.Word,
CustomMinimumSize = new Vector2(240, 0)
};
descLabel.AddThemeFontSizeOverride("font_size", 13);
descLabel.AddThemeColorOverride("font_color", new Color("#999999"));
vbox.AddChild(descLabel);
// Flexible spacer
vbox.AddChild(new Control { SizeFlagsVertical = SizeFlags.ExpandFill });
// Play button
var playBtn = new Button
{
Text = "Jouer",
CustomMinimumSize = new Vector2(120, 38),
Text = "Démarrer",
CustomMinimumSize = new Vector2(200, 52),
SizeFlagsHorizontal = SizeFlags.ShrinkCenter
};
var btnNormal = new StyleBoxFlat
{
BgColor = new Color("#8B6914"),
CornerRadiusTopLeft = 6,
CornerRadiusTopRight = 6,
CornerRadiusBottomLeft = 6,
CornerRadiusBottomRight = 6,
ContentMarginLeft = 24,
ContentMarginRight = 24,
ContentMarginTop = 8,
ContentMarginBottom = 8
CornerRadiusTopLeft = 8, CornerRadiusTopRight = 8,
CornerRadiusBottomLeft = 8, CornerRadiusBottomRight = 8,
ContentMarginLeft = 32, ContentMarginRight = 32,
ContentMarginTop = 12, ContentMarginBottom = 12
};
var btnHover = new StyleBoxFlat
{
BgColor = new Color("#B8860B"),
CornerRadiusTopLeft = 6,
CornerRadiusTopRight = 6,
CornerRadiusBottomLeft = 6,
CornerRadiusBottomRight = 6,
ContentMarginLeft = 24,
ContentMarginRight = 24,
ContentMarginTop = 8,
ContentMarginBottom = 8
CornerRadiusTopLeft = 8, CornerRadiusTopRight = 8,
CornerRadiusBottomLeft = 8, CornerRadiusBottomRight = 8,
ContentMarginLeft = 32, ContentMarginRight = 32,
ContentMarginTop = 12, ContentMarginBottom = 12
};
var btnPressed = new StyleBoxFlat
{
BgColor = new Color("#6B5010"),
CornerRadiusTopLeft = 6,
CornerRadiusTopRight = 6,
CornerRadiusBottomLeft = 6,
CornerRadiusBottomRight = 6,
ContentMarginLeft = 24,
ContentMarginRight = 24,
ContentMarginTop = 8,
ContentMarginBottom = 8
CornerRadiusTopLeft = 8, CornerRadiusTopRight = 8,
CornerRadiusBottomLeft = 8, CornerRadiusBottomRight = 8,
ContentMarginLeft = 32, ContentMarginRight = 32,
ContentMarginTop = 12, ContentMarginBottom = 12
};
playBtn.AddThemeStyleboxOverride("normal", btnNormal);
playBtn.AddThemeStyleboxOverride("hover", btnHover);
playBtn.AddThemeStyleboxOverride("pressed", btnPressed);
playBtn.AddThemeFontSizeOverride("font_size", 15);
startBtn.AddThemeStyleboxOverride("normal", btnNormal);
startBtn.AddThemeStyleboxOverride("hover", btnHover);
startBtn.AddThemeStyleboxOverride("pressed", btnPressed);
startBtn.AddThemeFontSizeOverride("font_size", 20);
var idx = index;
playBtn.Pressed += () => EmitSignal(SignalName.LevelSelected, idx);
vbox.AddChild(playBtn);
startBtn.Pressed += () => EmitSignal(SignalName.StartCampaignPressed);
vbox.AddChild(startBtn);
card.AddChild(vbox);
return card;
center.AddChild(vbox);
AddChild(center);
}
}

View file

@ -7,14 +7,13 @@ public partial class MetricsOverlay : PanelContainer
{
[Signal]
public delegate void NextLevelPressedEventHandler();
[Signal]
public delegate void RetryPressedEventHandler();
private Label _titleLabel = null!;
private Label _piecesLabel = null!;
private Label _turnsLabel = null!;
private Label _cellsLabel = null!;
private HBoxContainer _buttons = null!;
private Button _nextBtn = null!;
public override void _Ready()
{
@ -57,13 +56,9 @@ public partial class MetricsOverlay : PanelContainer
_buttons = new HBoxContainer { Alignment = BoxContainer.AlignmentMode.Center };
_buttons.AddThemeConstantOverride("separation", 16);
var retryBtn = CreateStyledButton("Rejouer");
retryBtn.Pressed += () => EmitSignal(SignalName.RetryPressed);
_buttons.AddChild(retryBtn);
var nextBtn = CreateStyledButton("Niveau suivant");
nextBtn.Pressed += () => EmitSignal(SignalName.NextLevelPressed);
_buttons.AddChild(nextBtn);
_nextBtn = CreateStyledButton("Mission suivante");
_nextBtn.Pressed += () => EmitSignal(SignalName.NextLevelPressed);
_buttons.AddChild(_nextBtn);
vbox.AddChild(_buttons);
AddChild(vbox);
@ -109,6 +104,37 @@ public partial class MetricsOverlay : PanelContainer
tween.TweenProperty(_buttons, "modulate:a", 1f, 0.2f);
}
public void ShowMissionComplete(int missionNum, int turns, bool isLast)
{
_titleLabel.Text = $"MISSION {missionNum} TERMINÉE !";
_piecesLabel.Text = "";
_turnsLabel.Text = $"Coups: {turns}";
_cellsLabel.Text = "";
_nextBtn.Text = isLast ? "Campagne terminée" : "Mission suivante";
// Start invisible, fade + scale in
Modulate = new Color(1, 1, 1, 0);
Scale = new Vector2(0.85f, 0.85f);
PivotOffset = Size / 2f;
Visible = true;
_turnsLabel.Modulate = new Color(1, 1, 1, 0);
_buttons.Modulate = new Color(1, 1, 1, 0);
var tween = CreateTween();
tween.SetParallel(true);
tween.TweenProperty(this, "modulate:a", 1f, 0.3f)
.SetEase(Tween.EaseType.Out);
tween.TweenProperty(this, "scale", Vector2.One, 0.35f)
.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Back);
tween.SetParallel(false);
tween.TweenInterval(0.15f);
tween.TweenProperty(_turnsLabel, "modulate:a", 1f, 0.2f);
tween.TweenInterval(0.15f);
tween.TweenProperty(_buttons, "modulate:a", 1f, 0.2f);
}
public new void Hide()
{
Visible = false;

View file

@ -6,7 +6,7 @@ namespace Chessistics.Scripts.UI;
public partial class ObjectivePanel : VBoxContainer
{
private readonly Dictionary<Coords, (Label label, ProgressBar bar, Label deadline)> _entries = new();
private readonly Dictionary<Coords, (Label label, ProgressBar bar, bool completed)> _entries = new();
public void Setup(IReadOnlyList<DemandDef> demands)
{
@ -57,13 +57,8 @@ public partial class ObjectivePanel : VBoxContainer
bar.AddThemeStyleboxOverride("fill", fillStyle);
vbox.AddChild(bar);
var deadline = new Label { Text = $"Deadline: {demand.Deadline} coups" };
deadline.AddThemeFontSizeOverride("font_size", 10);
deadline.AddThemeColorOverride("font_color", new Color("#777777"));
vbox.AddChild(deadline);
AddChild(vbox);
_entries[demand.Position] = (label, bar, deadline);
_entries[demand.Position] = (label, bar, false);
}
}
@ -71,15 +66,21 @@ public partial class ObjectivePanel : VBoxContainer
{
if (!_entries.TryGetValue(demandCell, out var entry)) return;
entry.label.Text = $"{name}: {current}/{required}";
// Once completed, stop updating
if (entry.completed) return;
// Cap display at required value
int displayCurrent = Math.Min(current, required);
entry.label.Text = $"{name}: {displayCurrent}/{required}";
// Animate the progress bar value
var tween = entry.bar.CreateTween();
tween.TweenProperty(entry.bar, "value", (double)current, 0.2f)
tween.TweenProperty(entry.bar, "value", (double)displayCurrent, 0.2f)
.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Cubic);
if (current >= required)
{
entry.label.Text = $"{name}: {required}/{required}";
entry.label.AddThemeColorOverride("font_color", new Color("#5AAC5A")); // warm green
// Flash the progress bar green
@ -90,6 +91,9 @@ public partial class ObjectivePanel : VBoxContainer
CornerRadiusBottomLeft = 3, CornerRadiusBottomRight = 3
};
entry.bar.AddThemeStyleboxOverride("fill", fillStyle);
// Mark as completed — no further updates
_entries[demandCell] = (entry.label, entry.bar, true);
}
}
}

View file

@ -58,7 +58,8 @@ public partial class PieceStockPanel : VBoxContainer
{
Text = GetPieceName(entry.Kind),
CustomMinimumSize = new Vector2(120, 32),
ToggleMode = false // We manage selection state ourselves
ToggleMode = false, // We manage selection state ourselves
FocusMode = FocusModeEnum.None // Prevent spacebar from activating buttons
};
ApplyButtonStyle(button, false);
@ -148,6 +149,13 @@ public partial class PieceStockPanel : VBoxContainer
UpdateButtonStates();
}
/// <summary>Automation hook — runs the same path as clicking a piece button.</summary>
public void SimulateSelect(PieceKind kind)
{
if (!_entries.ContainsKey(kind)) return;
OnPieceButtonPressed(kind);
}
private static string GetPieceName(PieceKind kind) => kind switch
{
PieceKind.Pawn => "Pion",

View file

@ -0,0 +1,156 @@
using Chessistics.Engine.Events;
using Chessistics.Engine.Model;
namespace Chessistics.Engine.Commands;
/// <summary>
/// Load a campaign: initializes the board with mission 0's terrain, stock, and unlocked pieces.
/// </summary>
public class LoadCampaignCommand : WorldCommand
{
public override void AssertApplicationConditions(BoardState state)
{
if (state.Campaign == null)
throw new CommandRejectedException(
new CommandRejectedEvent(nameof(LoadCampaignCommand), "No campaign defined."));
if (state.Campaign.CampaignDef.Missions.Count == 0)
throw new CommandRejectedException(
new CommandRejectedEvent(nameof(LoadCampaignCommand), "Campaign has no missions."));
}
protected override void DoApply(BoardState state, List<IWorldEvent> changeList)
{
var campaign = state.Campaign!;
var mission = campaign.CurrentMission;
// Apply terrain patch for mission 0
state.ApplyTerrainPatch(mission.TerrainPatch, campaign.CurrentMissionIndex);
// Add stock
state.AddStock(mission.Stock);
// Unlock pieces and levels
foreach (var kind in mission.UnlockedPieces)
{
campaign.AvailablePieceKinds.Add(kind);
changeList.Add(new PieceUnlockedEvent(kind, 1));
}
foreach (var upgrade in mission.UnlockedLevels)
{
campaign.AvailableLevels.Add(upgrade);
changeList.Add(new PieceUnlockedEvent(upgrade.Kind, upgrade.Level));
}
state.Phase = SimPhase.Paused;
changeList.Add(new CampaignLoadedEvent(campaign.CampaignDef.Name, 0));
changeList.Add(new MissionStartedEvent(0, state.Width, state.Height));
}
}
/// <summary>
/// Advance to the next mission: applies the terrain patch, adds stock, unlocks pieces.
/// </summary>
public class AdvanceMissionCommand : WorldCommand
{
public override void AssertApplicationConditions(BoardState state)
{
if (state.Campaign == null)
throw new CommandRejectedException(
new CommandRejectedEvent(nameof(AdvanceMissionCommand), "No campaign defined."));
if (state.Phase != SimPhase.MissionComplete)
throw new CommandRejectedException(
new CommandRejectedEvent(nameof(AdvanceMissionCommand), "Current mission is not complete."));
if (state.Campaign.IsLastMission)
throw new CommandRejectedException(
new CommandRejectedEvent(nameof(AdvanceMissionCommand), "No more missions."));
}
protected override void DoApply(BoardState state, List<IWorldEvent> changeList)
{
var campaign = state.Campaign!;
campaign.CurrentMissionIndex++;
var mission = campaign.CurrentMission;
// Apply terrain expansion
var oldWidth = state.Width;
var oldHeight = state.Height;
state.ApplyTerrainPatch(mission.TerrainPatch, campaign.CurrentMissionIndex);
if (state.Width != oldWidth || state.Height != oldHeight)
changeList.Add(new TerrainExpandedEvent(state.Width, state.Height, mission.TerrainPatch.Cells));
// Add stock
state.AddStock(mission.Stock);
// Unlock pieces and levels
foreach (var kind in mission.UnlockedPieces)
{
campaign.AvailablePieceKinds.Add(kind);
changeList.Add(new PieceUnlockedEvent(kind, 1));
}
foreach (var upgrade in mission.UnlockedLevels)
{
campaign.AvailableLevels.Add(upgrade);
changeList.Add(new PieceUnlockedEvent(upgrade.Kind, upgrade.Level));
}
state.Phase = SimPhase.Paused;
changeList.Add(new MissionStartedEvent(campaign.CurrentMissionIndex, state.Width, state.Height));
}
}
/// <summary>
/// Move a piece already on the board (drag & drop). Validates the new placement.
/// </summary>
public class MovePieceCommand : WorldCommand
{
public int PieceId { get; }
public Coords NewStart { get; }
public Coords NewEnd { get; }
public MovePieceCommand(int pieceId, Coords newStart, Coords newEnd)
{
PieceId = pieceId;
NewStart = newStart;
NewEnd = newEnd;
}
public override void AssertApplicationConditions(BoardState state)
{
var piece = state.GetPieceById(PieceId);
if (piece == null)
throw new CommandRejectedException(
new CommandRejectedEvent(nameof(MovePieceCommand), $"Piece {PieceId} not found."));
if (!state.IsOnBoard(NewStart) || !state.IsOnBoard(NewEnd))
throw new CommandRejectedException(
new CommandRejectedEvent(nameof(MovePieceCommand), "Position is off the board."));
if (state.GetCell(NewStart) == CellType.Wall)
throw new CommandRejectedException(
new CommandRejectedEvent(nameof(MovePieceCommand), "Cannot place on a wall."));
if (!Rules.MoveValidator.IsLegalPlacement(piece.Kind, NewStart, NewEnd, state))
throw new CommandRejectedException(
new CommandRejectedEvent(nameof(MovePieceCommand), "Illegal move for this piece type."));
}
protected override void DoApply(BoardState state, List<IWorldEvent> changeList)
{
var piece = state.GetPieceById(PieceId)!;
var oldStart = piece.StartCell;
var oldEnd = piece.EndCell;
piece.SetPosition(NewStart, NewEnd);
piece.Cargo = null;
state.OccupiedCells.Add(NewStart);
state.OccupiedCells.Add(NewEnd);
changeList.Add(new PieceMovedByPlayerEvent(PieceId, oldStart, oldEnd, NewStart, NewEnd));
}
}

View file

@ -0,0 +1 @@
uid://b2103p4uf8f3t

View file

@ -5,6 +5,10 @@ using Chessistics.Engine.Simulation;
namespace Chessistics.Engine.Commands;
/// <summary>
/// Place a piece on the board. Works in any phase (Running or Paused).
/// The placement takes effect between turns.
/// </summary>
public class PlacePieceCommand : WorldCommand
{
public PieceKind Kind { get; }
@ -22,10 +26,6 @@ public class PlacePieceCommand : WorldCommand
public override void AssertApplicationConditions(BoardState state)
{
if (state.Phase != SimPhase.Edit)
throw new CommandRejectedException(
new CommandRejectedEvent(nameof(PlacePieceCommand), "Can only place pieces during Edit phase."));
if (!state.RemainingStock.TryGetValue(Kind, out var remaining) || remaining <= 0)
throw new CommandRejectedException(
new PlacementRejectedEvent(Kind, Start, End, "No pieces of this type remaining in stock."));
@ -41,6 +41,16 @@ public class PlacePieceCommand : WorldCommand
if (!MoveValidator.IsLegalPlacement(Kind, Start, End, state))
throw new CommandRejectedException(
new PlacementRejectedEvent(Kind, Start, End, "Illegal move for this piece type."));
// Check piece kind is unlocked (campaign mode)
if (state.Campaign != null && !state.Campaign.IsPieceAvailable(Kind))
throw new CommandRejectedException(
new PlacementRejectedEvent(Kind, Start, End, $"Piece type {Kind} is not unlocked yet."));
// Check piece level is unlocked (campaign mode)
if (state.Campaign != null && !state.Campaign.IsLevelAvailable(Kind, Level))
throw new CommandRejectedException(
new PlacementRejectedEvent(Kind, Start, End, $"Level {Level} for {Kind} is not unlocked yet."));
}
protected override void DoApply(BoardState state, List<IWorldEvent> changeList)
@ -58,20 +68,21 @@ public class PlacePieceCommand : WorldCommand
changeList.Add(new PiecePlacedEvent(piece.Id, Kind, Start, End));
}
/// <summary>
/// Auto-assign cargo filter by tracing the relay chain back to a production.
/// Priority: direct adjacency to production, then shared relay with filtered piece.
/// </summary>
private static CargoType? InferCargoFilter(BoardState state, PieceState piece)
{
// Check if start or end cell is adjacent to a production
foreach (var (prodPos, prod) in state.Productions)
{
if (piece.StartCell.IsAdjacent4(prodPos) || piece.EndCell.IsAdjacent4(prodPos))
return prod.Cargo;
}
// Check if start or end shares a relay point with an existing piece that has a filter
// Transformer output acts like a production
foreach (var (tPos, transformer) in state.Transformers)
{
if (piece.StartCell.IsAdjacent4(tPos) || piece.EndCell.IsAdjacent4(tPos))
return transformer.OutputCargo;
}
foreach (var existing in state.Pieces)
{
if (existing.CargoFilter == null) continue;
@ -89,6 +100,9 @@ public class PlacePieceCommand : WorldCommand
}
}
/// <summary>
/// Remove a piece from the board. Works in any phase.
/// </summary>
public class RemovePieceCommand : WorldCommand
{
public int PieceId { get; }
@ -100,10 +114,6 @@ public class RemovePieceCommand : WorldCommand
public override void AssertApplicationConditions(BoardState state)
{
if (state.Phase != SimPhase.Edit)
throw new CommandRejectedException(
new CommandRejectedEvent(nameof(RemovePieceCommand), "Can only remove pieces during Edit phase."));
if (state.GetPieceById(PieceId) == null)
throw new CommandRejectedException(
new CommandRejectedEvent(nameof(RemovePieceCommand), $"Piece {PieceId} not found."));
@ -119,26 +129,6 @@ public class RemovePieceCommand : WorldCommand
}
}
public class StartSimulationCommand : WorldCommand
{
public override void AssertApplicationConditions(BoardState state)
{
if (state.Phase != SimPhase.Edit)
throw new CommandRejectedException(
new CommandRejectedEvent(nameof(StartSimulationCommand), "Can only start from Edit phase."));
if (state.Pieces.Count == 0)
throw new CommandRejectedException(
new CommandRejectedEvent(nameof(StartSimulationCommand), "Place at least one piece before starting."));
}
protected override void DoApply(BoardState state, List<IWorldEvent> changeList)
{
state.Phase = SimPhase.Running;
changeList.Add(new SimulationStartedEvent());
}
}
public class PauseSimulationCommand : WorldCommand
{
public override void AssertApplicationConditions(BoardState state)
@ -159,9 +149,9 @@ public class ResumeSimulationCommand : WorldCommand
{
public override void AssertApplicationConditions(BoardState state)
{
if (state.Phase != SimPhase.Paused)
if (state.Phase != SimPhase.Paused && state.Phase != SimPhase.MissionComplete)
throw new CommandRejectedException(
new CommandRejectedEvent(nameof(ResumeSimulationCommand), "Can only resume from Paused phase."));
new CommandRejectedEvent(nameof(ResumeSimulationCommand), "Can only resume from Paused or MissionComplete phase."));
}
protected override void DoApply(BoardState state, List<IWorldEvent> changeList)
@ -175,72 +165,20 @@ public class StepSimulationCommand : WorldCommand
{
public override void AssertApplicationConditions(BoardState state)
{
if (state.Phase == SimPhase.Edit && state.Pieces.Count == 0)
throw new CommandRejectedException(
new CommandRejectedEvent(nameof(StepSimulationCommand), "Place at least one piece before stepping."));
if (state.Phase != SimPhase.Edit && state.Phase != SimPhase.Running && state.Phase != SimPhase.Paused)
if (state.Phase != SimPhase.Running && state.Phase != SimPhase.Paused)
throw new CommandRejectedException(
new CommandRejectedEvent(nameof(StepSimulationCommand), "Cannot step in current phase."));
}
protected override void DoApply(BoardState state, List<IWorldEvent> changeList)
{
if (state.Phase == SimPhase.Edit)
state.Phase = SimPhase.Paused;
var wasRunning = state.Phase == SimPhase.Running;
TurnExecutor.ExecuteTurn(state, changeList);
// After a step, remain in Paused unless victory/defeat occurred
if (state.Phase == SimPhase.Running)
// After a manual step (was Paused), remain Paused.
// After an auto-play step (was Running), stay Running unless
// TurnExecutor changed it (collision → Paused, last mission → MissionComplete).
if (!wasRunning && state.Phase == SimPhase.Running)
state.Phase = SimPhase.Paused;
}
}
public class StopSimulationCommand : WorldCommand
{
public override void AssertApplicationConditions(BoardState state)
{
if (state.Phase == SimPhase.Edit)
throw new CommandRejectedException(
new CommandRejectedEvent(nameof(StopSimulationCommand), "Already in Edit phase."));
}
protected override void DoApply(BoardState state, List<IWorldEvent> changeList)
{
// Restore destroyed pieces
state.Pieces.AddRange(state.DestroyedPieces);
state.DestroyedPieces.Clear();
foreach (var piece in state.Pieces)
{
piece.CurrentCell = piece.StartCell;
piece.Cargo = null;
}
foreach (var pos in state.ProductionBuffers.Keys.ToList())
state.ProductionBuffers[pos] = 0;
foreach (var demand in state.Demands.Values)
demand.ReceivedCount = 0;
state.TurnNumber = 0;
state.Phase = SimPhase.Edit;
changeList.Add(new SimulationStoppedEvent());
}
}
public class ResetLevelCommand : WorldCommand
{
public override void AssertApplicationConditions(BoardState state)
{
// Reset is always valid
}
protected override void DoApply(BoardState state, List<IWorldEvent> changeList)
{
state.ResetFromLevel();
changeList.Add(new LevelResetEvent());
}
}

View file

@ -2,26 +2,42 @@ using Chessistics.Engine.Model;
namespace Chessistics.Engine.Events;
// Edit phase events
// Placement events (work in any phase)
public record PiecePlacedEvent(int PieceId, PieceKind Kind, Coords Start, Coords End) : IWorldEvent;
public record PieceRemovedEvent(int PieceId) : IWorldEvent;
public record PlacementRejectedEvent(PieceKind Kind, Coords Start, Coords End, string Reason) : IWorldEvent;
public record CommandRejectedEvent(string CommandType, string Reason) : IWorldEvent;
// Simulation lifecycle events
public record SimulationStartedEvent : IWorldEvent;
public record SimulationPausedEvent : IWorldEvent;
public record SimulationResumedEvent : IWorldEvent;
public record SimulationStoppedEvent : IWorldEvent;
public record LevelResetEvent : IWorldEvent;
// Campaign events
public record CampaignLoadedEvent(string CampaignName, int MissionIndex) : IWorldEvent;
public record MissionCompleteEvent(int TurnNumber, int MissionIndex) : IWorldEvent;
public record MissionStartedEvent(int MissionIndex, int NewWidth, int NewHeight) : IWorldEvent;
public record TerrainExpandedEvent(int NewWidth, int NewHeight, IReadOnlyList<PatchCell> NewCells) : IWorldEvent;
public record PieceUnlockedEvent(PieceKind Kind, int Level) : IWorldEvent;
// Transformer events
public record CargoConvertedEvent(int TurnNumber, Coords TransformerCell, CargoType InputCargo, CargoType OutputCargo, int OutputAmount) : IWorldEvent;
// Turn events — all carry TurnNumber for animation grouping
public record TurnStartedEvent(int TurnNumber) : IWorldEvent;
public record PieceMovedEvent(int TurnNumber, int PieceId, Coords From, Coords To) : IWorldEvent;
public record PieceDestroyedEvent(int TurnNumber, int PieceId, int? DestroyerPieceId, Coords Cell) : IWorldEvent;
public record PieceReturnedToStockEvent(int TurnNumber, int PieceId, PieceKind Kind, int? DestroyerPieceId, Coords Cell) : IWorldEvent;
public record CargoTransferredEvent(int TurnNumber, Coords From, Coords To, CargoType Type, int? GivingPieceId, int? ReceivingPieceId) : IWorldEvent;
public record CargoProducedEvent(int TurnNumber, Coords ProductionCell, CargoType Type) : IWorldEvent;
public record DemandProgressEvent(int TurnNumber, Coords DemandCell, string Name, int Current, int Required) : IWorldEvent;
public record VictoryEvent(int TurnNumber, Metrics Metrics) : IWorldEvent;
public record DeadlineExpiredEvent(int TurnNumber, Coords DemandCell, string Name) : IWorldEvent;
public record TurnEndedEvent(int TurnNumber) : IWorldEvent;
// Drag & drop
public record PieceMovedByPlayerEvent(int PieceId, Coords OldStart, Coords OldEnd, Coords NewStart, Coords NewEnd) : IWorldEvent;
// QuickSave/QuickLoad — presentation must rebuild all visuals from Snapshot on Restored.
public record StateSavedEvent(int TurnNumber, int? SlotId) : IWorldEvent;
public record StateRestoredEvent(BoardSnapshot Snapshot, int? SlotId) : IWorldEvent;
// Recurring demands
public record DemandShortageStartedEvent(int TurnNumber, Coords DemandCell, string Name) : IWorldEvent;
public record DemandShortageClearedEvent(int TurnNumber, Coords DemandCell, string Name) : IWorldEvent;

View file

@ -0,0 +1,221 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Chessistics.Engine.Model;
namespace Chessistics.Engine.Loading;
public static class CampaignLoader
{
private static readonly JsonSerializerOptions Options = new()
{
PropertyNameCaseInsensitive = true,
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }
};
public static CampaignDef Load(string json)
{
var dto = JsonSerializer.Deserialize<CampaignDto>(json, Options)
?? throw new JsonException("Failed to deserialize campaign JSON.");
Validate(dto);
return new CampaignDef
{
Name = dto.Name,
InitialWidth = dto.InitialWidth,
InitialHeight = dto.InitialHeight,
Missions = dto.Missions.Select(ParseMission).ToList()
};
}
public static CampaignDef LoadFromFile(string path)
{
var json = File.ReadAllText(path);
return Load(json);
}
private static MissionDef ParseMission(MissionDto m)
{
var patch = new TerrainPatch
{
NewWidth = m.TerrainPatch.NewWidth,
NewHeight = m.TerrainPatch.NewHeight,
Cells = m.TerrainPatch.Cells.Select(ParsePatchCell).ToList()
};
return new MissionDef
{
Id = m.Id,
Name = m.Name,
Description = m.Description ?? "",
Flavor = m.Flavor ?? "",
TerrainPatch = patch,
Stock = m.Stock?.Select(s => new PieceStock(ParseKind(s.Kind), s.Count, s.Level)).ToList() ?? [],
UnlockedPieces = m.UnlockedPieces?.Select(ParseKind).ToList() ?? [],
UnlockedLevels = m.UnlockedLevels?.Select(u => new PieceUpgrade(ParseKind(u.Kind), u.Level)).ToList() ?? []
};
}
private static PatchCell ParsePatchCell(CellDto c)
{
var cellType = c.Type.ToLowerInvariant() switch
{
"empty" => CellType.Empty,
"wall" => CellType.Wall,
"production" => CellType.Production,
"demand" => CellType.Demand,
"transformer" => CellType.Transformer,
_ => throw new JsonException($"Unknown cell type: '{c.Type}'")
};
ProductionDef? prod = null;
if (cellType == CellType.Production && c.Production != null)
{
prod = new ProductionDef(new Coords(c.Col, c.Row), c.Production.Name, ParseCargo(c.Production.Cargo), c.Production.Amount);
}
DemandDef? demand = null;
if (cellType == CellType.Demand && c.Demand != null)
{
demand = new DemandDef(new Coords(c.Col, c.Row), c.Demand.Name,
ParseCargo(c.Demand.Cargo), c.Demand.Amount,
ConsumptionPerTurn: c.Demand.ConsumptionPerTurn,
SustainTurns: c.Demand.SustainTurns);
}
TransformerDef? transformer = null;
if (cellType == CellType.Transformer && c.Transformer != null)
{
transformer = new TransformerDef(
new Coords(c.Col, c.Row), c.Transformer.Name,
ParseCargo(c.Transformer.InputCargo), c.Transformer.InputRequired,
ParseCargo(c.Transformer.OutputCargo), c.Transformer.OutputAmount);
}
return new PatchCell
{
Col = c.Col,
Row = c.Row,
Type = cellType,
Production = prod,
Demand = demand,
Transformer = transformer
};
}
private static CargoType ParseCargo(string cargo) => cargo.ToLowerInvariant() switch
{
"wood" => CargoType.Wood,
"stone" => CargoType.Stone,
"tools" => CargoType.Tools,
"arms" => CargoType.Arms,
"gold" => CargoType.Gold,
_ => throw new JsonException($"Unknown cargo type: '{cargo}'")
};
private static PieceKind ParseKind(string kind) => kind.ToLowerInvariant() switch
{
"pawn" => PieceKind.Pawn,
"rook" => PieceKind.Rook,
"bishop" => PieceKind.Bishop,
"knight" => PieceKind.Knight,
"queen" => PieceKind.Queen,
_ => throw new JsonException($"Unknown piece kind: '{kind}'")
};
private static void Validate(CampaignDto dto)
{
if (string.IsNullOrWhiteSpace(dto.Name))
throw new JsonException("Campaign name is required.");
if (dto.InitialWidth <= 0 || dto.InitialHeight <= 0)
throw new JsonException("Campaign dimensions must be positive.");
if (dto.Missions.Count == 0)
throw new JsonException("Campaign must have at least one mission.");
int prevWidth = dto.InitialWidth;
int prevHeight = dto.InitialHeight;
foreach (var mission in dto.Missions)
{
if (mission.TerrainPatch.NewWidth < prevWidth || mission.TerrainPatch.NewHeight < prevHeight)
throw new JsonException($"Mission '{mission.Name}': terrain cannot shrink (was {prevWidth}x{prevHeight}, got {mission.TerrainPatch.NewWidth}x{mission.TerrainPatch.NewHeight}).");
prevWidth = mission.TerrainPatch.NewWidth;
prevHeight = mission.TerrainPatch.NewHeight;
}
}
// DTOs
private class CampaignDto
{
public string Name { get; set; } = "";
public int InitialWidth { get; set; }
public int InitialHeight { get; set; }
public List<MissionDto> Missions { get; set; } = [];
}
private class MissionDto
{
public int Id { get; set; }
public string Name { get; set; } = "";
public string? Description { get; set; }
public string? Flavor { get; set; }
public TerrainPatchDto TerrainPatch { get; set; } = new();
public List<StockDto>? Stock { get; set; }
public List<string>? UnlockedPieces { get; set; }
public List<UpgradeDto>? UnlockedLevels { get; set; }
}
private class TerrainPatchDto
{
public int NewWidth { get; set; }
public int NewHeight { get; set; }
public List<CellDto> Cells { get; set; } = [];
}
private class CellDto
{
public int Col { get; set; }
public int Row { get; set; }
public string Type { get; set; } = "empty";
public ProductionDto? Production { get; set; }
public DemandDto? Demand { get; set; }
public TransformerDto? Transformer { get; set; }
}
private class ProductionDto
{
public string Name { get; set; } = "";
public string Cargo { get; set; } = "";
public int Amount { get; set; } = 1;
}
private class DemandDto
{
public string Name { get; set; } = "";
public string Cargo { get; set; } = "";
public int Amount { get; set; }
public int ConsumptionPerTurn { get; set; } = 0;
public int SustainTurns { get; set; } = 0;
}
private class TransformerDto
{
public string Name { get; set; } = "";
public string InputCargo { get; set; } = "";
public int InputRequired { get; set; } = 1;
public string OutputCargo { get; set; } = "";
public int OutputAmount { get; set; } = 1;
}
private class StockDto
{
public string Kind { get; set; } = "";
public int Count { get; set; }
public int Level { get; set; } = 1;
}
private class UpgradeDto
{
public string Kind { get; set; } = "";
public int Level { get; set; }
}
}

View file

@ -0,0 +1 @@
uid://dq4ycsj6oc1nh

View file

@ -8,10 +8,14 @@ public class BoardSnapshot
public IReadOnlyList<ProductionSnapshot> Productions { get; }
public IReadOnlyList<DemandSnapshot> Demands { get; }
public IReadOnlyList<PieceSnapshot> Pieces { get; }
public IReadOnlyList<TransformerSnapshot> Transformers { get; }
public SimPhase Phase { get; }
public int TurnNumber { get; }
public IReadOnlyDictionary<PieceKind, int> RemainingStock { get; }
// Campaign info (null in legacy level mode)
public CampaignSnapshot? Campaign { get; }
public BoardSnapshot(BoardState state)
{
Width = state.Width;
@ -28,17 +32,51 @@ public class BoardSnapshot
.ToList();
Demands = state.Demands.Values
.Select(d => new DemandSnapshot(d.Position, d.Name, d.Cargo, d.Required, d.Deadline, d.ReceivedCount, d.IsSatisfied))
.Select(d => new DemandSnapshot(d.Position, d.Name, d.Cargo, d.Required, d.Deadline,
d.ReceivedCount, d.IsSatisfied, d.MissionIndex,
d.Definition.ConsumptionPerTurn, d.Definition.SustainTurns,
d.Buffer, d.SustainedTurns, d.InShortage))
.ToList();
Pieces = state.Pieces
.Select(p => new PieceSnapshot(p.Id, p.Kind, p.Level, p.StartCell, p.EndCell, p.CurrentCell, p.Cargo, p.CargoFilter, p.SocialStatus))
.ToList();
Transformers = state.Transformers.Values
.Select(t => new TransformerSnapshot(t.Position, t.Name, t.InputCargo, t.InputRequired, t.OutputCargo, t.OutputAmount,
state.TransformerInputBuffers[t.Position], state.TransformerOutputBuffers[t.Position]))
.ToList();
RemainingStock = new Dictionary<PieceKind, int>(state.RemainingStock);
if (state.Campaign != null)
{
Campaign = new CampaignSnapshot(
state.Campaign.CampaignDef.Name,
state.Campaign.CurrentMissionIndex,
state.Campaign.CompletedMissions.ToList(),
state.Campaign.AvailablePieceKinds.ToHashSet(),
state.Campaign.AvailableLevels.ToHashSet()
);
}
}
}
public record ProductionSnapshot(Coords Position, string Name, CargoType Cargo, int Amount, int BufferCount);
public record DemandSnapshot(Coords Position, string Name, CargoType Cargo, int Required, int Deadline, int ReceivedCount, bool IsSatisfied);
public record DemandSnapshot(
Coords Position,
string Name,
CargoType Cargo,
int Required,
int Deadline,
int ReceivedCount,
bool IsSatisfied,
int MissionIndex = 0,
int ConsumptionPerTurn = 0,
int SustainTurns = 0,
int Buffer = 0,
int SustainedTurns = 0,
bool InShortage = false);
public record PieceSnapshot(int Id, PieceKind Kind, int Level, Coords StartCell, Coords EndCell, Coords CurrentCell, CargoType? Cargo, CargoType? CargoFilter, int SocialStatus);
public record TransformerSnapshot(Coords Position, string Name, CargoType InputCargo, int InputRequired, CargoType OutputCargo, int OutputAmount, int InputBufferCount, int OutputBufferCount);
public record CampaignSnapshot(string Name, int CurrentMissionIndex, IReadOnlyList<int> CompletedMissions, IReadOnlySet<PieceKind> AvailablePieceKinds, IReadOnlySet<PieceUpgrade> AvailableLevels);

View file

@ -4,98 +4,86 @@ namespace Chessistics.Engine.Model;
public class BoardState
{
public int Width { get; }
public int Height { get; }
public CellType[,] Grid { get; }
public int Width { get; private set; }
public int Height { get; private set; }
public CellType[,] Grid { get; private set; }
public Dictionary<Coords, ProductionDef> Productions { get; }
public Dictionary<Coords, DemandState> Demands { get; }
public List<PieceState> Pieces { get; }
public List<PieceState> DestroyedPieces { get; } = new();
public Dictionary<Coords, int> ProductionBuffers { get; }
public Dictionary<Coords, TransformerDef> Transformers { get; }
public Dictionary<Coords, int> TransformerInputBuffers { get; }
public Dictionary<Coords, int> TransformerOutputBuffers { get; }
public SimPhase Phase { get; set; }
public int TurnNumber { get; set; }
public int NextPieceId { get; set; }
public Dictionary<PieceKind, int> RemainingStock { get; }
public int MaxDeadline { get; }
// Campaign state (null for legacy level mode)
public CampaignState? Campaign { get; private set; }
// Tracks all cells ever occupied by a piece (for metrics)
public HashSet<Coords> OccupiedCells { get; }
private readonly LevelDef _levelDef;
private readonly LevelDef? _levelDef;
private bool _isApplyingCommand;
private BoardState(LevelDef level)
private BoardState(int width, int height)
{
_levelDef = level;
Width = level.Width;
Height = level.Height;
MaxDeadline = level.MaxDeadline;
Width = width;
Height = height;
Grid = new CellType[Width, Height];
Productions = new Dictionary<Coords, ProductionDef>();
Demands = new Dictionary<Coords, DemandState>();
Pieces = new List<PieceState>();
ProductionBuffers = new Dictionary<Coords, int>();
Transformers = new Dictionary<Coords, TransformerDef>();
TransformerInputBuffers = new Dictionary<Coords, int>();
TransformerOutputBuffers = new Dictionary<Coords, int>();
RemainingStock = new Dictionary<PieceKind, int>();
OccupiedCells = new HashSet<Coords>();
Phase = SimPhase.Edit;
Phase = SimPhase.Paused;
TurnNumber = 0;
NextPieceId = 1;
// Initialize grid as empty
for (int c = 0; c < Width; c++)
for (int r = 0; r < Height; r++)
Grid[c, r] = CellType.Empty;
// Place walls
foreach (var wall in level.Walls)
Grid[wall.Col, wall.Row] = CellType.Wall;
// Place productions
foreach (var prod in level.Productions)
{
Grid[prod.Position.Col, prod.Position.Row] = CellType.Production;
Productions[prod.Position] = prod;
ProductionBuffers[prod.Position] = 0;
}
// Place demands
foreach (var demand in level.Demands)
private BoardState(LevelDef level) : this(level.Width, level.Height)
{
Grid[demand.Position.Col, demand.Position.Row] = CellType.Demand;
Demands[demand.Position] = new DemandState(demand);
}
// Initialize stock
foreach (var stock in level.Stock)
RemainingStock[stock.Kind] = stock.Count;
_levelDef = level;
ApplyLevelDef(level);
}
public static BoardState FromLevel(LevelDef level) => new(level);
public static BoardState FromCampaign(CampaignDef campaignDef)
{
var state = new BoardState(campaignDef.InitialWidth, campaignDef.InitialHeight);
state.Campaign = new CampaignState(campaignDef);
return state;
}
public CellType GetCell(Coords coords) => Grid[coords.Col, coords.Row];
public bool IsOnBoard(Coords coords) => coords.IsOnBoard(Width, Height);
/// <summary>
/// Returns all cells currently occupied by any piece (both start and end during Edit, CurrentCell during sim).
/// Returns all cells currently occupied by any piece.
/// In campaign mode (no Edit phase), always uses CurrentCell.
/// </summary>
public HashSet<Coords> GetOccupiedCells()
{
var occupied = new HashSet<Coords>();
foreach (var piece in Pieces)
{
if (Phase == SimPhase.Edit)
{
occupied.Add(piece.StartCell);
occupied.Add(piece.EndCell);
}
else
{
occupied.Add(piece.CurrentCell);
}
}
return occupied;
}
@ -117,17 +105,96 @@ public class BoardState
}
}
/// <summary>
/// Expand the board to new dimensions, preserving existing state.
/// </summary>
public void Resize(int newWidth, int newHeight)
{
if (newWidth < Width || newHeight < Height)
throw new InvalidOperationException("Cannot shrink the board.");
if (newWidth == Width && newHeight == Height)
return;
var newGrid = new CellType[newWidth, newHeight];
for (int c = 0; c < newWidth; c++)
for (int r = 0; r < newHeight; r++)
newGrid[c, r] = CellType.Empty;
// Copy existing grid
for (int c = 0; c < Width; c++)
for (int r = 0; r < Height; r++)
newGrid[c, r] = Grid[c, r];
Grid = newGrid;
Width = newWidth;
Height = newHeight;
}
/// <summary>
/// Apply a terrain patch (new cells, productions, demands, walls).
/// </summary>
public void ApplyTerrainPatch(TerrainPatch patch, int missionIndex)
{
if (patch.NewWidth > Width || patch.NewHeight > Height)
Resize(patch.NewWidth, patch.NewHeight);
foreach (var cell in patch.Cells)
{
var coords = new Coords(cell.Col, cell.Row);
// Always clear existing buildings before overwriting
ClearBuildingAt(coords);
Grid[cell.Col, cell.Row] = cell.Type;
switch (cell.Type)
{
case CellType.Production when cell.Production != null:
Productions[coords] = cell.Production;
ProductionBuffers[coords] = 0;
break;
case CellType.Demand when cell.Demand != null:
Demands[coords] = new DemandState(cell.Demand, missionIndex);
break;
case CellType.Transformer when cell.Transformer != null:
Transformers[coords] = cell.Transformer;
TransformerInputBuffers[coords] = 0;
TransformerOutputBuffers[coords] = 0;
break;
case CellType.Wall:
// Remove pieces whose start or end cell is on this wall
RemovePiecesOnCell(coords);
break;
}
}
}
/// <summary>
/// Add stock to the remaining stock (cumulative for campaigns).
/// </summary>
public void AddStock(IReadOnlyList<PieceStock> stock)
{
foreach (var s in stock)
RemainingStock[s.Kind] = RemainingStock.GetValueOrDefault(s.Kind) + s.Count;
}
public void ResetFromLevel()
{
if (_levelDef == null)
throw new InvalidOperationException("Cannot reset: no level definition.");
Pieces.Clear();
DestroyedPieces.Clear();
Productions.Clear();
Demands.Clear();
ProductionBuffers.Clear();
Transformers.Clear();
TransformerInputBuffers.Clear();
TransformerOutputBuffers.Clear();
RemainingStock.Clear();
OccupiedCells.Clear();
Phase = SimPhase.Edit;
Phase = SimPhase.Paused;
TurnNumber = 0;
NextPieceId = 1;
@ -135,23 +202,149 @@ public class BoardState
for (int r = 0; r < Height; r++)
Grid[c, r] = CellType.Empty;
foreach (var wall in _levelDef.Walls)
ApplyLevelDef(_levelDef);
}
/// <summary>
/// Remove pieces whose StartCell or EndCell is on the given cell (return to stock).
/// Used when a wall overwrites an occupied cell during terrain patching.
/// </summary>
private void RemovePiecesOnCell(Coords coords)
{
var toRemove = Pieces
.Where(p => p.StartCell == coords || p.EndCell == coords)
.ToList();
foreach (var piece in toRemove)
{
Pieces.Remove(piece);
RemainingStock[piece.Kind] = RemainingStock.GetValueOrDefault(piece.Kind) + 1;
}
}
/// <summary>
/// Capture a deep copy of every mutable field (for QuickSave).
/// Immutable defs and CampaignDef are shared by reference.
/// </summary>
public WorldSave CaptureSave()
{
var grid = new CellType[Width, Height];
Array.Copy(Grid, grid, Grid.Length);
return new WorldSave
{
Width = Width,
Height = Height,
Grid = grid,
Phase = Phase,
TurnNumber = TurnNumber,
NextPieceId = NextPieceId,
Productions = new Dictionary<Coords, ProductionDef>(Productions),
ProductionBuffers = new Dictionary<Coords, int>(ProductionBuffers),
Demands = Demands.ToDictionary(kv => kv.Key, kv => kv.Value.Clone()),
Transformers = new Dictionary<Coords, TransformerDef>(Transformers),
TransformerInputBuffers = new Dictionary<Coords, int>(TransformerInputBuffers),
TransformerOutputBuffers = new Dictionary<Coords, int>(TransformerOutputBuffers),
Pieces = Pieces.Select(p => p.Clone()).ToList(),
DestroyedPieces = DestroyedPieces.Select(p => p.Clone()).ToList(),
RemainingStock = new Dictionary<PieceKind, int>(RemainingStock),
OccupiedCells = new HashSet<Coords>(OccupiedCells),
Campaign = Campaign == null ? null : new CampaignSaveData
{
CurrentMissionIndex = Campaign.CurrentMissionIndex,
CompletedMissions = new List<int>(Campaign.CompletedMissions),
AvailablePieceKinds = new HashSet<PieceKind>(Campaign.AvailablePieceKinds),
AvailableLevels = new HashSet<PieceUpgrade>(Campaign.AvailableLevels)
}
};
}
/// <summary>
/// Restore every mutable field from a save. Board dimensions can grow
/// or shrink; the grid is fully replaced.
/// </summary>
public void RestoreFromSave(WorldSave save)
{
Width = save.Width;
Height = save.Height;
Grid = new CellType[Width, Height];
Array.Copy(save.Grid, Grid, save.Grid.Length);
Phase = save.Phase;
TurnNumber = save.TurnNumber;
NextPieceId = save.NextPieceId;
Productions.Clear();
foreach (var kv in save.Productions) Productions[kv.Key] = kv.Value;
ProductionBuffers.Clear();
foreach (var kv in save.ProductionBuffers) ProductionBuffers[kv.Key] = kv.Value;
Demands.Clear();
foreach (var kv in save.Demands) Demands[kv.Key] = kv.Value.Clone();
Transformers.Clear();
foreach (var kv in save.Transformers) Transformers[kv.Key] = kv.Value;
TransformerInputBuffers.Clear();
foreach (var kv in save.TransformerInputBuffers) TransformerInputBuffers[kv.Key] = kv.Value;
TransformerOutputBuffers.Clear();
foreach (var kv in save.TransformerOutputBuffers) TransformerOutputBuffers[kv.Key] = kv.Value;
Pieces.Clear();
foreach (var p in save.Pieces) Pieces.Add(p.Clone());
DestroyedPieces.Clear();
foreach (var p in save.DestroyedPieces) DestroyedPieces.Add(p.Clone());
RemainingStock.Clear();
foreach (var kv in save.RemainingStock) RemainingStock[kv.Key] = kv.Value;
OccupiedCells.Clear();
foreach (var c in save.OccupiedCells) OccupiedCells.Add(c);
if (save.Campaign != null && Campaign != null)
{
Campaign.CurrentMissionIndex = save.Campaign.CurrentMissionIndex;
Campaign.CompletedMissions.Clear();
Campaign.CompletedMissions.AddRange(save.Campaign.CompletedMissions);
Campaign.AvailablePieceKinds.Clear();
foreach (var k in save.Campaign.AvailablePieceKinds) Campaign.AvailablePieceKinds.Add(k);
Campaign.AvailableLevels.Clear();
foreach (var u in save.Campaign.AvailableLevels) Campaign.AvailableLevels.Add(u);
}
}
private void ClearBuildingAt(Coords coords)
{
Productions.Remove(coords);
ProductionBuffers.Remove(coords);
Demands.Remove(coords);
Transformers.Remove(coords);
TransformerInputBuffers.Remove(coords);
TransformerOutputBuffers.Remove(coords);
}
private void ApplyLevelDef(LevelDef level)
{
foreach (var wall in level.Walls)
Grid[wall.Col, wall.Row] = CellType.Wall;
foreach (var prod in _levelDef.Productions)
foreach (var prod in level.Productions)
{
Grid[prod.Position.Col, prod.Position.Row] = CellType.Production;
Productions[prod.Position] = prod;
ProductionBuffers[prod.Position] = 0;
}
foreach (var demand in _levelDef.Demands)
foreach (var demand in level.Demands)
{
Grid[demand.Position.Col, demand.Position.Row] = CellType.Demand;
Demands[demand.Position] = new DemandState(demand);
}
foreach (var stock in _levelDef.Stock)
foreach (var stock in level.Stock)
RemainingStock[stock.Kind] = stock.Count;
}
}

View file

@ -0,0 +1,9 @@
namespace Chessistics.Engine.Model;
public class CampaignDef
{
public string Name { get; init; } = "";
public int InitialWidth { get; init; }
public int InitialHeight { get; init; }
public IReadOnlyList<MissionDef> Missions { get; init; } = [];
}

View file

@ -0,0 +1 @@
uid://cpyjhyp308ybb

View file

@ -0,0 +1,22 @@
namespace Chessistics.Engine.Model;
public class CampaignState
{
public CampaignDef CampaignDef { get; }
public int CurrentMissionIndex { get; set; }
public List<int> CompletedMissions { get; } = new();
public HashSet<PieceKind> AvailablePieceKinds { get; } = new();
public HashSet<PieceUpgrade> AvailableLevels { get; } = new();
public CampaignState(CampaignDef campaignDef)
{
CampaignDef = campaignDef;
CurrentMissionIndex = 0;
}
public MissionDef CurrentMission => CampaignDef.Missions[CurrentMissionIndex];
public bool IsLastMission => CurrentMissionIndex >= CampaignDef.Missions.Count - 1;
public bool IsPieceAvailable(PieceKind kind) => AvailablePieceKinds.Contains(kind);
public bool IsLevelAvailable(PieceKind kind, int level) => AvailableLevels.Contains(new PieceUpgrade(kind, level));
}

View file

@ -0,0 +1 @@
uid://bxmuxyxroua54

View file

@ -3,5 +3,8 @@ namespace Chessistics.Engine.Model;
public enum CargoType
{
Wood,
Stone
Stone,
Tools,
Arms,
Gold
}

View file

@ -5,5 +5,6 @@ public enum CellType
Empty,
Wall,
Production,
Demand
Demand,
Transformer
}

View file

@ -1,3 +1,22 @@
namespace Chessistics.Engine.Model;
public record DemandDef(Coords Position, string Name, CargoType Cargo, int Amount, int Deadline);
/// <summary>
/// A demand building.
///
/// Classic mode (default): counts deliveries up to <see cref="Amount"/>,
/// then <see cref="DemandState.IsSatisfied"/> stays true forever.
///
/// Recurring mode: set <see cref="ConsumptionPerTurn"/> &gt; 0. The demand
/// holds a buffer of delivered cargo; each turn it consumes that many
/// units. If the buffer runs dry it enters shortage. The demand is
/// considered satisfied once it has spent <see cref="SustainTurns"/>
/// consecutive turns without shortage.
/// </summary>
public record DemandDef(
Coords Position,
string Name,
CargoType Cargo,
int Amount,
int Deadline = 0,
int ConsumptionPerTurn = 0,
int SustainTurns = 0);

View file

@ -4,17 +4,40 @@ public class DemandState
{
public DemandDef Definition { get; }
public int ReceivedCount { get; set; }
public int MissionIndex { get; }
public DemandState(DemandDef definition)
// Recurring demand tracking (only used when Definition.ConsumptionPerTurn > 0)
public int Buffer { get; set; }
public int SustainedTurns { get; set; }
public bool InShortage { get; set; }
public DemandState(DemandDef definition, int missionIndex = 0)
{
Definition = definition;
MissionIndex = missionIndex;
ReceivedCount = 0;
}
public bool IsSatisfied => ReceivedCount >= Definition.Amount;
public bool IsRecurring => Definition.ConsumptionPerTurn > 0;
public bool IsSatisfied => IsRecurring
? SustainedTurns >= Definition.SustainTurns
: ReceivedCount >= Definition.Amount;
public Coords Position => Definition.Position;
public string Name => Definition.Name;
public CargoType Cargo => Definition.Cargo;
public int Required => Definition.Amount;
public int Deadline => Definition.Deadline;
public DemandState Clone()
{
return new DemandState(Definition, MissionIndex)
{
ReceivedCount = ReceivedCount,
Buffer = Buffer,
SustainedTurns = SustainedTurns,
InShortage = InShortage
};
}
}

View file

@ -0,0 +1,14 @@
namespace Chessistics.Engine.Model;
public class MissionDef
{
public int Id { get; init; }
public string Name { get; init; } = "";
public string Description { get; init; } = "";
public string Flavor { get; init; } = "";
public TerrainPatch TerrainPatch { get; init; } = new();
public IReadOnlyList<PieceStock> Stock { get; init; } = [];
public IReadOnlyList<DemandDef> Demands { get; init; } = [];
public IReadOnlyList<PieceKind> UnlockedPieces { get; init; } = [];
public IReadOnlyList<PieceUpgrade> UnlockedLevels { get; init; } = [];
}

View file

@ -0,0 +1 @@
uid://bh4mvmkqeohqj

View file

@ -5,8 +5,8 @@ public class PieceState
public int Id { get; }
public PieceKind Kind { get; }
public int Level { get; }
public Coords StartCell { get; }
public Coords EndCell { get; }
public Coords StartCell { get; private set; }
public Coords EndCell { get; private set; }
public Coords CurrentCell { get; set; }
public CargoType? Cargo { get; set; }
public CargoType? CargoFilter { get; set; }
@ -30,4 +30,25 @@ public class PieceState
/// Returns the cell this piece will move to next.
/// </summary>
public Coords TargetCell => CurrentCell == StartCell ? EndCell : StartCell;
/// <summary>
/// Relocate this piece (drag & drop).
/// </summary>
public void SetPosition(Coords newStart, Coords newEnd)
{
StartCell = newStart;
EndCell = newEnd;
CurrentCell = newStart;
}
public PieceState Clone()
{
var clone = new PieceState(Id, Kind, StartCell, EndCell, PlacementOrder, Level)
{
CurrentCell = CurrentCell,
Cargo = Cargo,
CargoFilter = CargoFilter
};
return clone;
}
}

View file

@ -0,0 +1,3 @@
namespace Chessistics.Engine.Model;
public record PieceUpgrade(PieceKind Kind, int Level);

View file

@ -0,0 +1 @@
uid://broa1hmowlt7

View file

@ -2,9 +2,7 @@ namespace Chessistics.Engine.Model;
public enum SimPhase
{
Edit,
Running,
Paused,
Victory,
Defeat
MissionComplete
}

View file

@ -0,0 +1,18 @@
namespace Chessistics.Engine.Model;
public class TerrainPatch
{
public int NewWidth { get; init; }
public int NewHeight { get; init; }
public IReadOnlyList<PatchCell> Cells { get; init; } = [];
}
public class PatchCell
{
public int Col { get; init; }
public int Row { get; init; }
public CellType Type { get; init; }
public ProductionDef? Production { get; init; }
public DemandDef? Demand { get; init; }
public TransformerDef? Transformer { get; init; }
}

View file

@ -0,0 +1 @@
uid://c51en0egfstje

View file

@ -0,0 +1,10 @@
namespace Chessistics.Engine.Model;
public record TransformerDef(
Coords Position,
string Name,
CargoType InputCargo,
int InputRequired,
CargoType OutputCargo,
int OutputAmount
);

View file

@ -0,0 +1 @@
uid://cu7cpt1u5mtxd

View file

@ -0,0 +1,40 @@
namespace Chessistics.Engine.Model;
/// <summary>
/// Deep copy of every mutable field needed to restore a BoardState to an
/// earlier point. Used by QuickSave/QuickLoad for rapid iteration during
/// UI/UX testing and by the harness to restore checkpoints.
///
/// Immutable refs (defs, CampaignDef) are shared; mutable state (Pieces,
/// DemandState, buffers, stock, campaign progression) is copied by value.
/// </summary>
public sealed class WorldSave
{
public int Width { get; init; }
public int Height { get; init; }
public CellType[,] Grid { get; init; } = new CellType[0, 0];
public SimPhase Phase { get; init; }
public int TurnNumber { get; init; }
public int NextPieceId { get; init; }
public Dictionary<Coords, ProductionDef> Productions { get; init; } = new();
public Dictionary<Coords, int> ProductionBuffers { get; init; } = new();
public Dictionary<Coords, DemandState> Demands { get; init; } = new();
public Dictionary<Coords, TransformerDef> Transformers { get; init; } = new();
public Dictionary<Coords, int> TransformerInputBuffers { get; init; } = new();
public Dictionary<Coords, int> TransformerOutputBuffers { get; init; } = new();
public List<PieceState> Pieces { get; init; } = new();
public List<PieceState> DestroyedPieces { get; init; } = new();
public Dictionary<PieceKind, int> RemainingStock { get; init; } = new();
public HashSet<Coords> OccupiedCells { get; init; } = new();
public CampaignSaveData? Campaign { get; init; }
}
public sealed class CampaignSaveData
{
public int CurrentMissionIndex { get; init; }
public List<int> CompletedMissions { get; init; } = new();
public HashSet<PieceKind> AvailablePieceKinds { get; init; } = new();
public HashSet<PieceUpgrade> AvailableLevels { get; init; } = new();
}

View file

@ -0,0 +1,25 @@
using Chessistics.Engine.Model;
namespace Chessistics.Engine.Rules;
public static class MissionChecker
{
/// <summary>
/// Check if all demands for the current mission are satisfied.
/// In campaign mode, only checks the current mission's demands.
/// In legacy level mode, checks all demands.
/// </summary>
public static bool AllCurrentDemandsMet(BoardState state)
{
var missionIndex = state.Campaign?.CurrentMissionIndex ?? 0;
return state.Demands.Values
.Where(d => d.MissionIndex == missionIndex)
.All(d => d.IsSatisfied);
}
/// <summary>
/// Check if all demands on the board are satisfied (all missions).
/// </summary>
public static bool AllDemandsMet(BoardState state)
=> state.Demands.Values.All(d => d.IsSatisfied);
}

View file

@ -0,0 +1 @@
uid://b3vg5keyv2aj6

View file

@ -14,7 +14,10 @@ public static class TransferResolver
// Phase A: Productions give to adjacent pieces
ResolveProductionTransfers(state, events, participated, productionGave);
// Phase B: Pieces give to demands or other pieces
// Phase A2: Transformer outputs give to adjacent pieces (like productions)
ResolveTransformerOutputTransfers(state, events, participated);
// Phase B: Pieces give to demands, transformers, or other pieces
ResolvePieceTransfers(state, events, participated);
return events;
@ -56,6 +59,35 @@ public static class TransferResolver
}
}
private static void ResolveTransformerOutputTransfers(
BoardState state, List<IWorldEvent> events, HashSet<int> participated)
{
var transformers = state.Transformers.Values
.Where(t => state.TransformerOutputBuffers[t.Position] > 0)
.OrderBy(t => t.Position.Col).ThenBy(t => t.Position.Row)
.ToList();
foreach (var transformer in transformers)
{
var cargoType = transformer.OutputCargo;
var receivers = GetAdjacentPiecesWithoutCargo(state, transformer.Position, participated,
cargoType: cargoType);
foreach (var receiver in receivers)
{
if (state.TransformerOutputBuffers[transformer.Position] <= 0) break;
receiver.Cargo = cargoType;
state.TransformerOutputBuffers[transformer.Position]--;
participated.Add(receiver.Id);
events.Add(new CargoTransferredEvent(
state.TurnNumber, transformer.Position, receiver.CurrentCell, cargoType,
GivingPieceId: null, ReceivingPieceId: receiver.Id));
}
}
}
private static void ResolvePieceTransfers(
BoardState state, List<IWorldEvent> events, HashSet<int> participated)
{
@ -78,6 +110,8 @@ public static class TransferResolver
{
giver.Cargo = null;
adjacentDemand.ReceivedCount++;
if (adjacentDemand.IsRecurring)
adjacentDemand.Buffer++;
participated.Add(giver.Id);
events.Add(new CargoTransferredEvent(
@ -91,7 +125,22 @@ public static class TransferResolver
continue;
}
// Priority 2: transfer to adjacent piece without cargo
// Priority 2: deliver to adjacent transformer (input side)
var adjacentTransformer = GetAdjacentCompatibleTransformer(state, giver.CurrentCell, cargoType);
if (adjacentTransformer != null)
{
giver.Cargo = null;
state.TransformerInputBuffers[adjacentTransformer.Position]++;
participated.Add(giver.Id);
events.Add(new CargoTransferredEvent(
state.TurnNumber, giver.CurrentCell, adjacentTransformer.Position, cargoType,
GivingPieceId: giver.Id, ReceivingPieceId: null));
continue;
}
// Priority 3: transfer to adjacent piece without cargo
var receivers = GetAdjacentPiecesWithoutCargo(state, giver.CurrentCell, participated,
cargoType: cargoType);
if (receivers.Count == 0) continue;
@ -136,6 +185,17 @@ public static class TransferResolver
.FirstOrDefault();
}
private static TransformerDef? GetAdjacentCompatibleTransformer(
BoardState state, Coords position, CargoType cargoType)
{
var adjacent = position.GetAdjacent4(state.Width, state.Height);
return state.Transformers.Values
.Where(t => t.InputCargo == cargoType
&& adjacent.Contains(t.Position))
.FirstOrDefault();
}
/// <summary>
/// Returns a sort key (0-3) based on cardinal direction from center to piece.
/// In y-up coordinates, clockwise from 0° (right):

View file

@ -1,17 +0,0 @@
using Chessistics.Engine.Model;
namespace Chessistics.Engine.Rules;
public static class VictoryChecker
{
public static bool AllDemandsMet(BoardState state)
=> state.Demands.Values.All(d => d.IsSatisfied);
public static bool AnyDeadlineExpired(BoardState state)
=> state.TurnNumber > state.MaxDeadline && !AllDemandsMet(state);
public static IReadOnlyList<DemandState> GetExpiredDemands(BoardState state)
=> state.Demands.Values
.Where(d => !d.IsSatisfied && state.TurnNumber > d.Deadline)
.ToList();
}

View file

@ -1 +0,0 @@
uid://uh7qhohnsxpa

View file

@ -7,14 +7,25 @@ namespace Chessistics.Engine.Simulation;
public class GameSim
{
private readonly BoardState _state;
private readonly Dictionary<int, WorldSave> _saveSlots = new();
private readonly LinkedList<WorldSave> _undoStack = new();
private const int DefaultSlot = 0;
private const int UndoStackLimit = 32;
public GameSim(LevelDef level)
{
_state = BoardState.FromLevel(level);
}
public GameSim(CampaignDef campaign)
{
_state = BoardState.FromCampaign(campaign);
}
public IReadOnlyList<IWorldEvent> ProcessCommand(IWorldCommand command)
{
WorldSave? undoCheckpoint = IsUndoable(command) ? _state.CaptureSave() : null;
var changeList = new List<IWorldEvent>();
try
{
@ -24,8 +35,73 @@ public class GameSim
{
return [ex.RejectionEvent];
}
if (undoCheckpoint != null && ContainsMutation(changeList))
PushUndo(undoCheckpoint);
return changeList;
}
public BoardSnapshot GetSnapshot() => new(_state);
private static bool IsUndoable(IWorldCommand command) => command switch
{
PlacePieceCommand => true,
RemovePieceCommand => true,
MovePieceCommand => true,
_ => false
};
private static bool ContainsMutation(List<IWorldEvent> events) =>
events.Any(e => e is PiecePlacedEvent or PieceRemovedEvent or PieceMovedByPlayerEvent);
private void PushUndo(WorldSave save)
{
_undoStack.AddLast(save);
while (_undoStack.Count > UndoStackLimit)
_undoStack.RemoveFirst();
}
public bool CanUndo => _undoStack.Count > 0;
/// <summary>
/// Revert the last undoable mutation (placement, removal, or move) by
/// restoring the pre-command snapshot. Emits StateRestoredEvent so the
/// presentation can rebuild visuals. Returns empty if nothing to undo.
/// </summary>
public IReadOnlyList<IWorldEvent> Undo()
{
if (_undoStack.Count == 0) return [];
var save = _undoStack.Last!.Value;
_undoStack.RemoveLast();
_state.RestoreFromSave(save);
return [new StateRestoredEvent(new BoardSnapshot(_state), null)];
}
public BoardSnapshot GetSnapshot() => new(_state);
/// <summary>
/// Capture a full deep-copy of the world into an in-memory slot.
/// Returns a single StateSavedEvent — no state mutation.
/// </summary>
public IReadOnlyList<IWorldEvent> QuickSave(int slot = DefaultSlot)
{
_saveSlots[slot] = _state.CaptureSave();
return [new StateSavedEvent(_state.TurnNumber, slot)];
}
/// <summary>
/// Restore the world from a saved slot. Emits StateRestoredEvent with a
/// fresh snapshot — the presentation layer must rebuild all visuals.
/// Returns an empty list (no event) if the slot is empty.
/// </summary>
public IReadOnlyList<IWorldEvent> QuickLoad(int slot = DefaultSlot)
{
if (!_saveSlots.TryGetValue(slot, out var save))
return [];
_state.RestoreFromSave(save);
_undoStack.Clear();
return [new StateRestoredEvent(new BoardSnapshot(_state), slot)];
}
public bool HasSave(int slot = DefaultSlot) => _saveSlots.ContainsKey(slot);
}

View file

@ -14,49 +14,98 @@ public static class TurnExecutor
// Sub-phase 1: PRODUCTION
ExecuteProduction(state, changeList);
// Sub-phase 2: TRANSFERS
// Sub-phase 2: TRANSFORMATION (convert accumulated input → output)
ExecuteTransformation(state, changeList);
// Sub-phase 3: TRANSFERS
var transferEvents = TransferResolver.ResolveTransfers(state);
changeList.AddRange(transferEvents);
// Sub-phase 3: MOVEMENT
// Sub-phase 4: MOVEMENT
ExecuteMovement(state, changeList);
// Sub-phase 4: COLLISION RESOLUTION
// Sub-phase 4b: RECURRING DEMAND CONSUMPTION
ExecuteRecurringConsumption(state, changeList);
// Sub-phase 5: COLLISION RESOLUTION
var collisions = CollisionResolver.ResolveCollisions(state.Pieces);
foreach (var (survivor, destroyed, cell) in collisions)
{
foreach (var victim in destroyed)
{
state.Pieces.Remove(victim);
state.DestroyedPieces.Add(victim);
victim.Cargo = null;
changeList.Add(new PieceDestroyedEvent(
state.TurnNumber, victim.Id, survivor?.Id, cell));
// Return piece to stock instead of destroying permanently
state.RemainingStock[victim.Kind] = state.RemainingStock.GetValueOrDefault(victim.Kind) + 1;
changeList.Add(new PieceReturnedToStockEvent(
state.TurnNumber, victim.Id, victim.Kind, survivor?.Id, cell));
}
}
// Check victory / defeat
if (VictoryChecker.AllDemandsMet(state))
// Auto-pause on collision
if (collisions.Count > 0)
{
state.Phase = SimPhase.Victory;
changeList.Add(new VictoryEvent(state.TurnNumber, ComputeMetrics(state)));
state.Phase = SimPhase.Paused;
changeList.Add(new SimulationPausedEvent());
}
else if (VictoryChecker.AnyDeadlineExpired(state))
// Check mission completion
if (MissionChecker.AllCurrentDemandsMet(state) && state.Demands.Count > 0)
{
state.Phase = SimPhase.Defeat;
foreach (var demand in VictoryChecker.GetExpiredDemands(state))
changeList.Add(new DeadlineExpiredEvent(state.TurnNumber, demand.Position, demand.Name));
var campaign = state.Campaign;
var missionIndex = campaign?.CurrentMissionIndex ?? 0;
campaign?.CompletedMissions.Add(missionIndex);
changeList.Add(new MissionCompleteEvent(state.TurnNumber, missionIndex));
// Auto-advance to next mission if available (campaign mode)
if (campaign != null && !campaign.IsLastMission)
{
AdvanceToNextMission(state, campaign, changeList);
// Phase stays Running — simulation continues
}
else
{
// Last mission or legacy mode — pause
state.Phase = SimPhase.MissionComplete;
}
}
changeList.Add(new TurnEndedEvent(state.TurnNumber));
}
private static void AdvanceToNextMission(BoardState state, CampaignState campaign, List<IWorldEvent> changeList)
{
campaign.CurrentMissionIndex++;
var mission = campaign.CurrentMission;
var oldWidth = state.Width;
var oldHeight = state.Height;
state.ApplyTerrainPatch(mission.TerrainPatch, campaign.CurrentMissionIndex);
if (state.Width != oldWidth || state.Height != oldHeight)
changeList.Add(new TerrainExpandedEvent(state.Width, state.Height, mission.TerrainPatch.Cells));
state.AddStock(mission.Stock);
foreach (var kind in mission.UnlockedPieces)
{
campaign.AvailablePieceKinds.Add(kind);
changeList.Add(new PieceUnlockedEvent(kind, 1));
}
foreach (var upgrade in mission.UnlockedLevels)
{
campaign.AvailableLevels.Add(upgrade);
changeList.Add(new PieceUnlockedEvent(upgrade.Kind, upgrade.Level));
}
changeList.Add(new MissionStartedEvent(campaign.CurrentMissionIndex, state.Width, state.Height));
}
private static void ExecuteMovement(BoardState state, List<IWorldEvent> changeList)
{
// Compute all targets first (simultaneous movement)
var moves = state.Pieces.Select(p => (piece: p, from: p.CurrentCell, to: p.TargetCell)).ToList();
// Apply all moves
foreach (var (piece, from, to) in moves)
{
piece.CurrentCell = to;
@ -65,6 +114,55 @@ public static class TurnExecutor
}
}
private static void ExecuteTransformation(BoardState state, List<IWorldEvent> changeList)
{
foreach (var (pos, transformer) in state.Transformers)
{
var inputBuffer = state.TransformerInputBuffers[pos];
if (inputBuffer >= transformer.InputRequired)
{
state.TransformerInputBuffers[pos] = inputBuffer - transformer.InputRequired;
state.TransformerOutputBuffers[pos] += transformer.OutputAmount;
changeList.Add(new CargoConvertedEvent(
state.TurnNumber, pos, transformer.InputCargo, transformer.OutputCargo, transformer.OutputAmount));
}
}
}
private static void ExecuteRecurringConsumption(BoardState state, List<IWorldEvent> changeList)
{
foreach (var demand in state.Demands.Values)
{
if (!demand.IsRecurring) continue;
// Consume from buffer
var consumed = Math.Min(demand.Buffer, demand.Definition.ConsumptionPerTurn);
demand.Buffer -= consumed;
var short_ = demand.Buffer <= 0;
if (short_)
{
if (!demand.InShortage)
{
demand.InShortage = true;
changeList.Add(new DemandShortageStartedEvent(
state.TurnNumber, demand.Position, demand.Name));
}
demand.SustainedTurns = 0;
}
else
{
if (demand.InShortage)
{
demand.InShortage = false;
changeList.Add(new DemandShortageClearedEvent(
state.TurnNumber, demand.Position, demand.Name));
}
demand.SustainedTurns++;
}
}
}
private static void ExecuteProduction(BoardState state, List<IWorldEvent> changeList)
{
foreach (var (pos, prod) in state.Productions)

View file

@ -13,6 +13,8 @@ public class SimHelper
public static SimHelper FromLevel(LevelDef level) => new(new GameSim(level));
public static SimHelper FromCampaign(CampaignDef campaign) => new(new GameSim(campaign));
public IReadOnlyList<IWorldEvent> Place(PieceKind kind, Coords start, Coords end)
=> Sim.ProcessCommand(new PlacePieceCommand(kind, start, end));
@ -22,9 +24,6 @@ public class SimHelper
public IReadOnlyList<IWorldEvent> Remove(int pieceId)
=> Sim.ProcessCommand(new RemovePieceCommand(pieceId));
public IReadOnlyList<IWorldEvent> Start()
=> Sim.ProcessCommand(new StartSimulationCommand());
public IReadOnlyList<IWorldEvent> Step()
=> Sim.ProcessCommand(new StepSimulationCommand());
@ -34,17 +33,20 @@ public class SimHelper
public IReadOnlyList<IWorldEvent> Resume()
=> Sim.ProcessCommand(new ResumeSimulationCommand());
public IReadOnlyList<IWorldEvent> Stop()
=> Sim.ProcessCommand(new StopSimulationCommand());
public IReadOnlyList<IWorldEvent> Reset()
=> Sim.ProcessCommand(new ResetLevelCommand());
public IReadOnlyList<IWorldEvent> AdvanceMission()
=> Sim.ProcessCommand(new AdvanceMissionCommand());
public List<IWorldEvent> StepN(int n)
{
var allEvents = new List<IWorldEvent>();
for (int i = 0; i < n; i++)
allEvents.AddRange(Step());
{
var events = Step();
allEvents.AddRange(events);
// Stop stepping if simulation halted (last mission complete)
if (Snapshot.Phase == SimPhase.MissionComplete)
break;
}
return allEvents;
}

View file

@ -0,0 +1,81 @@
using System.IO;
using Chessistics.Engine.Loading;
using Chessistics.Engine.Model;
using Xunit;
namespace Chessistics.Tests.Loading;
public class Campaign01Tests
{
private static CampaignDef LoadRealCampaign()
{
var repoRoot = Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..");
var path = Path.GetFullPath(Path.Combine(repoRoot, "Data", "campaigns", "campaign_01.json"));
return CampaignLoader.Load(File.ReadAllText(path));
}
[Fact]
public void Campaign_HasExpectedStructure()
{
var c = LoadRealCampaign();
Assert.Equal(9, c.Missions.Count);
Assert.Equal(4, c.InitialWidth);
Assert.Equal(4, c.InitialHeight);
}
[Fact]
public void Mission8_AddsTwoTransformersAndExpandsTo12x10()
{
var c = LoadRealCampaign();
var m8 = c.Missions[7];
Assert.Equal("L'Expansion Finale", m8.Name);
Assert.Equal(12, m8.TerrainPatch.NewWidth);
Assert.Equal(10, m8.TerrainPatch.NewHeight);
var transformers = m8.TerrainPatch.Cells.Where(p => p.Type == CellType.Transformer).ToList();
Assert.Equal(2, transformers.Count);
Assert.Contains(transformers, t => t.Transformer!.Name == "Forge Est");
Assert.Contains(transformers, t => t.Transformer!.Name == "Armurerie Est");
}
[Fact]
public void Mission9_CathedralDemandsAllThreeCargoTypes()
{
var c = LoadRealCampaign();
var m9 = c.Missions[8];
Assert.Equal("Le Couronnement", m9.Name);
Assert.Equal(12, m9.TerrainPatch.NewWidth);
Assert.Equal(12, m9.TerrainPatch.NewHeight);
var demands = m9.TerrainPatch.Cells.Where(p => p.Type == CellType.Demand).ToList();
Assert.Equal(3, demands.Count);
var types = demands.Select(d => d.Demand!.Cargo).ToHashSet();
Assert.Contains(CargoType.Tools, types);
Assert.Contains(CargoType.Arms, types);
Assert.Contains(CargoType.Gold, types);
}
[Fact]
public void Mission7_RenamedToComptoir()
{
var c = LoadRealCampaign();
var m7 = c.Missions[6];
Assert.Equal("Le Comptoir", m7.Name);
}
[Fact]
public void AllTerrainPatchesAreNonRegressive()
{
// Subsequent missions must only grow the board, never shrink it.
var c = LoadRealCampaign();
int w = c.InitialWidth, h = c.InitialHeight;
for (int i = 0; i < c.Missions.Count; i++)
{
var tp = c.Missions[i].TerrainPatch;
Assert.True(tp.NewWidth >= w, $"Mission {i}: width shrunk from {w} to {tp.NewWidth}");
Assert.True(tp.NewHeight >= h, $"Mission {i}: height shrunk from {h} to {tp.NewHeight}");
w = tp.NewWidth;
h = tp.NewHeight;
}
}
}

View file

@ -0,0 +1,63 @@
using Chessistics.Engine.Loading;
using Chessistics.Engine.Model;
using Xunit;
namespace Chessistics.Tests.Loading;
public class CampaignFileTests
{
[Fact]
public void Campaign01_LoadsSuccessfully()
{
var campaign = CampaignLoader.LoadFromFile("../../../../Data/campaigns/campaign_01.json");
Assert.Equal("La Quête du Roi", campaign.Name);
Assert.Equal(9, campaign.Missions.Count);
}
[Fact]
public void Campaign01_Mission5_HasTransformer()
{
var campaign = CampaignLoader.LoadFromFile("../../../../Data/campaigns/campaign_01.json");
var m5 = campaign.Missions[4]; // index 4
Assert.Equal("La Forge", m5.Name);
var transformerCell = m5.TerrainPatch.Cells
.FirstOrDefault(c => c.Type == CellType.Transformer);
Assert.NotNull(transformerCell);
Assert.Equal(CargoType.Wood, transformerCell.Transformer!.InputCargo);
Assert.Equal(CargoType.Tools, transformerCell.Transformer.OutputCargo);
}
[Fact]
public void Campaign01_Mission6_HasArmurerie()
{
var campaign = CampaignLoader.LoadFromFile("../../../../Data/campaigns/campaign_01.json");
var m6 = campaign.Missions[5];
Assert.Equal("L'Armurerie", m6.Name);
Assert.Equal(10, m6.TerrainPatch.NewWidth);
var transformerCell = m6.TerrainPatch.Cells
.FirstOrDefault(c => c.Type == CellType.Transformer);
Assert.NotNull(transformerCell);
Assert.Equal(CargoType.Stone, transformerCell.Transformer!.InputCargo);
Assert.Equal(CargoType.Arms, transformerCell.Transformer.OutputCargo);
}
[Fact]
public void Campaign01_Mission7_HasComptoir()
{
var campaign = CampaignLoader.LoadFromFile("../../../../Data/campaigns/campaign_01.json");
var m7 = campaign.Missions[6];
Assert.Equal("Le Comptoir", m7.Name);
var transformerCell = m7.TerrainPatch.Cells
.FirstOrDefault(c => c.Type == CellType.Transformer);
Assert.NotNull(transformerCell);
Assert.Equal(CargoType.Tools, transformerCell.Transformer!.InputCargo);
Assert.Equal(CargoType.Gold, transformerCell.Transformer.OutputCargo);
}
}

View file

@ -0,0 +1 @@
uid://c6sab8mq5a201

View file

@ -0,0 +1,220 @@
using System.Text.Json;
using Chessistics.Engine.Loading;
using Chessistics.Engine.Model;
using Xunit;
namespace Chessistics.Tests.Loading;
public class CampaignLoaderTests
{
private const string ValidCampaignJson = """
{
"name": "La Quête du Roi",
"initialWidth": 4,
"initialHeight": 4,
"missions": [
{
"id": 1,
"name": "Premier Convoi",
"description": "Les pions découvrent une scierie.",
"terrainPatch": {
"newWidth": 4,
"newHeight": 4,
"cells": [
{ "col": 0, "row": 0, "type": "production", "production": { "name": "Scierie", "cargo": "wood", "amount": 1 } },
{ "col": 3, "row": 0, "type": "demand", "demand": { "name": "Dépôt", "cargo": "wood", "amount": 3 } }
]
},
"unlockedPieces": ["pawn"],
"unlockedLevels": [{ "kind": "pawn", "level": 1 }],
"stock": [{ "kind": "pawn", "count": 6 }]
},
{
"id": 2,
"name": "Forger les Tours",
"description": "De nouveaux territoires s'ouvrent.",
"terrainPatch": {
"newWidth": 6,
"newHeight": 4,
"cells": [
{ "col": 4, "row": 0, "type": "empty" },
{ "col": 4, "row": 1, "type": "wall" },
{ "col": 5, "row": 2, "type": "production", "production": { "name": "Carrière", "cargo": "stone", "amount": 1 } },
{ "col": 5, "row": 3, "type": "demand", "demand": { "name": "Chantier", "cargo": "stone", "amount": 5 } }
]
},
"unlockedPieces": ["rook"],
"unlockedLevels": [{ "kind": "rook", "level": 1 }],
"stock": [
{ "kind": "pawn", "count": 2 },
{ "kind": "rook", "count": 3 }
]
}
]
}
""";
[Fact]
public void LoadValidCampaign_ParsesCorrectly()
{
var campaign = CampaignLoader.Load(ValidCampaignJson);
Assert.Equal("La Quête du Roi", campaign.Name);
Assert.Equal(4, campaign.InitialWidth);
Assert.Equal(4, campaign.InitialHeight);
Assert.Equal(2, campaign.Missions.Count);
}
[Fact]
public void LoadValidCampaign_Mission1_Correct()
{
var campaign = CampaignLoader.Load(ValidCampaignJson);
var m1 = campaign.Missions[0];
Assert.Equal(1, m1.Id);
Assert.Equal("Premier Convoi", m1.Name);
Assert.Equal(4, m1.TerrainPatch.NewWidth);
Assert.Equal(2, m1.TerrainPatch.Cells.Count);
Assert.Single(m1.UnlockedPieces);
Assert.Equal(PieceKind.Pawn, m1.UnlockedPieces[0]);
Assert.Single(m1.Stock);
Assert.Equal(6, m1.Stock[0].Count);
}
[Fact]
public void LoadValidCampaign_Mission2_TerrainExpands()
{
var campaign = CampaignLoader.Load(ValidCampaignJson);
var m2 = campaign.Missions[1];
Assert.Equal(6, m2.TerrainPatch.NewWidth);
Assert.Equal(4, m2.TerrainPatch.NewHeight);
var wallCell = m2.TerrainPatch.Cells.First(c => c.Type == CellType.Wall);
Assert.Equal(4, wallCell.Col);
Assert.Equal(1, wallCell.Row);
}
[Fact]
public void LoadCampaign_ShrinkingTerrain_Throws()
{
var badJson = """
{
"name": "Bad",
"initialWidth": 6,
"initialHeight": 6,
"missions": [
{
"id": 1, "name": "M1",
"terrainPatch": { "newWidth": 4, "newHeight": 4, "cells": [] },
"stock": []
}
]
}
""";
Assert.Throws<JsonException>(() => CampaignLoader.Load(badJson));
}
[Fact]
public void LoadCampaign_EmptyName_Throws()
{
var badJson = """
{
"name": "",
"initialWidth": 4,
"initialHeight": 4,
"missions": [{ "id": 1, "name": "M", "terrainPatch": { "newWidth": 4, "newHeight": 4, "cells": [] } }]
}
""";
Assert.Throws<JsonException>(() => CampaignLoader.Load(badJson));
}
[Fact]
public void LoadCampaign_NoMissions_Throws()
{
var badJson = """
{
"name": "Empty",
"initialWidth": 4,
"initialHeight": 4,
"missions": []
}
""";
Assert.Throws<JsonException>(() => CampaignLoader.Load(badJson));
}
[Fact]
public void LoadCampaign_TransformerCell_ParsesCorrectly()
{
var json = """
{
"name": "Forge Test",
"initialWidth": 5,
"initialHeight": 1,
"missions": [
{
"id": 1, "name": "M1",
"terrainPatch": {
"newWidth": 5, "newHeight": 1,
"cells": [
{ "col": 0, "row": 0, "type": "production", "production": { "name": "Scierie", "cargo": "wood", "amount": 4 } },
{ "col": 2, "row": 0, "type": "transformer", "transformer": { "name": "Forge", "inputCargo": "wood", "inputRequired": 2, "outputCargo": "tools", "outputAmount": 1 } },
{ "col": 4, "row": 0, "type": "demand", "demand": { "name": "Depot", "cargo": "tools", "amount": 3 } }
]
},
"stock": [{ "kind": "rook", "count": 3 }],
"unlockedPieces": ["rook"],
"unlockedLevels": [{ "kind": "rook", "level": 1 }]
}
]
}
""";
var campaign = CampaignLoader.Load(json);
var cells = campaign.Missions[0].TerrainPatch.Cells;
var transformerCell = cells.First(c => c.Type == CellType.Transformer);
Assert.NotNull(transformerCell.Transformer);
Assert.Equal("Forge", transformerCell.Transformer.Name);
Assert.Equal(CargoType.Wood, transformerCell.Transformer.InputCargo);
Assert.Equal(2, transformerCell.Transformer.InputRequired);
Assert.Equal(CargoType.Tools, transformerCell.Transformer.OutputCargo);
Assert.Equal(1, transformerCell.Transformer.OutputAmount);
}
[Fact]
public void LoadCampaign_NewCargoTypes_ParseCorrectly()
{
var json = """
{
"name": "Cargo Test",
"initialWidth": 3,
"initialHeight": 1,
"missions": [
{
"id": 1, "name": "M1",
"terrainPatch": {
"newWidth": 3, "newHeight": 1,
"cells": [
{ "col": 0, "row": 0, "type": "demand", "demand": { "name": "D1", "cargo": "arms", "amount": 1 } },
{ "col": 1, "row": 0, "type": "demand", "demand": { "name": "D2", "cargo": "gold", "amount": 1 } },
{ "col": 2, "row": 0, "type": "demand", "demand": { "name": "D3", "cargo": "tools", "amount": 1 } }
]
},
"stock": []
}
]
}
""";
var campaign = CampaignLoader.Load(json);
var cells = campaign.Missions[0].TerrainPatch.Cells;
Assert.Equal(CargoType.Arms, cells[0].Demand!.Cargo);
Assert.Equal(CargoType.Gold, cells[1].Demand!.Cargo);
Assert.Equal(CargoType.Tools, cells[2].Demand!.Cargo);
}
}

View file

@ -0,0 +1 @@
uid://boxlkyt1rnb6l

View file

@ -0,0 +1,135 @@
using Chessistics.Engine.Commands;
using Chessistics.Engine.Loading;
using Chessistics.Engine.Model;
using Chessistics.Engine.Simulation;
using Xunit;
namespace Chessistics.Tests.Loading;
/// <summary>
/// Validates campaign_01.json structural integrity:
/// no cell overlaps, unique building names, walls only on new cells.
/// </summary>
public class CampaignValidationTests
{
private static CampaignDef LoadCampaign()
=> CampaignLoader.LoadFromFile("../../../../Data/campaigns/campaign_01.json");
[Fact]
public void Campaign01_NoBuildingOverlaps()
{
var campaign = LoadCampaign();
var sim = new GameSim(campaign);
sim.ProcessCommand(new LoadCampaignCommand());
// Step through missions by manually applying patches and checking for conflicts
var state = BoardState.FromCampaign(campaign);
// Track all active buildings after each mission
for (int i = 0; i < campaign.Missions.Count; i++)
{
var mission = campaign.Missions[i];
state.ApplyTerrainPatch(mission.TerrainPatch, i);
// After each mission, verify no cell has multiple building types
foreach (var pos in state.Productions.Keys)
{
Assert.False(state.Demands.ContainsKey(pos),
$"Mission {i + 1}: Production and Demand overlap at {pos}");
Assert.False(state.Transformers.ContainsKey(pos),
$"Mission {i + 1}: Production and Transformer overlap at {pos}");
Assert.NotEqual(CellType.Wall, state.GetCell(pos));
}
foreach (var pos in state.Demands.Keys)
{
Assert.False(state.Transformers.ContainsKey(pos),
$"Mission {i + 1}: Demand and Transformer overlap at {pos}");
Assert.NotEqual(CellType.Wall, state.GetCell(pos));
}
foreach (var pos in state.Transformers.Keys)
{
Assert.NotEqual(CellType.Wall, state.GetCell(pos));
}
}
}
[Fact]
public void Campaign01_UniqueBuildingNames()
{
var campaign = LoadCampaign();
var state = BoardState.FromCampaign(campaign);
for (int i = 0; i < campaign.Missions.Count; i++)
{
state.ApplyTerrainPatch(campaign.Missions[i].TerrainPatch, i);
}
// Collect all building names
var names = new List<string>();
names.AddRange(state.Productions.Values.Select(p => p.Name));
names.AddRange(state.Demands.Values.Select(d => d.Name));
names.AddRange(state.Transformers.Values.Select(t => t.Name));
var duplicates = names.GroupBy(n => n).Where(g => g.Count() > 1).Select(g => g.Key).ToList();
Assert.Empty(duplicates);
}
[Fact]
public void Campaign01_WallsOnlyOnNewOrEmptyCells()
{
var campaign = LoadCampaign();
// Track which cells have buildings before each mission
var buildingCells = new HashSet<Coords>();
int prevWidth = campaign.InitialWidth;
int prevHeight = campaign.InitialHeight;
for (int i = 0; i < campaign.Missions.Count; i++)
{
var mission = campaign.Missions[i];
// Check walls in this mission's patch
foreach (var cell in mission.TerrainPatch.Cells)
{
if (cell.Type == CellType.Wall)
{
// Wall should be on a cell that was either:
// - Outside the previous board dimensions (new cell)
// - Not a building cell
bool isNewCell = cell.Col >= prevWidth || cell.Row >= prevHeight;
bool isBuilding = buildingCells.Contains(new Coords(cell.Col, cell.Row));
Assert.True(isNewCell || !isBuilding,
$"Mission {i + 1}: Wall at ({cell.Col},{cell.Row}) overwrites an existing building");
}
}
// Update building tracking
foreach (var cell in mission.TerrainPatch.Cells)
{
var coords = new Coords(cell.Col, cell.Row);
if (cell.Type is CellType.Production or CellType.Demand or CellType.Transformer)
buildingCells.Add(coords);
else
buildingCells.Remove(coords);
}
prevWidth = mission.TerrainPatch.NewWidth;
prevHeight = mission.TerrainPatch.NewHeight;
}
}
[Fact]
public void Campaign01_AllMissionsHaveFlavor()
{
var campaign = LoadCampaign();
for (int i = 0; i < campaign.Missions.Count; i++)
{
Assert.False(string.IsNullOrWhiteSpace(campaign.Missions[i].Flavor),
$"Mission {i + 1} ({campaign.Missions[i].Name}) has no flavor text");
}
}
}

View file

@ -0,0 +1 @@
uid://dle5bi0rtya8x

View file

@ -0,0 +1,112 @@
using Chessistics.Engine.Model;
using Xunit;
namespace Chessistics.Tests.Model;
public class TerrainPatchTests
{
[Fact]
public void TerrainPatch_DemandOverwritesProduction_ClearsProduction()
{
// Setup: board with a production at (2,0)
var state = BoardState.FromCampaign(new CampaignDef
{
Name = "Test", InitialWidth = 4, InitialHeight = 1,
Missions = [new MissionDef
{
Id = 1, Name = "M1",
TerrainPatch = new TerrainPatch
{
NewWidth = 4, NewHeight = 1,
Cells = [new PatchCell { Col = 2, Row = 0, Type = CellType.Production,
Production = new ProductionDef(new Coords(2, 0), "Prod", CargoType.Wood, 4) }]
},
Stock = []
}]
});
// Apply mission 0 terrain
state.ApplyTerrainPatch(state.Campaign!.CurrentMission.TerrainPatch, 0);
Assert.True(state.Productions.ContainsKey(new Coords(2, 0)));
// Now overwrite with a demand at same position
var patch = new TerrainPatch
{
NewWidth = 4, NewHeight = 1,
Cells = [new PatchCell { Col = 2, Row = 0, Type = CellType.Demand,
Demand = new DemandDef(new Coords(2, 0), "Demand", CargoType.Wood, 2) }]
};
state.ApplyTerrainPatch(patch, 1);
// Production should be gone, demand should exist
Assert.False(state.Productions.ContainsKey(new Coords(2, 0)));
Assert.True(state.Demands.ContainsKey(new Coords(2, 0)));
Assert.Equal(CellType.Demand, state.GetCell(new Coords(2, 0)));
}
[Fact]
public void TerrainPatch_WallRemovesPiecesOnCell()
{
var state = BoardState.FromCampaign(new CampaignDef
{
Name = "Test", InitialWidth = 4, InitialHeight = 2,
Missions = [new MissionDef
{
Id = 1, Name = "M1",
TerrainPatch = new TerrainPatch { NewWidth = 4, NewHeight = 2, Cells = [] },
Stock = [new PieceStock(PieceKind.Rook, 2)]
}]
});
state.ApplyTerrainPatch(state.Campaign!.CurrentMission.TerrainPatch, 0);
state.AddStock(state.Campaign.CurrentMission.Stock);
// Place a piece with start=(2,0), end=(3,0)
state.Pieces.Add(new PieceState(1, PieceKind.Rook, new Coords(2, 0), new Coords(3, 0), 1));
state.RemainingStock[PieceKind.Rook] = 1;
// Apply wall on (2,0) — should remove the piece
var patch = new TerrainPatch
{
NewWidth = 4, NewHeight = 2,
Cells = [new PatchCell { Col = 2, Row = 0, Type = CellType.Wall }]
};
state.ApplyTerrainPatch(patch, 1);
Assert.Empty(state.Pieces);
Assert.Equal(2, state.RemainingStock[PieceKind.Rook]); // returned to stock
}
[Fact]
public void TerrainPatch_ProductionOverwritesDemand_ClearsDemand()
{
var state = BoardState.FromCampaign(new CampaignDef
{
Name = "Test", InitialWidth = 4, InitialHeight = 1,
Missions = [new MissionDef
{
Id = 1, Name = "M1",
TerrainPatch = new TerrainPatch
{
NewWidth = 4, NewHeight = 1,
Cells = [new PatchCell { Col = 1, Row = 0, Type = CellType.Demand,
Demand = new DemandDef(new Coords(1, 0), "D", CargoType.Wood, 2) }]
},
Stock = []
}]
});
state.ApplyTerrainPatch(state.Campaign!.CurrentMission.TerrainPatch, 0);
Assert.True(state.Demands.ContainsKey(new Coords(1, 0)));
// Overwrite with production
var patch = new TerrainPatch
{
NewWidth = 4, NewHeight = 1,
Cells = [new PatchCell { Col = 1, Row = 0, Type = CellType.Production,
Production = new ProductionDef(new Coords(1, 0), "P", CargoType.Stone, 4) }]
};
state.ApplyTerrainPatch(patch, 1);
Assert.False(state.Demands.ContainsKey(new Coords(1, 0)));
Assert.True(state.Productions.ContainsKey(new Coords(1, 0)));
}
}

View file

@ -0,0 +1 @@
uid://kujovcfoy6j2

View file

@ -319,6 +319,32 @@ public class TransferResolverTests
Assert.DoesNotContain(events, e => e is CargoTransferredEvent ct && ct.GivingPieceId == 1 && ct.ReceivingPieceId == 2);
}
[Fact]
public void Production_Amount3_FeedsMultiplePieces()
{
var board = new BoardBuilder(4, 4)
.WithProduction(0, 0, "P", CargoType.Wood, amount: 3)
.WithDemand(3, 0, "D", CargoType.Wood, 10, 99)
.WithStock(PieceKind.Rook, 5)
.BuildState();
// Three pieces adjacent to production at (0,0): (1,0), (0,1)
var p1 = new PieceState(1, PieceKind.Rook, new Coords(1, 0), new Coords(2, 0), 0);
p1.CurrentCell = new Coords(1, 0);
var p2 = new PieceState(2, PieceKind.Rook, new Coords(0, 1), new Coords(0, 2), 1);
p2.CurrentCell = new Coords(0, 1);
board.Pieces.AddRange([p1, p2]);
board.ProductionBuffers[new Coords(0, 0)] = 3; // amount=3
var events = TransferResolver.ResolveTransfers(board);
// Both pieces should receive cargo (buffer had 3, 2 pieces adjacent)
Assert.Equal(CargoType.Wood, p1.Cargo);
Assert.Equal(CargoType.Wood, p2.Cargo);
Assert.Equal(1, board.ProductionBuffers[new Coords(0, 0)]); // 3 - 2 = 1 remaining
}
[Fact]
public void DemandPriority_OverPieceReceiver()
{

View file

@ -0,0 +1,308 @@
using Chessistics.Engine.Commands;
using Chessistics.Engine.Events;
using Chessistics.Engine.Model;
using Chessistics.Tests.Helpers;
using Xunit;
namespace Chessistics.Tests.Simulation;
public class CampaignTests
{
private static CampaignDef CreateTwOMissionCampaign()
{
return new CampaignDef
{
Name = "Test Campaign",
InitialWidth = 4,
InitialHeight = 4,
Missions =
[
new MissionDef
{
Id = 1,
Name = "Mission 1",
Description = "First mission",
TerrainPatch = new TerrainPatch
{
NewWidth = 4,
NewHeight = 4,
Cells =
[
new PatchCell { Col = 0, Row = 0, Type = CellType.Production,
Production = new ProductionDef(new Coords(0, 0), "Scierie", CargoType.Wood) },
new PatchCell { Col = 3, Row = 0, Type = CellType.Demand,
Demand = new DemandDef(new Coords(3, 0), "Depot", CargoType.Wood, 2) }
]
},
UnlockedPieces = [PieceKind.Pawn, PieceKind.Rook],
UnlockedLevels = [new PieceUpgrade(PieceKind.Pawn, 1), new PieceUpgrade(PieceKind.Rook, 1)],
Stock = [new PieceStock(PieceKind.Rook, 3)]
},
new MissionDef
{
Id = 2,
Name = "Mission 2",
Description = "Second mission — terrain expands east",
TerrainPatch = new TerrainPatch
{
NewWidth = 6,
NewHeight = 4,
Cells =
[
new PatchCell { Col = 4, Row = 0, Type = CellType.Empty },
new PatchCell { Col = 4, Row = 1, Type = CellType.Wall },
new PatchCell { Col = 5, Row = 0, Type = CellType.Production,
Production = new ProductionDef(new Coords(5, 0), "Carriere", CargoType.Stone) },
new PatchCell { Col = 5, Row = 3, Type = CellType.Demand,
Demand = new DemandDef(new Coords(5, 3), "Chantier", CargoType.Stone, 3) }
]
},
UnlockedPieces = [],
UnlockedLevels = [],
Stock = [new PieceStock(PieceKind.Rook, 2)]
}
]
};
}
[Fact]
public void LoadCampaign_InitializesBoard()
{
var campaign = CreateTwOMissionCampaign();
var sim = SimHelper.FromCampaign(campaign);
var events = sim.Sim.ProcessCommand(new LoadCampaignCommand());
Assert.Contains(events, e => e is CampaignLoadedEvent);
Assert.Contains(events, e => e is MissionStartedEvent ms && ms.MissionIndex == 0);
Assert.Contains(events, e => e is PieceUnlockedEvent pu && pu.Kind == PieceKind.Rook);
var snap = sim.Snapshot;
Assert.Equal(4, snap.Width);
Assert.Equal(4, snap.Height);
Assert.Equal(3, snap.RemainingStock[PieceKind.Rook]);
Assert.Equal(SimPhase.Paused, snap.Phase);
Assert.NotNull(snap.Campaign);
Assert.Equal(0, snap.Campaign.CurrentMissionIndex);
}
[Fact]
public void Campaign_CompleteMission1_AutoAdvances()
{
var campaign = CreateTwOMissionCampaign();
var sim = SimHelper.FromCampaign(campaign);
sim.Sim.ProcessCommand(new LoadCampaignCommand());
// Place rook to relay wood
sim.Place(PieceKind.Rook, (1, 0), (2, 0));
// Run — mission 1 completes and auto-advances to mission 2
var allEvents = sim.StepN(30);
Assert.Contains(allEvents, e => e is MissionCompleteEvent mc && mc.MissionIndex == 0);
Assert.Contains(allEvents, e => e is MissionStartedEvent ms && ms.MissionIndex == 1);
Assert.Contains(allEvents, e => e is TerrainExpandedEvent te && te.NewWidth == 6);
var snap = sim.Snapshot;
Assert.Equal(6, snap.Width);
Assert.Equal(4, snap.Height);
// Phase stays Paused (from StepSimulationCommand), NOT MissionComplete
Assert.Equal(SimPhase.Paused, snap.Phase);
Assert.Equal(1, snap.Campaign!.CurrentMissionIndex);
// Original rook is still in place
Assert.Single(snap.Pieces);
// Additional stock from mission 2 added
Assert.Equal(2 + 2, snap.RemainingStock[PieceKind.Rook]); // 2 leftover from M1 + 2 new
}
[Fact]
public void Campaign_PiecesRemainAfterAutoAdvance()
{
var campaign = CreateTwOMissionCampaign();
var sim = SimHelper.FromCampaign(campaign);
sim.Sim.ProcessCommand(new LoadCampaignCommand());
sim.Place(PieceKind.Rook, (1, 0), (2, 0));
// Run until auto-advance
sim.StepN(30);
// Piece is STILL on the board after auto-advancing
Assert.Single(sim.Snapshot.Pieces);
Assert.Equal(new Coords(1, 0), sim.Snapshot.Pieces[0].StartCell);
}
[Fact]
public void Campaign_PlacePieceDuringRunning()
{
var campaign = CreateTwOMissionCampaign();
var sim = SimHelper.FromCampaign(campaign);
sim.Sim.ProcessCommand(new LoadCampaignCommand());
sim.Resume(); // Paused → Running
var events = sim.Place(PieceKind.Rook, (1, 0), (2, 0));
Assert.IsType<PiecePlacedEvent>(events[0]);
}
[Fact]
public void Campaign_RemovePieceDuringRunning()
{
var campaign = CreateTwOMissionCampaign();
var sim = SimHelper.FromCampaign(campaign);
sim.Sim.ProcessCommand(new LoadCampaignCommand());
sim.Place(PieceKind.Rook, (1, 0), (2, 0));
sim.Resume(); // Running
var events = sim.Remove(1);
Assert.IsType<PieceRemovedEvent>(events[0]);
Assert.Equal(3, sim.Snapshot.RemainingStock[PieceKind.Rook]);
}
[Fact]
public void Campaign_CollisionReturnsPieceToStock()
{
// Two rooks heading to same cell → collision
var campaign = new CampaignDef
{
Name = "Collision Test",
InitialWidth = 4,
InitialHeight = 4,
Missions =
[
new MissionDef
{
Id = 1, Name = "M1",
TerrainPatch = new TerrainPatch
{
NewWidth = 4, NewHeight = 4,
Cells =
[
new PatchCell { Col = 0, Row = 0, Type = CellType.Production,
Production = new ProductionDef(new Coords(0, 0), "P", CargoType.Wood) },
new PatchCell { Col = 3, Row = 0, Type = CellType.Demand,
Demand = new DemandDef(new Coords(3, 0), "D", CargoType.Wood, 5) }
]
},
UnlockedPieces = [PieceKind.Rook, PieceKind.Queen],
UnlockedLevels = [new PieceUpgrade(PieceKind.Rook, 1), new PieceUpgrade(PieceKind.Queen, 1)],
Stock = [new PieceStock(PieceKind.Rook, 2), new PieceStock(PieceKind.Queen, 1)]
}
]
};
var sim = SimHelper.FromCampaign(campaign);
sim.Sim.ProcessCommand(new LoadCampaignCommand());
// Rook and Queen both pass through (1,0): collision!
// Rook: (0,0) ↔ (2,0), Queen: (2,0) ↔ (0,0) — they swap cells each turn, meeting at...
// Actually let's make them collide: same end cell
sim.Place(PieceKind.Rook, (0, 1), (1, 1));
sim.Place(PieceKind.Queen, (2, 1), (1, 1)); // same end cell → collision after first step
var stockBefore = sim.Snapshot.RemainingStock[PieceKind.Rook];
var events = sim.Step();
// Queen wins (status 7 vs 5), rook returns to stock
Assert.Contains(events, e => e is PieceReturnedToStockEvent ret && ret.Kind == PieceKind.Rook);
// Rook returned to stock
var stockAfter = sim.Snapshot.RemainingStock[PieceKind.Rook];
Assert.Equal(stockBefore + 1, stockAfter);
// Auto-pause on collision
Assert.Equal(SimPhase.Paused, sim.Snapshot.Phase);
}
[Fact]
public void Campaign_ManualAdvanceWithoutComplete_Rejected()
{
var campaign = CreateTwOMissionCampaign();
var sim = SimHelper.FromCampaign(campaign);
sim.Sim.ProcessCommand(new LoadCampaignCommand());
// AdvanceMissionCommand requires MissionComplete phase
var events = sim.AdvanceMission();
Assert.IsType<CommandRejectedEvent>(events[0]);
}
[Fact]
public void Campaign_LastMission_SetsMissionCompletePhase()
{
// Single-mission campaign — completing it should set MissionComplete phase
var campaign = new CampaignDef
{
Name = "Short", InitialWidth = 3, InitialHeight = 1,
Missions =
[
new MissionDef
{
Id = 1, Name = "Only",
TerrainPatch = new TerrainPatch
{
NewWidth = 3, NewHeight = 1,
Cells =
[
new PatchCell { Col = 0, Row = 0, Type = CellType.Production,
Production = new ProductionDef(new Coords(0, 0), "P", CargoType.Wood) },
new PatchCell { Col = 2, Row = 0, Type = CellType.Demand,
Demand = new DemandDef(new Coords(2, 0), "D", CargoType.Wood, 1) }
]
},
UnlockedPieces = [PieceKind.Rook],
UnlockedLevels = [new PieceUpgrade(PieceKind.Rook, 1)],
Stock = [new PieceStock(PieceKind.Rook, 1)]
}
]
};
var sim = SimHelper.FromCampaign(campaign);
sim.Sim.ProcessCommand(new LoadCampaignCommand());
sim.Place(PieceKind.Rook, (1, 0), (2, 0));
var allEvents = sim.StepN(10);
// Last mission → MissionComplete phase (no auto-advance)
Assert.Contains(allEvents, e => e is MissionCompleteEvent);
Assert.DoesNotContain(allEvents, e => e is MissionStartedEvent);
Assert.Equal(SimPhase.MissionComplete, sim.Snapshot.Phase);
}
[Fact]
public void Campaign_UnlockedPiecesEnforced()
{
var campaign = CreateTwOMissionCampaign();
var sim = SimHelper.FromCampaign(campaign);
sim.Sim.ProcessCommand(new LoadCampaignCommand());
// Bishop not unlocked — placement rejected
// First, add bishop to stock manually for this test
// Actually, the stock doesn't have bishops. Let's check that even if stock existed, unlock blocks it.
// The campaign only unlocks Pawn and Rook. No bishop stock either.
// Let's just verify the unlock set is correct.
var snap = sim.Snapshot;
Assert.Contains(PieceKind.Rook, snap.Campaign!.AvailablePieceKinds);
Assert.Contains(PieceKind.Pawn, snap.Campaign!.AvailablePieceKinds);
Assert.DoesNotContain(PieceKind.Bishop, snap.Campaign!.AvailablePieceKinds);
}
[Fact]
public void MovePiece_UpdatesPosition()
{
var campaign = CreateTwOMissionCampaign();
var sim = SimHelper.FromCampaign(campaign);
sim.Sim.ProcessCommand(new LoadCampaignCommand());
sim.Place(PieceKind.Rook, (1, 0), (2, 0));
var events = sim.Sim.ProcessCommand(new MovePieceCommand(1, new Coords(1, 1), new Coords(2, 1)));
Assert.Contains(events, e => e is PieceMovedByPlayerEvent mv
&& mv.OldStart == new Coords(1, 0) && mv.NewStart == new Coords(1, 1));
var snap = sim.Snapshot;
Assert.Equal(new Coords(1, 1), snap.Pieces[0].StartCell);
Assert.Equal(new Coords(2, 1), snap.Pieces[0].EndCell);
Assert.Equal(new Coords(1, 1), snap.Pieces[0].CurrentCell);
}
}

View file

@ -0,0 +1 @@
uid://bxt85xb77h4jn

View file

@ -8,10 +8,8 @@ namespace Chessistics.Tests.Simulation;
public class FullLevelTests
{
[Fact]
public void Level1_PremierConvoi_Victory()
public void Level1_PremierConvoi_MissionComplete()
{
// GDD Level 1: 4x4, Scierie(0,0) → Depot(3,0), 3 Rooks
// Solution: single rook relay at (1,0)↔(2,0)
var level = new BoardBuilder(4, 4)
.WithProduction(0, 0, "Scierie", CargoType.Wood)
.WithDemand(3, 0, "Depot Royal", CargoType.Wood, 3, 30)
@ -20,23 +18,14 @@ public class FullLevelTests
var sim = SimHelper.FromLevel(level);
sim.Place(PieceKind.Rook, (1, 0), (2, 0));
sim.Start();
var allEvents = sim.StepN(30);
Assert.Contains(allEvents, e => e is VictoryEvent);
Assert.Contains(allEvents, e => e is MissionCompleteEvent);
}
[Fact]
public void Level2_DeuxClients_Victory()
public void Level2_DeuxClients_MissionComplete()
{
// GDD Level 2: 6x6, Scierie(0,0), Depot Royal(5,0), Caserne(5,4)
// Stock: 6 Rooks + 1 Bishop (fixed from GDD's 4R+1B — insufficient)
//
// Solution requires two routes from single source:
// Route 1 → (5,0): A(1,0↔2,0), B(2,0↔4,0)
// Route 2 → (5,4): C(0,1↔0,2), D(0,2↔2,2), E(2,2↔3,2),
// Bishop(3,2↔4,3), G(4,3↔5,3)
// Total needed: 6 Rooks + 1 Bishop
var level = new BoardBuilder(6, 6)
.WithProduction(0, 0, "Scierie", CargoType.Wood)
.WithDemand(5, 0, "Depot Royal", CargoType.Wood, 2, 50)
@ -53,35 +42,21 @@ public class FullLevelTests
// Route 2: up then right → demand (5,4)
sim.Place(PieceKind.Rook, (0, 1), (0, 2));
sim.Place(PieceKind.Rook, (0, 2), (2, 2));
// 5th rook — stock exhausted at 4!
var events5 = sim.Place(PieceKind.Rook, (2, 2), (3, 2));
Assert.DoesNotContain(events5, e => e is PlacementRejectedEvent);
sim.Place(PieceKind.Bishop, (3, 2), (4, 3));
// 6th rook needed but only 4 in stock
var events6 = sim.Place(PieceKind.Rook, (4, 3), (5, 3));
Assert.DoesNotContain(events6, e => e is PlacementRejectedEvent);
sim.Start();
var allEvents = sim.StepN(60);
Assert.Contains(allEvents, e => e is VictoryEvent);
Assert.Contains(allEvents, e => e is MissionCompleteEvent);
}
[Fact]
public void Level3_LeCol_Victory()
public void Level3_LeCol_MissionComplete()
{
// GDD Level 3: 6x6, L-shaped wall, 2 cargo types, knights jump obstacle
// Stock: 8 Rooks + 2 Knights (fixed from GDD's 4R+1B+2K)
//
// CargoFilter (Phase 2) prevents cross-route contamination:
// pieces auto-inherit their production's cargo type via relay chain.
//
// Route Wood (0,0→5,5): R1(0,1↔1,1), K1(1,1↔3,2),
// R2(3,2↔4,2), R3(4,2↔5,2), R4(5,2↔5,3), R5(5,3↔5,4)
// Route Stone (5,0→0,5): S1(4,0↔3,0), S2(3,0↔2,0),
// K2(2,0↔1,2), S3(1,2↔1,3), S4(1,3↔1,4), S5(1,4↔0,4)
// Total: 10 Rooks + 2 Knights
var level = new BoardBuilder(6, 6)
.WithProduction(0, 0, "Scierie", CargoType.Wood)
.WithProduction(5, 0, "Carriere", CargoType.Stone)
@ -109,13 +84,12 @@ public class FullLevelTests
sim.Place(PieceKind.Rook, (1, 3), (1, 4));
sim.Place(PieceKind.Rook, (1, 4), (0, 4));
sim.Start();
var allEvents = sim.StepN(80);
Assert.Contains(allEvents, e => e is VictoryEvent);
Assert.Contains(allEvents, e => e is MissionCompleteEvent);
}
[Fact]
public void Level1_InsufficientPieces_NoVictory()
public void Level1_InsufficientPieces_NoMissionComplete()
{
var level = new BoardBuilder(4, 4)
.WithProduction(0, 0, "Scierie", CargoType.Wood)
@ -125,11 +99,10 @@ public class FullLevelTests
var sim = SimHelper.FromLevel(level);
sim.Place(PieceKind.Rook, (1, 1), (2, 1));
sim.Start();
var allEvents = sim.StepN(8);
Assert.DoesNotContain(allEvents, e => e is VictoryEvent);
Assert.Contains(allEvents, e => e is DeadlineExpiredEvent);
// No deadline concept anymore — just no mission complete
Assert.DoesNotContain(allEvents, e => e is MissionCompleteEvent);
}
}

View file

@ -44,41 +44,27 @@ public class GameSimTests
}
[Fact]
public void PlaceDuringRunning_Rejected()
public void PlaceDuringRunning_Succeeds()
{
// In the new system, placement works in any phase
var sim = CreateLevel1Sim();
sim.Place(PieceKind.Rook, (0, 0), (1, 0));
sim.Start();
sim.Resume(); // Paused → Running
var events = sim.Place(PieceKind.Rook, (0, 1), (1, 1));
Assert.IsType<CommandRejectedEvent>(events[0]);
Assert.IsType<PiecePlacedEvent>(events[0]);
}
[Fact]
public void StartWithNoPieces_Rejected()
{
var sim = CreateLevel1Sim();
var events = sim.Start();
Assert.IsType<CommandRejectedEvent>(events[0]);
}
[Fact]
public void RemoveDuringRunning_Rejected()
public void RemoveDuringRunning_Succeeds()
{
// In the new system, removal works in any phase
var sim = CreateLevel1Sim();
sim.Place(PieceKind.Rook, (0, 0), (1, 0));
sim.Start();
sim.Resume(); // Paused → Running
var events = sim.Remove(1);
Assert.IsType<CommandRejectedEvent>(events[0]);
}
[Fact]
public void StopDuringEdit_Rejected()
{
var sim = CreateLevel1Sim();
var events = sim.Stop();
Assert.IsType<CommandRejectedEvent>(events[0]);
Assert.IsType<PieceRemovedEvent>(events[0]);
}
[Fact]
@ -86,7 +72,6 @@ public class GameSimTests
{
var sim = CreateLevel1Sim();
sim.Place(PieceKind.Rook, (0, 0), (2, 0));
sim.Start();
// Step 1: piece moves from (0,0) to (2,0)
var events1 = sim.Step();
@ -105,16 +90,11 @@ public class GameSimTests
public void ChainedPieces_TransferCargo()
{
var sim = CreateLevel1Sim();
// Piece A: (0,0) → (1,0), Piece B: (2,0) → (3,0)
// Adjacent at (1,0)↔(2,0) when A is at end and B is at start
sim.Place(PieceKind.Rook, (0, 0), (1, 0));
sim.Place(PieceKind.Rook, (2, 0), (3, 0));
sim.Start();
// Run until we see a cargo transfer between pieces
var allEvents = sim.StepN(20);
// Should have production events and cargo transfers
Assert.Contains(allEvents, e => e is CargoProducedEvent);
Assert.Contains(allEvents, e => e is CargoTransferredEvent);
}
@ -125,18 +105,15 @@ public class GameSimTests
var sim = CreateLevel1Sim();
sim.Place(PieceKind.Rook, (0, 0), (1, 0));
sim.Start();
var allEvents = sim.StepN(6);
var prodEvents = allEvents.OfType<CargoProducedEvent>().ToList();
// Production fires every turn
Assert.Equal(6, prodEvents.Count);
}
[Fact]
public void Victory_WhenAllDemandsMet()
public void MissionComplete_WhenAllDemandsMet()
{
// Tiny level: prod adjacent to demand, just need one piece to relay
var level = new BoardBuilder(3, 1)
.WithProduction(0, 0, "P", CargoType.Wood)
.WithDemand(2, 0, "D", CargoType.Wood, 1, 30)
@ -145,59 +122,28 @@ public class GameSimTests
var sim = SimHelper.FromLevel(level);
sim.Place(PieceKind.Rook, (1, 0), (2, 0));
sim.Start();
// Run enough turns for production → piece → demand
var allEvents = sim.StepN(10);
Assert.Contains(allEvents, e => e is VictoryEvent);
Assert.Contains(allEvents, e => e is MissionCompleteEvent);
}
[Fact]
public void Defeat_WhenDeadlineExpires()
public void InitialPhase_IsPaused()
{
// Demand with very tight deadline, piece placed far from demand
var level = new BoardBuilder(4, 4)
.WithProduction(0, 0, "P", CargoType.Wood)
.WithDemand(3, 3, "D", CargoType.Wood, 10, 3) // need 10 in 3 turns — impossible
.WithStock(PieceKind.Rook, 3)
.Build();
var sim = SimHelper.FromLevel(level);
var sim = CreateLevel1Sim();
var snap = sim.Snapshot;
Assert.Equal(SimPhase.Paused, snap.Phase);
}
[Fact]
public void StepFromPaused_Works()
{
var sim = CreateLevel1Sim();
sim.Place(PieceKind.Rook, (1, 0), (2, 0));
sim.Start();
var allEvents = sim.StepN(5);
Assert.Contains(allEvents, e => e is DeadlineExpiredEvent);
}
[Fact]
public void StopResetsState()
{
var sim = CreateLevel1Sim();
sim.Place(PieceKind.Rook, (0, 0), (1, 0));
sim.Start();
sim.StepN(5);
sim.Stop();
var snap = sim.Snapshot;
Assert.Equal(SimPhase.Edit, snap.Phase);
Assert.Equal(0, snap.TurnNumber);
// Pieces should be back at start cells
Assert.All(snap.Pieces, p => Assert.Equal(p.StartCell, p.CurrentCell));
}
[Fact]
public void ResetClearsEverything()
{
var sim = CreateLevel1Sim();
sim.Place(PieceKind.Rook, (0, 0), (1, 0));
sim.Reset();
var snap = sim.Snapshot;
Assert.Equal(SimPhase.Edit, snap.Phase);
Assert.Empty(snap.Pieces);
Assert.Equal(3, snap.RemainingStock[PieceKind.Rook]);
// Step directly from Paused
var events = sim.Step();
Assert.Contains(events, e => e is TurnStartedEvent);
}
}

View file

@ -0,0 +1,247 @@
using Chessistics.Engine.Commands;
using Chessistics.Engine.Events;
using Chessistics.Engine.Model;
using Chessistics.Tests.Helpers;
using Xunit;
namespace Chessistics.Tests.Simulation;
/// <summary>
/// Regression tests for SimPhase transitions.
/// Bug: StepSimulationCommand always set phase to Paused, even during auto-play (Running).
/// </summary>
public class PhaseTests
{
private static SimHelper CreateSimpleRunnable()
{
var campaign = new CampaignDef
{
Name = "Phase Test", InitialWidth = 4, InitialHeight = 1,
Missions =
[
new MissionDef
{
Id = 1, Name = "M1",
TerrainPatch = new TerrainPatch
{
NewWidth = 4, NewHeight = 1,
Cells =
[
new PatchCell { Col = 0, Row = 0, Type = CellType.Production,
Production = new ProductionDef(new Coords(0, 0), "P", CargoType.Wood, 4) },
new PatchCell { Col = 3, Row = 0, Type = CellType.Demand,
Demand = new DemandDef(new Coords(3, 0), "D", CargoType.Wood, 99) }
]
},
UnlockedPieces = [PieceKind.Rook],
UnlockedLevels = [new PieceUpgrade(PieceKind.Rook, 1)],
Stock = [new PieceStock(PieceKind.Rook, 2)]
}
]
};
var sim = SimHelper.FromCampaign(campaign);
sim.Sim.ProcessCommand(new LoadCampaignCommand());
sim.Place(PieceKind.Rook, (1, 0), (2, 0));
return sim;
}
[Fact]
public void ManualStep_FromPaused_RemainsInPaused()
{
var sim = CreateSimpleRunnable();
Assert.Equal(SimPhase.Paused, sim.Snapshot.Phase);
sim.Step();
Assert.Equal(SimPhase.Paused, sim.Snapshot.Phase);
}
[Fact]
public void AutoPlayStep_FromRunning_StaysRunning()
{
var sim = CreateSimpleRunnable();
// Resume → Running, then step (simulates auto-play timer)
sim.Resume();
Assert.Equal(SimPhase.Running, sim.Snapshot.Phase);
sim.Step();
// Phase should stay Running — this was the bug (used to revert to Paused)
Assert.Equal(SimPhase.Running, sim.Snapshot.Phase);
}
[Fact]
public void AutoPlayStep_MultipleSteps_StaysRunning()
{
var sim = CreateSimpleRunnable();
sim.Resume();
// Multiple consecutive steps from Running should all stay Running
for (int i = 0; i < 5; i++)
{
sim.Step();
Assert.Equal(SimPhase.Running, sim.Snapshot.Phase);
}
}
[Fact]
public void AutoPlayStep_CollisionCausesPause()
{
var campaign = new CampaignDef
{
Name = "Collision Phase Test", InitialWidth = 4, InitialHeight = 2,
Missions =
[
new MissionDef
{
Id = 1, Name = "M1",
TerrainPatch = new TerrainPatch
{
NewWidth = 4, NewHeight = 2,
Cells =
[
new PatchCell { Col = 0, Row = 0, Type = CellType.Production,
Production = new ProductionDef(new Coords(0, 0), "P", CargoType.Wood, 4) },
new PatchCell { Col = 3, Row = 0, Type = CellType.Demand,
Demand = new DemandDef(new Coords(3, 0), "D", CargoType.Wood, 99) }
]
},
UnlockedPieces = [PieceKind.Rook],
UnlockedLevels = [new PieceUpgrade(PieceKind.Rook, 1)],
Stock = [new PieceStock(PieceKind.Rook, 3)]
}
]
};
var sim = SimHelper.FromCampaign(campaign);
sim.Sim.ProcessCommand(new LoadCampaignCommand());
// Two rooks with same end cell → collision on first step
sim.Place(PieceKind.Rook, (0, 1), (1, 1));
sim.Place(PieceKind.Rook, (2, 1), (1, 1));
sim.Resume();
Assert.Equal(SimPhase.Running, sim.Snapshot.Phase);
var events = sim.Step();
// Collision should auto-pause even from Running
Assert.Contains(events, e => e is PieceReturnedToStockEvent);
Assert.Equal(SimPhase.Paused, sim.Snapshot.Phase);
}
[Fact]
public void AutoPlayStep_LastMissionComplete_SetsMissionComplete()
{
var campaign = new CampaignDef
{
Name = "Last Mission Phase Test", InitialWidth = 3, InitialHeight = 1,
Missions =
[
new MissionDef
{
Id = 1, Name = "M1",
TerrainPatch = new TerrainPatch
{
NewWidth = 3, NewHeight = 1,
Cells =
[
new PatchCell { Col = 0, Row = 0, Type = CellType.Production,
Production = new ProductionDef(new Coords(0, 0), "P", CargoType.Wood, 4) },
new PatchCell { Col = 2, Row = 0, Type = CellType.Demand,
Demand = new DemandDef(new Coords(2, 0), "D", CargoType.Wood, 1) }
]
},
UnlockedPieces = [PieceKind.Rook],
UnlockedLevels = [new PieceUpgrade(PieceKind.Rook, 1)],
Stock = [new PieceStock(PieceKind.Rook, 1)]
}
]
};
var sim = SimHelper.FromCampaign(campaign);
sim.Sim.ProcessCommand(new LoadCampaignCommand());
sim.Place(PieceKind.Rook, (1, 0), (2, 0));
// Running auto-play → last mission completes → MissionComplete phase
sim.Resume();
Assert.Equal(SimPhase.Running, sim.Snapshot.Phase);
// Step until mission complete
for (int i = 0; i < 20; i++)
{
sim.Step();
if (sim.Snapshot.Phase == SimPhase.MissionComplete)
break;
}
Assert.Equal(SimPhase.MissionComplete, sim.Snapshot.Phase);
}
[Fact]
public void AutoPlayStep_NonLastMissionComplete_AutoAdvances_StaysRunning()
{
var campaign = new CampaignDef
{
Name = "Auto-Advance Phase Test", InitialWidth = 3, InitialHeight = 1,
Missions =
[
new MissionDef
{
Id = 1, Name = "M1",
TerrainPatch = new TerrainPatch
{
NewWidth = 3, NewHeight = 1,
Cells =
[
new PatchCell { Col = 0, Row = 0, Type = CellType.Production,
Production = new ProductionDef(new Coords(0, 0), "P", CargoType.Wood, 4) },
new PatchCell { Col = 2, Row = 0, Type = CellType.Demand,
Demand = new DemandDef(new Coords(2, 0), "D", CargoType.Wood, 1) }
]
},
UnlockedPieces = [PieceKind.Rook],
UnlockedLevels = [new PieceUpgrade(PieceKind.Rook, 1)],
Stock = [new PieceStock(PieceKind.Rook, 2)]
},
new MissionDef
{
Id = 2, Name = "M2",
TerrainPatch = new TerrainPatch
{
NewWidth = 5, NewHeight = 1,
Cells =
[
new PatchCell { Col = 4, Row = 0, Type = CellType.Demand,
Demand = new DemandDef(new Coords(4, 0), "D2", CargoType.Wood, 99) }
]
},
UnlockedPieces = [],
UnlockedLevels = [],
Stock = [new PieceStock(PieceKind.Rook, 1)]
}
]
};
var sim = SimHelper.FromCampaign(campaign);
sim.Sim.ProcessCommand(new LoadCampaignCommand());
sim.Place(PieceKind.Rook, (1, 0), (2, 0));
sim.Resume();
// Step until mission 1 completes and auto-advances
bool advanced = false;
for (int i = 0; i < 20; i++)
{
var events = sim.Step();
if (events.Any(e => e is MissionStartedEvent ms && ms.MissionIndex == 1))
{
advanced = true;
// Phase should still be Running after auto-advance
Assert.Equal(SimPhase.Running, sim.Snapshot.Phase);
break;
}
}
Assert.True(advanced, "Mission 1 should have auto-advanced to Mission 2");
Assert.Equal(1, sim.Snapshot.Campaign!.CurrentMissionIndex);
}
}

View file

@ -0,0 +1 @@
uid://dxv44w3l5rw66

View file

@ -0,0 +1,181 @@
using Chessistics.Engine.Commands;
using Chessistics.Engine.Events;
using Chessistics.Engine.Model;
using Chessistics.Tests.Helpers;
using Xunit;
namespace Chessistics.Tests.Simulation;
public class QuickSaveTests
{
private SimHelper CreateSim()
{
var level = new BoardBuilder(4, 4)
.WithProduction(0, 0, "Scierie", CargoType.Wood, amount: 1)
.WithDemand(3, 0, "Depot", CargoType.Wood, 3, 30)
.WithStock(PieceKind.Rook, 3)
.Build();
return SimHelper.FromLevel(level);
}
[Fact]
public void QuickSave_ReturnsSavedEvent()
{
var sim = CreateSim();
var events = sim.Sim.QuickSave();
Assert.Single(events);
Assert.IsType<StateSavedEvent>(events[0]);
}
[Fact]
public void QuickLoad_WithoutSave_ReturnsEmpty()
{
var sim = CreateSim();
var events = sim.Sim.QuickLoad();
Assert.Empty(events);
}
[Fact]
public void QuickLoad_AfterSave_EmitsRestoredEvent()
{
var sim = CreateSim();
sim.Sim.QuickSave();
var events = sim.Sim.QuickLoad();
Assert.Single(events);
var restored = Assert.IsType<StateRestoredEvent>(events[0]);
Assert.NotNull(restored.Snapshot);
}
[Fact]
public void Save_MutateState_Load_RestoresPreviousState()
{
var sim = CreateSim();
// Place a piece, then save
sim.Place(PieceKind.Rook, (0, 0), (1, 0));
sim.Sim.QuickSave();
var beforeSnap = sim.Snapshot;
Assert.Single(beforeSnap.Pieces);
Assert.Equal(3 - 1, beforeSnap.RemainingStock[PieceKind.Rook]);
// Mutate: place another piece, step simulation
sim.Place(PieceKind.Rook, (0, 1), (1, 1));
sim.Step();
sim.Step();
var dirtySnap = sim.Snapshot;
Assert.Equal(2, dirtySnap.Pieces.Count);
Assert.Equal(2, dirtySnap.TurnNumber);
// Load: should match beforeSnap
sim.Sim.QuickLoad();
var afterSnap = sim.Snapshot;
Assert.Single(afterSnap.Pieces);
Assert.Equal(0, afterSnap.TurnNumber);
Assert.Equal(3 - 1, afterSnap.RemainingStock[PieceKind.Rook]);
Assert.Equal(beforeSnap.Pieces[0].Id, afterSnap.Pieces[0].Id);
Assert.Equal(beforeSnap.Pieces[0].StartCell, afterSnap.Pieces[0].StartCell);
}
[Fact]
public void Load_IsIndependent_FromFurtherChanges()
{
// Saving should not alias; mutating state after save must not affect the save.
var sim = CreateSim();
sim.Place(PieceKind.Rook, (0, 0), (1, 0));
sim.Sim.QuickSave();
// Mutate after save
sim.Place(PieceKind.Rook, (0, 1), (1, 1));
sim.Step();
// Load should restore to 1 piece, turn 0
sim.Sim.QuickLoad();
var snap = sim.Snapshot;
Assert.Single(snap.Pieces);
Assert.Equal(0, snap.TurnNumber);
// Mutate again, load again — save still usable
sim.Place(PieceKind.Rook, (0, 2), (1, 2));
sim.Sim.QuickLoad();
snap = sim.Snapshot;
Assert.Single(snap.Pieces);
}
[Fact]
public void QuickSave_PreservesCampaignProgression()
{
var campaign = CampaignBuilder();
var sim = SimHelper.FromCampaign(campaign);
sim.Sim.ProcessCommand(new LoadCampaignCommand());
sim.Place(PieceKind.Pawn, (0, 0), (1, 0));
sim.Sim.QuickSave();
var before = sim.Snapshot;
Assert.Equal(0, before.Campaign!.CurrentMissionIndex);
// Mutate
sim.Step();
sim.Step();
sim.Sim.QuickLoad();
var after = sim.Snapshot;
Assert.Equal(before.Campaign!.CurrentMissionIndex, after.Campaign!.CurrentMissionIndex);
Assert.Equal(before.Pieces.Count, after.Pieces.Count);
Assert.Equal(0, after.TurnNumber);
}
[Fact]
public void MultipleSlots_AreIndependent()
{
var sim = CreateSim();
sim.Place(PieceKind.Rook, (0, 0), (1, 0));
sim.Sim.QuickSave(slot: 1);
sim.Place(PieceKind.Rook, (0, 1), (1, 1));
sim.Sim.QuickSave(slot: 2);
// Slot 1 should have 1 piece; slot 2 should have 2
sim.Sim.QuickLoad(slot: 1);
Assert.Single(sim.Snapshot.Pieces);
sim.Sim.QuickLoad(slot: 2);
Assert.Equal(2, sim.Snapshot.Pieces.Count);
}
private static CampaignDef CampaignBuilder()
{
return new CampaignDef
{
Name = "TestCampaign",
InitialWidth = 4,
InitialHeight = 4,
Missions = new List<MissionDef>
{
new()
{
Id = 1,
Name = "M1",
TerrainPatch = new TerrainPatch
{
NewWidth = 4,
NewHeight = 4,
Cells = new List<PatchCell>
{
new() { Col = 0, Row = 0, Type = CellType.Production,
Production = new ProductionDef(new Coords(0, 0), "S", CargoType.Wood, 1) },
new() { Col = 3, Row = 0, Type = CellType.Demand,
Demand = new DemandDef(new Coords(3, 0), "D", CargoType.Wood, 3) }
}
},
UnlockedPieces = new List<PieceKind> { PieceKind.Pawn },
UnlockedLevels = new List<PieceUpgrade> { new(PieceKind.Pawn, 1) },
Stock = new List<PieceStock> { new(PieceKind.Pawn, 4) }
}
}
};
}
}

View file

@ -0,0 +1,99 @@
using Chessistics.Engine.Commands;
using Chessistics.Engine.Events;
using Chessistics.Engine.Model;
using Chessistics.Tests.Helpers;
using Xunit;
namespace Chessistics.Tests.Simulation;
public class RecurringDemandTests
{
private SimHelper BuildRecurringLevel()
{
var level = new LevelDef
{
Width = 3,
Height = 1,
Productions = new List<ProductionDef>
{
new(new Coords(0, 0), "Scierie", CargoType.Wood, 1)
},
Demands = new List<DemandDef>
{
new(new Coords(2, 0), "Ville", CargoType.Wood, Amount: 1,
Deadline: 0, ConsumptionPerTurn: 1, SustainTurns: 3)
},
Walls = new List<Coords>(),
Stock = new List<PieceStock>
{
new(PieceKind.Rook, 1)
}
};
return SimHelper.FromLevel(level);
}
[Fact]
public void RecurringDemand_WithoutSupply_EntersShortage()
{
var sim = BuildRecurringLevel();
// No piece placed — demand never fed, but doesn't consume anything
// it doesn't have. The first turn should already flag shortage.
var events = sim.Step();
Assert.Contains(events, e => e is DemandShortageStartedEvent);
// SustainedTurns should stay 0 while in shortage
var demand = sim.Snapshot.Demands[0];
Assert.False(demand.IsSatisfied);
}
[Fact]
public void RecurringDemand_BufferFeedsConsumption()
{
// Direct unit-style test: start a recurring demand with a filled
// buffer and confirm consumption + sustain counter tick correctly.
var demand = new DemandState(
new DemandDef(new Coords(0, 0), "V", CargoType.Wood, Amount: 0,
Deadline: 0, ConsumptionPerTurn: 1, SustainTurns: 3))
{
Buffer = 5
};
// Simulate 3 turns of consumption with no deliveries: should not
// shortage (buffer covers), SustainedTurns accrues.
for (int i = 0; i < 3; i++)
{
var consumed = System.Math.Min(demand.Buffer, demand.Definition.ConsumptionPerTurn);
demand.Buffer -= consumed;
if (demand.Buffer <= 0)
{
demand.InShortage = true;
demand.SustainedTurns = 0;
}
else
{
demand.InShortage = false;
demand.SustainedTurns++;
}
}
Assert.False(demand.InShortage);
Assert.Equal(3, demand.SustainedTurns);
Assert.True(demand.IsSatisfied);
}
[Fact]
public void ClassicDemand_NotRecurring_BehavesAsBefore()
{
var level = new BoardBuilder(3, 1)
.WithProduction(0, 0, "S", CargoType.Wood)
.WithDemand(2, 0, "D", CargoType.Wood, 2, 20)
.WithStock(PieceKind.Rook, 1)
.Build();
var sim = SimHelper.FromLevel(level);
sim.Place(PieceKind.Rook, (1, 0), (2, 0));
var events = sim.StepN(20);
Assert.DoesNotContain(events, e => e is DemandShortageStartedEvent);
Assert.Contains(events, e => e is MissionCompleteEvent);
}
}

View file

@ -1,3 +1,4 @@
using Chessistics.Engine.Commands;
using Chessistics.Engine.Events;
using Chessistics.Engine.Model;
using Chessistics.Tests.Helpers;
@ -7,17 +8,13 @@ namespace Chessistics.Tests.Simulation;
/// <summary>
/// End-to-end solvability tests: each test places pieces, runs the simulation,
/// and asserts VictoryEvent is produced — proving the level is winnable.
/// and asserts MissionCompleteEvent is produced — proving the level is winnable.
/// </summary>
public class SolvabilityTests
{
[Fact]
public void SingleRook_ShortRelay_Victory()
public void SingleRook_ShortRelay_MissionComplete()
{
// 3x1: Prod(0,0) — Rook(1,0↔2,0) — Demand(2,0)
// Rook at (1,0) picks up from prod, at (2,0) is ON demand (not adjacent).
// Delivery happens when rook returns to (1,0), adjacent to demand at (2,0).
// Wait — (1,0) is adjacent to (2,0) ✓ so delivery from (1,0).
var level = new BoardBuilder(3, 1)
.WithProduction(0, 0, "Scierie", CargoType.Wood)
.WithDemand(2, 0, "Depot", CargoType.Wood, 2, 30)
@ -26,21 +23,15 @@ public class SolvabilityTests
var sim = SimHelper.FromLevel(level);
sim.Place(PieceKind.Rook, (1, 0), (2, 0));
sim.Start();
var allEvents = sim.StepN(20);
Assert.Contains(allEvents, e => e is VictoryEvent);
Assert.Contains(allEvents, e => e is MissionCompleteEvent);
}
[Fact]
public void ThreePieceChain_SharedRelayPoints_Victory()
public void ThreePieceChain_SharedRelayPoints_MissionComplete()
{
// 5x2: three rooks form a chain with shared relay points.
// Prod(0,0) — A(1,0↔2,0) — B(2,0↔3,0) — C(3,0↔4,0) — Demand(4,0)
// Pieces share cells (2,0) and (3,0) but never collide:
// Odd turns: A@(2,0) B@(3,0) C@(4,0)
// Even turns: A@(1,0) B@(2,0) C@(3,0)
var level = new BoardBuilder(5, 2)
.WithProduction(0, 0, "Scierie", CargoType.Wood)
.WithDemand(4, 0, "Depot", CargoType.Wood, 2, 40)
@ -51,12 +42,10 @@ public class SolvabilityTests
sim.Place(PieceKind.Rook, (1, 0), (2, 0));
sim.Place(PieceKind.Rook, (2, 0), (3, 0));
sim.Place(PieceKind.Rook, (3, 0), (4, 0));
sim.Start();
var allEvents = sim.StepN(30);
Assert.Contains(allEvents, e => e is VictoryEvent);
// Verify cargo actually traversed the chain (not just a shortcut)
Assert.Contains(allEvents, e => e is MissionCompleteEvent);
Assert.True(
allEvents.OfType<CargoTransferredEvent>().Count() >= 4,
"Expected at least 4 cargo transfers across the 3-piece chain");
@ -65,12 +54,6 @@ public class SolvabilityTests
[Fact]
public void TwoDemands_SingleSource_BothSatisfied()
{
// 4x3: one production feeds two demands via two rooks.
// Prod(0,0) at origin.
// D1(2,0) along row 0, D2(0,2) along col 0.
// Rook A(1,0↔2,0): picks up at (1,0), delivers to D1 from (1,0).
// Rook B(0,1↔0,2): picks up at (0,1), delivers to D2 from (0,1).
// Both rooks compete for the same buffer; A gets priority (placed first).
var level = new BoardBuilder(4, 3)
.WithProduction(0, 0, "Scierie", CargoType.Wood)
.WithDemand(2, 0, "Depot Royal", CargoType.Wood, 2, 30)
@ -81,24 +64,18 @@ public class SolvabilityTests
sim.Place(PieceKind.Rook, (1, 0), (2, 0));
sim.Place(PieceKind.Rook, (0, 1), (0, 2));
sim.Start();
var allEvents = sim.StepN(20);
Assert.Contains(allEvents, e => e is VictoryEvent);
// Both demands must have received progress events
Assert.Contains(allEvents, e => e is MissionCompleteEvent);
var demandProgress = allEvents.OfType<DemandProgressEvent>().ToList();
Assert.Contains(demandProgress, dp => dp.DemandCell == new Coords(2, 0) && dp.Current == dp.Required);
Assert.Contains(demandProgress, dp => dp.DemandCell == new Coords(0, 2) && dp.Current == dp.Required);
}
[Fact]
public void TwoCargoTypes_ParallelRoutes_Victory()
public void TwoCargoTypes_ParallelRoutes_MissionComplete()
{
// 4x2: two independent production→demand chains, one Wood, one Stone.
// Row 0: Prod_Wood(0,0) — Rook A(1,0↔2,0) — Demand_Wood(3,0)
// Row 1: Prod_Stone(0,1) — Rook B(1,1↔2,1) — Demand_Stone(3,1)
// Proves two cargo types flow independently to their matching demands.
var level = new BoardBuilder(4, 2)
.WithProduction(0, 0, "Scierie", CargoType.Wood)
.WithProduction(0, 1, "Carriere", CargoType.Stone)
@ -110,12 +87,10 @@ public class SolvabilityTests
sim.Place(PieceKind.Rook, (1, 0), (2, 0));
sim.Place(PieceKind.Rook, (1, 1), (2, 1));
sim.Start();
var allEvents = sim.StepN(20);
Assert.Contains(allEvents, e => e is VictoryEvent);
// Verify no wrong-type delivery (Wood to Stone demand or vice-versa)
Assert.Contains(allEvents, e => e is MissionCompleteEvent);
var transfers = allEvents.OfType<CargoTransferredEvent>().ToList();
foreach (var t in transfers.Where(t => t.To == new Coords(3, 0)))
Assert.Equal(CargoType.Wood, t.Type);
@ -124,14 +99,8 @@ public class SolvabilityTests
}
[Fact]
public void Bishop_DiagonalRelay_Victory()
public void Bishop_DiagonalRelay_MissionComplete()
{
// 4x3: bishop provides the diagonal link in a two-piece chain.
// Prod(0,0), Demand(2,1).
// Rook(0,1↔0,0): at (0,1) picks up from prod.
// Bishop(1,1↔2,2): at (1,1) receives from rook, at (2,2) delivers to demand (2,1).
// Even turns: Rook@(0,1), Bishop@(1,1) — adjacent, transfer.
// Odd turns: Rook@(0,0), Bishop@(2,2) — bishop delivers.
var level = new BoardBuilder(4, 3)
.WithProduction(0, 0, "Scierie", CargoType.Wood)
.WithDemand(2, 1, "Depot", CargoType.Wood, 2, 30)
@ -142,23 +111,15 @@ public class SolvabilityTests
sim.Place(PieceKind.Rook, (0, 1), (0, 0));
sim.Place(PieceKind.Bishop, (1, 1), (2, 2));
sim.Start();
var allEvents = sim.StepN(20);
Assert.Contains(allEvents, e => e is VictoryEvent);
Assert.Contains(allEvents, e => e is MissionCompleteEvent);
}
[Fact]
public void Knight_JumpsWall_Victory()
public void Knight_JumpsWall_MissionComplete()
{
// 5x3: a wall blocks the direct path, knight jumps over it.
// Prod(0,0), Demand(4,0).
// Walls: full column 2 — (2,0), (2,1), (2,2).
// Rook(1,0↔1,1): at (1,0) picks up from prod.
// Knight(1,1↔3,0): L-shape (+2,-1) jumps over wall, at (3,0) delivers to demand (4,0).
// Even turns: Rook@(1,0), Knight@(1,1) — adjacent, transfer.
// Odd turns: Knight@(3,0), adjacent to demand — delivers.
var level = new BoardBuilder(5, 3)
.WithProduction(0, 0, "Scierie", CargoType.Wood)
.WithDemand(4, 0, "Depot", CargoType.Wood, 2, 30)
@ -170,43 +131,16 @@ public class SolvabilityTests
sim.Place(PieceKind.Rook, (1, 0), (1, 1));
sim.Place(PieceKind.Knight, (1, 1), (3, 0));
sim.Start();
var allEvents = sim.StepN(20);
Assert.Contains(allEvents, e => e is VictoryEvent);
// Verify the knight actually moved across the wall
Assert.Contains(allEvents, e => e is MissionCompleteEvent);
Assert.Contains(allEvents, e => e is PieceMovedEvent m && m.To == new Coords(3, 0));
}
[Fact]
public void Victory_ReportsCorrectMetrics()
{
// 3x1: single rook relay, verify PiecesUsed, TurnsTaken, CellsOccupied.
var level = new BoardBuilder(3, 1)
.WithProduction(0, 0, "P", CargoType.Wood)
.WithDemand(2, 0, "D", CargoType.Wood, 2, 30)
.WithStock(PieceKind.Rook, 1)
.Build();
var sim = SimHelper.FromLevel(level);
sim.Place(PieceKind.Rook, (1, 0), (2, 0));
sim.Start();
var allEvents = sim.StepN(20);
var victory = allEvents.OfType<VictoryEvent>().FirstOrDefault();
Assert.NotNull(victory);
Assert.Equal(1, victory.Metrics.PiecesUsed);
Assert.True(victory.Metrics.TurnsTaken > 0);
Assert.Equal(2, victory.Metrics.CellsOccupied); // cells (1,0) and (2,0)
}
[Fact]
public void NoCollision_WithSharedRelayPoints()
{
// Two rooks sharing a relay point never collide.
// A(1,0↔2,0), B(2,0↔3,0) — share cell (2,0) but occupy it on alternate turns.
var level = new BoardBuilder(5, 2)
.WithProduction(0, 0, "P", CargoType.Wood)
.WithDemand(4, 0, "D", CargoType.Wood, 1, 40)
@ -216,22 +150,15 @@ public class SolvabilityTests
sim.Place(PieceKind.Rook, (1, 0), (2, 0));
sim.Place(PieceKind.Rook, (2, 0), (3, 0));
sim.Start();
var allEvents = sim.StepN(20);
Assert.DoesNotContain(allEvents, e => e is PieceDestroyedEvent);
Assert.DoesNotContain(allEvents, e => e is PieceReturnedToStockEvent);
}
[Fact]
public void CargoFilter_AutoAssigned_PreventsContamination()
{
// 4x1: two productions side by side, two routes with adjacent pieces.
// Prod_Wood(0,0), Prod_Stone(3,0)
// Rook A(1,0↔2,0) — adjacent to both prods on alternating turns.
// Without CargoFilter, A would pick up both types randomly.
// With CargoFilter, A's start (1,0) is adjacent to prod_Wood(0,0),
// so A is filtered to Wood and ignores Stone.
var level = new BoardBuilder(4, 1)
.WithProduction(0, 0, "Scierie", CargoType.Wood)
.WithProduction(3, 0, "Carriere", CargoType.Stone)
@ -242,24 +169,19 @@ public class SolvabilityTests
sim.Place(PieceKind.Rook, (1, 0), (2, 0));
// Verify CargoFilter was auto-assigned
var snapshot = sim.Snapshot;
Assert.Equal(CargoType.Wood, snapshot.Pieces[0].CargoFilter);
sim.Start();
var allEvents = sim.StepN(20);
// Piece should only carry Wood — never Stone
var transfers = allEvents.OfType<CargoTransferredEvent>().ToList();
Assert.All(transfers, t => Assert.Equal(CargoType.Wood, t.Type));
Assert.Contains(allEvents, e => e is VictoryEvent);
Assert.Contains(allEvents, e => e is MissionCompleteEvent);
}
[Fact]
public void CargoFilter_PropagatesThroughChain()
{
// 5x2: chain of 3 rooks, first adjacent to Wood production.
// All should inherit Wood filter via relay chain propagation.
var level = new BoardBuilder(5, 2)
.WithProduction(0, 0, "Scierie", CargoType.Wood)
.WithDemand(4, 0, "Depot", CargoType.Wood, 2, 40)
@ -267,9 +189,9 @@ public class SolvabilityTests
.Build();
var sim = SimHelper.FromLevel(level);
sim.Place(PieceKind.Rook, (1, 0), (2, 0)); // adj to prod → Wood
sim.Place(PieceKind.Rook, (2, 0), (3, 0)); // shares (2,0) → inherits Wood
sim.Place(PieceKind.Rook, (3, 0), (4, 0)); // shares (3,0) → inherits Wood
sim.Place(PieceKind.Rook, (1, 0), (2, 0));
sim.Place(PieceKind.Rook, (2, 0), (3, 0));
sim.Place(PieceKind.Rook, (3, 0), (4, 0));
var snapshot = sim.Snapshot;
Assert.Equal(CargoType.Wood, snapshot.Pieces[0].CargoFilter);
@ -278,9 +200,8 @@ public class SolvabilityTests
}
[Fact]
public void StepFromEdit_AutoStartsSimulation()
public void StepFromPaused_Works()
{
// Stepping from Edit phase should auto-start without needing Start command.
var level = new BoardBuilder(3, 1)
.WithProduction(0, 0, "P", CargoType.Wood)
.WithDemand(2, 0, "D", CargoType.Wood, 1, 30)
@ -289,10 +210,109 @@ public class SolvabilityTests
var sim = SimHelper.FromLevel(level);
sim.Place(PieceKind.Rook, (1, 0), (2, 0));
// No Start() — step directly from Edit
// Step directly from Paused
var allEvents = sim.StepN(20);
Assert.Contains(allEvents, e => e is TurnStartedEvent);
Assert.Contains(allEvents, e => e is VictoryEvent);
Assert.Contains(allEvents, e => e is MissionCompleteEvent);
}
/// <summary>
/// Full transformer chain: Production(Wood) → Piece → Forge(Wood→Tools) → Piece → Demand(Tools)
/// </summary>
[Fact]
public void TransformerChain_WoodToTools_MissionComplete()
{
var campaign = new CampaignDef
{
Name = "Solvability: Transformer", InitialWidth = 5, InitialHeight = 1,
Missions =
[
new MissionDef
{
Id = 1, Name = "Forge",
TerrainPatch = new TerrainPatch
{
NewWidth = 5, NewHeight = 1,
Cells =
[
new PatchCell { Col = 0, Row = 0, Type = CellType.Production,
Production = new ProductionDef(new Coords(0, 0), "Scierie", CargoType.Wood, 4) },
new PatchCell { Col = 2, Row = 0, Type = CellType.Transformer,
Transformer = new TransformerDef(new Coords(2, 0), "Forge", CargoType.Wood, 2, CargoType.Tools, 1) },
new PatchCell { Col = 4, Row = 0, Type = CellType.Demand,
Demand = new DemandDef(new Coords(4, 0), "Atelier", CargoType.Tools, 2) }
]
},
UnlockedPieces = [PieceKind.Rook],
UnlockedLevels = [new PieceUpgrade(PieceKind.Rook, 1)],
Stock = [new PieceStock(PieceKind.Rook, 3)]
}
]
};
var sim = SimHelper.FromCampaign(campaign);
sim.Sim.ProcessCommand(new LoadCampaignCommand());
// Rook 1: delivers wood to forge input
sim.Place(PieceKind.Rook, (0, 0), (1, 0));
// Rook 2: picks up tools from forge output, delivers to demand
sim.Place(PieceKind.Rook, (3, 0), (4, 0));
var allEvents = sim.StepN(50);
Assert.Contains(allEvents, e => e is CargoConvertedEvent);
Assert.Contains(allEvents, e => e is MissionCompleteEvent);
}
/// <summary>
/// Two-stage transformation: Wood → Forge → Tools → Comptoir → Gold
/// </summary>
[Fact]
public void DoubleTransformerChain_WoodToToolsToGold_MissionComplete()
{
var campaign = new CampaignDef
{
Name = "Solvability: Double Transformer", InitialWidth = 7, InitialHeight = 1,
Missions =
[
new MissionDef
{
Id = 1, Name = "Double Chain",
TerrainPatch = new TerrainPatch
{
NewWidth = 7, NewHeight = 1,
Cells =
[
new PatchCell { Col = 0, Row = 0, Type = CellType.Production,
Production = new ProductionDef(new Coords(0, 0), "Scierie", CargoType.Wood, 4) },
new PatchCell { Col = 2, Row = 0, Type = CellType.Transformer,
Transformer = new TransformerDef(new Coords(2, 0), "Forge", CargoType.Wood, 2, CargoType.Tools, 1) },
new PatchCell { Col = 4, Row = 0, Type = CellType.Transformer,
Transformer = new TransformerDef(new Coords(4, 0), "Comptoir", CargoType.Tools, 2, CargoType.Gold, 1) },
new PatchCell { Col = 6, Row = 0, Type = CellType.Demand,
Demand = new DemandDef(new Coords(6, 0), "Tresor", CargoType.Gold, 1) }
]
},
UnlockedPieces = [PieceKind.Rook],
UnlockedLevels = [new PieceUpgrade(PieceKind.Rook, 1)],
Stock = [new PieceStock(PieceKind.Rook, 4)]
}
]
};
var sim = SimHelper.FromCampaign(campaign);
sim.Sim.ProcessCommand(new LoadCampaignCommand());
// Chain: Scierie → Rook1 → Forge → Rook2 → Comptoir → Rook3 → Tresor
sim.Place(PieceKind.Rook, (0, 0), (1, 0)); // wood delivery
sim.Place(PieceKind.Rook, (3, 0), (4, 0)); // tools delivery (picks from forge, delivers to comptoir)
sim.Place(PieceKind.Rook, (5, 0), (6, 0)); // gold delivery
var allEvents = sim.StepN(80);
// Should see both transformations
var conversions = allEvents.OfType<CargoConvertedEvent>().ToList();
Assert.Contains(conversions, c => c.OutputCargo == CargoType.Tools);
Assert.Contains(conversions, c => c.OutputCargo == CargoType.Gold);
Assert.Contains(allEvents, e => e is MissionCompleteEvent);
}
}

View file

@ -0,0 +1,219 @@
using Chessistics.Engine.Commands;
using Chessistics.Engine.Events;
using Chessistics.Engine.Model;
using Chessistics.Tests.Helpers;
using Xunit;
namespace Chessistics.Tests.Simulation;
public class TransformerTests
{
/// <summary>
/// Build a campaign with a production, a transformer, and a demand:
/// Production(Wood) → [Transformer: Wood→Tools] → Demand(Tools)
/// Layout (5x1):
/// (0,0) Production(Wood,4)
/// (2,0) Transformer(Wood→Tools, input=2, output=1)
/// (4,0) Demand(Tools, 2)
/// </summary>
private static CampaignDef CreateTransformerCampaign()
{
return new CampaignDef
{
Name = "Transformer Test",
InitialWidth = 5,
InitialHeight = 1,
Missions =
[
new MissionDef
{
Id = 1,
Name = "Forge",
TerrainPatch = new TerrainPatch
{
NewWidth = 5,
NewHeight = 1,
Cells =
[
new PatchCell { Col = 0, Row = 0, Type = CellType.Production,
Production = new ProductionDef(new Coords(0, 0), "Scierie", CargoType.Wood, 4) },
new PatchCell { Col = 2, Row = 0, Type = CellType.Transformer,
Transformer = new TransformerDef(new Coords(2, 0), "Forge", CargoType.Wood, 2, CargoType.Tools, 1) },
new PatchCell { Col = 4, Row = 0, Type = CellType.Demand,
Demand = new DemandDef(new Coords(4, 0), "Armurerie", CargoType.Tools, 2) }
]
},
UnlockedPieces = [PieceKind.Rook],
UnlockedLevels = [new PieceUpgrade(PieceKind.Rook, 1)],
Stock = [new PieceStock(PieceKind.Rook, 3)]
}
]
};
}
[Fact]
public void Transformer_ReceivesInputCargo()
{
var campaign = CreateTransformerCampaign();
var sim = SimHelper.FromCampaign(campaign);
sim.Sim.ProcessCommand(new LoadCampaignCommand());
// Place rook between production and transformer: (1,0)↔(2,0)
// Rook oscillates between (1,0) and (2,0), picking up wood and delivering to transformer
sim.Place(PieceKind.Rook, (0, 0), (1, 0));
var events = sim.StepN(5);
// Should see cargo transferred to the transformer position
Assert.Contains(events, e => e is CargoTransferredEvent ct
&& ct.To == new Coords(2, 0) && ct.Type == CargoType.Wood);
}
[Fact]
public void Transformer_ConvertsAfterInputThreshold()
{
var campaign = CreateTransformerCampaign();
var sim = SimHelper.FromCampaign(campaign);
sim.Sim.ProcessCommand(new LoadCampaignCommand());
// Rook delivers wood to transformer
sim.Place(PieceKind.Rook, (0, 0), (1, 0));
var events = sim.StepN(10);
// After enough deliveries, transformation should occur
Assert.Contains(events, e => e is CargoConvertedEvent cc
&& cc.TransformerCell == new Coords(2, 0)
&& cc.InputCargo == CargoType.Wood
&& cc.OutputCargo == CargoType.Tools);
}
[Fact]
public void Transformer_OutputPickedUpByPiece()
{
var campaign = CreateTransformerCampaign();
var sim = SimHelper.FromCampaign(campaign);
sim.Sim.ProcessCommand(new LoadCampaignCommand());
// Rook 1: delivers wood to transformer
sim.Place(PieceKind.Rook, (0, 0), (1, 0));
// Rook 2: picks up tools from transformer and delivers to demand
sim.Place(PieceKind.Rook, (3, 0), (4, 0));
var events = sim.StepN(20);
// Should see tools being transferred from transformer to rook 2
Assert.Contains(events, e => e is CargoTransferredEvent ct
&& ct.From == new Coords(2, 0) && ct.Type == CargoType.Tools);
}
[Fact]
public void Transformer_FullChain_CompleteMission()
{
var campaign = CreateTransformerCampaign();
var sim = SimHelper.FromCampaign(campaign);
sim.Sim.ProcessCommand(new LoadCampaignCommand());
// Full chain: Production → Rook1 → Transformer → Rook2 → Demand
sim.Place(PieceKind.Rook, (0, 0), (1, 0)); // delivers wood to transformer
sim.Place(PieceKind.Rook, (3, 0), (4, 0)); // picks up tools, delivers to demand
var events = sim.StepN(50);
// Mission should complete (2 tools delivered)
Assert.Contains(events, e => e is MissionCompleteEvent);
}
[Fact]
public void Transformer_DoesNotConvertWrongCargo()
{
// Transformer expects Wood but receives Stone — should not convert
var campaign = new CampaignDef
{
Name = "Wrong Cargo",
InitialWidth = 4,
InitialHeight = 1,
Missions =
[
new MissionDef
{
Id = 1, Name = "M1",
TerrainPatch = new TerrainPatch
{
NewWidth = 4, NewHeight = 1,
Cells =
[
new PatchCell { Col = 0, Row = 0, Type = CellType.Production,
Production = new ProductionDef(new Coords(0, 0), "Carriere", CargoType.Stone, 4) },
new PatchCell { Col = 2, Row = 0, Type = CellType.Transformer,
Transformer = new TransformerDef(new Coords(2, 0), "Forge", CargoType.Wood, 1, CargoType.Tools, 1) },
new PatchCell { Col = 3, Row = 0, Type = CellType.Demand,
Demand = new DemandDef(new Coords(3, 0), "D", CargoType.Tools, 1) }
]
},
UnlockedPieces = [PieceKind.Rook],
UnlockedLevels = [new PieceUpgrade(PieceKind.Rook, 1)],
Stock = [new PieceStock(PieceKind.Rook, 2)]
}
]
};
var sim = SimHelper.FromCampaign(campaign);
sim.Sim.ProcessCommand(new LoadCampaignCommand());
sim.Place(PieceKind.Rook, (0, 0), (1, 0));
var events = sim.StepN(10);
// No conversion should happen (stone != wood)
Assert.DoesNotContain(events, e => e is CargoConvertedEvent);
}
[Fact]
public void Transformer_AccumulatesInput()
{
// Transformer with inputRequired=3 should accumulate over multiple deliveries
var campaign = new CampaignDef
{
Name = "Accumulate",
InitialWidth = 4,
InitialHeight = 1,
Missions =
[
new MissionDef
{
Id = 1, Name = "M1",
TerrainPatch = new TerrainPatch
{
NewWidth = 4, NewHeight = 1,
Cells =
[
new PatchCell { Col = 0, Row = 0, Type = CellType.Production,
Production = new ProductionDef(new Coords(0, 0), "Scierie", CargoType.Wood, 4) },
new PatchCell { Col = 2, Row = 0, Type = CellType.Transformer,
Transformer = new TransformerDef(new Coords(2, 0), "Forge", CargoType.Wood, 3, CargoType.Tools, 2) },
new PatchCell { Col = 3, Row = 0, Type = CellType.Demand,
Demand = new DemandDef(new Coords(3, 0), "D", CargoType.Tools, 2) }
]
},
UnlockedPieces = [PieceKind.Rook],
UnlockedLevels = [new PieceUpgrade(PieceKind.Rook, 1)],
Stock = [new PieceStock(PieceKind.Rook, 2)]
}
]
};
var sim = SimHelper.FromCampaign(campaign);
sim.Sim.ProcessCommand(new LoadCampaignCommand());
sim.Place(PieceKind.Rook, (0, 0), (1, 0));
sim.Place(PieceKind.Rook, (2, 0), (3, 0));
var events = sim.StepN(30);
// After 3 wood deliveries, conversion should produce 2 tools
var conversions = events.Where(e => e is CargoConvertedEvent cc && cc.OutputAmount == 2).ToList();
Assert.NotEmpty(conversions);
// Mission should complete (2 tools delivered)
Assert.Contains(events, e => e is MissionCompleteEvent);
}
}

View file

@ -0,0 +1 @@
uid://c7ifw7o8xahpv

View file

@ -0,0 +1,120 @@
using Chessistics.Engine.Commands;
using Chessistics.Engine.Events;
using Chessistics.Engine.Model;
using Chessistics.Tests.Helpers;
using Xunit;
namespace Chessistics.Tests.Simulation;
public class UndoTests
{
private SimHelper CreateSim()
{
var level = new BoardBuilder(4, 4)
.WithProduction(0, 0, "Scierie", CargoType.Wood, amount: 1)
.WithDemand(3, 0, "Depot", CargoType.Wood, 3, 30)
.WithStock(PieceKind.Rook, 3)
.Build();
return SimHelper.FromLevel(level);
}
[Fact]
public void Undo_EmptyStack_ReturnsNoEvents()
{
var sim = CreateSim();
Assert.False(sim.Sim.CanUndo);
Assert.Empty(sim.Sim.Undo());
}
[Fact]
public void Undo_AfterPlace_RestoresPreviousState()
{
var sim = CreateSim();
Assert.False(sim.Sim.CanUndo);
sim.Place(PieceKind.Rook, (0, 0), (1, 0));
Assert.Single(sim.Snapshot.Pieces);
Assert.True(sim.Sim.CanUndo);
var events = sim.Sim.Undo();
Assert.Single(events);
Assert.IsType<StateRestoredEvent>(events[0]);
Assert.Empty(sim.Snapshot.Pieces);
Assert.Equal(3, sim.Snapshot.RemainingStock[PieceKind.Rook]);
Assert.False(sim.Sim.CanUndo);
}
[Fact]
public void Undo_AfterRemove_RestoresPiece()
{
var sim = CreateSim();
sim.Place(PieceKind.Rook, (0, 0), (1, 0));
var pieceId = sim.Snapshot.Pieces[0].Id;
sim.Remove(pieceId);
Assert.Empty(sim.Snapshot.Pieces);
sim.Sim.Undo();
Assert.Single(sim.Snapshot.Pieces);
Assert.Equal(pieceId, sim.Snapshot.Pieces[0].Id);
}
[Fact]
public void Undo_MultipleMutations_UndoneInReverseOrder()
{
var sim = CreateSim();
sim.Place(PieceKind.Rook, (0, 0), (1, 0));
sim.Place(PieceKind.Rook, (0, 1), (1, 1));
sim.Place(PieceKind.Rook, (0, 2), (1, 2));
Assert.Equal(3, sim.Snapshot.Pieces.Count);
sim.Sim.Undo();
Assert.Equal(2, sim.Snapshot.Pieces.Count);
sim.Sim.Undo();
Assert.Single(sim.Snapshot.Pieces);
sim.Sim.Undo();
Assert.Empty(sim.Snapshot.Pieces);
Assert.False(sim.Sim.CanUndo);
}
[Fact]
public void Undo_RejectedPlacement_DoesNotCheckpoint()
{
var sim = CreateSim();
// Invalid placement (off the board) — should be rejected and not undoable
sim.Place(PieceKind.Rook, (99, 99), (100, 100));
Assert.False(sim.Sim.CanUndo);
}
[Fact]
public void Undo_AfterSimulationStep_RewindsTurnsToo()
{
// Placing then stepping means the sim advanced. Undo of the placement
// should restore the pre-placement state — which also means turn 0.
var sim = CreateSim();
sim.Place(PieceKind.Rook, (0, 0), (1, 0));
sim.Step();
sim.Step();
Assert.Equal(2, sim.Snapshot.TurnNumber);
sim.Sim.Undo();
Assert.Equal(0, sim.Snapshot.TurnNumber);
Assert.Empty(sim.Snapshot.Pieces);
}
[Fact]
public void QuickLoad_ClearsUndoStack()
{
var sim = CreateSim();
sim.Place(PieceKind.Rook, (0, 0), (1, 0));
sim.Sim.QuickSave();
sim.Place(PieceKind.Rook, (0, 1), (1, 1));
// Two undoable mutations so far — load clears the stack
sim.Sim.QuickLoad();
Assert.False(sim.Sim.CanUndo);
}
}

View file

@ -16,17 +16,21 @@ Chaque piece est un **maillon de convoyeur**. La strategie est dans la compositi
**Core loop** :
```
OBSERVER la situation (productions, demandes, terrain, pieces disponibles)
OBSERVER le reseau en fonctionnement
|
PLACER des pieces sur le plateau (point de depart + point d'arrivee)
IDENTIFIER un goulet, une mission non remplie, ou une collision
|
LANCER la simulation — les pieces font leurs allers-retours,
les colis se transmettent automatiquement entre pieces adjacentes
PLACER ou RETIRER des pieces (la simulation se met en pause automatiquement
pendant le placement, puis reprend)
|
+---> Le debit est insuffisant ? Observer les goulets, reorganiser
+---> Le debit est atteint ? Optimiser ou niveau suivant
OBSERVER le resultat — le reseau s'adapte immediatement
|
+---> Le debit est insuffisant ? Reorganiser les chaines
+---> La mission est remplie ? Avancer vers la mission suivante
```
La simulation tourne en continu. Le joueur ne "lance" jamais — il intervient sur un systeme vivant.
**Ce qui distingue Chessistics** :
- La logistique (macro) : le joueur compose des chaines, choisit sa flotte, gere l'espace
- Le puzzle chess (micro) : les contraintes de mouvement creent des enigmes de couverture et d'espacement emergentes
@ -51,7 +55,7 @@ Le plateau est un damier avec des cases claires et sombres alternees. Chaque cas
| **Case claire** | Carre clair du damier | Traversable normalement |
| **Case sombre** | Carre sombre du damier | Traversable (les Fous sont limites a une couleur) |
| **Mur** | Case barree, gris fonce | Bloque toutes les pieces sauf le Cavalier (qui saute) |
| **Production** | Icone ressource + nom (ex: "Scierie") | Produit M cargaisons tous les N coups (M=1 dans le proto, futur: 2-4). Le buffer est ecrase a chaque production — les cargaisons non recuperees sont perdues. Donne automatiquement aux pieces adjacentes disponibles. |
| **Production** | Icone ressource + nom (ex: "Scierie") | Produit M cargaisons par tour (M=1 a 4 selon le batiment). Le buffer max = M — les cargaisons non recuperees sont ecrasees au tour suivant. Donne automatiquement aux pieces adjacentes disponibles. |
| **Demande** | Icone cible + nom (ex: "Depot") + jauge | Recoit la cargaison d'une piece adjacente qui arrive avec un colis compatible. Accepte toujours un colis du bon type, meme si l'objectif est deja atteint. |
### 2.3 Cargaison
@ -171,7 +175,8 @@ C'est tout. Pas de programmation, pas de route multi-etapes. La piece fait l'all
- Une piece bloque le passage des autres pieces (sauf le Cavalier qui saute)
- Si deux pieces arrivent sur la meme case apres le mouvement, la piece de **statut le plus eleve** detruit l'autre. A statut egal, le **niveau** departage. A egalite parfaite (meme statut ET meme niveau), **destruction mutuelle**.
- La piece survivante reste sur la case avec sa cargaison intacte. La cargaison des pieces detruites est perdue.
- Les pieces detruites sont restaurees quand le joueur arrete la simulation (retour en mode edition).
- Les pieces detruites **retournent immediatement dans le stock** du joueur — il peut les replacer a tout moment.
- En cas de collision, la simulation se met en **pause automatique**. La camera effectue un pan et zoom vers la zone de collision pour montrer ce qui s'est passe. Une notification apparait dans un coin de l'ecran pour expliciter l'evenement (ex: "Tour II detruite par Dame — retournee au stock").
---
@ -188,11 +193,13 @@ Les transferts se produisent entre :
### 4.2 Quand le transfert a lieu
Un transfert se produit quand, a la **fin d'un coup** (une fois que toutes les pieces ont bouge) :
Les transferts se resolvent **avant le mouvement**, dans la meme sequence de coup (voir §5.1). Une piece adjacente a une production prend le colis puis se deplace avec dans le meme tour.
Condition pour qu'un transfert ait lieu :
- Une entite avec colis et une entite sans colis (ou une demande) sont sur des **cases adjacentes** (4-connecte)
- Le colis est compatible (la demande accepte ce type de cargaison)
Le transfert est **instantane** : le colis passe d'une entite a l'autre entre deux coups.
Le transfert est **instantane** au sein du tour.
### 4.3 Priorite et departage
@ -286,36 +293,33 @@ Quand deux pieces ou plus occupent la meme case apres le mouvement :
- A statut egal, le **niveau** departage (niveau superieur survit)
- A egalite parfaite (meme statut ET meme niveau), **destruction mutuelle** (aucun survivant)
- La piece survivante conserve sa cargaison. Les cargaisons detruites sont perdues.
- La simulation **continue** (pas de pause automatique)
- Les pieces detruites **retournent immediatement dans le stock** du joueur.
- La simulation se met en **pause automatique**. La camera pan et zoom vers la zone de collision. Une notification explicite l'evenement.
Les collisions deviennent un element strategique — le joueur doit anticiper les croisements de trajectoires et espacer ses chaines pour les eviter.
### 5.3 Condition de victoire
### 5.3 Condition de completion de mission
Le niveau est reussi quand **toutes les demandes** ont ete satisfaites selon leur objectif.
La mission courante est completee quand **toutes ses demandes** ont ete satisfaites.
Chaque demande specifie : "recevoir N colis de type X en Y coups ou moins".
Chaque demande specifie : "recevoir N colis de type X".
Exemple : "Le Depot Royal demande 3 livraisons de Bois en 30 coups."
Exemple : "Le Depot Royal demande 3 livraisons de Bois."
Le compteur de coups tourne en temps reel. Le joueur voit sa progression.
Il n'y a pas de deadline. Le compteur de tours est affiche comme **metrique d'optimisation** (le joueur voit combien de tours il a mis), mais ne constitue pas une contrainte. Le joueur prend le temps qu'il veut.
---
## 6. Les metriques
A la completion d'un niveau, 3 metriques sont affichees :
A la completion d'une mission, 3 metriques sont affichees :
| Metrique | Description | Ce que ca mesure |
|----------|-------------|------------------|
| **Pieces** | Nombre de pieces deployees | Economie de flotte |
| **Pieces** | Nombre de pieces deployees pour cette mission | Economie de flotte |
| **Coups** | Nombre de coups pour atteindre l'objectif | Efficacite du reseau |
| **Espace** | Nombre de cases du plateau utilisees (occupees par une piece au moins 1 coup) | Compacite du reseau |
Chaque metrique a un **histogramme** montrant la distribution des solutions de tous les joueurs.
> **Proto** : histogrammes avec donnees fictives pour tester l'UI.
**Triangle d'optimisation** :
- Moins de pieces = chaines courtes = couverture limitee → plus de coups
- Moins de coups = pieces puissantes ou nombreuses → plus de pieces, plus d'espace
@ -323,7 +327,7 @@ Chaque metrique a un **histogramme** montrant la distribution des solutions de t
**Affichage en jeu** (pendant la simulation) :
```
Coup: 12/30 Depot Royal: 2/3 Bois ✓ Forge: 0/2 Pierre ✗
Tour: 12 Depot Royal: 2/3 Bois ✓ Forge: 0/2 Pierre ✗
```
---
@ -336,34 +340,36 @@ Le plateau est le centre. L'interface est minimale.
```
+---------------------------------------------------------------+
| CHESSISTICS La Scierie Royale [≡] [?] [←] |
| CHESSISTICS La Quete du Roi [≡] [?] [←] |
+---------------------------------------------------------------+
| | |
| | OBJECTIF |
| | Depot Royal |
| | 3x Bois / 30c |
| P L A T E A U | |
| (damier interactif) | ───────── |
| | |
| Les pieces et leurs trajets | PIECES |
| sont visibles sur le plateau | [Tour II] x3 |
| | [Fou II] x1 |
| | [Cavalier] x1 |
| | MISSION 3/8 |
| | Forger les Tours |
| | Depot: 0/3 Bois |
| P L A T E A U | ✓ Mission 1 |
| (damier interactif) | ✓ Mission 2 |
| | ───────── |
| Les pieces et leurs trajets | |
| sont visibles sur le plateau | PIECES |
| | [Pion I] x4 |
| | [Tour I] x3 |
| | |
+---------------------------------------------------------------+
| [▶ PLAY] [⏩ x2] [⏸] [⏹ STOP] Coup: -- |
| [⏸ / ▶] [x1] [x2] [x4] Tour: 42 Mission: 3/8 |
+---------------------------------------------------------------+
```
### 7.2 Placement d'une piece
Le flux de placement est en 2 clics :
Le flux de placement est en 2 clics. **La simulation se met en pause automatiquement** des que le joueur selectionne un type de piece, et reprend une fois le placement confirme ou annule.
1. Le joueur **selectionne un type de piece** dans le panneau de droite
1. Le joueur **selectionne un type de piece** dans le panneau de droite → **pause automatique**
2. Il **clique une case du plateau** → c'est le point de depart. Les cases d'arrivee possibles s'affichent en surbrillance.
3. Il **clique une case en surbrillance** → c'est le point d'arrivee. La piece est placee.
3. Il **clique une case en surbrillance** → c'est le point d'arrivee. La piece est placee. → **la simulation reprend**
4. Un trait apparait entre depart et arrivee, montrant le trajet.
Si le joueur annule (Echap), la simulation reprend sans placement.
```
Placement d'une Tour II :
@ -379,9 +385,9 @@ Le flux de placement est en 2 clics :
```
**Interactions** :
- **Clic sur une piece placee** → la selectionne, affiche son trajet, panneau de detail
- **Clic droit sur une piece** → la retire du plateau (retourne dans le stock)
- **Glisser une piece** → deplace son point de depart (recalcule les arrivees possibles)
- **Clic gauche sur une piece placee** → la selectionne, affiche son trajet, panneau de detail
- **Touche Suppr** (piece selectionnee) → la retire du plateau (retourne dans le stock)
- **Bouton [Retirer]** dans le panneau de detail → meme effet
### 7.3 Visualisation des trajets
@ -411,21 +417,20 @@ Quand une piece est selectionnee :
+---------------------------+
```
### 7.5 Phases de jeu
### 7.5 Simulation continue
**Phase EDIT** (temps arrete)
- Placer, deplacer, retirer des pieces
- Pas de limite de temps
- Les trajets sont visibles comme des traits sur le plateau
La simulation tourne en continu — il n'y a pas de phases Edit/Exec separees. Le joueur modifie son reseau a tout moment, la simulation integre les changements au tour suivant.
**Phase EXEC** (simulation)
- Les pieces font leurs allers-retours simultanement
- Les colis se transmettent automatiquement aux points de contact
- Compteur de coups et progression des objectifs en temps reel
- Controles : pause, step-by-step, vitesse x1/x2/x4, stop (retour a EDIT)
- En cas de collision → pause auto, pieces en erreur surlignees
**Controles** :
- **Espace** : pause / reprendre (le tour en cours se termine avant la pause)
- **Vitesse** : x1, x2, x4
- **Placement d'une piece** : met la simulation en pause automatiquement le temps du placement (voir §7.2)
Le joueur peut arreter la simulation a tout moment, reorganiser, et relancer.
**Pauses automatiques** :
- Quand le joueur selectionne une piece a placer → pause jusqu'a confirmation ou annulation
- Quand une collision se produit → pause + pan/zoom camera vers la zone + notification (voir §5.2)
Le joueur peut placer, retirer et reorganiser ses pieces a tout moment, en pause ou pendant que la simulation tourne.
### 7.6 Feedback visuel
@ -444,13 +449,23 @@ Le joueur peut arreter la simulation a tout moment, reorganiser, et relancer.
- Les demandes ont une **jauge** de progression (ex: "2/3")
- Jauge verte = objectif atteint. Jauge rouge = pas encore.
**Erreurs** :
- Collision : flash rouge + shake des deux pieces
**Collisions** :
- Flash rouge + shake des deux pieces
- Simulation en pause automatiquement
- La camera pan et zoom vers la zone de collision
- Notification dans un coin de l'ecran : "Tour II detruite par Dame — retournee au stock"
- La piece detruite retourne dans le stock, le joueur peut la replacer
**Victoire** :
**Completion de mission** :
- Toutes les jauges au vert → animation sobre (les trajets scintillent en dore)
- Overlay des metriques + histogrammes
- Overlay de felicitations avec metriques de la mission
- Bouton "Mission suivante" pour avancer
**Transition de mission** :
- Titre "Nouvelle mission" apparait en plein ecran en fade-in
- La camera se lock (pan et zoom desactives) pour montrer la zone de la prochaine mission
- Les nouvelles cases apparaissent sur le plateau avec une animation d'expansion
- Le titre de mission se deplace vers la zone d'objectif dans le panneau lateral avant de disparaitre, emmenant l'oeil du joueur vers les nouveaux objectifs
---
@ -470,7 +485,7 @@ Le joueur peut arreter la simulation a tout moment, reorganiser, et relancer.
```
- Plateau : **4x4**
- S = Scierie (a1, produit du Bois tous les 2 coups)
- S = Scierie (a1, produit du Bois 1 par tour)
- D = Depot Royal (d1, objectif : recevoir 3 Bois en 30 coups)
- Pieces disponibles : **3x Tour II**
@ -514,17 +529,15 @@ Ou : Tour A couvre a1↔c1 (2 cases), Tour B couvre c1↔d1 (1 case). Ils ne son
```
6 . . . . . .
5 . . . . . [D2] Caserne — 2 Bois en 30 coups
4 . . . . . .
5 . . . . . [D2] Caserne — 2 Bois 4 . . . . . .
3 . . . . . .
2 . . . . . .
1 [S] . . . . [D1] Depot Royal — 2 Bois en 30 coups
1 [S] . . . . [D1] Depot Royal — 2 Bois
a b c d e f
```
- Plateau : **6x6**
- S = Scierie (a1, produit du Bois tous les 2 coups)
- S = Scierie (a1, produit du Bois 1 par tour)
- D1 = Depot Royal (f1, objectif : 2 Bois en 30 coups)
- D2 = Caserne (f5, objectif : 2 Bois en 30 coups)
- Pieces disponibles : **4x Tour II, 1x Fou II**
@ -532,7 +545,7 @@ Ou : Tour A couvre a1↔c1 (2 cases), Tour B couvre c1↔d1 (1 case). Ils ne son
**L'enjeu** :
- S→D1 : 5 cases en ligne droite. Faisable avec une chaine de Tours.
- S→D2 : trajet en angle (droite + haut). Plusieurs routes possibles.
- La Scierie ne produit qu'un colis tous les 2 coups. Les deux chaines partagent la meme source.
- La Scierie ne produit qu'un colis 1 par tour. Les deux chaines partagent la meme source.
- Le joueur doit decider : comment repartir les colis entre les deux destinations ?
**Le statut social entre en jeu** :
@ -559,9 +572,7 @@ Le Fou peut couvrir des trajets diagonaux que les Tours ne peuvent pas. Pour att
**Intention** : un vrai reseau avec terrain, 2 types de cargaison, et le Cavalier comme solution aux obstacles.
```
6 [D2] . . . . [D1] Depot Royal — 2 Bois en 40 coups
5 . . # # # . Forge — 2 Pierre en 40 coups
4 . . # . . .
6 [D2] . . . . [D1] Depot Royal — 2 Bois 5 . . # # # . Forge — 2 Pierre 4 . . # . . .
3 . . # . . .
2 . . . . . .
1 [S1] . . . . [S2] Scierie (Bois)
@ -570,8 +581,8 @@ Le Fou peut couvrir des trajets diagonaux que les Tours ne peuvent pas. Pour att
```
- Plateau : **6x6**
- S1 = Scierie (a1, Bois, tous les 2 coups)
- S2 = Carriere (f1, Pierre, tous les 2 coups)
- S1 = Scierie (a1, Bois, 1 par tour)
- S2 = Carriere (f1, Pierre, 1 par tour)
- D1 = Depot Royal (f6, objectif : 2 Bois en 40 coups)
- D2 = Forge (a6, objectif : 2 Pierre en 40 coups)
- Murs : c3, c4, c5, d5, e5 (barriere en L)
@ -613,21 +624,19 @@ Le Cavalier saute le mur en L. Il peut connecter les deux cotes du plateau la ou
```
8 . . . . . . . .
7 [D2] . . . . . . . Forge — 3 Pierre en 40 coups
6 . . . . . . . .
7 [D2] . . . . . . . Forge — 3 Pierre 6 . . . . . . . .
5 . . . ## . . . .
4 . . . ## . . . .
3 . . . . . . . .
2 . . . . . . . .
1 [S1] . . . . . . [D1] Depot Royal — 3 Bois en 40 coups
1 [S1] . . . . . . [D1] Depot Royal — 3 Bois
a b c d e f g h
[S2] Carriere (h8)
```
- Plateau : **8x8**
- S1 = Scierie (a1, Bois), S2 = Carriere (h8, Pierre)
- D1 = Depot Royal (h1, 3 Bois/40c), D2 = Forge (a8, 3 Pierre/40c)
- D1 = Depot Royal (h1, 3 Bois), D2 = Forge (a8, 3 Pierre)
- Murs : bloc 2x2 au centre (d4, e5, d5, e4)
- Stock : 8 Pions, 4 Tours, 2 Fous, 2 Cavaliers
@ -642,17 +651,15 @@ Le Cavalier saute le mur en L. Il peut connecter les deux cotes du plateau la ou
```
6 [S2] . # . # . # . Carriere (a6)
5 . . # . # . # .
4 . . # . # . . [D1] Depot Royal — 3 Bois en 50 coups
3 . . # . . . # .
4 . . # . # . . [D1] Depot Royal — 3 Bois 3 . . # . . . # .
2 . . . . # . # .
1 [S1] . . . # . . [D2] Forge — 3 Pierre en 50 coups
1 [S1] . . . # . . [D2] Forge — 3 Pierre
a b c d e f g h
```
- Plateau : **8x6**
- S1 = Scierie (a1, Bois), S2 = Carriere (a6, Pierre)
- D1 = Depot Royal (h6, 3 Bois/50c), D2 = Forge (h1, 3 Pierre/50c)
- D1 = Depot Royal (h6, 3 Bois), D2 = Forge (h1, 3 Pierre)
- Murs : 3 colonnes partielles formant un labyrinthe
- Stock : 10 Pions, 4 Tours, 2 Fous, 3 Cavaliers
@ -665,21 +672,19 @@ Le Cavalier saute le mur en L. Il peut connecter les deux cotes du plateau la ou
**Intention** : reseau a 3 productions et 3 demandes, plateau 10x8. Le joueur gere un vrai reseau logistique.
```
8 [S2] . . # . . # . . [D2] Forge — 3 Pierre en 50 coups
7 . . . # . . # . . .
8 [S2] . . # . . # . . [D2] Forge — 3 Pierre 7 . . . # . . # . . .
6 . . . # ## . # . . .
5 . . . . . . . . . .
4 . . . # ## . # . . [S3] Scierie Est (j4, Bois)
3 . . . # . . # . . .
2 . . . . . . . . . .
1 [S1] . . . [D3] . . . . [D1] Depot Royal — 3 Bois en 50 coups
1 [S1] . . . [D3] . . . . [D1] Depot Royal — 3 Bois
a b c d e f g h i j
```
- Plateau : **10x8**
- S1 = Scierie (a1, Bois), S2 = Carriere (a8, Pierre), S3 = Scierie Est (j4, Bois)
- D1 = Depot Royal (j1, 3 Bois/50c), D2 = Forge (j8, 3 Pierre/50c), D3 = Chantier (e8, 3 Bois/50c)
- D1 = Depot Royal (j1, 3 Bois), D2 = Forge (j8, 3 Pierre), D3 = Chantier (e8, 3 Bois)
- Murs : deux colonnes avec pont horizontal
- Stock : 14 Pions, 6 Tours, 3 Fous, 4 Cavaliers
@ -746,7 +751,7 @@ Chessistics/
UI/
ObjectivePanel.tscn — Objectifs + stock de pieces
DetailPanel.tscn — Detail piece selectionnee
ControlBar.tscn — Play / pause / stop / vitesse
ControlBar.tscn — Pause / vitesse
MetricsOverlay.tscn — Resultats post-victoire
LevelSelect.tscn — Selection de niveau
scripts/
@ -762,7 +767,7 @@ Chessistics/
LevelLoader.cs — Chargement JSON
UI/
PiecePlacer.cs — Logique du placement 2 clics
ControlBar.cs — Play/pause/stop/vitesse
ControlBar.cs — Pause/vitesse
ProgressDisplay.cs — Compteur de coups + progression objectifs
data/
levels/
@ -781,10 +786,10 @@ Chessistics/
"width": 4,
"height": 4,
"productions": [
{ "x": 0, "y": 0, "name": "Scierie", "cargo": "wood", "interval": 2 }
{ "x": 0, "y": 0, "name": "Scierie", "cargo": "wood", "amount": 1 }
],
"demands": [
{ "x": 3, "y": 0, "name": "Depot Royal", "cargo": "wood", "amount": 3, "deadline": 30 }
{ "x": 3, "y": 0, "name": "Depot Royal", "cargo": "wood", "amount": 3 }
],
"walls": [],
"pieces": [
@ -801,7 +806,7 @@ Chessistics/
|----------|---------|----------------|
| Adjacence pour transfert ? | 4-connecte (bords) vs 8-connecte (bords + coins) | **4-connecte** — plus de contrainte = plus de puzzle |
| La piece tourne a vide ? | Oui (aller-retour permanent) vs attend un colis | **Oui, tourne en permanence** — plus visuel, plus simple |
| Collision = destruction ? | Destruction (fort mange faible) vs erreur stricte (pause) | **Destruction** — la piece de plus haut statut/niveau survit, les autres sont detruites. Destruction mutuelle si egalite parfaite. |
| Collision = destruction ? | Destruction (fort mange faible) vs erreur stricte (pause) | **Destruction + retour au stock** — la piece de plus haut statut/niveau survit, les autres retournent au stock. Pause auto + camera pan vers la collision + notification. Destruction mutuelle si egalite parfaite. |
| Sources infinies ? | Oui (production periodique sans stock) vs stock limite | **Production periodique infinie** — le proto teste le reseau, pas la gestion de stock |
| Pieces fixes par niveau ? | Fixes (catalogue impose) vs achat libre | **Fixes** — plus facile a designer. L'achat/fabrication est post-proto. |
| Egalite de statut social ? | Niveau > direction horaire | **Niveau puis direction horaire alternee** (pairs: depuis droite, impairs: depuis gauche) — deterministe, pas de biais permanent |

37
docs/PLAN.md Normal file
View file

@ -0,0 +1,37 @@
# Chessistics — Plan de travail (restant)
Consolidation des sections non implementees des anciens `PLAN_missions.md` et
`PLAN_leveldesign.md`. Le moteur (black-box sim, campagne, transformateurs,
missions 1-7) est en place. Ce qui suit concerne la finition UX, les visuels
et l'extension de la campagne.
---
## 1. UX / Presentation — gaps Godot
Le moteur expose deja les commandes et events requis ; cote Godot il manque
les surfaces d'interaction et d'animation.
---
## 2. Extension de la campagne
`campaign_01.json` compte actuellement 7 missions (Pion → Tour → Cavalier →
Fou → Dame + 2 transformateurs). La vision GDD/plan prevoit une campagne
plus longue et un final orchestrant toutes les chaines.
### 2.2 Demandes recurrentes — wiring restant
Le moteur gere le mode recurrent (`ConsumptionPerTurn`, `SustainTurns`,
shortage tracking, events `DemandShortageStarted/Cleared`). Il reste :
- Concevoir des missions utilisant le mode recurrent (post-campagne 1).
- Visualisation UI du shortage (jauge buffer rouge, pulsation d'alerte).
- Ajuster la condition de fin si un mix classique + recurrent coexiste.
---
## 3. Idées restantes (nice-to-have)
- Animation flash input → flash output distincte sur la cellule du
transformateur (actuellement un seul flash de conversion).
- Icônes cargo plus riches que des carrés colorés (pictogrammes
par type).

6
global.json Normal file
View file

@ -0,0 +1,6 @@
{
"sdk": {
"version": "9.0.312",
"rollForward": "latestMinor"
}
}

View file

@ -0,0 +1,96 @@
# Chessistics automation harness
Drive a running Chessistics build via file-based IPC, so an AI agent (or a
scripted test) can take screenshots, inject inputs, and read game state
without a human in the loop.
## How it works
- Launch Godot with `--automation=<dir>`. The game activates an
`AutomationHarness` node that polls `<dir>/inbox/` each frame.
- Send commands as JSON files; the game writes results to `<dir>/outbox/`
and screenshots to `<dir>/screens/`.
- A handshake file `<dir>/ready.json` is written on startup.
Without the flag, the harness is not instantiated — zero runtime overhead
and zero behavior change for normal play.
## Quick start
```bash
# From repo root
python tools/automation/smoke.py # end-to-end smoke test
python tools/automation/run_game.py # interactive REPL
```
Requirements:
- Python 3.10+ (stdlib only)
- Godot 4.6 mono build at `C:\Apps\godot\Godot_v4.6.2-stable_mono_win64_console.exe`
(override with `Harness(godot_exe=...)` or edit the default in `harness.py`).
- A compiled build: run `dotnet build Chessistics.csproj` first.
## Python API
```python
from tools.automation.harness import Harness
with Harness.launch() as h:
h.load_mission("campaign_01", 0)
state = h.state() # full snapshot as dict
h.screenshot("before") # → .automation_runs/<ts>/screens/before.png
h.place("Rook", (0, 0), (0, 3)) # place a piece
h.step() # one simulation tick (auto-waits for animation)
h.screenshot("after")
h.set_speed(0.1); h.play() # auto-run fast
```
Methods on `Harness`:
- `screenshot(name) -> Path`
- `state() -> dict` — full snapshot + `animating` flag
- `select(kind)` — e.g. `"Rook"`, `"Knight"`
- `place(kind, start, end, level=1)` — returns `{placed, pieceId, reason}`
- `click_cell(col, row, button="left"|"right")`
- `key(name)``"Space"` (play/pause), `"Escape"` (cancel)
- `play()` / `pause()` / `step()` / `wait_idle()`
- `set_speed(interval_seconds)` — auto-step interval
- `load_mission(campaign, missionIndex=0)`
- `back_to_menu()` / `quit()`
Every non-query command auto-waits for `EventAnimator.IsAnimating == false`
before returning, so consecutive calls see a fully-settled state.
## JSON protocol
Inbox: `{ "id": "<uuid>", "cmd": "...", "args": {...} }`
Outbox: `{ "id": "<uuid>", "ok": true, "result": {...} }` or
`{ "id": "<uuid>", "ok": false, "error": "..." }`
See the `cmd` table in `Scripts/Automation/CommandDispatcher.cs` for the
complete list.
## Output locations
- `.automation_runs/<name>/ready.json` — handshake
- `.automation_runs/<name>/inbox/`, `outbox/` — command queue
- `.automation_runs/<name>/screens/*.png` — 1280x720 PNGs
`.automation_runs/` is a good candidate for `.gitignore` (not added here
automatically — add it manually if needed).
## Troubleshooting
- **Godot exits before ready.json** — build probably didn't compile, or the
`--path` arg is wrong. Run `dotnet build Chessistics.csproj` and check
stderr from the harness.
- **Screenshot is all black**`--headless` was passed; the harness needs
a real rendering context. Don't use `--headless` with automation.
- **Command times out** — an animation may be stuck; check the Godot
console log for errors.
## Architecture notes
The harness sits behind a thin facade (`AutomationFacade`) that the
dispatcher uses. It never reaches into engine internals — only calls
existing public surfaces on `GameSim`, `InputMapper`, `EventAnimator`,
`ControlBar`, `PieceStockPanel`. The black-box simulation separation
stays intact.

320
tools/automation/harness.py Normal file
View file

@ -0,0 +1,320 @@
"""
Thin Python wrapper around the file-based Chessistics automation IPC.
Usage:
from harness import Harness
with Harness.launch() as h:
h.load_mission("campaign_01", 0)
h.screenshot("00_initial")
print(h.state()["phase"])
h.place("Rook", (0, 0), (0, 3))
h.step()
h.screenshot("01_after_step")
No third-party dependencies stdlib only.
"""
from __future__ import annotations
# Guard against user-site `json_extensions` namespace packages that shadow the
# stdlib json module. Harmless if nothing is shadowing.
import sys as _sys
for _k in [k for k in list(_sys.modules) if k == "json" or k.startswith("json.")]:
if _sys.modules[_k].__file__ is None: # namespace package → purge
del _sys.modules[_k]
_sys.path[:] = [p for p in _sys.path if "Roaming\\Python" not in p and "Roaming/Python" not in p]
import json
import os
import subprocess
import sys
import time
import uuid
from contextlib import contextmanager
from pathlib import Path
from typing import Any
# Resolve defaults relative to the repo root (parent of tools/).
_REPO_ROOT = Path(__file__).resolve().parents[2]
_DEFAULT_RUNS = _REPO_ROOT / ".automation_runs"
def _default_godot_exe() -> Path:
"""Locate a Godot binary. Env var wins; otherwise platform defaults."""
env = os.environ.get("GODOT_BIN")
if env:
return Path(env)
if sys.platform.startswith("linux"):
return Path("/opt/godot/godot")
return Path(r"C:\Apps\godot\Godot_v4.6.2-stable_mono_win64_console.exe")
_DEFAULT_GODOT = _default_godot_exe()
def _wrap_with_xvfb(argv: list[str]) -> list[str]:
"""On Linux without an existing DISPLAY, wrap Godot in xvfb-run so the
GL-compatibility renderer has a framebuffer to target.
"""
if not sys.platform.startswith("linux"):
return argv
if os.environ.get("DISPLAY"):
return argv
return [
"xvfb-run", "-a",
"--server-args=-screen 0 1280x720x24 -ac +extension GLX +render -noreset",
*argv,
]
class HarnessError(RuntimeError):
"""Raised when a command fails or times out."""
class Harness:
"""Drives a running Chessistics build via file-based IPC.
The game writes `<root>/ready.json` when the automation node is live,
reads commands from `<root>/inbox/<id>.json`, and writes results to
`<root>/outbox/<id>.json`. Screenshots land in `<root>/screens/`.
"""
def __init__(
self,
root: Path,
godot_exe: Path | None = None,
project_path: Path | None = None,
) -> None:
self.root = Path(root).resolve()
self.godot_exe = Path(godot_exe or _DEFAULT_GODOT)
self.project_path = Path(project_path or _REPO_ROOT)
self.inbox = self.root / "inbox"
self.outbox = self.root / "outbox"
self.screens = self.root / "screens"
self.ready_file = self.root / "ready.json"
self._proc: subprocess.Popen[bytes] | None = None
self._seq = 0
# ---------------- lifecycle ----------------
@classmethod
def launch(
cls,
run_name: str | None = None,
godot_exe: Path | None = None,
project_path: Path | None = None,
ready_timeout: float = 20.0,
) -> "Harness":
name = run_name or time.strftime("%Y%m%d_%H%M%S")
root = _DEFAULT_RUNS / name
h = cls(root=root, godot_exe=godot_exe, project_path=project_path)
h.start(ready_timeout=ready_timeout)
return h
def start(self, ready_timeout: float = 20.0) -> None:
# Prepare directories and wipe stale state.
for d in (self.inbox, self.outbox, self.screens):
d.mkdir(parents=True, exist_ok=True)
self._clear_dir(self.inbox)
self._clear_dir(self.outbox)
if self.ready_file.exists():
self.ready_file.unlink()
if not self.godot_exe.exists():
raise HarnessError(f"Godot executable not found: {self.godot_exe}")
if not self.project_path.exists():
raise HarnessError(f"Project path not found: {self.project_path}")
args = _wrap_with_xvfb([
str(self.godot_exe),
"--path", str(self.project_path),
f"--automation={self.root}",
])
print(f"[harness] launching: {' '.join(args)}", file=sys.stderr)
# Inherit stdout/stderr so GD.Print output is visible.
self._proc = subprocess.Popen(args)
# Wait for ready.json handshake.
deadline = time.time() + ready_timeout
while time.time() < deadline:
if self.ready_file.exists():
try:
info = json.loads(self.ready_file.read_text())
print(f"[harness] ready: {info}", file=sys.stderr)
return
except json.JSONDecodeError:
pass
if self._proc.poll() is not None:
raise HarnessError(
f"Godot exited before ready (code={self._proc.returncode})."
)
time.sleep(0.1)
raise HarnessError(f"Timed out waiting for ready.json after {ready_timeout}s.")
def close(self, timeout: float = 5.0) -> None:
if self._proc is None:
return
try:
if self._proc.poll() is None:
# Send quit command if still alive.
try:
self.send("quit", timeout=2.0)
except Exception:
pass
deadline = time.time() + timeout
while time.time() < deadline and self._proc.poll() is None:
time.sleep(0.1)
if self._proc.poll() is None:
self._proc.terminate()
self._proc.wait(timeout=3.0)
finally:
self._proc = None
def __enter__(self) -> "Harness":
return self
def __exit__(self, *_exc) -> None:
self.close()
# ---------------- low-level send ----------------
def send(
self,
cmd: str,
args: dict[str, Any] | None = None,
timeout: float = 15.0,
) -> dict[str, Any]:
self._seq += 1
cmd_id = f"{self._seq:06d}-{uuid.uuid4().hex[:8]}"
envelope = {"id": cmd_id, "cmd": cmd, "args": args or {}}
inbox_path = self.inbox / f"{cmd_id}.json"
outbox_path = self.outbox / f"{cmd_id}.json"
tmp_path = inbox_path.with_suffix(".json.tmp")
tmp_path.write_text(json.dumps(envelope))
os.replace(tmp_path, inbox_path)
deadline = time.time() + timeout
while time.time() < deadline:
if outbox_path.exists():
try:
response = json.loads(outbox_path.read_text())
except json.JSONDecodeError:
time.sleep(0.05)
continue
outbox_path.unlink(missing_ok=True)
if not response.get("ok"):
raise HarnessError(
f"{cmd} failed: {response.get('error', response)}"
)
return response.get("result") or {}
if self._proc and self._proc.poll() is not None:
raise HarnessError(
f"Godot exited during {cmd} (code={self._proc.returncode})."
)
time.sleep(0.05)
raise HarnessError(f"Timed out waiting for {cmd} result after {timeout}s.")
# ---------------- convenience methods ----------------
def screenshot(self, name: str) -> Path:
result = self.send("screenshot", {"name": name})
return Path(result["abs_path"])
def state(self) -> dict[str, Any]:
return self.send("get_state")
def select(self, kind: str) -> dict[str, Any]:
return self.send("select_piece", {"kind": kind})
def place(
self,
kind: str,
start: tuple[int, int],
end: tuple[int, int],
level: int = 1,
) -> dict[str, Any]:
return self.send("place", {
"kind": kind,
"start": list(start),
"end": list(end),
"level": level,
})
def click_cell(self, col: int, row: int, button: str = "left") -> dict[str, Any]:
return self.send("click_cell", {"col": col, "row": row, "button": button})
def key(self, key_name: str) -> dict[str, Any]:
return self.send("key", {"key": key_name})
def play(self) -> dict[str, Any]:
return self.send("play")
def pause(self) -> dict[str, Any]:
return self.send("pause")
def step(self) -> dict[str, Any]:
return self.send("step", timeout=20.0)
def wait_idle(self, timeout_ms: int = 10000) -> dict[str, Any]:
return self.send("wait_idle", {"timeoutMs": timeout_ms})
def set_speed(self, interval: float) -> dict[str, Any]:
return self.send("set_speed", {"interval": interval})
def load_mission(self, campaign: str = "campaign_01", index: int = 0) -> dict[str, Any]:
return self.send("load_mission", {"campaign": campaign, "missionIndex": index}, timeout=20.0)
def back_to_menu(self) -> dict[str, Any]:
return self.send("back_to_menu")
def quick_save(self) -> dict[str, Any]:
return self.send("quick_save")
def quick_load(self) -> dict[str, Any]:
return self.send("quick_load")
def undo(self) -> dict[str, Any]:
return self.send("undo")
def relocate(
self,
piece_id: int,
new_start: tuple[int, int],
new_end: tuple[int, int],
) -> dict[str, Any]:
return self.send("relocate", {
"pieceId": piece_id,
"newStart": list(new_start),
"newEnd": list(new_end),
})
def quit(self) -> dict[str, Any]:
return self.send("quit", timeout=5.0)
# ---------------- private helpers ----------------
@staticmethod
def _clear_dir(p: Path) -> None:
for f in p.iterdir() if p.exists() else []:
try:
f.unlink()
except OSError:
pass
@contextmanager
def launched(**kwargs):
"""Convenience context manager: `with launched() as h: ...`."""
h = Harness.launch(**kwargs)
try:
yield h
finally:
h.close()
if __name__ == "__main__":
# Tiny REPL for manual testing.
with Harness.launch() as h:
print("Ready.", h.root)
print("State:", json.dumps(h.state(), indent=2))

View file

@ -0,0 +1,33 @@
"""Launch a Chessistics build with the automation harness enabled and drop
into an interactive Python REPL.
python tools/automation/run_game.py
Then at the prompt: `h.load_mission()`, `h.state()`, `h.screenshot("foo")`...
"""
from __future__ import annotations
import code
import sys
from harness import Harness
def main() -> None:
h = Harness.launch(run_name="repl")
try:
print(f"\nHarness launched. Working directory: {h.root}")
print("Ready-to-use object: `h` (see harness.py for the full API)\n")
banner = "Chessistics automation REPL — type h.<tab> for commands. Ctrl-D to quit."
local = {"h": h}
code.interact(banner=banner, local=local)
finally:
h.close()
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
sys.exit(130)

114
tools/automation/smoke.py Normal file
View file

@ -0,0 +1,114 @@
"""End-to-end smoke test for the automation harness.
Runs a scripted playthrough: load mission 0, place a rook, take screenshots,
step forward, and validate state at each step. Fails hard on any anomaly.
"""
from __future__ import annotations
import hashlib
import json
import sys
from pathlib import Path
from harness import Harness, HarnessError
def sha256(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def main() -> int:
with Harness.launch(run_name="smoke") as h:
print(f"\n[smoke] run dir: {h.root}\n")
# --- 1. initial state ---
print("[smoke] load_mission")
state = h.load_mission("campaign_01", 0)
assert state["width"] > 0 and state["height"] > 0, state
assert state["phase"] == "Paused", f"expected Paused, got {state['phase']}"
assert state["remainingStock"], "stock is empty"
print(f" board {state['width']}x{state['height']}, phase={state['phase']}, stock={state['remainingStock']}")
shot1 = h.screenshot("01_loaded")
assert shot1.exists(), shot1
print(f" screenshot -> {shot1.name}")
# --- 2. place a piece ---
snap_before = h.state()
stock_before = dict(snap_before["remainingStock"])
first_kind = next(iter(stock_before))
print(f"[smoke] try placing {first_kind} at (0,0)->(0,0)")
# Find any legal placement. Simplest: for a Rook-ish piece, same cell start=end
# isn't legal — try adjacent cells. We scan for one kind we have in stock and a
# simple legal move.
placed = None
for kind in stock_before:
for s in [(0, 0), (1, 0), (0, 1)]:
for e in [(0, 1), (1, 1), (2, 0), (0, 2)]:
if s == e:
continue
result = h.place(kind, s, e)
if result.get("placed"):
placed = (kind, s, e, result)
break
if placed:
break
if placed:
break
if not placed:
print("[smoke] no legal placement found — inspect state dump:")
print(json.dumps(snap_before, indent=2))
return 2
kind, start, end, result = placed
print(f" placed {kind} {start}->{end}, pieceId={result.get('pieceId')}")
h.screenshot("02_placed")
snap_after = h.state()
assert len(snap_after["pieces"]) == len(snap_before["pieces"]) + 1, "piece count didn't grow"
assert snap_after["remainingStock"][kind] == stock_before[kind] - 1, "stock didn't decrement"
# --- 3. determinism check: two screenshots of a paused state must match ---
print("[smoke] determinism check")
a = h.screenshot("det_a")
b = h.screenshot("det_b")
if sha256(a) != sha256(b):
print(f" WARN: screenshots differ (hover cursor likely) — {sha256(a)[:8]} vs {sha256(b)[:8]}")
else:
print(" identical OK")
# --- 4. stepping ---
print("[smoke] stepping up to 10 turns")
for i in range(10):
step_info = h.step()
h.screenshot(f"step_{i:02}")
phase = step_info.get("phase")
print(f" turn={step_info.get('turn')} phase={phase}")
if phase == "MissionComplete":
print(" mission complete!")
break
# --- 5. negative test ---
print("[smoke] negative test: off-board placement")
try:
bad = h.place(kind, (-1, -1), (0, 0))
assert not bad.get("placed"), f"expected placed:false, got {bad}"
assert bad.get("reason"), f"expected reason, got {bad}"
print(f" rejected with: {bad['reason']}")
except HarnessError as e:
print(f" harness error (acceptable): {e}")
# --- 6. responsive after negative ---
print("[smoke] final state dump")
final = h.state()
print(f" phase={final['phase']} turn={final['turn']} pieces={len(final['pieces'])}")
print("\n[smoke] PASS\n")
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,35 @@
"""Smoke test for collision camera pan + notification toast."""
import sys, time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from tools.automation.harness import Harness
def main():
with Harness.launch(run_name="collision") as h:
h.load_mission("campaign_01", 0)
# Two Pawns that will collide on (1,0) at turn 1 — mutual destruction
h.place("Pawn", (0, 0), (1, 0))
h.place("Pawn", (2, 0), (1, 0))
h.screenshot("01_before_collision")
h.set_speed(0.2)
h.play()
time.sleep(1.5)
h.screenshot("02_during_pan_zoom")
time.sleep(1.0)
h.screenshot("03_toast_visible")
s = h.state()
print(f"[after] phase={s['phase']} pieces={len(s['pieces'])} stock={s['remainingStock']}")
assert s['phase'] == 'Paused', f"Expected auto-pause after collision, got {s['phase']}"
assert len(s['pieces']) == 0, "Both pawns should have been returned to stock"
print("OK — collision auto-pause, pieces returned to stock")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,30 @@
"""Smoke test for Delete key removing a selected piece."""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from tools.automation.harness import Harness
def main():
with Harness.launch(run_name="delete_key") as h:
h.load_mission("campaign_01", 0)
h.place("Pawn", (0, 0), (0, 1))
# Select the piece by clicking its start cell
h.click_cell(0, 0)
h.screenshot("01_selected")
assert len(h.state()['pieces']) == 1
# Press Delete
h.key("delete")
h.screenshot("02_deleted")
s = h.state()
assert len(s['pieces']) == 0, "Piece should be removed"
assert s['remainingStock']['Pawn'] == 4, "Stock should be replenished"
print("OK — Delete key removes the selected piece")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,48 @@
"""End-to-end smoke test for quick save / quick load."""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from tools.automation.harness import Harness
def main():
with Harness.launch(run_name="quicksave") as h:
h.load_mission("campaign_01", 0)
h.screenshot("01_loaded")
initial = h.state()
print(f"[initial] turn={initial['turn']} pieces={len(initial['pieces'])}")
# Save a clean checkpoint
saved = h.quick_save()
print(f"[quick_save] {saved}")
# Mutate: place a piece and step
h.place("Pawn", (0, 0), (0, 1))
h.screenshot("02_after_place")
h.set_speed(0.1)
h.play()
import time as _t
_t.sleep(1.5)
h.pause()
dirty = h.state()
print(f"[dirty] turn={dirty['turn']} pieces={len(dirty['pieces'])}")
# Load — should be back to initial state
loaded = h.quick_load()
print(f"[quick_load] {loaded}")
h.screenshot("03_after_load")
restored = h.state()
print(f"[restored] turn={restored['turn']} pieces={len(restored['pieces'])}")
assert restored['turn'] == initial['turn'], "Turn mismatch after restore"
assert len(restored['pieces']) == len(initial['pieces']), "Piece count mismatch"
print("OK — quick save/load roundtrip successful")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,35 @@
"""End-to-end smoke test for piece relocation (drag & drop via IPC)."""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from tools.automation.harness import Harness
def main():
with Harness.launch(run_name="relocate") as h:
h.load_mission("campaign_01", 0)
h.place("Pawn", (0, 0), (0, 1))
h.screenshot("01_placed")
s = h.state()
pid = s['pieces'][0]['id']
assert s['pieces'][0]['start'] == [0, 0]
assert s['pieces'][0]['end'] == [0, 1]
# Relocate pawn to (1,0)→(1,1) — vector preserved
r = h.relocate(pid, (1, 0), (1, 1))
print(f"[relocate] {r}")
assert r['relocated'], r
h.screenshot("02_relocated")
s = h.state()
assert s['pieces'][0]['start'] == [1, 0]
assert s['pieces'][0]['end'] == [1, 1]
print("OK — relocation works")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,24 @@
"""Visual smoke for trajectory arrows + pulsation."""
import sys, time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from tools.automation.harness import Harness
def main():
with Harness.launch(run_name="trajectory") as h:
h.load_mission("campaign_01", 0)
h.place("Pawn", (0, 1), (1, 1))
h.place("Pawn", (2, 1), (3, 1))
# Capture two frames at different phases of the pulse loop
h.screenshot("01_placed_a")
time.sleep(0.8)
h.screenshot("02_placed_b_pulse")
print("OK — trajectories rendered; see screens for arrows + pulse")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,44 @@
"""End-to-end smoke test for Undo."""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from tools.automation.harness import Harness
def main():
with Harness.launch(run_name="undo") as h:
h.load_mission("campaign_01", 0)
h.screenshot("01_loaded")
initial = h.state()
assert len(initial['pieces']) == 0
h.place("Pawn", (0, 0), (0, 1))
h.place("Pawn", (1, 0), (1, 1))
h.screenshot("02_two_pieces")
s = h.state()
assert len(s['pieces']) == 2, f"expected 2 pieces, got {len(s['pieces'])}"
r = h.undo()
print(f"[undo] {r}")
h.screenshot("03_after_first_undo")
s = h.state()
assert len(s['pieces']) == 1, f"expected 1 piece, got {len(s['pieces'])}"
r = h.undo()
print(f"[undo] {r}")
h.screenshot("04_after_second_undo")
s = h.state()
assert len(s['pieces']) == 0
assert s['remainingStock']['Pawn'] == 4
r = h.undo()
print(f"[undo empty] {r}")
assert r['undone'] is False
print("OK — Undo works")
if __name__ == "__main__":
main()