Compare commits
20 commits
358ab48d59
...
dbeac32b0f
| Author | SHA1 | Date | |
|---|---|---|---|
| dbeac32b0f | |||
| 2d2375e569 | |||
| 8a377c2e41 | |||
| e3eb10570b | |||
| 480c783bd6 | |||
| 1d0999a78e | |||
| 6c28665c38 | |||
| c4f6ecbf44 | |||
| 1522b70398 | |||
| 97bca7d7df | |||
| 2537bfe828 | |||
| bd1763f372 | |||
| eba81400a8 | |||
| 3077b2d669 | |||
|
|
5146798f5c | ||
|
|
c451a50349 | ||
|
|
8f3b1b39e7 | ||
|
|
62a208934c | ||
|
|
f86b9abecd | ||
|
|
2d1aea0a7a |
98 changed files with 6710 additions and 956 deletions
|
|
@ -25,8 +25,80 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
jq \
|
jq \
|
||||||
nano \
|
nano \
|
||||||
vim \
|
vim \
|
||||||
|
ca-certificates \
|
||||||
|
curl \
|
||||||
|
wget \
|
||||||
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
&& 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
|
# Ensure default node user has access to /usr/local/share
|
||||||
RUN mkdir -p /usr/local/share/npm-global && \
|
RUN mkdir -p /usr/local/share/npm-global && \
|
||||||
chown -R node:node /usr/local/share
|
chown -R node:node /usr/local/share
|
||||||
|
|
|
||||||
15
.devcontainer/godot-xvfb.sh
Normal file
15
.devcontainer/godot-xvfb.sh
Normal 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" "$@"
|
||||||
|
|
@ -69,12 +69,13 @@ for domain in \
|
||||||
"api.anthropic.com" \
|
"api.anthropic.com" \
|
||||||
"sentry.io" \
|
"sentry.io" \
|
||||||
"statsig.anthropic.com" \
|
"statsig.anthropic.com" \
|
||||||
"statsig.com"; do
|
"statsig.com" \
|
||||||
|
"api.nuget.org"; do
|
||||||
echo "Resolving $domain..."
|
echo "Resolving $domain..."
|
||||||
ips=$(dig +noall +answer A "$domain" | awk '$4 == "A" {print $5}')
|
ips=$(dig +noall +answer A "$domain" | awk '$4 == "A" {print $5}')
|
||||||
if [ -z "$ips" ]; then
|
if [ -z "$ips" ]; then
|
||||||
echo "ERROR: Failed to resolve $domain"
|
echo "WARN: Failed to resolve $domain - skipping"
|
||||||
exit 1
|
continue
|
||||||
fi
|
fi
|
||||||
|
|
||||||
while read -r ip; do
|
while read -r ip; do
|
||||||
|
|
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -21,3 +21,8 @@ Thumbs.db
|
||||||
# Claude Code
|
# Claude Code
|
||||||
.claude/
|
.claude/
|
||||||
.idea
|
.idea
|
||||||
|
|
||||||
|
# Automation harness run outputs
|
||||||
|
.automation_runs/
|
||||||
|
**/__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|
|
||||||
177
CLAUDE.md
177
CLAUDE.md
|
|
@ -29,4 +29,179 @@ Tout `Control` (ColorRect, Label…) a `MouseFilter = Stop` par defaut. Quand un
|
||||||
|
|
||||||
### Plans
|
### 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.
|
||||||
|
|
|
||||||
312
Data/campaigns/campaign_01.json
Normal file
312
Data/campaigns/campaign_01.json
Normal 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
55
PLAN.md
|
|
@ -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
217
README.md
Normal 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.
|
||||||
72
Scripts/Automation/AutomationFacade.cs
Normal file
72
Scripts/Automation/AutomationFacade.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
127
Scripts/Automation/AutomationHarness.cs
Normal file
127
Scripts/Automation/AutomationHarness.cs
Normal 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=<dir>. Polls <dir>/inbox/ each frame for JSON command
|
||||||
|
/// files, dispatches them, and writes results to <dir>/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);
|
||||||
|
}
|
||||||
|
}
|
||||||
332
Scripts/Automation/CommandDispatcher.cs
Normal file
332
Scripts/Automation/CommandDispatcher.cs
Normal 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}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
65
Scripts/Automation/IpcFiles.cs
Normal file
65
Scripts/Automation/IpcFiles.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
123
Scripts/Automation/SnapshotSerializer.cs
Normal file
123
Scripts/Automation/SnapshotSerializer.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
public Coords? PixelToCoords(Vector2 localPos)
|
||||||
{
|
{
|
||||||
int col = Mathf.FloorToInt(localPos.X / CellSize);
|
int col = Mathf.FloorToInt(localPos.X / CellSize);
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ public partial class CellView : Node2D
|
||||||
private static readonly Color WallColor = new("#3A3A3A"); // charcoal
|
private static readonly Color WallColor = new("#3A3A3A"); // charcoal
|
||||||
private static readonly Color ProductionColor = new("#4A6E3A"); // deep forest
|
private static readonly Color ProductionColor = new("#4A6E3A"); // deep forest
|
||||||
private static readonly Color DemandColor = new("#B8942A"); // aged gold
|
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 HighlightColor = new("#44FF4444");
|
||||||
private static readonly Color HoverOutlineColor = new("#FFFFFF88");
|
private static readonly Color HoverOutlineColor = new("#FFFFFF88");
|
||||||
|
|
||||||
|
|
@ -47,6 +48,7 @@ public partial class CellView : Node2D
|
||||||
CellType.Wall => WallColor,
|
CellType.Wall => WallColor,
|
||||||
CellType.Production => ProductionColor,
|
CellType.Production => ProductionColor,
|
||||||
CellType.Demand => DemandColor,
|
CellType.Demand => DemandColor,
|
||||||
|
CellType.Transformer => TransformerColor,
|
||||||
_ => baseColor
|
_ => baseColor
|
||||||
};
|
};
|
||||||
AddChild(_background);
|
AddChild(_background);
|
||||||
|
|
@ -132,4 +134,24 @@ public partial class CellView : Node2D
|
||||||
.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Cubic);
|
.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Cubic);
|
||||||
tween.TweenCallback(Callable.From(() => _highlight.Visible = false));
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,9 +16,13 @@ public partial class InputMapper : Node
|
||||||
public delegate void CellClickedEventHandler(int col, int row);
|
public delegate void CellClickedEventHandler(int col, int row);
|
||||||
[Signal]
|
[Signal]
|
||||||
public delegate void CancelledEventHandler();
|
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 }
|
public enum PlacementPhase { None, SelectingStart, SelectingEnd }
|
||||||
|
|
||||||
|
private const float DragThreshold = 8f;
|
||||||
|
|
||||||
private BoardView _boardView = null!;
|
private BoardView _boardView = null!;
|
||||||
private PieceKind? _selectedKind;
|
private PieceKind? _selectedKind;
|
||||||
private Coords? _selectedStart;
|
private Coords? _selectedStart;
|
||||||
|
|
@ -26,6 +30,11 @@ public partial class InputMapper : Node
|
||||||
private BoardSnapshot? _snapshot;
|
private BoardSnapshot? _snapshot;
|
||||||
private Coords? _hoverCoords;
|
private Coords? _hoverCoords;
|
||||||
|
|
||||||
|
// Drag & drop of a placed piece
|
||||||
|
private int? _dragPieceId;
|
||||||
|
private Vector2 _dragMouseStart;
|
||||||
|
private bool _dragging;
|
||||||
|
|
||||||
public PlacementPhase CurrentPhase => _phase;
|
public PlacementPhase CurrentPhase => _phase;
|
||||||
|
|
||||||
public void Initialize(BoardView boardView)
|
public void Initialize(BoardView boardView)
|
||||||
|
|
@ -72,28 +81,140 @@ public partial class InputMapper : Node
|
||||||
|
|
||||||
public override void _UnhandledInput(InputEvent @event)
|
public override void _UnhandledInput(InputEvent @event)
|
||||||
{
|
{
|
||||||
if (@event is InputEventMouseButton mouseEvent && mouseEvent.Pressed)
|
if (@event is InputEventMouseButton mouseEvent && mouseEvent.ButtonIndex == MouseButton.Left)
|
||||||
{
|
|
||||||
if (mouseEvent.ButtonIndex == MouseButton.Right)
|
|
||||||
{
|
|
||||||
Cancel();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mouseEvent.ButtonIndex == MouseButton.Left)
|
|
||||||
{
|
{
|
||||||
var localPos = _boardView.GetLocalMousePosition();
|
var localPos = _boardView.GetLocalMousePosition();
|
||||||
GD.Print($"[InputMapper] LEFT CLICK — localPos={localPos}, phase={_phase}");
|
if (mouseEvent.Pressed)
|
||||||
HandleLeftClick();
|
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)
|
if (@event is InputEventKey keyEvent && keyEvent.Pressed && keyEvent.Keycode == Key.Escape)
|
||||||
{
|
{
|
||||||
|
CancelDrag();
|
||||||
Cancel();
|
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()
|
private void HandleLeftClick()
|
||||||
{
|
{
|
||||||
var localPos = _boardView.GetLocalMousePosition();
|
var localPos = _boardView.GetLocalMousePosition();
|
||||||
|
|
@ -107,22 +228,41 @@ public partial class InputMapper : Node
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
HandleClickAt(coords.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HandleClickAt(Coords coords)
|
||||||
|
{
|
||||||
switch (_phase)
|
switch (_phase)
|
||||||
{
|
{
|
||||||
case PlacementPhase.SelectingStart:
|
case PlacementPhase.SelectingStart:
|
||||||
OnStartSelected(coords.Value);
|
OnStartSelected(coords);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case PlacementPhase.SelectingEnd:
|
case PlacementPhase.SelectingEnd:
|
||||||
OnEndSelected(coords.Value);
|
OnEndSelected(coords);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
EmitSignal(SignalName.CellClicked, coords.Value.Col, coords.Value.Row);
|
EmitSignal(SignalName.CellClicked, coords.Col, coords.Row);
|
||||||
break;
|
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)
|
private void OnStartSelected(Coords start)
|
||||||
{
|
{
|
||||||
if (_selectedKind == null || _snapshot == null)
|
if (_selectedKind == null || _snapshot == null)
|
||||||
|
|
|
||||||
730
Scripts/Main.cs
730
Scripts/Main.cs
File diff suppressed because it is too large
Load diff
|
|
@ -25,6 +25,9 @@ public partial class PieceView : Node2D
|
||||||
private static readonly Color QueenColor = new("#8E3D5A"); // deep burgundy
|
private static readonly Color QueenColor = new("#8E3D5A"); // deep burgundy
|
||||||
private static readonly Color WoodCargoColor = new("#A67C32");
|
private static readonly Color WoodCargoColor = new("#A67C32");
|
||||||
private static readonly Color StoneCargoColor = new("#7A7A7A");
|
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);
|
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)
|
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.Wood => WoodCargoColor,
|
||||||
CargoType.Stone => StoneCargoColor,
|
CargoType.Stone => StoneCargoColor,
|
||||||
|
CargoType.Tools => ToolsCargoColor,
|
||||||
|
CargoType.Arms => ArmsCargoColor,
|
||||||
|
CargoType.Gold => GoldCargoColor,
|
||||||
_ => Colors.White
|
_ => Colors.White
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,34 +5,54 @@ namespace Chessistics.Scripts.Pieces;
|
||||||
public partial class TrajectView : Line2D
|
public partial class TrajectView : Line2D
|
||||||
{
|
{
|
||||||
public int PieceId { get; private set; }
|
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)
|
public void Setup(int pieceId, Vector2 from, Vector2 to, Color color)
|
||||||
{
|
{
|
||||||
PieceId = pieceId;
|
PieceId = pieceId;
|
||||||
Width = 2.5f;
|
_baseColor = color;
|
||||||
DefaultColor = new Color(color, 0.35f);
|
Width = 3f;
|
||||||
|
DefaultColor = new Color(color, 0.4f);
|
||||||
Antialiased = true;
|
Antialiased = true;
|
||||||
ClearPoints();
|
ClearPoints();
|
||||||
AddPoint(from);
|
AddPoint(from);
|
||||||
AddPoint(to);
|
AddPoint(to);
|
||||||
ZIndex = -1;
|
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 dir = (to - from).Normalized();
|
||||||
var perp = new Vector2(-dir.Y, dir.X);
|
var perp = new Vector2(-dir.Y, dir.X);
|
||||||
float arrowSize = 8f;
|
const float arrowSize = 9f;
|
||||||
var tip = to - dir * 4f; // slightly inset from end
|
var tip = to - dir * 4f;
|
||||||
var baseL = tip - dir * arrowSize + perp * arrowSize * 0.5f;
|
var baseL = tip - dir * arrowSize + perp * arrowSize * 0.5f;
|
||||||
var baseR = tip - dir * arrowSize - perp * arrowSize * 0.5f;
|
var baseR = tip - dir * arrowSize - perp * arrowSize * 0.5f;
|
||||||
|
return new Polygon2D
|
||||||
_arrow = new Polygon2D
|
|
||||||
{
|
{
|
||||||
Polygon = [tip - Position, baseL - Position, baseR - Position],
|
Polygon = [tip, baseL, baseR],
|
||||||
Color = new Color(color, 0.4f),
|
Color = new Color(color, 0.5f)
|
||||||
Position = Vector2.Zero
|
|
||||||
};
|
};
|
||||||
// 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,9 @@ public partial class EventAnimator : Node
|
||||||
|
|
||||||
private static readonly Color WoodCargoColor = new("#A67C32");
|
private static readonly Color WoodCargoColor = new("#A67C32");
|
||||||
private static readonly Color StoneCargoColor = new("#7A7A7A");
|
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 ProduceDuration = 0.35f;
|
||||||
private const float TransferDuration = 0.28f;
|
private const float TransferDuration = 0.28f;
|
||||||
|
|
@ -36,6 +39,10 @@ public partial class EventAnimator : Node
|
||||||
public delegate void TurnAnimationCompletedEventHandler();
|
public delegate void TurnAnimationCompletedEventHandler();
|
||||||
[Signal]
|
[Signal]
|
||||||
public delegate void VictoryReachedEventHandler();
|
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,
|
public void Initialize(BoardView boardView, ObjectivePanel objectivePanel,
|
||||||
ControlBar controlBar, MetricsOverlay metricsOverlay)
|
ControlBar controlBar, MetricsOverlay metricsOverlay)
|
||||||
|
|
@ -73,16 +80,20 @@ public partial class EventAnimator : Node
|
||||||
tween.SetParallel(false);
|
tween.SetParallel(false);
|
||||||
|
|
||||||
var produceEvents = new List<CargoProducedEvent>();
|
var produceEvents = new List<CargoProducedEvent>();
|
||||||
|
var transformerEvents = new List<CargoConvertedEvent>();
|
||||||
var transferEvents = new List<IWorldEvent>();
|
var transferEvents = new List<IWorldEvent>();
|
||||||
var moveEvents = new List<PieceMovedEvent>();
|
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)
|
foreach (var evt in events)
|
||||||
{
|
{
|
||||||
switch (evt)
|
switch (evt)
|
||||||
{
|
{
|
||||||
case TurnStartedEvent ts:
|
case TurnStartedEvent ts:
|
||||||
FlushPhases(tween, produceEvents, transferEvents, moveEvents, collisionEvents);
|
FlushPhases(tween, produceEvents, transformerEvents, transferEvents, moveEvents, collisionEvents);
|
||||||
tween.TweenCallback(Callable.From(() => _controlBar.UpdateTurn(ts.TurnNumber)));
|
tween.TweenCallback(Callable.From(() => _controlBar.UpdateTurn(ts.TurnNumber)));
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
|
@ -90,6 +101,10 @@ public partial class EventAnimator : Node
|
||||||
produceEvents.Add(produced);
|
produceEvents.Add(produced);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case CargoConvertedEvent converted:
|
||||||
|
transformerEvents.Add(converted);
|
||||||
|
break;
|
||||||
|
|
||||||
case CargoTransferredEvent:
|
case CargoTransferredEvent:
|
||||||
case DemandProgressEvent:
|
case DemandProgressEvent:
|
||||||
transferEvents.Add(evt);
|
transferEvents.Add(evt);
|
||||||
|
|
@ -99,23 +114,35 @@ public partial class EventAnimator : Node
|
||||||
moveEvents.Add(moved);
|
moveEvents.Add(moved);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case PieceDestroyedEvent destroyed:
|
case PieceReturnedToStockEvent returned:
|
||||||
collisionEvents.Add(destroyed);
|
collisionEvents.Add(returned);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case VictoryEvent victory:
|
case MissionCompleteEvent:
|
||||||
FlushPhases(tween, produceEvents, transferEvents, moveEvents, collisionEvents);
|
FlushPhases(tween, produceEvents, transformerEvents, transferEvents, moveEvents, collisionEvents);
|
||||||
tween.TweenCallback(Callable.From(() =>
|
tween.TweenCallback(Callable.From(() =>
|
||||||
{
|
{
|
||||||
SfxManager.Instance?.PlayVictory();
|
SfxManager.Instance?.PlayVictory();
|
||||||
SpawnConfetti();
|
SpawnConfetti();
|
||||||
_metricsOverlay.ShowMetrics(victory.Metrics);
|
if (!hasAutoAdvance)
|
||||||
EmitSignal(SignalName.VictoryReached);
|
EmitSignal(SignalName.VictoryReached);
|
||||||
}));
|
}));
|
||||||
break;
|
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:
|
case TurnEndedEvent:
|
||||||
FlushPhases(tween, produceEvents, transferEvents, moveEvents, collisionEvents);
|
FlushPhases(tween, produceEvents, transformerEvents, transferEvents, moveEvents, collisionEvents);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
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(() =>
|
tween.TweenCallback(Callable.From(() =>
|
||||||
{
|
{
|
||||||
|
|
@ -135,10 +162,29 @@ public partial class EventAnimator : Node
|
||||||
private void FlushPhases(
|
private void FlushPhases(
|
||||||
Tween tween,
|
Tween tween,
|
||||||
List<CargoProducedEvent> produceEvents,
|
List<CargoProducedEvent> produceEvents,
|
||||||
|
List<CargoConvertedEvent> transformerEvents,
|
||||||
List<IWorldEvent> transferEvents,
|
List<IWorldEvent> transferEvents,
|
||||||
List<PieceMovedEvent> moveEvents,
|
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
|
// Phase 1: Produce — warm golden flash + particle burst
|
||||||
if (produceEvents.Count > 0)
|
if (produceEvents.Count > 0)
|
||||||
{
|
{
|
||||||
|
|
@ -222,16 +268,16 @@ public partial class EventAnimator : Node
|
||||||
moveEvents.Clear();
|
moveEvents.Clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 4: Collision/Destruction — shrink + spin + particles
|
// Phase 4: Collision — piece returned to stock (shrink + spin + particles)
|
||||||
if (collisionEvents.Count > 0)
|
if (collisionEvents.Count > 0)
|
||||||
{
|
{
|
||||||
var captured = collisionEvents.ToList();
|
var captured = collisionEvents.ToList();
|
||||||
tween.TweenCallback(Callable.From(() =>
|
tween.TweenCallback(Callable.From(() =>
|
||||||
{
|
{
|
||||||
SfxManager.Instance?.PlayDestroy();
|
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);
|
SpawnDestroyParticles(pv.Position);
|
||||||
|
|
||||||
|
|
@ -243,12 +289,18 @@ public partial class EventAnimator : Node
|
||||||
dt.TweenProperty(pv, "modulate:a", 0f, DestroyDuration);
|
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.TweenInterval(DestroyDuration);
|
||||||
tween.TweenCallback(Callable.From(() =>
|
tween.TweenCallback(Callable.From(() =>
|
||||||
{
|
{
|
||||||
foreach (var destroyed in captured)
|
foreach (var returned in captured)
|
||||||
UnregisterPiece(destroyed.PieceId);
|
UnregisterPiece(returned.PieceId);
|
||||||
}));
|
}));
|
||||||
collisionEvents.Clear();
|
collisionEvents.Clear();
|
||||||
}
|
}
|
||||||
|
|
@ -423,6 +475,9 @@ public partial class EventAnimator : Node
|
||||||
{
|
{
|
||||||
CargoType.Wood => WoodCargoColor,
|
CargoType.Wood => WoodCargoColor,
|
||||||
CargoType.Stone => StoneCargoColor,
|
CargoType.Stone => StoneCargoColor,
|
||||||
|
CargoType.Tools => ToolsCargoColor,
|
||||||
|
CargoType.Arms => ArmsCargoColor,
|
||||||
|
CargoType.Gold => GoldCargoColor,
|
||||||
_ => Colors.White
|
_ => Colors.White
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,15 +12,14 @@ public partial class ControlBar : HBoxContainer
|
||||||
public delegate void PausePressedEventHandler();
|
public delegate void PausePressedEventHandler();
|
||||||
[Signal]
|
[Signal]
|
||||||
public delegate void StepPressedEventHandler();
|
public delegate void StepPressedEventHandler();
|
||||||
[Signal]
|
// Stop removed in campaign mode
|
||||||
public delegate void StopPressedEventHandler();
|
|
||||||
[Signal]
|
[Signal]
|
||||||
public delegate void SpeedChangedEventHandler(float speed);
|
public delegate void SpeedChangedEventHandler(float speed);
|
||||||
|
|
||||||
private Button _playButton = null!;
|
private Button _playButton = null!;
|
||||||
private Button _pauseButton = null!;
|
private Button _pauseButton = null!;
|
||||||
private Button _stepButton = null!;
|
private Button _stepButton = null!;
|
||||||
private Button _stopButton = null!;
|
// _stopButton removed
|
||||||
private OptionButton _speedSelect = null!;
|
private OptionButton _speedSelect = null!;
|
||||||
private Label _turnLabel = null!;
|
private Label _turnLabel = null!;
|
||||||
|
|
||||||
|
|
@ -46,9 +45,7 @@ public partial class ControlBar : HBoxContainer
|
||||||
_stepButton.Pressed += () => EmitSignal(SignalName.StepPressed);
|
_stepButton.Pressed += () => EmitSignal(SignalName.StepPressed);
|
||||||
AddChild(_stepButton);
|
AddChild(_stepButton);
|
||||||
|
|
||||||
_stopButton = CreateStyledButton("STOP");
|
// Stop button removed in campaign mode
|
||||||
_stopButton.Pressed += () => EmitSignal(SignalName.StopPressed);
|
|
||||||
AddChild(_stopButton);
|
|
||||||
|
|
||||||
// Spacer
|
// Spacer
|
||||||
AddChild(new Control { CustomMinimumSize = new Vector2(12, 0) });
|
AddChild(new Control { CustomMinimumSize = new Vector2(12, 0) });
|
||||||
|
|
@ -68,7 +65,7 @@ public partial class ControlBar : HBoxContainer
|
||||||
_turnLabel.AddThemeColorOverride("font_color", new Color("#999999"));
|
_turnLabel.AddThemeColorOverride("font_color", new Color("#999999"));
|
||||||
AddChild(_turnLabel);
|
AddChild(_turnLabel);
|
||||||
|
|
||||||
UpdateForPhase(SimPhase.Edit);
|
UpdateForPhase(SimPhase.Paused);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Button CreateStyledButton(string text)
|
private static Button CreateStyledButton(string text)
|
||||||
|
|
@ -76,7 +73,8 @@ public partial class ControlBar : HBoxContainer
|
||||||
var btn = new Button
|
var btn = new Button
|
||||||
{
|
{
|
||||||
Text = text,
|
Text = text,
|
||||||
CustomMinimumSize = new Vector2(70, 30)
|
CustomMinimumSize = new Vector2(70, 30),
|
||||||
|
FocusMode = FocusModeEnum.None
|
||||||
};
|
};
|
||||||
btn.AddThemeFontSizeOverride("font_size", 11);
|
btn.AddThemeFontSizeOverride("font_size", 11);
|
||||||
|
|
||||||
|
|
@ -124,10 +122,9 @@ public partial class ControlBar : HBoxContainer
|
||||||
|
|
||||||
public void UpdateForPhase(SimPhase phase)
|
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;
|
_pauseButton.Disabled = phase != SimPhase.Running;
|
||||||
_stepButton.Disabled = phase == SimPhase.Running || phase == SimPhase.Victory || phase == SimPhase.Defeat;
|
_stepButton.Disabled = phase == SimPhase.Running;
|
||||||
_stopButton.Disabled = phase == SimPhase.Edit;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void UpdateTurn(int turn)
|
public void UpdateTurn(int turn)
|
||||||
|
|
|
||||||
|
|
@ -79,4 +79,6 @@ public partial class DetailPanel : PanelContainer
|
||||||
}
|
}
|
||||||
|
|
||||||
public new void Hide() => Visible = false;
|
public new void Hide() => Visible = false;
|
||||||
|
|
||||||
|
public int? CurrentPieceId => Visible ? _currentPieceId : null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
73
Scripts/UI/FlavorBanner.cs
Normal file
73
Scripts/UI/FlavorBanner.cs
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
1
Scripts/UI/FlavorBanner.cs.uid
Normal file
1
Scripts/UI/FlavorBanner.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://bcapogap6qff2
|
||||||
|
|
@ -6,19 +6,7 @@ namespace Chessistics.Scripts.UI;
|
||||||
public partial class LevelSelectScreen : Control
|
public partial class LevelSelectScreen : Control
|
||||||
{
|
{
|
||||||
[Signal]
|
[Signal]
|
||||||
public delegate void LevelSelectedEventHandler(int levelIndex);
|
public delegate void StartCampaignPressedEventHandler();
|
||||||
|
|
||||||
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 override void _Ready()
|
public override void _Ready()
|
||||||
{
|
{
|
||||||
|
|
@ -30,199 +18,79 @@ public partial class LevelSelectScreen : Control
|
||||||
bg.MouseFilter = MouseFilterEnum.Ignore;
|
bg.MouseFilter = MouseFilterEnum.Ignore;
|
||||||
AddChild(bg);
|
AddChild(bg);
|
||||||
|
|
||||||
// Outer margin
|
// Center content
|
||||||
var margin = new MarginContainer();
|
var center = new CenterContainer();
|
||||||
margin.SetAnchorsPreset(LayoutPreset.FullRect);
|
center.SetAnchorsPreset(LayoutPreset.FullRect);
|
||||||
margin.AddThemeConstantOverride("margin_left", 80);
|
center.MouseFilter = MouseFilterEnum.Ignore;
|
||||||
margin.AddThemeConstantOverride("margin_right", 80);
|
|
||||||
margin.AddThemeConstantOverride("margin_top", 60);
|
|
||||||
margin.AddThemeConstantOverride("margin_bottom", 60);
|
|
||||||
margin.MouseFilter = MouseFilterEnum.Ignore;
|
|
||||||
|
|
||||||
var outerVBox = new VBoxContainer();
|
var vbox = new VBoxContainer { Alignment = BoxContainer.AlignmentMode.Center };
|
||||||
outerVBox.AddThemeConstantOverride("separation", 0);
|
vbox.AddThemeConstantOverride("separation", 24);
|
||||||
outerVBox.MouseFilter = MouseFilterEnum.Ignore;
|
vbox.MouseFilter = MouseFilterEnum.Ignore;
|
||||||
|
|
||||||
// --- Header section ---
|
|
||||||
var headerBox = new VBoxContainer();
|
|
||||||
headerBox.AddThemeConstantOverride("separation", 4);
|
|
||||||
headerBox.MouseFilter = MouseFilterEnum.Ignore;
|
|
||||||
|
|
||||||
|
// Title
|
||||||
var title = new Label
|
var title = new Label
|
||||||
{
|
{
|
||||||
Text = "CHESSISTICS",
|
Text = "CHESSISTICS",
|
||||||
HorizontalAlignment = HorizontalAlignment.Center
|
HorizontalAlignment = HorizontalAlignment.Center
|
||||||
};
|
};
|
||||||
title.AddThemeFontSizeOverride("font_size", 48);
|
title.AddThemeFontSizeOverride("font_size", 56);
|
||||||
title.AddThemeColorOverride("font_color", new Color("#FFD700"));
|
title.AddThemeColorOverride("font_color", new Color("#FFD700"));
|
||||||
headerBox.AddChild(title);
|
vbox.AddChild(title);
|
||||||
|
|
||||||
|
// Subtitle
|
||||||
var subtitle = new Label
|
var subtitle = new Label
|
||||||
{
|
{
|
||||||
Text = "Selectionnez un niveau",
|
Text = "La Quête du Roi",
|
||||||
HorizontalAlignment = HorizontalAlignment.Center
|
HorizontalAlignment = HorizontalAlignment.Center
|
||||||
};
|
};
|
||||||
subtitle.AddThemeFontSizeOverride("font_size", 15);
|
subtitle.AddThemeFontSizeOverride("font_size", 18);
|
||||||
subtitle.AddThemeColorOverride("font_color", new Color("#777777"));
|
subtitle.AddThemeColorOverride("font_color", new Color("#999999"));
|
||||||
headerBox.AddChild(subtitle);
|
vbox.AddChild(subtitle);
|
||||||
|
|
||||||
outerVBox.AddChild(headerBox);
|
|
||||||
|
|
||||||
// Spacer
|
// 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 ---
|
// Start button
|
||||||
var scroll = new ScrollContainer
|
var startBtn = new Button
|
||||||
{
|
{
|
||||||
SizeFlagsVertical = SizeFlags.ExpandFill,
|
Text = "Démarrer",
|
||||||
HorizontalScrollMode = ScrollContainer.ScrollMode.Disabled
|
CustomMinimumSize = new Vector2(200, 52),
|
||||||
};
|
|
||||||
|
|
||||||
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),
|
|
||||||
SizeFlagsHorizontal = SizeFlags.ShrinkCenter
|
SizeFlagsHorizontal = SizeFlags.ShrinkCenter
|
||||||
};
|
};
|
||||||
|
|
||||||
var btnNormal = new StyleBoxFlat
|
var btnNormal = new StyleBoxFlat
|
||||||
{
|
{
|
||||||
BgColor = new Color("#8B6914"),
|
BgColor = new Color("#8B6914"),
|
||||||
CornerRadiusTopLeft = 6,
|
CornerRadiusTopLeft = 8, CornerRadiusTopRight = 8,
|
||||||
CornerRadiusTopRight = 6,
|
CornerRadiusBottomLeft = 8, CornerRadiusBottomRight = 8,
|
||||||
CornerRadiusBottomLeft = 6,
|
ContentMarginLeft = 32, ContentMarginRight = 32,
|
||||||
CornerRadiusBottomRight = 6,
|
ContentMarginTop = 12, ContentMarginBottom = 12
|
||||||
ContentMarginLeft = 24,
|
|
||||||
ContentMarginRight = 24,
|
|
||||||
ContentMarginTop = 8,
|
|
||||||
ContentMarginBottom = 8
|
|
||||||
};
|
};
|
||||||
var btnHover = new StyleBoxFlat
|
var btnHover = new StyleBoxFlat
|
||||||
{
|
{
|
||||||
BgColor = new Color("#B8860B"),
|
BgColor = new Color("#B8860B"),
|
||||||
CornerRadiusTopLeft = 6,
|
CornerRadiusTopLeft = 8, CornerRadiusTopRight = 8,
|
||||||
CornerRadiusTopRight = 6,
|
CornerRadiusBottomLeft = 8, CornerRadiusBottomRight = 8,
|
||||||
CornerRadiusBottomLeft = 6,
|
ContentMarginLeft = 32, ContentMarginRight = 32,
|
||||||
CornerRadiusBottomRight = 6,
|
ContentMarginTop = 12, ContentMarginBottom = 12
|
||||||
ContentMarginLeft = 24,
|
|
||||||
ContentMarginRight = 24,
|
|
||||||
ContentMarginTop = 8,
|
|
||||||
ContentMarginBottom = 8
|
|
||||||
};
|
};
|
||||||
var btnPressed = new StyleBoxFlat
|
var btnPressed = new StyleBoxFlat
|
||||||
{
|
{
|
||||||
BgColor = new Color("#6B5010"),
|
BgColor = new Color("#6B5010"),
|
||||||
CornerRadiusTopLeft = 6,
|
CornerRadiusTopLeft = 8, CornerRadiusTopRight = 8,
|
||||||
CornerRadiusTopRight = 6,
|
CornerRadiusBottomLeft = 8, CornerRadiusBottomRight = 8,
|
||||||
CornerRadiusBottomLeft = 6,
|
ContentMarginLeft = 32, ContentMarginRight = 32,
|
||||||
CornerRadiusBottomRight = 6,
|
ContentMarginTop = 12, ContentMarginBottom = 12
|
||||||
ContentMarginLeft = 24,
|
|
||||||
ContentMarginRight = 24,
|
|
||||||
ContentMarginTop = 8,
|
|
||||||
ContentMarginBottom = 8
|
|
||||||
};
|
};
|
||||||
playBtn.AddThemeStyleboxOverride("normal", btnNormal);
|
startBtn.AddThemeStyleboxOverride("normal", btnNormal);
|
||||||
playBtn.AddThemeStyleboxOverride("hover", btnHover);
|
startBtn.AddThemeStyleboxOverride("hover", btnHover);
|
||||||
playBtn.AddThemeStyleboxOverride("pressed", btnPressed);
|
startBtn.AddThemeStyleboxOverride("pressed", btnPressed);
|
||||||
playBtn.AddThemeFontSizeOverride("font_size", 15);
|
startBtn.AddThemeFontSizeOverride("font_size", 20);
|
||||||
|
|
||||||
var idx = index;
|
startBtn.Pressed += () => EmitSignal(SignalName.StartCampaignPressed);
|
||||||
playBtn.Pressed += () => EmitSignal(SignalName.LevelSelected, idx);
|
vbox.AddChild(startBtn);
|
||||||
vbox.AddChild(playBtn);
|
|
||||||
|
|
||||||
card.AddChild(vbox);
|
center.AddChild(vbox);
|
||||||
return card;
|
AddChild(center);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,14 +7,13 @@ public partial class MetricsOverlay : PanelContainer
|
||||||
{
|
{
|
||||||
[Signal]
|
[Signal]
|
||||||
public delegate void NextLevelPressedEventHandler();
|
public delegate void NextLevelPressedEventHandler();
|
||||||
[Signal]
|
|
||||||
public delegate void RetryPressedEventHandler();
|
|
||||||
|
|
||||||
private Label _titleLabel = null!;
|
private Label _titleLabel = null!;
|
||||||
private Label _piecesLabel = null!;
|
private Label _piecesLabel = null!;
|
||||||
private Label _turnsLabel = null!;
|
private Label _turnsLabel = null!;
|
||||||
private Label _cellsLabel = null!;
|
private Label _cellsLabel = null!;
|
||||||
private HBoxContainer _buttons = null!;
|
private HBoxContainer _buttons = null!;
|
||||||
|
private Button _nextBtn = null!;
|
||||||
|
|
||||||
public override void _Ready()
|
public override void _Ready()
|
||||||
{
|
{
|
||||||
|
|
@ -57,13 +56,9 @@ public partial class MetricsOverlay : PanelContainer
|
||||||
_buttons = new HBoxContainer { Alignment = BoxContainer.AlignmentMode.Center };
|
_buttons = new HBoxContainer { Alignment = BoxContainer.AlignmentMode.Center };
|
||||||
_buttons.AddThemeConstantOverride("separation", 16);
|
_buttons.AddThemeConstantOverride("separation", 16);
|
||||||
|
|
||||||
var retryBtn = CreateStyledButton("Rejouer");
|
_nextBtn = CreateStyledButton("Mission suivante");
|
||||||
retryBtn.Pressed += () => EmitSignal(SignalName.RetryPressed);
|
_nextBtn.Pressed += () => EmitSignal(SignalName.NextLevelPressed);
|
||||||
_buttons.AddChild(retryBtn);
|
_buttons.AddChild(_nextBtn);
|
||||||
|
|
||||||
var nextBtn = CreateStyledButton("Niveau suivant");
|
|
||||||
nextBtn.Pressed += () => EmitSignal(SignalName.NextLevelPressed);
|
|
||||||
_buttons.AddChild(nextBtn);
|
|
||||||
|
|
||||||
vbox.AddChild(_buttons);
|
vbox.AddChild(_buttons);
|
||||||
AddChild(vbox);
|
AddChild(vbox);
|
||||||
|
|
@ -109,6 +104,37 @@ public partial class MetricsOverlay : PanelContainer
|
||||||
tween.TweenProperty(_buttons, "modulate:a", 1f, 0.2f);
|
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()
|
public new void Hide()
|
||||||
{
|
{
|
||||||
Visible = false;
|
Visible = false;
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ namespace Chessistics.Scripts.UI;
|
||||||
|
|
||||||
public partial class ObjectivePanel : VBoxContainer
|
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)
|
public void Setup(IReadOnlyList<DemandDef> demands)
|
||||||
{
|
{
|
||||||
|
|
@ -57,13 +57,8 @@ public partial class ObjectivePanel : VBoxContainer
|
||||||
bar.AddThemeStyleboxOverride("fill", fillStyle);
|
bar.AddThemeStyleboxOverride("fill", fillStyle);
|
||||||
vbox.AddChild(bar);
|
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);
|
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;
|
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
|
// Animate the progress bar value
|
||||||
var tween = entry.bar.CreateTween();
|
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);
|
.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Cubic);
|
||||||
|
|
||||||
if (current >= required)
|
if (current >= required)
|
||||||
{
|
{
|
||||||
|
entry.label.Text = $"{name}: {required}/{required}";
|
||||||
entry.label.AddThemeColorOverride("font_color", new Color("#5AAC5A")); // warm green
|
entry.label.AddThemeColorOverride("font_color", new Color("#5AAC5A")); // warm green
|
||||||
|
|
||||||
// Flash the progress bar green
|
// Flash the progress bar green
|
||||||
|
|
@ -90,6 +91,9 @@ public partial class ObjectivePanel : VBoxContainer
|
||||||
CornerRadiusBottomLeft = 3, CornerRadiusBottomRight = 3
|
CornerRadiusBottomLeft = 3, CornerRadiusBottomRight = 3
|
||||||
};
|
};
|
||||||
entry.bar.AddThemeStyleboxOverride("fill", fillStyle);
|
entry.bar.AddThemeStyleboxOverride("fill", fillStyle);
|
||||||
|
|
||||||
|
// Mark as completed — no further updates
|
||||||
|
_entries[demandCell] = (entry.label, entry.bar, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,8 @@ public partial class PieceStockPanel : VBoxContainer
|
||||||
{
|
{
|
||||||
Text = GetPieceName(entry.Kind),
|
Text = GetPieceName(entry.Kind),
|
||||||
CustomMinimumSize = new Vector2(120, 32),
|
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);
|
ApplyButtonStyle(button, false);
|
||||||
|
|
||||||
|
|
@ -148,6 +149,13 @@ public partial class PieceStockPanel : VBoxContainer
|
||||||
UpdateButtonStates();
|
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
|
private static string GetPieceName(PieceKind kind) => kind switch
|
||||||
{
|
{
|
||||||
PieceKind.Pawn => "Pion",
|
PieceKind.Pawn => "Pion",
|
||||||
|
|
|
||||||
156
chessistics-engine/Commands/CampaignCommands.cs
Normal file
156
chessistics-engine/Commands/CampaignCommands.cs
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
1
chessistics-engine/Commands/CampaignCommands.cs.uid
Normal file
1
chessistics-engine/Commands/CampaignCommands.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://b2103p4uf8f3t
|
||||||
|
|
@ -5,6 +5,10 @@ using Chessistics.Engine.Simulation;
|
||||||
|
|
||||||
namespace Chessistics.Engine.Commands;
|
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 class PlacePieceCommand : WorldCommand
|
||||||
{
|
{
|
||||||
public PieceKind Kind { get; }
|
public PieceKind Kind { get; }
|
||||||
|
|
@ -22,10 +26,6 @@ public class PlacePieceCommand : WorldCommand
|
||||||
|
|
||||||
public override void AssertApplicationConditions(BoardState state)
|
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)
|
if (!state.RemainingStock.TryGetValue(Kind, out var remaining) || remaining <= 0)
|
||||||
throw new CommandRejectedException(
|
throw new CommandRejectedException(
|
||||||
new PlacementRejectedEvent(Kind, Start, End, "No pieces of this type remaining in stock."));
|
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))
|
if (!MoveValidator.IsLegalPlacement(Kind, Start, End, state))
|
||||||
throw new CommandRejectedException(
|
throw new CommandRejectedException(
|
||||||
new PlacementRejectedEvent(Kind, Start, End, "Illegal move for this piece type."));
|
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)
|
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));
|
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)
|
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)
|
foreach (var (prodPos, prod) in state.Productions)
|
||||||
{
|
{
|
||||||
if (piece.StartCell.IsAdjacent4(prodPos) || piece.EndCell.IsAdjacent4(prodPos))
|
if (piece.StartCell.IsAdjacent4(prodPos) || piece.EndCell.IsAdjacent4(prodPos))
|
||||||
return prod.Cargo;
|
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)
|
foreach (var existing in state.Pieces)
|
||||||
{
|
{
|
||||||
if (existing.CargoFilter == null) continue;
|
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 class RemovePieceCommand : WorldCommand
|
||||||
{
|
{
|
||||||
public int PieceId { get; }
|
public int PieceId { get; }
|
||||||
|
|
@ -100,10 +114,6 @@ public class RemovePieceCommand : WorldCommand
|
||||||
|
|
||||||
public override void AssertApplicationConditions(BoardState state)
|
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)
|
if (state.GetPieceById(PieceId) == null)
|
||||||
throw new CommandRejectedException(
|
throw new CommandRejectedException(
|
||||||
new CommandRejectedEvent(nameof(RemovePieceCommand), $"Piece {PieceId} not found."));
|
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 class PauseSimulationCommand : WorldCommand
|
||||||
{
|
{
|
||||||
public override void AssertApplicationConditions(BoardState state)
|
public override void AssertApplicationConditions(BoardState state)
|
||||||
|
|
@ -159,9 +149,9 @@ public class ResumeSimulationCommand : WorldCommand
|
||||||
{
|
{
|
||||||
public override void AssertApplicationConditions(BoardState state)
|
public override void AssertApplicationConditions(BoardState state)
|
||||||
{
|
{
|
||||||
if (state.Phase != SimPhase.Paused)
|
if (state.Phase != SimPhase.Paused && state.Phase != SimPhase.MissionComplete)
|
||||||
throw new CommandRejectedException(
|
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)
|
protected override void DoApply(BoardState state, List<IWorldEvent> changeList)
|
||||||
|
|
@ -175,72 +165,20 @@ public class StepSimulationCommand : WorldCommand
|
||||||
{
|
{
|
||||||
public override void AssertApplicationConditions(BoardState state)
|
public override void AssertApplicationConditions(BoardState state)
|
||||||
{
|
{
|
||||||
if (state.Phase == SimPhase.Edit && state.Pieces.Count == 0)
|
if (state.Phase != SimPhase.Running && state.Phase != SimPhase.Paused)
|
||||||
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)
|
|
||||||
throw new CommandRejectedException(
|
throw new CommandRejectedException(
|
||||||
new CommandRejectedEvent(nameof(StepSimulationCommand), "Cannot step in current phase."));
|
new CommandRejectedEvent(nameof(StepSimulationCommand), "Cannot step in current phase."));
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void DoApply(BoardState state, List<IWorldEvent> changeList)
|
protected override void DoApply(BoardState state, List<IWorldEvent> changeList)
|
||||||
{
|
{
|
||||||
if (state.Phase == SimPhase.Edit)
|
var wasRunning = state.Phase == SimPhase.Running;
|
||||||
state.Phase = SimPhase.Paused;
|
|
||||||
|
|
||||||
TurnExecutor.ExecuteTurn(state, changeList);
|
TurnExecutor.ExecuteTurn(state, changeList);
|
||||||
|
|
||||||
// After a step, remain in Paused unless victory/defeat occurred
|
// After a manual step (was Paused), remain Paused.
|
||||||
if (state.Phase == SimPhase.Running)
|
// 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;
|
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -2,26 +2,42 @@ using Chessistics.Engine.Model;
|
||||||
|
|
||||||
namespace Chessistics.Engine.Events;
|
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 PiecePlacedEvent(int PieceId, PieceKind Kind, Coords Start, Coords End) : IWorldEvent;
|
||||||
public record PieceRemovedEvent(int PieceId) : IWorldEvent;
|
public record PieceRemovedEvent(int PieceId) : IWorldEvent;
|
||||||
public record PlacementRejectedEvent(PieceKind Kind, Coords Start, Coords End, string Reason) : IWorldEvent;
|
public record PlacementRejectedEvent(PieceKind Kind, Coords Start, Coords End, string Reason) : IWorldEvent;
|
||||||
public record CommandRejectedEvent(string CommandType, string Reason) : IWorldEvent;
|
public record CommandRejectedEvent(string CommandType, string Reason) : IWorldEvent;
|
||||||
|
|
||||||
// Simulation lifecycle events
|
// Simulation lifecycle events
|
||||||
public record SimulationStartedEvent : IWorldEvent;
|
|
||||||
public record SimulationPausedEvent : IWorldEvent;
|
public record SimulationPausedEvent : IWorldEvent;
|
||||||
public record SimulationResumedEvent : 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
|
// Turn events — all carry TurnNumber for animation grouping
|
||||||
public record TurnStartedEvent(int TurnNumber) : IWorldEvent;
|
public record TurnStartedEvent(int TurnNumber) : IWorldEvent;
|
||||||
public record PieceMovedEvent(int TurnNumber, int PieceId, Coords From, Coords To) : 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 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 CargoProducedEvent(int TurnNumber, Coords ProductionCell, CargoType Type) : IWorldEvent;
|
||||||
public record DemandProgressEvent(int TurnNumber, Coords DemandCell, string Name, int Current, int Required) : 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;
|
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;
|
||||||
|
|
|
||||||
221
chessistics-engine/Loading/CampaignLoader.cs
Normal file
221
chessistics-engine/Loading/CampaignLoader.cs
Normal 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
1
chessistics-engine/Loading/CampaignLoader.cs.uid
Normal file
1
chessistics-engine/Loading/CampaignLoader.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://dq4ycsj6oc1nh
|
||||||
|
|
@ -8,10 +8,14 @@ public class BoardSnapshot
|
||||||
public IReadOnlyList<ProductionSnapshot> Productions { get; }
|
public IReadOnlyList<ProductionSnapshot> Productions { get; }
|
||||||
public IReadOnlyList<DemandSnapshot> Demands { get; }
|
public IReadOnlyList<DemandSnapshot> Demands { get; }
|
||||||
public IReadOnlyList<PieceSnapshot> Pieces { get; }
|
public IReadOnlyList<PieceSnapshot> Pieces { get; }
|
||||||
|
public IReadOnlyList<TransformerSnapshot> Transformers { get; }
|
||||||
public SimPhase Phase { get; }
|
public SimPhase Phase { get; }
|
||||||
public int TurnNumber { get; }
|
public int TurnNumber { get; }
|
||||||
public IReadOnlyDictionary<PieceKind, int> RemainingStock { get; }
|
public IReadOnlyDictionary<PieceKind, int> RemainingStock { get; }
|
||||||
|
|
||||||
|
// Campaign info (null in legacy level mode)
|
||||||
|
public CampaignSnapshot? Campaign { get; }
|
||||||
|
|
||||||
public BoardSnapshot(BoardState state)
|
public BoardSnapshot(BoardState state)
|
||||||
{
|
{
|
||||||
Width = state.Width;
|
Width = state.Width;
|
||||||
|
|
@ -28,17 +32,51 @@ public class BoardSnapshot
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
Demands = state.Demands.Values
|
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();
|
.ToList();
|
||||||
|
|
||||||
Pieces = state.Pieces
|
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))
|
.Select(p => new PieceSnapshot(p.Id, p.Kind, p.Level, p.StartCell, p.EndCell, p.CurrentCell, p.Cargo, p.CargoFilter, p.SocialStatus))
|
||||||
.ToList();
|
.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);
|
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 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 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);
|
||||||
|
|
|
||||||
|
|
@ -4,98 +4,86 @@ namespace Chessistics.Engine.Model;
|
||||||
|
|
||||||
public class BoardState
|
public class BoardState
|
||||||
{
|
{
|
||||||
public int Width { get; }
|
public int Width { get; private set; }
|
||||||
public int Height { get; }
|
public int Height { get; private set; }
|
||||||
public CellType[,] Grid { get; }
|
public CellType[,] Grid { get; private set; }
|
||||||
public Dictionary<Coords, ProductionDef> Productions { get; }
|
public Dictionary<Coords, ProductionDef> Productions { get; }
|
||||||
public Dictionary<Coords, DemandState> Demands { get; }
|
public Dictionary<Coords, DemandState> Demands { get; }
|
||||||
public List<PieceState> Pieces { get; }
|
public List<PieceState> Pieces { get; }
|
||||||
public List<PieceState> DestroyedPieces { get; } = new();
|
public List<PieceState> DestroyedPieces { get; } = new();
|
||||||
public Dictionary<Coords, int> ProductionBuffers { get; }
|
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 SimPhase Phase { get; set; }
|
||||||
public int TurnNumber { get; set; }
|
public int TurnNumber { get; set; }
|
||||||
public int NextPieceId { get; set; }
|
public int NextPieceId { get; set; }
|
||||||
public Dictionary<PieceKind, int> RemainingStock { get; }
|
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)
|
// Tracks all cells ever occupied by a piece (for metrics)
|
||||||
public HashSet<Coords> OccupiedCells { get; }
|
public HashSet<Coords> OccupiedCells { get; }
|
||||||
|
|
||||||
private readonly LevelDef _levelDef;
|
private readonly LevelDef? _levelDef;
|
||||||
private bool _isApplyingCommand;
|
private bool _isApplyingCommand;
|
||||||
|
|
||||||
private BoardState(LevelDef level)
|
private BoardState(int width, int height)
|
||||||
{
|
{
|
||||||
_levelDef = level;
|
Width = width;
|
||||||
Width = level.Width;
|
Height = height;
|
||||||
Height = level.Height;
|
|
||||||
MaxDeadline = level.MaxDeadline;
|
|
||||||
|
|
||||||
Grid = new CellType[Width, Height];
|
Grid = new CellType[Width, Height];
|
||||||
Productions = new Dictionary<Coords, ProductionDef>();
|
Productions = new Dictionary<Coords, ProductionDef>();
|
||||||
Demands = new Dictionary<Coords, DemandState>();
|
Demands = new Dictionary<Coords, DemandState>();
|
||||||
Pieces = new List<PieceState>();
|
Pieces = new List<PieceState>();
|
||||||
ProductionBuffers = new Dictionary<Coords, int>();
|
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>();
|
RemainingStock = new Dictionary<PieceKind, int>();
|
||||||
OccupiedCells = new HashSet<Coords>();
|
OccupiedCells = new HashSet<Coords>();
|
||||||
|
|
||||||
Phase = SimPhase.Edit;
|
Phase = SimPhase.Paused;
|
||||||
TurnNumber = 0;
|
TurnNumber = 0;
|
||||||
NextPieceId = 1;
|
NextPieceId = 1;
|
||||||
|
|
||||||
// Initialize grid as empty
|
|
||||||
for (int c = 0; c < Width; c++)
|
for (int c = 0; c < Width; c++)
|
||||||
for (int r = 0; r < Height; r++)
|
for (int r = 0; r < Height; r++)
|
||||||
Grid[c, r] = CellType.Empty;
|
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
|
private BoardState(LevelDef level) : this(level.Width, level.Height)
|
||||||
foreach (var demand in level.Demands)
|
|
||||||
{
|
{
|
||||||
Grid[demand.Position.Col, demand.Position.Row] = CellType.Demand;
|
_levelDef = level;
|
||||||
Demands[demand.Position] = new DemandState(demand);
|
ApplyLevelDef(level);
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize stock
|
|
||||||
foreach (var stock in level.Stock)
|
|
||||||
RemainingStock[stock.Kind] = stock.Count;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static BoardState FromLevel(LevelDef level) => new(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 CellType GetCell(Coords coords) => Grid[coords.Col, coords.Row];
|
||||||
|
|
||||||
public bool IsOnBoard(Coords coords) => coords.IsOnBoard(Width, Height);
|
public bool IsOnBoard(Coords coords) => coords.IsOnBoard(Width, Height);
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
public HashSet<Coords> GetOccupiedCells()
|
public HashSet<Coords> GetOccupiedCells()
|
||||||
{
|
{
|
||||||
var occupied = new HashSet<Coords>();
|
var occupied = new HashSet<Coords>();
|
||||||
foreach (var piece in Pieces)
|
foreach (var piece in Pieces)
|
||||||
{
|
|
||||||
if (Phase == SimPhase.Edit)
|
|
||||||
{
|
|
||||||
occupied.Add(piece.StartCell);
|
|
||||||
occupied.Add(piece.EndCell);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
{
|
||||||
occupied.Add(piece.CurrentCell);
|
occupied.Add(piece.CurrentCell);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return occupied;
|
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()
|
public void ResetFromLevel()
|
||||||
{
|
{
|
||||||
|
if (_levelDef == null)
|
||||||
|
throw new InvalidOperationException("Cannot reset: no level definition.");
|
||||||
|
|
||||||
Pieces.Clear();
|
Pieces.Clear();
|
||||||
DestroyedPieces.Clear();
|
DestroyedPieces.Clear();
|
||||||
Productions.Clear();
|
Productions.Clear();
|
||||||
Demands.Clear();
|
Demands.Clear();
|
||||||
ProductionBuffers.Clear();
|
ProductionBuffers.Clear();
|
||||||
|
Transformers.Clear();
|
||||||
|
TransformerInputBuffers.Clear();
|
||||||
|
TransformerOutputBuffers.Clear();
|
||||||
RemainingStock.Clear();
|
RemainingStock.Clear();
|
||||||
OccupiedCells.Clear();
|
OccupiedCells.Clear();
|
||||||
|
|
||||||
Phase = SimPhase.Edit;
|
Phase = SimPhase.Paused;
|
||||||
TurnNumber = 0;
|
TurnNumber = 0;
|
||||||
NextPieceId = 1;
|
NextPieceId = 1;
|
||||||
|
|
||||||
|
|
@ -135,23 +202,149 @@ public class BoardState
|
||||||
for (int r = 0; r < Height; r++)
|
for (int r = 0; r < Height; r++)
|
||||||
Grid[c, r] = CellType.Empty;
|
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;
|
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;
|
Grid[prod.Position.Col, prod.Position.Row] = CellType.Production;
|
||||||
Productions[prod.Position] = prod;
|
Productions[prod.Position] = prod;
|
||||||
ProductionBuffers[prod.Position] = 0;
|
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;
|
Grid[demand.Position.Col, demand.Position.Row] = CellType.Demand;
|
||||||
Demands[demand.Position] = new DemandState(demand);
|
Demands[demand.Position] = new DemandState(demand);
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var stock in _levelDef.Stock)
|
foreach (var stock in level.Stock)
|
||||||
RemainingStock[stock.Kind] = stock.Count;
|
RemainingStock[stock.Kind] = stock.Count;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
9
chessistics-engine/Model/CampaignDef.cs
Normal file
9
chessistics-engine/Model/CampaignDef.cs
Normal 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; } = [];
|
||||||
|
}
|
||||||
1
chessistics-engine/Model/CampaignDef.cs.uid
Normal file
1
chessistics-engine/Model/CampaignDef.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://cpyjhyp308ybb
|
||||||
22
chessistics-engine/Model/CampaignState.cs
Normal file
22
chessistics-engine/Model/CampaignState.cs
Normal 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));
|
||||||
|
}
|
||||||
1
chessistics-engine/Model/CampaignState.cs.uid
Normal file
1
chessistics-engine/Model/CampaignState.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://bxmuxyxroua54
|
||||||
|
|
@ -3,5 +3,8 @@ namespace Chessistics.Engine.Model;
|
||||||
public enum CargoType
|
public enum CargoType
|
||||||
{
|
{
|
||||||
Wood,
|
Wood,
|
||||||
Stone
|
Stone,
|
||||||
|
Tools,
|
||||||
|
Arms,
|
||||||
|
Gold
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,5 +5,6 @@ public enum CellType
|
||||||
Empty,
|
Empty,
|
||||||
Wall,
|
Wall,
|
||||||
Production,
|
Production,
|
||||||
Demand
|
Demand,
|
||||||
|
Transformer
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,22 @@
|
||||||
namespace Chessistics.Engine.Model;
|
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"/> > 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);
|
||||||
|
|
|
||||||
|
|
@ -4,17 +4,40 @@ public class DemandState
|
||||||
{
|
{
|
||||||
public DemandDef Definition { get; }
|
public DemandDef Definition { get; }
|
||||||
public int ReceivedCount { get; set; }
|
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;
|
Definition = definition;
|
||||||
|
MissionIndex = missionIndex;
|
||||||
ReceivedCount = 0;
|
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 Coords Position => Definition.Position;
|
||||||
public string Name => Definition.Name;
|
public string Name => Definition.Name;
|
||||||
public CargoType Cargo => Definition.Cargo;
|
public CargoType Cargo => Definition.Cargo;
|
||||||
public int Required => Definition.Amount;
|
public int Required => Definition.Amount;
|
||||||
public int Deadline => Definition.Deadline;
|
public int Deadline => Definition.Deadline;
|
||||||
|
|
||||||
|
public DemandState Clone()
|
||||||
|
{
|
||||||
|
return new DemandState(Definition, MissionIndex)
|
||||||
|
{
|
||||||
|
ReceivedCount = ReceivedCount,
|
||||||
|
Buffer = Buffer,
|
||||||
|
SustainedTurns = SustainedTurns,
|
||||||
|
InShortage = InShortage
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
14
chessistics-engine/Model/MissionDef.cs
Normal file
14
chessistics-engine/Model/MissionDef.cs
Normal 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; } = [];
|
||||||
|
}
|
||||||
1
chessistics-engine/Model/MissionDef.cs.uid
Normal file
1
chessistics-engine/Model/MissionDef.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://bh4mvmkqeohqj
|
||||||
|
|
@ -5,8 +5,8 @@ public class PieceState
|
||||||
public int Id { get; }
|
public int Id { get; }
|
||||||
public PieceKind Kind { get; }
|
public PieceKind Kind { get; }
|
||||||
public int Level { get; }
|
public int Level { get; }
|
||||||
public Coords StartCell { get; }
|
public Coords StartCell { get; private set; }
|
||||||
public Coords EndCell { get; }
|
public Coords EndCell { get; private set; }
|
||||||
public Coords CurrentCell { get; set; }
|
public Coords CurrentCell { get; set; }
|
||||||
public CargoType? Cargo { get; set; }
|
public CargoType? Cargo { get; set; }
|
||||||
public CargoType? CargoFilter { get; set; }
|
public CargoType? CargoFilter { get; set; }
|
||||||
|
|
@ -30,4 +30,25 @@ public class PieceState
|
||||||
/// Returns the cell this piece will move to next.
|
/// Returns the cell this piece will move to next.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Coords TargetCell => CurrentCell == StartCell ? EndCell : StartCell;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
3
chessistics-engine/Model/PieceUpgrade.cs
Normal file
3
chessistics-engine/Model/PieceUpgrade.cs
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
namespace Chessistics.Engine.Model;
|
||||||
|
|
||||||
|
public record PieceUpgrade(PieceKind Kind, int Level);
|
||||||
1
chessistics-engine/Model/PieceUpgrade.cs.uid
Normal file
1
chessistics-engine/Model/PieceUpgrade.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://broa1hmowlt7
|
||||||
|
|
@ -2,9 +2,7 @@ namespace Chessistics.Engine.Model;
|
||||||
|
|
||||||
public enum SimPhase
|
public enum SimPhase
|
||||||
{
|
{
|
||||||
Edit,
|
|
||||||
Running,
|
Running,
|
||||||
Paused,
|
Paused,
|
||||||
Victory,
|
MissionComplete
|
||||||
Defeat
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
18
chessistics-engine/Model/TerrainPatch.cs
Normal file
18
chessistics-engine/Model/TerrainPatch.cs
Normal 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; }
|
||||||
|
}
|
||||||
1
chessistics-engine/Model/TerrainPatch.cs.uid
Normal file
1
chessistics-engine/Model/TerrainPatch.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://c51en0egfstje
|
||||||
10
chessistics-engine/Model/TransformerDef.cs
Normal file
10
chessistics-engine/Model/TransformerDef.cs
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
namespace Chessistics.Engine.Model;
|
||||||
|
|
||||||
|
public record TransformerDef(
|
||||||
|
Coords Position,
|
||||||
|
string Name,
|
||||||
|
CargoType InputCargo,
|
||||||
|
int InputRequired,
|
||||||
|
CargoType OutputCargo,
|
||||||
|
int OutputAmount
|
||||||
|
);
|
||||||
1
chessistics-engine/Model/TransformerDef.cs.uid
Normal file
1
chessistics-engine/Model/TransformerDef.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://cu7cpt1u5mtxd
|
||||||
40
chessistics-engine/Model/WorldSave.cs
Normal file
40
chessistics-engine/Model/WorldSave.cs
Normal 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();
|
||||||
|
}
|
||||||
25
chessistics-engine/Rules/MissionChecker.cs
Normal file
25
chessistics-engine/Rules/MissionChecker.cs
Normal 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);
|
||||||
|
}
|
||||||
1
chessistics-engine/Rules/MissionChecker.cs.uid
Normal file
1
chessistics-engine/Rules/MissionChecker.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://b3vg5keyv2aj6
|
||||||
|
|
@ -14,7 +14,10 @@ public static class TransferResolver
|
||||||
// Phase A: Productions give to adjacent pieces
|
// Phase A: Productions give to adjacent pieces
|
||||||
ResolveProductionTransfers(state, events, participated, productionGave);
|
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);
|
ResolvePieceTransfers(state, events, participated);
|
||||||
|
|
||||||
return events;
|
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(
|
private static void ResolvePieceTransfers(
|
||||||
BoardState state, List<IWorldEvent> events, HashSet<int> participated)
|
BoardState state, List<IWorldEvent> events, HashSet<int> participated)
|
||||||
{
|
{
|
||||||
|
|
@ -78,6 +110,8 @@ public static class TransferResolver
|
||||||
{
|
{
|
||||||
giver.Cargo = null;
|
giver.Cargo = null;
|
||||||
adjacentDemand.ReceivedCount++;
|
adjacentDemand.ReceivedCount++;
|
||||||
|
if (adjacentDemand.IsRecurring)
|
||||||
|
adjacentDemand.Buffer++;
|
||||||
participated.Add(giver.Id);
|
participated.Add(giver.Id);
|
||||||
|
|
||||||
events.Add(new CargoTransferredEvent(
|
events.Add(new CargoTransferredEvent(
|
||||||
|
|
@ -91,7 +125,22 @@ public static class TransferResolver
|
||||||
continue;
|
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,
|
var receivers = GetAdjacentPiecesWithoutCargo(state, giver.CurrentCell, participated,
|
||||||
cargoType: cargoType);
|
cargoType: cargoType);
|
||||||
if (receivers.Count == 0) continue;
|
if (receivers.Count == 0) continue;
|
||||||
|
|
@ -136,6 +185,17 @@ public static class TransferResolver
|
||||||
.FirstOrDefault();
|
.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>
|
/// <summary>
|
||||||
/// Returns a sort key (0-3) based on cardinal direction from center to piece.
|
/// Returns a sort key (0-3) based on cardinal direction from center to piece.
|
||||||
/// In y-up coordinates, clockwise from 0° (right):
|
/// In y-up coordinates, clockwise from 0° (right):
|
||||||
|
|
|
||||||
|
|
@ -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();
|
|
||||||
}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
uid://uh7qhohnsxpa
|
|
||||||
|
|
@ -7,14 +7,25 @@ namespace Chessistics.Engine.Simulation;
|
||||||
public class GameSim
|
public class GameSim
|
||||||
{
|
{
|
||||||
private readonly BoardState _state;
|
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)
|
public GameSim(LevelDef level)
|
||||||
{
|
{
|
||||||
_state = BoardState.FromLevel(level);
|
_state = BoardState.FromLevel(level);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public GameSim(CampaignDef campaign)
|
||||||
|
{
|
||||||
|
_state = BoardState.FromCampaign(campaign);
|
||||||
|
}
|
||||||
|
|
||||||
public IReadOnlyList<IWorldEvent> ProcessCommand(IWorldCommand command)
|
public IReadOnlyList<IWorldEvent> ProcessCommand(IWorldCommand command)
|
||||||
{
|
{
|
||||||
|
WorldSave? undoCheckpoint = IsUndoable(command) ? _state.CaptureSave() : null;
|
||||||
|
|
||||||
var changeList = new List<IWorldEvent>();
|
var changeList = new List<IWorldEvent>();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|
@ -24,8 +35,73 @@ public class GameSim
|
||||||
{
|
{
|
||||||
return [ex.RejectionEvent];
|
return [ex.RejectionEvent];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (undoCheckpoint != null && ContainsMutation(changeList))
|
||||||
|
PushUndo(undoCheckpoint);
|
||||||
|
|
||||||
return changeList;
|
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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,49 +14,98 @@ public static class TurnExecutor
|
||||||
// Sub-phase 1: PRODUCTION
|
// Sub-phase 1: PRODUCTION
|
||||||
ExecuteProduction(state, changeList);
|
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);
|
var transferEvents = TransferResolver.ResolveTransfers(state);
|
||||||
changeList.AddRange(transferEvents);
|
changeList.AddRange(transferEvents);
|
||||||
|
|
||||||
// Sub-phase 3: MOVEMENT
|
// Sub-phase 4: MOVEMENT
|
||||||
ExecuteMovement(state, changeList);
|
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);
|
var collisions = CollisionResolver.ResolveCollisions(state.Pieces);
|
||||||
foreach (var (survivor, destroyed, cell) in collisions)
|
foreach (var (survivor, destroyed, cell) in collisions)
|
||||||
{
|
{
|
||||||
foreach (var victim in destroyed)
|
foreach (var victim in destroyed)
|
||||||
{
|
{
|
||||||
state.Pieces.Remove(victim);
|
state.Pieces.Remove(victim);
|
||||||
state.DestroyedPieces.Add(victim);
|
|
||||||
victim.Cargo = null;
|
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
|
// Auto-pause on collision
|
||||||
if (VictoryChecker.AllDemandsMet(state))
|
if (collisions.Count > 0)
|
||||||
{
|
{
|
||||||
state.Phase = SimPhase.Victory;
|
state.Phase = SimPhase.Paused;
|
||||||
changeList.Add(new VictoryEvent(state.TurnNumber, ComputeMetrics(state)));
|
changeList.Add(new SimulationPausedEvent());
|
||||||
}
|
}
|
||||||
else if (VictoryChecker.AnyDeadlineExpired(state))
|
|
||||||
|
// Check mission completion
|
||||||
|
if (MissionChecker.AllCurrentDemandsMet(state) && state.Demands.Count > 0)
|
||||||
{
|
{
|
||||||
state.Phase = SimPhase.Defeat;
|
var campaign = state.Campaign;
|
||||||
foreach (var demand in VictoryChecker.GetExpiredDemands(state))
|
var missionIndex = campaign?.CurrentMissionIndex ?? 0;
|
||||||
changeList.Add(new DeadlineExpiredEvent(state.TurnNumber, demand.Position, demand.Name));
|
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));
|
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)
|
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();
|
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)
|
foreach (var (piece, from, to) in moves)
|
||||||
{
|
{
|
||||||
piece.CurrentCell = to;
|
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)
|
private static void ExecuteProduction(BoardState state, List<IWorldEvent> changeList)
|
||||||
{
|
{
|
||||||
foreach (var (pos, prod) in state.Productions)
|
foreach (var (pos, prod) in state.Productions)
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@ public class SimHelper
|
||||||
|
|
||||||
public static SimHelper FromLevel(LevelDef level) => new(new GameSim(level));
|
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)
|
public IReadOnlyList<IWorldEvent> Place(PieceKind kind, Coords start, Coords end)
|
||||||
=> Sim.ProcessCommand(new PlacePieceCommand(kind, start, end));
|
=> Sim.ProcessCommand(new PlacePieceCommand(kind, start, end));
|
||||||
|
|
||||||
|
|
@ -22,9 +24,6 @@ public class SimHelper
|
||||||
public IReadOnlyList<IWorldEvent> Remove(int pieceId)
|
public IReadOnlyList<IWorldEvent> Remove(int pieceId)
|
||||||
=> Sim.ProcessCommand(new RemovePieceCommand(pieceId));
|
=> Sim.ProcessCommand(new RemovePieceCommand(pieceId));
|
||||||
|
|
||||||
public IReadOnlyList<IWorldEvent> Start()
|
|
||||||
=> Sim.ProcessCommand(new StartSimulationCommand());
|
|
||||||
|
|
||||||
public IReadOnlyList<IWorldEvent> Step()
|
public IReadOnlyList<IWorldEvent> Step()
|
||||||
=> Sim.ProcessCommand(new StepSimulationCommand());
|
=> Sim.ProcessCommand(new StepSimulationCommand());
|
||||||
|
|
||||||
|
|
@ -34,17 +33,20 @@ public class SimHelper
|
||||||
public IReadOnlyList<IWorldEvent> Resume()
|
public IReadOnlyList<IWorldEvent> Resume()
|
||||||
=> Sim.ProcessCommand(new ResumeSimulationCommand());
|
=> Sim.ProcessCommand(new ResumeSimulationCommand());
|
||||||
|
|
||||||
public IReadOnlyList<IWorldEvent> Stop()
|
public IReadOnlyList<IWorldEvent> AdvanceMission()
|
||||||
=> Sim.ProcessCommand(new StopSimulationCommand());
|
=> Sim.ProcessCommand(new AdvanceMissionCommand());
|
||||||
|
|
||||||
public IReadOnlyList<IWorldEvent> Reset()
|
|
||||||
=> Sim.ProcessCommand(new ResetLevelCommand());
|
|
||||||
|
|
||||||
public List<IWorldEvent> StepN(int n)
|
public List<IWorldEvent> StepN(int n)
|
||||||
{
|
{
|
||||||
var allEvents = new List<IWorldEvent>();
|
var allEvents = new List<IWorldEvent>();
|
||||||
for (int i = 0; i < n; i++)
|
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;
|
return allEvents;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
81
chessistics-tests/Loading/Campaign01Tests.cs
Normal file
81
chessistics-tests/Loading/Campaign01Tests.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
63
chessistics-tests/Loading/CampaignFileTests.cs
Normal file
63
chessistics-tests/Loading/CampaignFileTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
1
chessistics-tests/Loading/CampaignFileTests.cs.uid
Normal file
1
chessistics-tests/Loading/CampaignFileTests.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://c6sab8mq5a201
|
||||||
220
chessistics-tests/Loading/CampaignLoaderTests.cs
Normal file
220
chessistics-tests/Loading/CampaignLoaderTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
1
chessistics-tests/Loading/CampaignLoaderTests.cs.uid
Normal file
1
chessistics-tests/Loading/CampaignLoaderTests.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://boxlkyt1rnb6l
|
||||||
135
chessistics-tests/Loading/CampaignValidationTests.cs
Normal file
135
chessistics-tests/Loading/CampaignValidationTests.cs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
chessistics-tests/Loading/CampaignValidationTests.cs.uid
Normal file
1
chessistics-tests/Loading/CampaignValidationTests.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://dle5bi0rtya8x
|
||||||
112
chessistics-tests/Model/TerrainPatchTests.cs
Normal file
112
chessistics-tests/Model/TerrainPatchTests.cs
Normal 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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
1
chessistics-tests/Model/TerrainPatchTests.cs.uid
Normal file
1
chessistics-tests/Model/TerrainPatchTests.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://kujovcfoy6j2
|
||||||
|
|
@ -319,6 +319,32 @@ public class TransferResolverTests
|
||||||
Assert.DoesNotContain(events, e => e is CargoTransferredEvent ct && ct.GivingPieceId == 1 && ct.ReceivingPieceId == 2);
|
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]
|
[Fact]
|
||||||
public void DemandPriority_OverPieceReceiver()
|
public void DemandPriority_OverPieceReceiver()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
308
chessistics-tests/Simulation/CampaignTests.cs
Normal file
308
chessistics-tests/Simulation/CampaignTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
1
chessistics-tests/Simulation/CampaignTests.cs.uid
Normal file
1
chessistics-tests/Simulation/CampaignTests.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://bxt85xb77h4jn
|
||||||
|
|
@ -8,10 +8,8 @@ namespace Chessistics.Tests.Simulation;
|
||||||
public class FullLevelTests
|
public class FullLevelTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[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)
|
var level = new BoardBuilder(4, 4)
|
||||||
.WithProduction(0, 0, "Scierie", CargoType.Wood)
|
.WithProduction(0, 0, "Scierie", CargoType.Wood)
|
||||||
.WithDemand(3, 0, "Depot Royal", CargoType.Wood, 3, 30)
|
.WithDemand(3, 0, "Depot Royal", CargoType.Wood, 3, 30)
|
||||||
|
|
@ -20,23 +18,14 @@ public class FullLevelTests
|
||||||
var sim = SimHelper.FromLevel(level);
|
var sim = SimHelper.FromLevel(level);
|
||||||
|
|
||||||
sim.Place(PieceKind.Rook, (1, 0), (2, 0));
|
sim.Place(PieceKind.Rook, (1, 0), (2, 0));
|
||||||
sim.Start();
|
|
||||||
|
|
||||||
var allEvents = sim.StepN(30);
|
var allEvents = sim.StepN(30);
|
||||||
Assert.Contains(allEvents, e => e is VictoryEvent);
|
Assert.Contains(allEvents, e => e is MissionCompleteEvent);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[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)
|
var level = new BoardBuilder(6, 6)
|
||||||
.WithProduction(0, 0, "Scierie", CargoType.Wood)
|
.WithProduction(0, 0, "Scierie", CargoType.Wood)
|
||||||
.WithDemand(5, 0, "Depot Royal", CargoType.Wood, 2, 50)
|
.WithDemand(5, 0, "Depot Royal", CargoType.Wood, 2, 50)
|
||||||
|
|
@ -53,35 +42,21 @@ public class FullLevelTests
|
||||||
// Route 2: up then right → demand (5,4)
|
// Route 2: up then right → demand (5,4)
|
||||||
sim.Place(PieceKind.Rook, (0, 1), (0, 2));
|
sim.Place(PieceKind.Rook, (0, 1), (0, 2));
|
||||||
sim.Place(PieceKind.Rook, (0, 2), (2, 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));
|
var events5 = sim.Place(PieceKind.Rook, (2, 2), (3, 2));
|
||||||
Assert.DoesNotContain(events5, e => e is PlacementRejectedEvent);
|
Assert.DoesNotContain(events5, e => e is PlacementRejectedEvent);
|
||||||
|
|
||||||
sim.Place(PieceKind.Bishop, (3, 2), (4, 3));
|
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));
|
var events6 = sim.Place(PieceKind.Rook, (4, 3), (5, 3));
|
||||||
Assert.DoesNotContain(events6, e => e is PlacementRejectedEvent);
|
Assert.DoesNotContain(events6, e => e is PlacementRejectedEvent);
|
||||||
|
|
||||||
sim.Start();
|
|
||||||
var allEvents = sim.StepN(60);
|
var allEvents = sim.StepN(60);
|
||||||
Assert.Contains(allEvents, e => e is VictoryEvent);
|
Assert.Contains(allEvents, e => e is MissionCompleteEvent);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[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)
|
var level = new BoardBuilder(6, 6)
|
||||||
.WithProduction(0, 0, "Scierie", CargoType.Wood)
|
.WithProduction(0, 0, "Scierie", CargoType.Wood)
|
||||||
.WithProduction(5, 0, "Carriere", CargoType.Stone)
|
.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, 3), (1, 4));
|
||||||
sim.Place(PieceKind.Rook, (1, 4), (0, 4));
|
sim.Place(PieceKind.Rook, (1, 4), (0, 4));
|
||||||
|
|
||||||
sim.Start();
|
|
||||||
var allEvents = sim.StepN(80);
|
var allEvents = sim.StepN(80);
|
||||||
Assert.Contains(allEvents, e => e is VictoryEvent);
|
Assert.Contains(allEvents, e => e is MissionCompleteEvent);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Level1_InsufficientPieces_NoVictory()
|
public void Level1_InsufficientPieces_NoMissionComplete()
|
||||||
{
|
{
|
||||||
var level = new BoardBuilder(4, 4)
|
var level = new BoardBuilder(4, 4)
|
||||||
.WithProduction(0, 0, "Scierie", CargoType.Wood)
|
.WithProduction(0, 0, "Scierie", CargoType.Wood)
|
||||||
|
|
@ -125,11 +99,10 @@ public class FullLevelTests
|
||||||
var sim = SimHelper.FromLevel(level);
|
var sim = SimHelper.FromLevel(level);
|
||||||
|
|
||||||
sim.Place(PieceKind.Rook, (1, 1), (2, 1));
|
sim.Place(PieceKind.Rook, (1, 1), (2, 1));
|
||||||
sim.Start();
|
|
||||||
|
|
||||||
var allEvents = sim.StepN(8);
|
var allEvents = sim.StepN(8);
|
||||||
|
|
||||||
Assert.DoesNotContain(allEvents, e => e is VictoryEvent);
|
// No deadline concept anymore — just no mission complete
|
||||||
Assert.Contains(allEvents, e => e is DeadlineExpiredEvent);
|
Assert.DoesNotContain(allEvents, e => e is MissionCompleteEvent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,41 +44,27 @@ public class GameSimTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void PlaceDuringRunning_Rejected()
|
public void PlaceDuringRunning_Succeeds()
|
||||||
{
|
{
|
||||||
|
// In the new system, placement works in any phase
|
||||||
var sim = CreateLevel1Sim();
|
var sim = CreateLevel1Sim();
|
||||||
sim.Place(PieceKind.Rook, (0, 0), (1, 0));
|
sim.Place(PieceKind.Rook, (0, 0), (1, 0));
|
||||||
sim.Start();
|
sim.Resume(); // Paused → Running
|
||||||
|
|
||||||
var events = sim.Place(PieceKind.Rook, (0, 1), (1, 1));
|
var events = sim.Place(PieceKind.Rook, (0, 1), (1, 1));
|
||||||
Assert.IsType<CommandRejectedEvent>(events[0]);
|
Assert.IsType<PiecePlacedEvent>(events[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void StartWithNoPieces_Rejected()
|
public void RemoveDuringRunning_Succeeds()
|
||||||
{
|
|
||||||
var sim = CreateLevel1Sim();
|
|
||||||
var events = sim.Start();
|
|
||||||
Assert.IsType<CommandRejectedEvent>(events[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void RemoveDuringRunning_Rejected()
|
|
||||||
{
|
{
|
||||||
|
// In the new system, removal works in any phase
|
||||||
var sim = CreateLevel1Sim();
|
var sim = CreateLevel1Sim();
|
||||||
sim.Place(PieceKind.Rook, (0, 0), (1, 0));
|
sim.Place(PieceKind.Rook, (0, 0), (1, 0));
|
||||||
sim.Start();
|
sim.Resume(); // Paused → Running
|
||||||
|
|
||||||
var events = sim.Remove(1);
|
var events = sim.Remove(1);
|
||||||
Assert.IsType<CommandRejectedEvent>(events[0]);
|
Assert.IsType<PieceRemovedEvent>(events[0]);
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void StopDuringEdit_Rejected()
|
|
||||||
{
|
|
||||||
var sim = CreateLevel1Sim();
|
|
||||||
var events = sim.Stop();
|
|
||||||
Assert.IsType<CommandRejectedEvent>(events[0]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -86,7 +72,6 @@ public class GameSimTests
|
||||||
{
|
{
|
||||||
var sim = CreateLevel1Sim();
|
var sim = CreateLevel1Sim();
|
||||||
sim.Place(PieceKind.Rook, (0, 0), (2, 0));
|
sim.Place(PieceKind.Rook, (0, 0), (2, 0));
|
||||||
sim.Start();
|
|
||||||
|
|
||||||
// Step 1: piece moves from (0,0) to (2,0)
|
// Step 1: piece moves from (0,0) to (2,0)
|
||||||
var events1 = sim.Step();
|
var events1 = sim.Step();
|
||||||
|
|
@ -105,16 +90,11 @@ public class GameSimTests
|
||||||
public void ChainedPieces_TransferCargo()
|
public void ChainedPieces_TransferCargo()
|
||||||
{
|
{
|
||||||
var sim = CreateLevel1Sim();
|
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, (0, 0), (1, 0));
|
||||||
sim.Place(PieceKind.Rook, (2, 0), (3, 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);
|
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 CargoProducedEvent);
|
||||||
Assert.Contains(allEvents, e => e is CargoTransferredEvent);
|
Assert.Contains(allEvents, e => e is CargoTransferredEvent);
|
||||||
}
|
}
|
||||||
|
|
@ -125,18 +105,15 @@ public class GameSimTests
|
||||||
var sim = CreateLevel1Sim();
|
var sim = CreateLevel1Sim();
|
||||||
sim.Place(PieceKind.Rook, (0, 0), (1, 0));
|
sim.Place(PieceKind.Rook, (0, 0), (1, 0));
|
||||||
|
|
||||||
sim.Start();
|
|
||||||
var allEvents = sim.StepN(6);
|
var allEvents = sim.StepN(6);
|
||||||
|
|
||||||
var prodEvents = allEvents.OfType<CargoProducedEvent>().ToList();
|
var prodEvents = allEvents.OfType<CargoProducedEvent>().ToList();
|
||||||
// Production fires every turn
|
|
||||||
Assert.Equal(6, prodEvents.Count);
|
Assert.Equal(6, prodEvents.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[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)
|
var level = new BoardBuilder(3, 1)
|
||||||
.WithProduction(0, 0, "P", CargoType.Wood)
|
.WithProduction(0, 0, "P", CargoType.Wood)
|
||||||
.WithDemand(2, 0, "D", CargoType.Wood, 1, 30)
|
.WithDemand(2, 0, "D", CargoType.Wood, 1, 30)
|
||||||
|
|
@ -145,59 +122,28 @@ public class GameSimTests
|
||||||
var sim = SimHelper.FromLevel(level);
|
var sim = SimHelper.FromLevel(level);
|
||||||
|
|
||||||
sim.Place(PieceKind.Rook, (1, 0), (2, 0));
|
sim.Place(PieceKind.Rook, (1, 0), (2, 0));
|
||||||
sim.Start();
|
|
||||||
|
|
||||||
// Run enough turns for production → piece → demand
|
|
||||||
var allEvents = sim.StepN(10);
|
var allEvents = sim.StepN(10);
|
||||||
|
|
||||||
Assert.Contains(allEvents, e => e is VictoryEvent);
|
Assert.Contains(allEvents, e => e is MissionCompleteEvent);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Defeat_WhenDeadlineExpires()
|
public void InitialPhase_IsPaused()
|
||||||
{
|
{
|
||||||
// Demand with very tight deadline, piece placed far from demand
|
var sim = CreateLevel1Sim();
|
||||||
var level = new BoardBuilder(4, 4)
|
var snap = sim.Snapshot;
|
||||||
.WithProduction(0, 0, "P", CargoType.Wood)
|
Assert.Equal(SimPhase.Paused, snap.Phase);
|
||||||
.WithDemand(3, 3, "D", CargoType.Wood, 10, 3) // need 10 in 3 turns — impossible
|
}
|
||||||
.WithStock(PieceKind.Rook, 3)
|
|
||||||
.Build();
|
|
||||||
var sim = SimHelper.FromLevel(level);
|
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StepFromPaused_Works()
|
||||||
|
{
|
||||||
|
var sim = CreateLevel1Sim();
|
||||||
sim.Place(PieceKind.Rook, (1, 0), (2, 0));
|
sim.Place(PieceKind.Rook, (1, 0), (2, 0));
|
||||||
sim.Start();
|
|
||||||
|
|
||||||
var allEvents = sim.StepN(5);
|
// Step directly from Paused
|
||||||
|
var events = sim.Step();
|
||||||
Assert.Contains(allEvents, e => e is DeadlineExpiredEvent);
|
Assert.Contains(events, e => e is TurnStartedEvent);
|
||||||
}
|
|
||||||
|
|
||||||
[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]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
247
chessistics-tests/Simulation/PhaseTests.cs
Normal file
247
chessistics-tests/Simulation/PhaseTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
1
chessistics-tests/Simulation/PhaseTests.cs.uid
Normal file
1
chessistics-tests/Simulation/PhaseTests.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://dxv44w3l5rw66
|
||||||
181
chessistics-tests/Simulation/QuickSaveTests.cs
Normal file
181
chessistics-tests/Simulation/QuickSaveTests.cs
Normal 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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
99
chessistics-tests/Simulation/RecurringDemandTests.cs
Normal file
99
chessistics-tests/Simulation/RecurringDemandTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using Chessistics.Engine.Commands;
|
||||||
using Chessistics.Engine.Events;
|
using Chessistics.Engine.Events;
|
||||||
using Chessistics.Engine.Model;
|
using Chessistics.Engine.Model;
|
||||||
using Chessistics.Tests.Helpers;
|
using Chessistics.Tests.Helpers;
|
||||||
|
|
@ -7,17 +8,13 @@ namespace Chessistics.Tests.Simulation;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// End-to-end solvability tests: each test places pieces, runs the simulation,
|
/// 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>
|
/// </summary>
|
||||||
public class SolvabilityTests
|
public class SolvabilityTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[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)
|
var level = new BoardBuilder(3, 1)
|
||||||
.WithProduction(0, 0, "Scierie", CargoType.Wood)
|
.WithProduction(0, 0, "Scierie", CargoType.Wood)
|
||||||
.WithDemand(2, 0, "Depot", CargoType.Wood, 2, 30)
|
.WithDemand(2, 0, "Depot", CargoType.Wood, 2, 30)
|
||||||
|
|
@ -26,21 +23,15 @@ public class SolvabilityTests
|
||||||
var sim = SimHelper.FromLevel(level);
|
var sim = SimHelper.FromLevel(level);
|
||||||
|
|
||||||
sim.Place(PieceKind.Rook, (1, 0), (2, 0));
|
sim.Place(PieceKind.Rook, (1, 0), (2, 0));
|
||||||
sim.Start();
|
|
||||||
|
|
||||||
var allEvents = sim.StepN(20);
|
var allEvents = sim.StepN(20);
|
||||||
|
|
||||||
Assert.Contains(allEvents, e => e is VictoryEvent);
|
Assert.Contains(allEvents, e => e is MissionCompleteEvent);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[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)
|
var level = new BoardBuilder(5, 2)
|
||||||
.WithProduction(0, 0, "Scierie", CargoType.Wood)
|
.WithProduction(0, 0, "Scierie", CargoType.Wood)
|
||||||
.WithDemand(4, 0, "Depot", CargoType.Wood, 2, 40)
|
.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, (1, 0), (2, 0));
|
||||||
sim.Place(PieceKind.Rook, (2, 0), (3, 0));
|
sim.Place(PieceKind.Rook, (2, 0), (3, 0));
|
||||||
sim.Place(PieceKind.Rook, (3, 0), (4, 0));
|
sim.Place(PieceKind.Rook, (3, 0), (4, 0));
|
||||||
sim.Start();
|
|
||||||
|
|
||||||
var allEvents = sim.StepN(30);
|
var allEvents = sim.StepN(30);
|
||||||
|
|
||||||
Assert.Contains(allEvents, e => e is VictoryEvent);
|
Assert.Contains(allEvents, e => e is MissionCompleteEvent);
|
||||||
// Verify cargo actually traversed the chain (not just a shortcut)
|
|
||||||
Assert.True(
|
Assert.True(
|
||||||
allEvents.OfType<CargoTransferredEvent>().Count() >= 4,
|
allEvents.OfType<CargoTransferredEvent>().Count() >= 4,
|
||||||
"Expected at least 4 cargo transfers across the 3-piece chain");
|
"Expected at least 4 cargo transfers across the 3-piece chain");
|
||||||
|
|
@ -65,12 +54,6 @@ public class SolvabilityTests
|
||||||
[Fact]
|
[Fact]
|
||||||
public void TwoDemands_SingleSource_BothSatisfied()
|
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)
|
var level = new BoardBuilder(4, 3)
|
||||||
.WithProduction(0, 0, "Scierie", CargoType.Wood)
|
.WithProduction(0, 0, "Scierie", CargoType.Wood)
|
||||||
.WithDemand(2, 0, "Depot Royal", CargoType.Wood, 2, 30)
|
.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, (1, 0), (2, 0));
|
||||||
sim.Place(PieceKind.Rook, (0, 1), (0, 2));
|
sim.Place(PieceKind.Rook, (0, 1), (0, 2));
|
||||||
sim.Start();
|
|
||||||
|
|
||||||
var allEvents = sim.StepN(20);
|
var allEvents = sim.StepN(20);
|
||||||
|
|
||||||
Assert.Contains(allEvents, e => e is VictoryEvent);
|
Assert.Contains(allEvents, e => e is MissionCompleteEvent);
|
||||||
// Both demands must have received progress events
|
|
||||||
var demandProgress = allEvents.OfType<DemandProgressEvent>().ToList();
|
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(2, 0) && dp.Current == dp.Required);
|
||||||
Assert.Contains(demandProgress, dp => dp.DemandCell == new Coords(0, 2) && dp.Current == dp.Required);
|
Assert.Contains(demandProgress, dp => dp.DemandCell == new Coords(0, 2) && dp.Current == dp.Required);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[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)
|
var level = new BoardBuilder(4, 2)
|
||||||
.WithProduction(0, 0, "Scierie", CargoType.Wood)
|
.WithProduction(0, 0, "Scierie", CargoType.Wood)
|
||||||
.WithProduction(0, 1, "Carriere", CargoType.Stone)
|
.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, 0), (2, 0));
|
||||||
sim.Place(PieceKind.Rook, (1, 1), (2, 1));
|
sim.Place(PieceKind.Rook, (1, 1), (2, 1));
|
||||||
sim.Start();
|
|
||||||
|
|
||||||
var allEvents = sim.StepN(20);
|
var allEvents = sim.StepN(20);
|
||||||
|
|
||||||
Assert.Contains(allEvents, e => e is VictoryEvent);
|
Assert.Contains(allEvents, e => e is MissionCompleteEvent);
|
||||||
// Verify no wrong-type delivery (Wood to Stone demand or vice-versa)
|
|
||||||
var transfers = allEvents.OfType<CargoTransferredEvent>().ToList();
|
var transfers = allEvents.OfType<CargoTransferredEvent>().ToList();
|
||||||
foreach (var t in transfers.Where(t => t.To == new Coords(3, 0)))
|
foreach (var t in transfers.Where(t => t.To == new Coords(3, 0)))
|
||||||
Assert.Equal(CargoType.Wood, t.Type);
|
Assert.Equal(CargoType.Wood, t.Type);
|
||||||
|
|
@ -124,14 +99,8 @@ public class SolvabilityTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[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)
|
var level = new BoardBuilder(4, 3)
|
||||||
.WithProduction(0, 0, "Scierie", CargoType.Wood)
|
.WithProduction(0, 0, "Scierie", CargoType.Wood)
|
||||||
.WithDemand(2, 1, "Depot", CargoType.Wood, 2, 30)
|
.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.Rook, (0, 1), (0, 0));
|
||||||
sim.Place(PieceKind.Bishop, (1, 1), (2, 2));
|
sim.Place(PieceKind.Bishop, (1, 1), (2, 2));
|
||||||
sim.Start();
|
|
||||||
|
|
||||||
var allEvents = sim.StepN(20);
|
var allEvents = sim.StepN(20);
|
||||||
|
|
||||||
Assert.Contains(allEvents, e => e is VictoryEvent);
|
Assert.Contains(allEvents, e => e is MissionCompleteEvent);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[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)
|
var level = new BoardBuilder(5, 3)
|
||||||
.WithProduction(0, 0, "Scierie", CargoType.Wood)
|
.WithProduction(0, 0, "Scierie", CargoType.Wood)
|
||||||
.WithDemand(4, 0, "Depot", CargoType.Wood, 2, 30)
|
.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.Rook, (1, 0), (1, 1));
|
||||||
sim.Place(PieceKind.Knight, (1, 1), (3, 0));
|
sim.Place(PieceKind.Knight, (1, 1), (3, 0));
|
||||||
sim.Start();
|
|
||||||
|
|
||||||
var allEvents = sim.StepN(20);
|
var allEvents = sim.StepN(20);
|
||||||
|
|
||||||
Assert.Contains(allEvents, e => e is VictoryEvent);
|
Assert.Contains(allEvents, e => e is MissionCompleteEvent);
|
||||||
// Verify the knight actually moved across the wall
|
|
||||||
Assert.Contains(allEvents, e => e is PieceMovedEvent m && m.To == new Coords(3, 0));
|
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]
|
[Fact]
|
||||||
public void NoCollision_WithSharedRelayPoints()
|
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)
|
var level = new BoardBuilder(5, 2)
|
||||||
.WithProduction(0, 0, "P", CargoType.Wood)
|
.WithProduction(0, 0, "P", CargoType.Wood)
|
||||||
.WithDemand(4, 0, "D", CargoType.Wood, 1, 40)
|
.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, (1, 0), (2, 0));
|
||||||
sim.Place(PieceKind.Rook, (2, 0), (3, 0));
|
sim.Place(PieceKind.Rook, (2, 0), (3, 0));
|
||||||
sim.Start();
|
|
||||||
|
|
||||||
var allEvents = sim.StepN(20);
|
var allEvents = sim.StepN(20);
|
||||||
|
|
||||||
Assert.DoesNotContain(allEvents, e => e is PieceDestroyedEvent);
|
Assert.DoesNotContain(allEvents, e => e is PieceReturnedToStockEvent);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void CargoFilter_AutoAssigned_PreventsContamination()
|
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)
|
var level = new BoardBuilder(4, 1)
|
||||||
.WithProduction(0, 0, "Scierie", CargoType.Wood)
|
.WithProduction(0, 0, "Scierie", CargoType.Wood)
|
||||||
.WithProduction(3, 0, "Carriere", CargoType.Stone)
|
.WithProduction(3, 0, "Carriere", CargoType.Stone)
|
||||||
|
|
@ -242,24 +169,19 @@ public class SolvabilityTests
|
||||||
|
|
||||||
sim.Place(PieceKind.Rook, (1, 0), (2, 0));
|
sim.Place(PieceKind.Rook, (1, 0), (2, 0));
|
||||||
|
|
||||||
// Verify CargoFilter was auto-assigned
|
|
||||||
var snapshot = sim.Snapshot;
|
var snapshot = sim.Snapshot;
|
||||||
Assert.Equal(CargoType.Wood, snapshot.Pieces[0].CargoFilter);
|
Assert.Equal(CargoType.Wood, snapshot.Pieces[0].CargoFilter);
|
||||||
|
|
||||||
sim.Start();
|
|
||||||
var allEvents = sim.StepN(20);
|
var allEvents = sim.StepN(20);
|
||||||
|
|
||||||
// Piece should only carry Wood — never Stone
|
|
||||||
var transfers = allEvents.OfType<CargoTransferredEvent>().ToList();
|
var transfers = allEvents.OfType<CargoTransferredEvent>().ToList();
|
||||||
Assert.All(transfers, t => Assert.Equal(CargoType.Wood, t.Type));
|
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]
|
[Fact]
|
||||||
public void CargoFilter_PropagatesThroughChain()
|
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)
|
var level = new BoardBuilder(5, 2)
|
||||||
.WithProduction(0, 0, "Scierie", CargoType.Wood)
|
.WithProduction(0, 0, "Scierie", CargoType.Wood)
|
||||||
.WithDemand(4, 0, "Depot", CargoType.Wood, 2, 40)
|
.WithDemand(4, 0, "Depot", CargoType.Wood, 2, 40)
|
||||||
|
|
@ -267,9 +189,9 @@ public class SolvabilityTests
|
||||||
.Build();
|
.Build();
|
||||||
var sim = SimHelper.FromLevel(level);
|
var sim = SimHelper.FromLevel(level);
|
||||||
|
|
||||||
sim.Place(PieceKind.Rook, (1, 0), (2, 0)); // adj to prod → Wood
|
sim.Place(PieceKind.Rook, (1, 0), (2, 0));
|
||||||
sim.Place(PieceKind.Rook, (2, 0), (3, 0)); // shares (2,0) → inherits Wood
|
sim.Place(PieceKind.Rook, (2, 0), (3, 0));
|
||||||
sim.Place(PieceKind.Rook, (3, 0), (4, 0)); // shares (3,0) → inherits Wood
|
sim.Place(PieceKind.Rook, (3, 0), (4, 0));
|
||||||
|
|
||||||
var snapshot = sim.Snapshot;
|
var snapshot = sim.Snapshot;
|
||||||
Assert.Equal(CargoType.Wood, snapshot.Pieces[0].CargoFilter);
|
Assert.Equal(CargoType.Wood, snapshot.Pieces[0].CargoFilter);
|
||||||
|
|
@ -278,9 +200,8 @@ public class SolvabilityTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[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)
|
var level = new BoardBuilder(3, 1)
|
||||||
.WithProduction(0, 0, "P", CargoType.Wood)
|
.WithProduction(0, 0, "P", CargoType.Wood)
|
||||||
.WithDemand(2, 0, "D", CargoType.Wood, 1, 30)
|
.WithDemand(2, 0, "D", CargoType.Wood, 1, 30)
|
||||||
|
|
@ -289,10 +210,109 @@ public class SolvabilityTests
|
||||||
var sim = SimHelper.FromLevel(level);
|
var sim = SimHelper.FromLevel(level);
|
||||||
|
|
||||||
sim.Place(PieceKind.Rook, (1, 0), (2, 0));
|
sim.Place(PieceKind.Rook, (1, 0), (2, 0));
|
||||||
// No Start() — step directly from Edit
|
// Step directly from Paused
|
||||||
var allEvents = sim.StepN(20);
|
var allEvents = sim.StepN(20);
|
||||||
|
|
||||||
Assert.Contains(allEvents, e => e is TurnStartedEvent);
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
219
chessistics-tests/Simulation/TransformerTests.cs
Normal file
219
chessistics-tests/Simulation/TransformerTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
1
chessistics-tests/Simulation/TransformerTests.cs.uid
Normal file
1
chessistics-tests/Simulation/TransformerTests.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://c7ifw7o8xahpv
|
||||||
120
chessistics-tests/Simulation/UndoTests.cs
Normal file
120
chessistics-tests/Simulation/UndoTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -16,17 +16,21 @@ Chaque piece est un **maillon de convoyeur**. La strategie est dans la compositi
|
||||||
**Core loop** :
|
**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,
|
PLACER ou RETIRER des pieces (la simulation se met en pause automatiquement
|
||||||
les colis se transmettent automatiquement entre pieces adjacentes
|
pendant le placement, puis reprend)
|
||||||
|
|
|
|
||||||
+---> Le debit est insuffisant ? Observer les goulets, reorganiser
|
OBSERVER le resultat — le reseau s'adapte immediatement
|
||||||
+---> Le debit est atteint ? Optimiser ou niveau suivant
|
|
|
||||||
|
+---> 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** :
|
**Ce qui distingue Chessistics** :
|
||||||
- La logistique (macro) : le joueur compose des chaines, choisit sa flotte, gere l'espace
|
- 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
|
- 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 claire** | Carre clair du damier | Traversable normalement |
|
||||||
| **Case sombre** | Carre sombre du damier | Traversable (les Fous sont limites a une couleur) |
|
| **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) |
|
| **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. |
|
| **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
|
### 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)
|
- 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**.
|
- 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.
|
- 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
|
### 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)
|
- 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 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
|
### 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 statut egal, le **niveau** departage (niveau superieur survit)
|
||||||
- A egalite parfaite (meme statut ET meme niveau), **destruction mutuelle** (aucun survivant)
|
- A egalite parfaite (meme statut ET meme niveau), **destruction mutuelle** (aucun survivant)
|
||||||
- La piece survivante conserve sa cargaison. Les cargaisons detruites sont perdues.
|
- 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.
|
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
|
## 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 |
|
| 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 |
|
| **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 |
|
| **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** :
|
**Triangle d'optimisation** :
|
||||||
- Moins de pieces = chaines courtes = couverture limitee → plus de coups
|
- Moins de pieces = chaines courtes = couverture limitee → plus de coups
|
||||||
- Moins de coups = pieces puissantes ou nombreuses → plus de pieces, plus d'espace
|
- 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) :
|
**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 |
|
| | MISSION 3/8 |
|
||||||
| | Depot Royal |
|
| | Forger les Tours |
|
||||||
| | 3x Bois / 30c |
|
| | Depot: 0/3 Bois |
|
||||||
| P L A T E A U | |
|
| P L A T E A U | ✓ Mission 1 |
|
||||||
| (damier interactif) | ───────── |
|
| (damier interactif) | ✓ Mission 2 |
|
||||||
| | |
|
| | ───────── |
|
||||||
| Les pieces et leurs trajets | PIECES |
|
| Les pieces et leurs trajets | |
|
||||||
| sont visibles sur le plateau | [Tour II] x3 |
|
| sont visibles sur le plateau | PIECES |
|
||||||
| | [Fou II] x1 |
|
| | [Pion I] x4 |
|
||||||
| | [Cavalier] x1 |
|
| | [Tour I] x3 |
|
||||||
| | |
|
| | |
|
||||||
+---------------------------------------------------------------+
|
+---------------------------------------------------------------+
|
||||||
| [▶ PLAY] [⏩ x2] [⏸] [⏹ STOP] Coup: -- |
|
| [⏸ / ▶] [x1] [x2] [x4] Tour: 42 Mission: 3/8 |
|
||||||
+---------------------------------------------------------------+
|
+---------------------------------------------------------------+
|
||||||
```
|
```
|
||||||
|
|
||||||
### 7.2 Placement d'une piece
|
### 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.
|
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.
|
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 :
|
Placement d'une Tour II :
|
||||||
|
|
||||||
|
|
@ -379,9 +385,9 @@ Le flux de placement est en 2 clics :
|
||||||
```
|
```
|
||||||
|
|
||||||
**Interactions** :
|
**Interactions** :
|
||||||
- **Clic sur une piece placee** → la selectionne, affiche son trajet, panneau de detail
|
- **Clic gauche 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)
|
- **Touche Suppr** (piece selectionnee) → la retire du plateau (retourne dans le stock)
|
||||||
- **Glisser une piece** → deplace son point de depart (recalcule les arrivees possibles)
|
- **Bouton [Retirer]** dans le panneau de detail → meme effet
|
||||||
|
|
||||||
### 7.3 Visualisation des trajets
|
### 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)
|
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.
|
||||||
- Placer, deplacer, retirer des pieces
|
|
||||||
- Pas de limite de temps
|
|
||||||
- Les trajets sont visibles comme des traits sur le plateau
|
|
||||||
|
|
||||||
**Phase EXEC** (simulation)
|
**Controles** :
|
||||||
- Les pieces font leurs allers-retours simultanement
|
- **Espace** : pause / reprendre (le tour en cours se termine avant la pause)
|
||||||
- Les colis se transmettent automatiquement aux points de contact
|
- **Vitesse** : x1, x2, x4
|
||||||
- Compteur de coups et progression des objectifs en temps reel
|
- **Placement d'une piece** : met la simulation en pause automatiquement le temps du placement (voir §7.2)
|
||||||
- Controles : pause, step-by-step, vitesse x1/x2/x4, stop (retour a EDIT)
|
|
||||||
- En cas de collision → pause auto, pieces en erreur surlignees
|
|
||||||
|
|
||||||
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
|
### 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")
|
- Les demandes ont une **jauge** de progression (ex: "2/3")
|
||||||
- Jauge verte = objectif atteint. Jauge rouge = pas encore.
|
- Jauge verte = objectif atteint. Jauge rouge = pas encore.
|
||||||
|
|
||||||
**Erreurs** :
|
**Collisions** :
|
||||||
- Collision : flash rouge + shake des deux pieces
|
- Flash rouge + shake des deux pieces
|
||||||
- Simulation en pause automatiquement
|
- 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)
|
- 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**
|
- 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)
|
- D = Depot Royal (d1, objectif : recevoir 3 Bois en 30 coups)
|
||||||
- Pieces disponibles : **3x Tour II**
|
- 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 . . . . . .
|
6 . . . . . .
|
||||||
5 . . . . . [D2] Caserne — 2 Bois en 30 coups
|
5 . . . . . [D2] Caserne — 2 Bois 4 . . . . . .
|
||||||
4 . . . . . .
|
|
||||||
3 . . . . . .
|
3 . . . . . .
|
||||||
2 . . . . . .
|
2 . . . . . .
|
||||||
1 [S] . . . . [D1] Depot Royal — 2 Bois en 30 coups
|
1 [S] . . . . [D1] Depot Royal — 2 Bois
|
||||||
|
|
||||||
a b c d e f
|
a b c d e f
|
||||||
```
|
```
|
||||||
|
|
||||||
- Plateau : **6x6**
|
- 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)
|
- D1 = Depot Royal (f1, objectif : 2 Bois en 30 coups)
|
||||||
- D2 = Caserne (f5, objectif : 2 Bois en 30 coups)
|
- D2 = Caserne (f5, objectif : 2 Bois en 30 coups)
|
||||||
- Pieces disponibles : **4x Tour II, 1x Fou II**
|
- 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** :
|
**L'enjeu** :
|
||||||
- S→D1 : 5 cases en ligne droite. Faisable avec une chaine de Tours.
|
- S→D1 : 5 cases en ligne droite. Faisable avec une chaine de Tours.
|
||||||
- S→D2 : trajet en angle (droite + haut). Plusieurs routes possibles.
|
- 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 joueur doit decider : comment repartir les colis entre les deux destinations ?
|
||||||
|
|
||||||
**Le statut social entre en jeu** :
|
**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.
|
**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
|
6 [D2] . . . . [D1] Depot Royal — 2 Bois 5 . . # # # . Forge — 2 Pierre 4 . . # . . .
|
||||||
5 . . # # # . Forge — 2 Pierre en 40 coups
|
|
||||||
4 . . # . . .
|
|
||||||
3 . . # . . .
|
3 . . # . . .
|
||||||
2 . . . . . .
|
2 . . . . . .
|
||||||
1 [S1] . . . . [S2] Scierie (Bois)
|
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**
|
- Plateau : **6x6**
|
||||||
- S1 = Scierie (a1, Bois, tous les 2 coups)
|
- S1 = Scierie (a1, Bois, 1 par tour)
|
||||||
- S2 = Carriere (f1, Pierre, tous les 2 coups)
|
- S2 = Carriere (f1, Pierre, 1 par tour)
|
||||||
- D1 = Depot Royal (f6, objectif : 2 Bois en 40 coups)
|
- D1 = Depot Royal (f6, objectif : 2 Bois en 40 coups)
|
||||||
- D2 = Forge (a6, objectif : 2 Pierre en 40 coups)
|
- D2 = Forge (a6, objectif : 2 Pierre en 40 coups)
|
||||||
- Murs : c3, c4, c5, d5, e5 (barriere en L)
|
- 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 . . . . . . . .
|
8 . . . . . . . .
|
||||||
7 [D2] . . . . . . . Forge — 3 Pierre en 40 coups
|
7 [D2] . . . . . . . Forge — 3 Pierre 6 . . . . . . . .
|
||||||
6 . . . . . . . .
|
|
||||||
5 . . . ## . . . .
|
5 . . . ## . . . .
|
||||||
4 . . . ## . . . .
|
4 . . . ## . . . .
|
||||||
3 . . . . . . . .
|
3 . . . . . . . .
|
||||||
2 . . . . . . . .
|
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
|
a b c d e f g h
|
||||||
[S2] Carriere (h8)
|
[S2] Carriere (h8)
|
||||||
```
|
```
|
||||||
|
|
||||||
- Plateau : **8x8**
|
- Plateau : **8x8**
|
||||||
- S1 = Scierie (a1, Bois), S2 = Carriere (h8, Pierre)
|
- 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)
|
- Murs : bloc 2x2 au centre (d4, e5, d5, e4)
|
||||||
- Stock : 8 Pions, 4 Tours, 2 Fous, 2 Cavaliers
|
- 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)
|
6 [S2] . # . # . # . Carriere (a6)
|
||||||
5 . . # . # . # .
|
5 . . # . # . # .
|
||||||
4 . . # . # . . [D1] Depot Royal — 3 Bois en 50 coups
|
4 . . # . # . . [D1] Depot Royal — 3 Bois 3 . . # . . . # .
|
||||||
3 . . # . . . # .
|
|
||||||
2 . . . . # . # .
|
2 . . . . # . # .
|
||||||
1 [S1] . . . # . . [D2] Forge — 3 Pierre en 50 coups
|
1 [S1] . . . # . . [D2] Forge — 3 Pierre
|
||||||
|
|
||||||
a b c d e f g h
|
a b c d e f g h
|
||||||
```
|
```
|
||||||
|
|
||||||
- Plateau : **8x6**
|
- Plateau : **8x6**
|
||||||
- S1 = Scierie (a1, Bois), S2 = Carriere (a6, Pierre)
|
- 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
|
- Murs : 3 colonnes partielles formant un labyrinthe
|
||||||
- Stock : 10 Pions, 4 Tours, 2 Fous, 3 Cavaliers
|
- 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.
|
**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
|
8 [S2] . . # . . # . . [D2] Forge — 3 Pierre 7 . . . # . . # . . .
|
||||||
7 . . . # . . # . . .
|
|
||||||
6 . . . # ## . # . . .
|
6 . . . # ## . # . . .
|
||||||
5 . . . . . . . . . .
|
5 . . . . . . . . . .
|
||||||
4 . . . # ## . # . . [S3] Scierie Est (j4, Bois)
|
4 . . . # ## . # . . [S3] Scierie Est (j4, Bois)
|
||||||
3 . . . # . . # . . .
|
3 . . . # . . # . . .
|
||||||
2 . . . . . . . . . .
|
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
|
a b c d e f g h i j
|
||||||
```
|
```
|
||||||
|
|
||||||
- Plateau : **10x8**
|
- Plateau : **10x8**
|
||||||
- S1 = Scierie (a1, Bois), S2 = Carriere (a8, Pierre), S3 = Scierie Est (j4, Bois)
|
- 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
|
- Murs : deux colonnes avec pont horizontal
|
||||||
- Stock : 14 Pions, 6 Tours, 3 Fous, 4 Cavaliers
|
- Stock : 14 Pions, 6 Tours, 3 Fous, 4 Cavaliers
|
||||||
|
|
||||||
|
|
@ -746,7 +751,7 @@ Chessistics/
|
||||||
UI/
|
UI/
|
||||||
ObjectivePanel.tscn — Objectifs + stock de pieces
|
ObjectivePanel.tscn — Objectifs + stock de pieces
|
||||||
DetailPanel.tscn — Detail piece selectionnee
|
DetailPanel.tscn — Detail piece selectionnee
|
||||||
ControlBar.tscn — Play / pause / stop / vitesse
|
ControlBar.tscn — Pause / vitesse
|
||||||
MetricsOverlay.tscn — Resultats post-victoire
|
MetricsOverlay.tscn — Resultats post-victoire
|
||||||
LevelSelect.tscn — Selection de niveau
|
LevelSelect.tscn — Selection de niveau
|
||||||
scripts/
|
scripts/
|
||||||
|
|
@ -762,7 +767,7 @@ Chessistics/
|
||||||
LevelLoader.cs — Chargement JSON
|
LevelLoader.cs — Chargement JSON
|
||||||
UI/
|
UI/
|
||||||
PiecePlacer.cs — Logique du placement 2 clics
|
PiecePlacer.cs — Logique du placement 2 clics
|
||||||
ControlBar.cs — Play/pause/stop/vitesse
|
ControlBar.cs — Pause/vitesse
|
||||||
ProgressDisplay.cs — Compteur de coups + progression objectifs
|
ProgressDisplay.cs — Compteur de coups + progression objectifs
|
||||||
data/
|
data/
|
||||||
levels/
|
levels/
|
||||||
|
|
@ -781,10 +786,10 @@ Chessistics/
|
||||||
"width": 4,
|
"width": 4,
|
||||||
"height": 4,
|
"height": 4,
|
||||||
"productions": [
|
"productions": [
|
||||||
{ "x": 0, "y": 0, "name": "Scierie", "cargo": "wood", "interval": 2 }
|
{ "x": 0, "y": 0, "name": "Scierie", "cargo": "wood", "amount": 1 }
|
||||||
],
|
],
|
||||||
"demands": [
|
"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": [],
|
"walls": [],
|
||||||
"pieces": [
|
"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 |
|
| 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 |
|
| 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 |
|
| 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. |
|
| 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 |
|
| 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
37
docs/PLAN.md
Normal 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
6
global.json
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"sdk": {
|
||||||
|
"version": "9.0.312",
|
||||||
|
"rollForward": "latestMinor"
|
||||||
|
}
|
||||||
|
}
|
||||||
96
tools/automation/README.md
Normal file
96
tools/automation/README.md
Normal 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
320
tools/automation/harness.py
Normal 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))
|
||||||
33
tools/automation/run_game.py
Normal file
33
tools/automation/run_game.py
Normal 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
114
tools/automation/smoke.py
Normal 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())
|
||||||
35
tools/automation/test_collision.py
Normal file
35
tools/automation/test_collision.py
Normal 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()
|
||||||
30
tools/automation/test_delete_key.py
Normal file
30
tools/automation/test_delete_key.py
Normal 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()
|
||||||
48
tools/automation/test_quicksave.py
Normal file
48
tools/automation/test_quicksave.py
Normal 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()
|
||||||
35
tools/automation/test_relocate.py
Normal file
35
tools/automation/test_relocate.py
Normal 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()
|
||||||
24
tools/automation/test_trajectory.py
Normal file
24
tools/automation/test_trajectory.py
Normal 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()
|
||||||
44
tools/automation/test_undo.py
Normal file
44
tools/automation/test_undo.py
Normal 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()
|
||||||
Loading…
Add table
Reference in a new issue