Compare commits
No commits in common. "dbeac32b0ff50cb16e028bb16b4bb05f49b65228" and "358ab48d596406b8bc540717e0cb6b15a327f558" have entirely different histories.
dbeac32b0f
...
358ab48d59
98 changed files with 955 additions and 6709 deletions
|
|
@ -25,80 +25,8 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
#!/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,13 +69,12 @@ for domain in \
|
||||||
"api.anthropic.com" \
|
"api.anthropic.com" \
|
||||||
"sentry.io" \
|
"sentry.io" \
|
||||||
"statsig.anthropic.com" \
|
"statsig.anthropic.com" \
|
||||||
"statsig.com" \
|
"statsig.com"; do
|
||||||
"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 "WARN: Failed to resolve $domain - skipping"
|
echo "ERROR: Failed to resolve $domain"
|
||||||
continue
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
while read -r ip; do
|
while read -r ip; do
|
||||||
|
|
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -21,8 +21,3 @@ 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,179 +29,4 @@ Tout `Control` (ColorRect, Label…) a `MouseFilter = Stop` par defaut. Quand un
|
||||||
|
|
||||||
### Plans
|
### Plans
|
||||||
|
|
||||||
Les plans vivants (travail restant, features a faire) vont dans
|
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.
|
||||||
[`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.
|
|
||||||
|
|
|
||||||
|
|
@ -1,312 +0,0 @@
|
||||||
{
|
|
||||||
"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
Normal file
55
PLAN.md
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
# 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
217
README.md
|
|
@ -1,217 +0,0 @@
|
||||||
# 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.
|
|
||||||
|
|
@ -1,72 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,127 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,332 +0,0 @@
|
||||||
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}.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,123 +0,0 @@
|
||||||
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,48 +50,6 @@ 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,7 +24,6 @@ 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");
|
||||||
|
|
||||||
|
|
@ -48,7 +47,6 @@ 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);
|
||||||
|
|
@ -134,24 +132,4 @@ 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,13 +16,9 @@ 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;
|
||||||
|
|
@ -30,11 +26,6 @@ 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)
|
||||||
|
|
@ -81,140 +72,28 @@ public partial class InputMapper : Node
|
||||||
|
|
||||||
public override void _UnhandledInput(InputEvent @event)
|
public override void _UnhandledInput(InputEvent @event)
|
||||||
{
|
{
|
||||||
if (@event is InputEventMouseButton mouseEvent && mouseEvent.ButtonIndex == MouseButton.Left)
|
if (@event is InputEventMouseButton mouseEvent && mouseEvent.Pressed)
|
||||||
{
|
{
|
||||||
var localPos = _boardView.GetLocalMousePosition();
|
if (mouseEvent.ButtonIndex == MouseButton.Right)
|
||||||
if (mouseEvent.Pressed)
|
{
|
||||||
HandleLeftPress(localPos);
|
Cancel();
|
||||||
else
|
return;
|
||||||
HandleLeftRelease(localPos);
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (@event is InputEventMouseMotion && _dragPieceId != null)
|
if (mouseEvent.ButtonIndex == MouseButton.Left)
|
||||||
{
|
{
|
||||||
var localPos = _boardView.GetLocalMousePosition();
|
var localPos = _boardView.GetLocalMousePosition();
|
||||||
UpdateDrag(localPos);
|
GD.Print($"[InputMapper] LEFT CLICK — localPos={localPos}, phase={_phase}");
|
||||||
|
HandleLeftClick();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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();
|
||||||
|
|
@ -228,41 +107,22 @@ 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);
|
OnStartSelected(coords.Value);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case PlacementPhase.SelectingEnd:
|
case PlacementPhase.SelectingEnd:
|
||||||
OnEndSelected(coords);
|
OnEndSelected(coords.Value);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
EmitSignal(SignalName.CellClicked, coords.Col, coords.Row);
|
EmitSignal(SignalName.CellClicked, coords.Value.Col, coords.Value.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)
|
||||||
|
|
|
||||||
728
Scripts/Main.cs
728
Scripts/Main.cs
File diff suppressed because it is too large
Load diff
|
|
@ -25,9 +25,6 @@ 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)
|
||||||
|
|
@ -139,9 +136,6 @@ 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,54 +5,34 @@ 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? _arrowEnd;
|
private Polygon2D? _arrow;
|
||||||
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;
|
||||||
_baseColor = color;
|
Width = 2.5f;
|
||||||
Width = 3f;
|
DefaultColor = new Color(color, 0.35f);
|
||||||
DefaultColor = new Color(color, 0.4f);
|
|
||||||
Antialiased = true;
|
Antialiased = true;
|
||||||
ClearPoints();
|
ClearPoints();
|
||||||
AddPoint(from);
|
AddPoint(from);
|
||||||
AddPoint(to);
|
AddPoint(to);
|
||||||
ZIndex = -1;
|
ZIndex = -1;
|
||||||
|
|
||||||
_arrowEnd = BuildArrow(from, to, color);
|
// Arrowhead at the end point
|
||||||
_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);
|
||||||
const float arrowSize = 9f;
|
float arrowSize = 8f;
|
||||||
var tip = to - dir * 4f;
|
var tip = to - dir * 4f; // slightly inset from end
|
||||||
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
|
|
||||||
{
|
|
||||||
Polygon = [tip, baseL, baseR],
|
|
||||||
Color = new Color(color, 0.5f)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private void StartPulse()
|
_arrow = new Polygon2D
|
||||||
{
|
{
|
||||||
_pulseTween?.Kill();
|
Polygon = [tip - Position, baseL - Position, baseR - Position],
|
||||||
_pulseTween = CreateTween();
|
Color = new Color(color, 0.4f),
|
||||||
_pulseTween.SetLoops();
|
Position = Vector2.Zero
|
||||||
_pulseTween.TweenProperty(this, "default_color:a", 0.75f, 1.1f)
|
};
|
||||||
.SetEase(Tween.EaseType.InOut).SetTrans(Tween.TransitionType.Sine);
|
// Position relative to parent, not this Line2D
|
||||||
_pulseTween.TweenProperty(this, "default_color:a", 0.3f, 1.1f)
|
AddChild(_arrow);
|
||||||
.SetEase(Tween.EaseType.InOut).SetTrans(Tween.TransitionType.Sine);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,9 +25,6 @@ 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;
|
||||||
|
|
@ -39,10 +36,6 @@ 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)
|
||||||
|
|
@ -80,20 +73,16 @@ 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<PieceReturnedToStockEvent>();
|
var collisionEvents = new List<PieceDestroyedEvent>();
|
||||||
|
|
||||||
// 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, transformerEvents, transferEvents, moveEvents, collisionEvents);
|
FlushPhases(tween, produceEvents, transferEvents, moveEvents, collisionEvents);
|
||||||
tween.TweenCallback(Callable.From(() => _controlBar.UpdateTurn(ts.TurnNumber)));
|
tween.TweenCallback(Callable.From(() => _controlBar.UpdateTurn(ts.TurnNumber)));
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
|
@ -101,10 +90,6 @@ 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);
|
||||||
|
|
@ -114,35 +99,23 @@ public partial class EventAnimator : Node
|
||||||
moveEvents.Add(moved);
|
moveEvents.Add(moved);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case PieceReturnedToStockEvent returned:
|
case PieceDestroyedEvent destroyed:
|
||||||
collisionEvents.Add(returned);
|
collisionEvents.Add(destroyed);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case MissionCompleteEvent:
|
case VictoryEvent victory:
|
||||||
FlushPhases(tween, produceEvents, transformerEvents, transferEvents, moveEvents, collisionEvents);
|
FlushPhases(tween, produceEvents, transferEvents, moveEvents, collisionEvents);
|
||||||
tween.TweenCallback(Callable.From(() =>
|
tween.TweenCallback(Callable.From(() =>
|
||||||
{
|
{
|
||||||
SfxManager.Instance?.PlayVictory();
|
SfxManager.Instance?.PlayVictory();
|
||||||
SpawnConfetti();
|
SpawnConfetti();
|
||||||
if (!hasAutoAdvance)
|
_metricsOverlay.ShowMetrics(victory.Metrics);
|
||||||
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, transformerEvents, transferEvents, moveEvents, collisionEvents);
|
FlushPhases(tween, produceEvents, transferEvents, moveEvents, collisionEvents);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
|
|
@ -150,7 +123,7 @@ public partial class EventAnimator : Node
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
FlushPhases(tween, produceEvents, transformerEvents, transferEvents, moveEvents, collisionEvents);
|
FlushPhases(tween, produceEvents, transferEvents, moveEvents, collisionEvents);
|
||||||
|
|
||||||
tween.TweenCallback(Callable.From(() =>
|
tween.TweenCallback(Callable.From(() =>
|
||||||
{
|
{
|
||||||
|
|
@ -162,29 +135,10 @@ 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<PieceReturnedToStockEvent> collisionEvents)
|
List<PieceDestroyedEvent> 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)
|
||||||
{
|
{
|
||||||
|
|
@ -268,16 +222,16 @@ public partial class EventAnimator : Node
|
||||||
moveEvents.Clear();
|
moveEvents.Clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 4: Collision — piece returned to stock (shrink + spin + particles)
|
// Phase 4: Collision/Destruction — 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 returned in captured)
|
foreach (var destroyed in captured)
|
||||||
{
|
{
|
||||||
if (_pieceViews.TryGetValue(returned.PieceId, out var pv))
|
if (_pieceViews.TryGetValue(destroyed.PieceId, out var pv))
|
||||||
{
|
{
|
||||||
SpawnDestroyParticles(pv.Position);
|
SpawnDestroyParticles(pv.Position);
|
||||||
|
|
||||||
|
|
@ -289,18 +243,12 @@ 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 returned in captured)
|
foreach (var destroyed in captured)
|
||||||
UnregisterPiece(returned.PieceId);
|
UnregisterPiece(destroyed.PieceId);
|
||||||
}));
|
}));
|
||||||
collisionEvents.Clear();
|
collisionEvents.Clear();
|
||||||
}
|
}
|
||||||
|
|
@ -475,9 +423,6 @@ 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,14 +12,15 @@ public partial class ControlBar : HBoxContainer
|
||||||
public delegate void PausePressedEventHandler();
|
public delegate void PausePressedEventHandler();
|
||||||
[Signal]
|
[Signal]
|
||||||
public delegate void StepPressedEventHandler();
|
public delegate void StepPressedEventHandler();
|
||||||
// Stop removed in campaign mode
|
[Signal]
|
||||||
|
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!;
|
||||||
// _stopButton removed
|
private Button _stopButton = null!;
|
||||||
private OptionButton _speedSelect = null!;
|
private OptionButton _speedSelect = null!;
|
||||||
private Label _turnLabel = null!;
|
private Label _turnLabel = null!;
|
||||||
|
|
||||||
|
|
@ -45,7 +46,9 @@ public partial class ControlBar : HBoxContainer
|
||||||
_stepButton.Pressed += () => EmitSignal(SignalName.StepPressed);
|
_stepButton.Pressed += () => EmitSignal(SignalName.StepPressed);
|
||||||
AddChild(_stepButton);
|
AddChild(_stepButton);
|
||||||
|
|
||||||
// Stop button removed in campaign mode
|
_stopButton = CreateStyledButton("STOP");
|
||||||
|
_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) });
|
||||||
|
|
@ -65,7 +68,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.Paused);
|
UpdateForPhase(SimPhase.Edit);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Button CreateStyledButton(string text)
|
private static Button CreateStyledButton(string text)
|
||||||
|
|
@ -73,8 +76,7 @@ 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);
|
||||||
|
|
||||||
|
|
@ -122,9 +124,10 @@ public partial class ControlBar : HBoxContainer
|
||||||
|
|
||||||
public void UpdateForPhase(SimPhase phase)
|
public void UpdateForPhase(SimPhase phase)
|
||||||
{
|
{
|
||||||
_playButton.Disabled = phase != SimPhase.Paused && phase != SimPhase.MissionComplete;
|
_playButton.Disabled = phase != SimPhase.Edit && phase != SimPhase.Paused;
|
||||||
_pauseButton.Disabled = phase != SimPhase.Running;
|
_pauseButton.Disabled = phase != SimPhase.Running;
|
||||||
_stepButton.Disabled = phase == SimPhase.Running;
|
_stepButton.Disabled = phase == SimPhase.Running || phase == SimPhase.Victory || phase == SimPhase.Defeat;
|
||||||
|
_stopButton.Disabled = phase == SimPhase.Edit;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void UpdateTurn(int turn)
|
public void UpdateTurn(int turn)
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,4 @@ public partial class DetailPanel : PanelContainer
|
||||||
}
|
}
|
||||||
|
|
||||||
public new void Hide() => Visible = false;
|
public new void Hide() => Visible = false;
|
||||||
|
|
||||||
public int? CurrentPieceId => Visible ? _currentPieceId : null;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,73 +0,0 @@
|
||||||
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 +0,0 @@
|
||||||
uid://bcapogap6qff2
|
|
||||||
|
|
@ -6,7 +6,19 @@ namespace Chessistics.Scripts.UI;
|
||||||
public partial class LevelSelectScreen : Control
|
public partial class LevelSelectScreen : Control
|
||||||
{
|
{
|
||||||
[Signal]
|
[Signal]
|
||||||
public delegate void StartCampaignPressedEventHandler();
|
public delegate void LevelSelectedEventHandler(int levelIndex);
|
||||||
|
|
||||||
|
private readonly (string name, string desc)[] _levels =
|
||||||
|
[
|
||||||
|
("Premier Convoi", "Acheminez du bois de la scierie au depot."),
|
||||||
|
("Deux Clients", "Fournissez deux destinations depuis une seule scierie."),
|
||||||
|
("Le Col", "Franchissez le mur et gerez deux types de cargaison."),
|
||||||
|
("Le Carrefour", "Deux productions, deux demandes, et un carrefour au centre."),
|
||||||
|
("Le Labyrinthe", "Un couloir etroit serpente a travers les murs."),
|
||||||
|
("Trois Royaumes", "Trois productions, trois demandes. Gerez un reseau complet."),
|
||||||
|
("La Dame Blanche", "La Dame entre en jeu. Portee supreme sur 8 directions."),
|
||||||
|
("Le Grand Reseau", "Quatre productions, quatre demandes. Reseau complet.")
|
||||||
|
];
|
||||||
|
|
||||||
public override void _Ready()
|
public override void _Ready()
|
||||||
{
|
{
|
||||||
|
|
@ -18,79 +30,199 @@ public partial class LevelSelectScreen : Control
|
||||||
bg.MouseFilter = MouseFilterEnum.Ignore;
|
bg.MouseFilter = MouseFilterEnum.Ignore;
|
||||||
AddChild(bg);
|
AddChild(bg);
|
||||||
|
|
||||||
// Center content
|
// Outer margin
|
||||||
var center = new CenterContainer();
|
var margin = new MarginContainer();
|
||||||
center.SetAnchorsPreset(LayoutPreset.FullRect);
|
margin.SetAnchorsPreset(LayoutPreset.FullRect);
|
||||||
center.MouseFilter = MouseFilterEnum.Ignore;
|
margin.AddThemeConstantOverride("margin_left", 80);
|
||||||
|
margin.AddThemeConstantOverride("margin_right", 80);
|
||||||
|
margin.AddThemeConstantOverride("margin_top", 60);
|
||||||
|
margin.AddThemeConstantOverride("margin_bottom", 60);
|
||||||
|
margin.MouseFilter = MouseFilterEnum.Ignore;
|
||||||
|
|
||||||
var vbox = new VBoxContainer { Alignment = BoxContainer.AlignmentMode.Center };
|
var outerVBox = new VBoxContainer();
|
||||||
vbox.AddThemeConstantOverride("separation", 24);
|
outerVBox.AddThemeConstantOverride("separation", 0);
|
||||||
vbox.MouseFilter = MouseFilterEnum.Ignore;
|
outerVBox.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", 56);
|
title.AddThemeFontSizeOverride("font_size", 48);
|
||||||
title.AddThemeColorOverride("font_color", new Color("#FFD700"));
|
title.AddThemeColorOverride("font_color", new Color("#FFD700"));
|
||||||
vbox.AddChild(title);
|
headerBox.AddChild(title);
|
||||||
|
|
||||||
// Subtitle
|
|
||||||
var subtitle = new Label
|
var subtitle = new Label
|
||||||
{
|
{
|
||||||
Text = "La Quête du Roi",
|
Text = "Selectionnez un niveau",
|
||||||
HorizontalAlignment = HorizontalAlignment.Center
|
HorizontalAlignment = HorizontalAlignment.Center
|
||||||
};
|
};
|
||||||
subtitle.AddThemeFontSizeOverride("font_size", 18);
|
subtitle.AddThemeFontSizeOverride("font_size", 15);
|
||||||
subtitle.AddThemeColorOverride("font_color", new Color("#999999"));
|
subtitle.AddThemeColorOverride("font_color", new Color("#777777"));
|
||||||
vbox.AddChild(subtitle);
|
headerBox.AddChild(subtitle);
|
||||||
|
|
||||||
|
outerVBox.AddChild(headerBox);
|
||||||
|
|
||||||
// Spacer
|
// Spacer
|
||||||
vbox.AddChild(new Control { CustomMinimumSize = new Vector2(0, 32) });
|
outerVBox.AddChild(new Control { CustomMinimumSize = new Vector2(0, 48) });
|
||||||
|
|
||||||
// Start button
|
// --- Level cards in a scrollable grid ---
|
||||||
var startBtn = new Button
|
var scroll = new ScrollContainer
|
||||||
{
|
{
|
||||||
Text = "Démarrer",
|
SizeFlagsVertical = SizeFlags.ExpandFill,
|
||||||
CustomMinimumSize = new Vector2(200, 52),
|
HorizontalScrollMode = ScrollContainer.ScrollMode.Disabled
|
||||||
|
};
|
||||||
|
|
||||||
|
var grid = new GridContainer
|
||||||
|
{
|
||||||
|
Columns = 3,
|
||||||
|
SizeFlagsHorizontal = SizeFlags.ShrinkCenter,
|
||||||
|
MouseFilter = MouseFilterEnum.Ignore
|
||||||
|
};
|
||||||
|
grid.AddThemeConstantOverride("h_separation", 28);
|
||||||
|
grid.AddThemeConstantOverride("v_separation", 28);
|
||||||
|
|
||||||
|
for (int i = 0; i < _levels.Length; i++)
|
||||||
|
{
|
||||||
|
var (name, desc) = _levels[i];
|
||||||
|
grid.AddChild(CreateLevelCard(i, name, desc));
|
||||||
|
}
|
||||||
|
|
||||||
|
scroll.AddChild(grid);
|
||||||
|
outerVBox.AddChild(scroll);
|
||||||
|
|
||||||
|
margin.AddChild(outerVBox);
|
||||||
|
AddChild(margin);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Control CreateLevelCard(int index, string name, string description)
|
||||||
|
{
|
||||||
|
var card = new PanelContainer
|
||||||
|
{
|
||||||
|
CustomMinimumSize = new Vector2(300, 240),
|
||||||
|
SizeFlagsVertical = SizeFlags.ShrinkCenter
|
||||||
|
};
|
||||||
|
|
||||||
|
var cardStyle = new StyleBoxFlat
|
||||||
|
{
|
||||||
|
BgColor = new Color(0.17f, 0.17f, 0.19f),
|
||||||
|
BorderColor = new Color(0.28f, 0.28f, 0.32f),
|
||||||
|
BorderWidthBottom = 1,
|
||||||
|
BorderWidthTop = 1,
|
||||||
|
BorderWidthLeft = 1,
|
||||||
|
BorderWidthRight = 1,
|
||||||
|
CornerRadiusTopLeft = 8,
|
||||||
|
CornerRadiusTopRight = 8,
|
||||||
|
CornerRadiusBottomLeft = 8,
|
||||||
|
CornerRadiusBottomRight = 8,
|
||||||
|
ContentMarginLeft = 24,
|
||||||
|
ContentMarginRight = 24,
|
||||||
|
ContentMarginTop = 24,
|
||||||
|
ContentMarginBottom = 24
|
||||||
|
};
|
||||||
|
card.AddThemeStyleboxOverride("panel", cardStyle);
|
||||||
|
|
||||||
|
var vbox = new VBoxContainer();
|
||||||
|
vbox.AddThemeConstantOverride("separation", 10);
|
||||||
|
|
||||||
|
// Level number
|
||||||
|
var numLabel = new Label
|
||||||
|
{
|
||||||
|
Text = $"Niveau {index + 1}",
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Center
|
||||||
|
};
|
||||||
|
numLabel.AddThemeFontSizeOverride("font_size", 12);
|
||||||
|
numLabel.AddThemeColorOverride("font_color", new Color("#666666"));
|
||||||
|
vbox.AddChild(numLabel);
|
||||||
|
|
||||||
|
// Level name
|
||||||
|
var nameLabel = new Label
|
||||||
|
{
|
||||||
|
Text = name,
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Center
|
||||||
|
};
|
||||||
|
nameLabel.AddThemeFontSizeOverride("font_size", 22);
|
||||||
|
nameLabel.AddThemeColorOverride("font_color", new Color("#EEEEEE"));
|
||||||
|
vbox.AddChild(nameLabel);
|
||||||
|
|
||||||
|
// Thin separator
|
||||||
|
var sep = new HSeparator { CustomMinimumSize = new Vector2(0, 4) };
|
||||||
|
vbox.AddChild(sep);
|
||||||
|
|
||||||
|
// Description
|
||||||
|
var descLabel = new Label
|
||||||
|
{
|
||||||
|
Text = description,
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Center,
|
||||||
|
AutowrapMode = TextServer.AutowrapMode.Word,
|
||||||
|
CustomMinimumSize = new Vector2(240, 0)
|
||||||
|
};
|
||||||
|
descLabel.AddThemeFontSizeOverride("font_size", 13);
|
||||||
|
descLabel.AddThemeColorOverride("font_color", new Color("#999999"));
|
||||||
|
vbox.AddChild(descLabel);
|
||||||
|
|
||||||
|
// Flexible spacer
|
||||||
|
vbox.AddChild(new Control { SizeFlagsVertical = SizeFlags.ExpandFill });
|
||||||
|
|
||||||
|
// Play button
|
||||||
|
var playBtn = new Button
|
||||||
|
{
|
||||||
|
Text = "Jouer",
|
||||||
|
CustomMinimumSize = new Vector2(120, 38),
|
||||||
SizeFlagsHorizontal = SizeFlags.ShrinkCenter
|
SizeFlagsHorizontal = SizeFlags.ShrinkCenter
|
||||||
};
|
};
|
||||||
|
|
||||||
var btnNormal = new StyleBoxFlat
|
var btnNormal = new StyleBoxFlat
|
||||||
{
|
{
|
||||||
BgColor = new Color("#8B6914"),
|
BgColor = new Color("#8B6914"),
|
||||||
CornerRadiusTopLeft = 8, CornerRadiusTopRight = 8,
|
CornerRadiusTopLeft = 6,
|
||||||
CornerRadiusBottomLeft = 8, CornerRadiusBottomRight = 8,
|
CornerRadiusTopRight = 6,
|
||||||
ContentMarginLeft = 32, ContentMarginRight = 32,
|
CornerRadiusBottomLeft = 6,
|
||||||
ContentMarginTop = 12, ContentMarginBottom = 12
|
CornerRadiusBottomRight = 6,
|
||||||
|
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 = 8, CornerRadiusTopRight = 8,
|
CornerRadiusTopLeft = 6,
|
||||||
CornerRadiusBottomLeft = 8, CornerRadiusBottomRight = 8,
|
CornerRadiusTopRight = 6,
|
||||||
ContentMarginLeft = 32, ContentMarginRight = 32,
|
CornerRadiusBottomLeft = 6,
|
||||||
ContentMarginTop = 12, ContentMarginBottom = 12
|
CornerRadiusBottomRight = 6,
|
||||||
|
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 = 8, CornerRadiusTopRight = 8,
|
CornerRadiusTopLeft = 6,
|
||||||
CornerRadiusBottomLeft = 8, CornerRadiusBottomRight = 8,
|
CornerRadiusTopRight = 6,
|
||||||
ContentMarginLeft = 32, ContentMarginRight = 32,
|
CornerRadiusBottomLeft = 6,
|
||||||
ContentMarginTop = 12, ContentMarginBottom = 12
|
CornerRadiusBottomRight = 6,
|
||||||
|
ContentMarginLeft = 24,
|
||||||
|
ContentMarginRight = 24,
|
||||||
|
ContentMarginTop = 8,
|
||||||
|
ContentMarginBottom = 8
|
||||||
};
|
};
|
||||||
startBtn.AddThemeStyleboxOverride("normal", btnNormal);
|
playBtn.AddThemeStyleboxOverride("normal", btnNormal);
|
||||||
startBtn.AddThemeStyleboxOverride("hover", btnHover);
|
playBtn.AddThemeStyleboxOverride("hover", btnHover);
|
||||||
startBtn.AddThemeStyleboxOverride("pressed", btnPressed);
|
playBtn.AddThemeStyleboxOverride("pressed", btnPressed);
|
||||||
startBtn.AddThemeFontSizeOverride("font_size", 20);
|
playBtn.AddThemeFontSizeOverride("font_size", 15);
|
||||||
|
|
||||||
startBtn.Pressed += () => EmitSignal(SignalName.StartCampaignPressed);
|
var idx = index;
|
||||||
vbox.AddChild(startBtn);
|
playBtn.Pressed += () => EmitSignal(SignalName.LevelSelected, idx);
|
||||||
|
vbox.AddChild(playBtn);
|
||||||
|
|
||||||
center.AddChild(vbox);
|
card.AddChild(vbox);
|
||||||
AddChild(center);
|
return card;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,13 +7,14 @@ 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()
|
||||||
{
|
{
|
||||||
|
|
@ -56,9 +57,13 @@ 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);
|
||||||
|
|
||||||
_nextBtn = CreateStyledButton("Mission suivante");
|
var retryBtn = CreateStyledButton("Rejouer");
|
||||||
_nextBtn.Pressed += () => EmitSignal(SignalName.NextLevelPressed);
|
retryBtn.Pressed += () => EmitSignal(SignalName.RetryPressed);
|
||||||
_buttons.AddChild(_nextBtn);
|
_buttons.AddChild(retryBtn);
|
||||||
|
|
||||||
|
var nextBtn = CreateStyledButton("Niveau suivant");
|
||||||
|
nextBtn.Pressed += () => EmitSignal(SignalName.NextLevelPressed);
|
||||||
|
_buttons.AddChild(nextBtn);
|
||||||
|
|
||||||
vbox.AddChild(_buttons);
|
vbox.AddChild(_buttons);
|
||||||
AddChild(vbox);
|
AddChild(vbox);
|
||||||
|
|
@ -104,37 +109,6 @@ 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, bool completed)> _entries = new();
|
private readonly Dictionary<Coords, (Label label, ProgressBar bar, Label deadline)> _entries = new();
|
||||||
|
|
||||||
public void Setup(IReadOnlyList<DemandDef> demands)
|
public void Setup(IReadOnlyList<DemandDef> demands)
|
||||||
{
|
{
|
||||||
|
|
@ -57,8 +57,13 @@ 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, false);
|
_entries[demand.Position] = (label, bar, deadline);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -66,21 +71,15 @@ public partial class ObjectivePanel : VBoxContainer
|
||||||
{
|
{
|
||||||
if (!_entries.TryGetValue(demandCell, out var entry)) return;
|
if (!_entries.TryGetValue(demandCell, out var entry)) return;
|
||||||
|
|
||||||
// Once completed, stop updating
|
entry.label.Text = $"{name}: {current}/{required}";
|
||||||
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)displayCurrent, 0.2f)
|
tween.TweenProperty(entry.bar, "value", (double)current, 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
|
||||||
|
|
@ -91,9 +90,6 @@ 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,8 +58,7 @@ 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);
|
||||||
|
|
||||||
|
|
@ -149,13 +148,6 @@ 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",
|
||||||
|
|
|
||||||
|
|
@ -1,156 +0,0 @@
|
||||||
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 +0,0 @@
|
||||||
uid://b2103p4uf8f3t
|
|
||||||
|
|
@ -5,10 +5,6 @@ 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; }
|
||||||
|
|
@ -26,6 +22,10 @@ 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,16 +41,6 @@ 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)
|
||||||
|
|
@ -68,21 +58,20 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Transformer output acts like a production
|
// Check if start or end shares a relay point with an existing piece that has a filter
|
||||||
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;
|
||||||
|
|
@ -100,9 +89,6 @@ 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; }
|
||||||
|
|
@ -114,6 +100,10 @@ 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."));
|
||||||
|
|
@ -129,6 +119,26 @@ 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)
|
||||||
|
|
@ -149,9 +159,9 @@ public class ResumeSimulationCommand : WorldCommand
|
||||||
{
|
{
|
||||||
public override void AssertApplicationConditions(BoardState state)
|
public override void AssertApplicationConditions(BoardState state)
|
||||||
{
|
{
|
||||||
if (state.Phase != SimPhase.Paused && state.Phase != SimPhase.MissionComplete)
|
if (state.Phase != SimPhase.Paused)
|
||||||
throw new CommandRejectedException(
|
throw new CommandRejectedException(
|
||||||
new CommandRejectedEvent(nameof(ResumeSimulationCommand), "Can only resume from Paused or MissionComplete phase."));
|
new CommandRejectedEvent(nameof(ResumeSimulationCommand), "Can only resume from Paused phase."));
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void DoApply(BoardState state, List<IWorldEvent> changeList)
|
protected override void DoApply(BoardState state, List<IWorldEvent> changeList)
|
||||||
|
|
@ -165,20 +175,72 @@ public class StepSimulationCommand : WorldCommand
|
||||||
{
|
{
|
||||||
public override void AssertApplicationConditions(BoardState state)
|
public override void AssertApplicationConditions(BoardState state)
|
||||||
{
|
{
|
||||||
if (state.Phase != SimPhase.Running && state.Phase != SimPhase.Paused)
|
if (state.Phase == SimPhase.Edit && state.Pieces.Count == 0)
|
||||||
|
throw new CommandRejectedException(
|
||||||
|
new CommandRejectedEvent(nameof(StepSimulationCommand), "Place at least one piece before stepping."));
|
||||||
|
|
||||||
|
if (state.Phase != SimPhase.Edit && state.Phase != SimPhase.Running && state.Phase != SimPhase.Paused)
|
||||||
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)
|
||||||
{
|
{
|
||||||
var wasRunning = state.Phase == SimPhase.Running;
|
if (state.Phase == SimPhase.Edit)
|
||||||
|
state.Phase = SimPhase.Paused;
|
||||||
|
|
||||||
TurnExecutor.ExecuteTurn(state, changeList);
|
TurnExecutor.ExecuteTurn(state, changeList);
|
||||||
|
|
||||||
// After a manual step (was Paused), remain Paused.
|
// After a step, remain in Paused unless victory/defeat occurred
|
||||||
// After an auto-play step (was Running), stay Running unless
|
if (state.Phase == SimPhase.Running)
|
||||||
// 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,42 +2,26 @@ using Chessistics.Engine.Model;
|
||||||
|
|
||||||
namespace Chessistics.Engine.Events;
|
namespace Chessistics.Engine.Events;
|
||||||
|
|
||||||
// Placement events (work in any phase)
|
// Edit phase events
|
||||||
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;
|
||||||
// Campaign events
|
public record LevelResetEvent : IWorldEvent;
|
||||||
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 PieceReturnedToStockEvent(int TurnNumber, int PieceId, PieceKind Kind, int? DestroyerPieceId, Coords Cell) : IWorldEvent;
|
public record PieceDestroyedEvent(int TurnNumber, int PieceId, 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;
|
|
||||||
|
|
|
||||||
|
|
@ -1,221 +0,0 @@
|
||||||
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 +0,0 @@
|
||||||
uid://dq4ycsj6oc1nh
|
|
||||||
|
|
@ -8,14 +8,10 @@ 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;
|
||||||
|
|
@ -32,51 +28,17 @@ 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,
|
.Select(d => new DemandSnapshot(d.Position, d.Name, d.Cargo, d.Required, d.Deadline, d.ReceivedCount, d.IsSatisfied))
|
||||||
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(
|
public record DemandSnapshot(Coords Position, string Name, CargoType Cargo, int Required, int Deadline, int ReceivedCount, bool IsSatisfied);
|
||||||
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,85 +4,97 @@ namespace Chessistics.Engine.Model;
|
||||||
|
|
||||||
public class BoardState
|
public class BoardState
|
||||||
{
|
{
|
||||||
public int Width { get; private set; }
|
public int Width { get; }
|
||||||
public int Height { get; private set; }
|
public int Height { get; }
|
||||||
public CellType[,] Grid { get; private set; }
|
public CellType[,] Grid { get; }
|
||||||
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(int width, int height)
|
private BoardState(LevelDef level)
|
||||||
{
|
{
|
||||||
Width = width;
|
_levelDef = level;
|
||||||
Height = height;
|
Width = level.Width;
|
||||||
|
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.Paused;
|
Phase = SimPhase.Edit;
|
||||||
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;
|
||||||
}
|
|
||||||
|
|
||||||
private BoardState(LevelDef level) : this(level.Width, level.Height)
|
// Place walls
|
||||||
{
|
foreach (var wall in level.Walls)
|
||||||
_levelDef = level;
|
Grid[wall.Col, wall.Row] = CellType.Wall;
|
||||||
ApplyLevelDef(level);
|
|
||||||
|
// Place productions
|
||||||
|
foreach (var prod in level.Productions)
|
||||||
|
{
|
||||||
|
Grid[prod.Position.Col, prod.Position.Row] = CellType.Production;
|
||||||
|
Productions[prod.Position] = prod;
|
||||||
|
ProductionBuffers[prod.Position] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Place demands
|
||||||
|
foreach (var demand in level.Demands)
|
||||||
|
{
|
||||||
|
Grid[demand.Position.Col, demand.Position.Row] = CellType.Demand;
|
||||||
|
Demands[demand.Position] = new DemandState(demand);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize stock
|
||||||
|
foreach (var stock in level.Stock)
|
||||||
|
RemainingStock[stock.Kind] = stock.Count;
|
||||||
}
|
}
|
||||||
|
|
||||||
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.
|
/// Returns all cells currently occupied by any piece (both start and end during Edit, CurrentCell during sim).
|
||||||
/// 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)
|
||||||
{
|
{
|
||||||
occupied.Add(piece.CurrentCell);
|
if (Phase == SimPhase.Edit)
|
||||||
|
{
|
||||||
|
occupied.Add(piece.StartCell);
|
||||||
|
occupied.Add(piece.EndCell);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
occupied.Add(piece.CurrentCell);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return occupied;
|
return occupied;
|
||||||
}
|
}
|
||||||
|
|
@ -105,96 +117,17 @@ 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.Paused;
|
Phase = SimPhase.Edit;
|
||||||
TurnNumber = 0;
|
TurnNumber = 0;
|
||||||
NextPieceId = 1;
|
NextPieceId = 1;
|
||||||
|
|
||||||
|
|
@ -202,149 +135,23 @@ 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;
|
||||||
|
|
||||||
ApplyLevelDef(_levelDef);
|
foreach (var wall in _levelDef.Walls)
|
||||||
}
|
|
||||||
|
|
||||||
/// <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 level.Productions)
|
foreach (var prod in _levelDef.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 level.Demands)
|
foreach (var demand in _levelDef.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 level.Stock)
|
foreach (var stock in _levelDef.Stock)
|
||||||
RemainingStock[stock.Kind] = stock.Count;
|
RemainingStock[stock.Kind] = stock.Count;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
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 +0,0 @@
|
||||||
uid://cpyjhyp308ybb
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
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 +0,0 @@
|
||||||
uid://bxmuxyxroua54
|
|
||||||
|
|
@ -3,8 +3,5 @@ namespace Chessistics.Engine.Model;
|
||||||
public enum CargoType
|
public enum CargoType
|
||||||
{
|
{
|
||||||
Wood,
|
Wood,
|
||||||
Stone,
|
Stone
|
||||||
Tools,
|
|
||||||
Arms,
|
|
||||||
Gold
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,5 @@ public enum CellType
|
||||||
Empty,
|
Empty,
|
||||||
Wall,
|
Wall,
|
||||||
Production,
|
Production,
|
||||||
Demand,
|
Demand
|
||||||
Transformer
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,3 @@
|
||||||
namespace Chessistics.Engine.Model;
|
namespace Chessistics.Engine.Model;
|
||||||
|
|
||||||
/// <summary>
|
public record DemandDef(Coords Position, string Name, CargoType Cargo, int Amount, int Deadline);
|
||||||
/// 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,40 +4,17 @@ 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; }
|
|
||||||
|
|
||||||
// Recurring demand tracking (only used when Definition.ConsumptionPerTurn > 0)
|
public DemandState(DemandDef definition)
|
||||||
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 IsRecurring => Definition.ConsumptionPerTurn > 0;
|
public bool IsSatisfied => ReceivedCount >= Definition.Amount;
|
||||||
|
|
||||||
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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
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 +0,0 @@
|
||||||
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; private set; }
|
public Coords StartCell { get; }
|
||||||
public Coords EndCell { get; private set; }
|
public Coords EndCell { get; }
|
||||||
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,25 +30,4 @@ 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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
namespace Chessistics.Engine.Model;
|
|
||||||
|
|
||||||
public record PieceUpgrade(PieceKind Kind, int Level);
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
uid://broa1hmowlt7
|
|
||||||
|
|
@ -2,7 +2,9 @@ namespace Chessistics.Engine.Model;
|
||||||
|
|
||||||
public enum SimPhase
|
public enum SimPhase
|
||||||
{
|
{
|
||||||
|
Edit,
|
||||||
Running,
|
Running,
|
||||||
Paused,
|
Paused,
|
||||||
MissionComplete
|
Victory,
|
||||||
|
Defeat
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
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 +0,0 @@
|
||||||
uid://c51en0egfstje
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
namespace Chessistics.Engine.Model;
|
|
||||||
|
|
||||||
public record TransformerDef(
|
|
||||||
Coords Position,
|
|
||||||
string Name,
|
|
||||||
CargoType InputCargo,
|
|
||||||
int InputRequired,
|
|
||||||
CargoType OutputCargo,
|
|
||||||
int OutputAmount
|
|
||||||
);
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
uid://cu7cpt1u5mtxd
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
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 +0,0 @@
|
||||||
uid://b3vg5keyv2aj6
|
|
||||||
|
|
@ -14,10 +14,7 @@ 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 A2: Transformer outputs give to adjacent pieces (like productions)
|
// Phase B: Pieces give to demands or other pieces
|
||||||
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;
|
||||||
|
|
@ -59,35 +56,6 @@ 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)
|
||||||
{
|
{
|
||||||
|
|
@ -110,8 +78,6 @@ 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(
|
||||||
|
|
@ -125,22 +91,7 @@ public static class TransferResolver
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Priority 2: deliver to adjacent transformer (input side)
|
// Priority 2: transfer to adjacent piece without cargo
|
||||||
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;
|
||||||
|
|
@ -185,17 +136,6 @@ 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):
|
||||||
|
|
|
||||||
17
chessistics-engine/Rules/VictoryChecker.cs
Normal file
17
chessistics-engine/Rules/VictoryChecker.cs
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
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
chessistics-engine/Rules/VictoryChecker.cs.uid
Normal file
1
chessistics-engine/Rules/VictoryChecker.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://uh7qhohnsxpa
|
||||||
|
|
@ -7,25 +7,14 @@ 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
|
||||||
{
|
{
|
||||||
|
|
@ -35,73 +24,8 @@ public class GameSim
|
||||||
{
|
{
|
||||||
return [ex.RejectionEvent];
|
return [ex.RejectionEvent];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (undoCheckpoint != null && ContainsMutation(changeList))
|
|
||||||
PushUndo(undoCheckpoint);
|
|
||||||
|
|
||||||
return changeList;
|
return changeList;
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
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,98 +14,49 @@ public static class TurnExecutor
|
||||||
// Sub-phase 1: PRODUCTION
|
// Sub-phase 1: PRODUCTION
|
||||||
ExecuteProduction(state, changeList);
|
ExecuteProduction(state, changeList);
|
||||||
|
|
||||||
// Sub-phase 2: TRANSFORMATION (convert accumulated input → output)
|
// Sub-phase 2: TRANSFERS
|
||||||
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 4: MOVEMENT
|
// Sub-phase 3: MOVEMENT
|
||||||
ExecuteMovement(state, changeList);
|
ExecuteMovement(state, changeList);
|
||||||
|
|
||||||
// Sub-phase 4b: RECURRING DEMAND CONSUMPTION
|
// Sub-phase 4: COLLISION RESOLUTION
|
||||||
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(
|
||||||
// Return piece to stock instead of destroying permanently
|
state.TurnNumber, victim.Id, survivor?.Id, cell));
|
||||||
state.RemainingStock[victim.Kind] = state.RemainingStock.GetValueOrDefault(victim.Kind) + 1;
|
|
||||||
changeList.Add(new PieceReturnedToStockEvent(
|
|
||||||
state.TurnNumber, victim.Id, victim.Kind, survivor?.Id, cell));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-pause on collision
|
// Check victory / defeat
|
||||||
if (collisions.Count > 0)
|
if (VictoryChecker.AllDemandsMet(state))
|
||||||
{
|
{
|
||||||
state.Phase = SimPhase.Paused;
|
state.Phase = SimPhase.Victory;
|
||||||
changeList.Add(new SimulationPausedEvent());
|
changeList.Add(new VictoryEvent(state.TurnNumber, ComputeMetrics(state)));
|
||||||
}
|
}
|
||||||
|
else if (VictoryChecker.AnyDeadlineExpired(state))
|
||||||
// Check mission completion
|
|
||||||
if (MissionChecker.AllCurrentDemandsMet(state) && state.Demands.Count > 0)
|
|
||||||
{
|
{
|
||||||
var campaign = state.Campaign;
|
state.Phase = SimPhase.Defeat;
|
||||||
var missionIndex = campaign?.CurrentMissionIndex ?? 0;
|
foreach (var demand in VictoryChecker.GetExpiredDemands(state))
|
||||||
campaign?.CompletedMissions.Add(missionIndex);
|
changeList.Add(new DeadlineExpiredEvent(state.TurnNumber, demand.Position, demand.Name));
|
||||||
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;
|
||||||
|
|
@ -114,55 +65,6 @@ 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,8 +13,6 @@ 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));
|
||||||
|
|
||||||
|
|
@ -24,6 +22,9 @@ 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());
|
||||||
|
|
||||||
|
|
@ -33,20 +34,17 @@ public class SimHelper
|
||||||
public IReadOnlyList<IWorldEvent> Resume()
|
public IReadOnlyList<IWorldEvent> Resume()
|
||||||
=> Sim.ProcessCommand(new ResumeSimulationCommand());
|
=> Sim.ProcessCommand(new ResumeSimulationCommand());
|
||||||
|
|
||||||
public IReadOnlyList<IWorldEvent> AdvanceMission()
|
public IReadOnlyList<IWorldEvent> Stop()
|
||||||
=> Sim.ProcessCommand(new AdvanceMissionCommand());
|
=> Sim.ProcessCommand(new StopSimulationCommand());
|
||||||
|
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,81 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
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 +0,0 @@
|
||||||
uid://c6sab8mq5a201
|
|
||||||
|
|
@ -1,220 +0,0 @@
|
||||||
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 +0,0 @@
|
||||||
uid://boxlkyt1rnb6l
|
|
||||||
|
|
@ -1,135 +0,0 @@
|
||||||
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 +0,0 @@
|
||||||
uid://dle5bi0rtya8x
|
|
||||||
|
|
@ -1,112 +0,0 @@
|
||||||
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 +0,0 @@
|
||||||
uid://kujovcfoy6j2
|
|
||||||
|
|
@ -319,32 +319,6 @@ 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()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,308 +0,0 @@
|
||||||
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 +0,0 @@
|
||||||
uid://bxt85xb77h4jn
|
|
||||||
|
|
@ -8,8 +8,10 @@ namespace Chessistics.Tests.Simulation;
|
||||||
public class FullLevelTests
|
public class FullLevelTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Level1_PremierConvoi_MissionComplete()
|
public void Level1_PremierConvoi_Victory()
|
||||||
{
|
{
|
||||||
|
// 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)
|
||||||
|
|
@ -18,14 +20,23 @@ 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 MissionCompleteEvent);
|
Assert.Contains(allEvents, e => e is VictoryEvent);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Level2_DeuxClients_MissionComplete()
|
public void Level2_DeuxClients_Victory()
|
||||||
{
|
{
|
||||||
|
// 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)
|
||||||
|
|
@ -42,21 +53,35 @@ 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 MissionCompleteEvent);
|
Assert.Contains(allEvents, e => e is VictoryEvent);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Level3_LeCol_MissionComplete()
|
public void Level3_LeCol_Victory()
|
||||||
{
|
{
|
||||||
|
// 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)
|
||||||
|
|
@ -84,12 +109,13 @@ 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 MissionCompleteEvent);
|
Assert.Contains(allEvents, e => e is VictoryEvent);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Level1_InsufficientPieces_NoMissionComplete()
|
public void Level1_InsufficientPieces_NoVictory()
|
||||||
{
|
{
|
||||||
var level = new BoardBuilder(4, 4)
|
var level = new BoardBuilder(4, 4)
|
||||||
.WithProduction(0, 0, "Scierie", CargoType.Wood)
|
.WithProduction(0, 0, "Scierie", CargoType.Wood)
|
||||||
|
|
@ -99,10 +125,11 @@ 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);
|
||||||
|
|
||||||
// No deadline concept anymore — just no mission complete
|
Assert.DoesNotContain(allEvents, e => e is VictoryEvent);
|
||||||
Assert.DoesNotContain(allEvents, e => e is MissionCompleteEvent);
|
Assert.Contains(allEvents, e => e is DeadlineExpiredEvent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,27 +44,41 @@ public class GameSimTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void PlaceDuringRunning_Succeeds()
|
public void PlaceDuringRunning_Rejected()
|
||||||
{
|
{
|
||||||
// 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.Resume(); // Paused → Running
|
sim.Start();
|
||||||
|
|
||||||
var events = sim.Place(PieceKind.Rook, (0, 1), (1, 1));
|
var events = sim.Place(PieceKind.Rook, (0, 1), (1, 1));
|
||||||
Assert.IsType<PiecePlacedEvent>(events[0]);
|
Assert.IsType<CommandRejectedEvent>(events[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void RemoveDuringRunning_Succeeds()
|
public void StartWithNoPieces_Rejected()
|
||||||
|
{
|
||||||
|
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.Resume(); // Paused → Running
|
sim.Start();
|
||||||
|
|
||||||
var events = sim.Remove(1);
|
var events = sim.Remove(1);
|
||||||
Assert.IsType<PieceRemovedEvent>(events[0]);
|
Assert.IsType<CommandRejectedEvent>(events[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StopDuringEdit_Rejected()
|
||||||
|
{
|
||||||
|
var sim = CreateLevel1Sim();
|
||||||
|
var events = sim.Stop();
|
||||||
|
Assert.IsType<CommandRejectedEvent>(events[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -72,6 +86,7 @@ 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();
|
||||||
|
|
@ -90,11 +105,16 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
@ -105,15 +125,18 @@ 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 MissionComplete_WhenAllDemandsMet()
|
public void Victory_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)
|
||||||
|
|
@ -122,28 +145,59 @@ 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 MissionCompleteEvent);
|
Assert.Contains(allEvents, e => e is VictoryEvent);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void InitialPhase_IsPaused()
|
public void Defeat_WhenDeadlineExpires()
|
||||||
{
|
{
|
||||||
var sim = CreateLevel1Sim();
|
// Demand with very tight deadline, piece placed far from demand
|
||||||
var snap = sim.Snapshot;
|
var level = new BoardBuilder(4, 4)
|
||||||
Assert.Equal(SimPhase.Paused, snap.Phase);
|
.WithProduction(0, 0, "P", CargoType.Wood)
|
||||||
}
|
.WithDemand(3, 3, "D", CargoType.Wood, 10, 3) // need 10 in 3 turns — impossible
|
||||||
|
.WithStock(PieceKind.Rook, 3)
|
||||||
|
.Build();
|
||||||
|
var sim = SimHelper.FromLevel(level);
|
||||||
|
|
||||||
[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();
|
||||||
|
|
||||||
// Step directly from Paused
|
var allEvents = sim.StepN(5);
|
||||||
var events = sim.Step();
|
|
||||||
Assert.Contains(events, e => e is TurnStartedEvent);
|
Assert.Contains(allEvents, e => e is DeadlineExpiredEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StopResetsState()
|
||||||
|
{
|
||||||
|
var sim = CreateLevel1Sim();
|
||||||
|
sim.Place(PieceKind.Rook, (0, 0), (1, 0));
|
||||||
|
sim.Start();
|
||||||
|
sim.StepN(5);
|
||||||
|
sim.Stop();
|
||||||
|
|
||||||
|
var snap = sim.Snapshot;
|
||||||
|
Assert.Equal(SimPhase.Edit, snap.Phase);
|
||||||
|
Assert.Equal(0, snap.TurnNumber);
|
||||||
|
// Pieces should be back at start cells
|
||||||
|
Assert.All(snap.Pieces, p => Assert.Equal(p.StartCell, p.CurrentCell));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ResetClearsEverything()
|
||||||
|
{
|
||||||
|
var sim = CreateLevel1Sim();
|
||||||
|
sim.Place(PieceKind.Rook, (0, 0), (1, 0));
|
||||||
|
sim.Reset();
|
||||||
|
|
||||||
|
var snap = sim.Snapshot;
|
||||||
|
Assert.Equal(SimPhase.Edit, snap.Phase);
|
||||||
|
Assert.Empty(snap.Pieces);
|
||||||
|
Assert.Equal(3, snap.RemainingStock[PieceKind.Rook]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,247 +0,0 @@
|
||||||
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 +0,0 @@
|
||||||
uid://dxv44w3l5rw66
|
|
||||||
|
|
@ -1,181 +0,0 @@
|
||||||
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) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,99 +0,0 @@
|
||||||
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,4 +1,3 @@
|
||||||
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;
|
||||||
|
|
@ -8,13 +7,17 @@ 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 MissionCompleteEvent is produced — proving the level is winnable.
|
/// and asserts VictoryEvent is produced — proving the level is winnable.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class SolvabilityTests
|
public class SolvabilityTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[Fact]
|
||||||
public void SingleRook_ShortRelay_MissionComplete()
|
public void SingleRook_ShortRelay_Victory()
|
||||||
{
|
{
|
||||||
|
// 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)
|
||||||
|
|
@ -23,15 +26,21 @@ 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 MissionCompleteEvent);
|
Assert.Contains(allEvents, e => e is VictoryEvent);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ThreePieceChain_SharedRelayPoints_MissionComplete()
|
public void ThreePieceChain_SharedRelayPoints_Victory()
|
||||||
{
|
{
|
||||||
|
// 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)
|
||||||
|
|
@ -42,10 +51,12 @@ 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 MissionCompleteEvent);
|
Assert.Contains(allEvents, e => e is VictoryEvent);
|
||||||
|
// 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");
|
||||||
|
|
@ -54,6 +65,12 @@ 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)
|
||||||
|
|
@ -64,18 +81,24 @@ 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 MissionCompleteEvent);
|
Assert.Contains(allEvents, e => e is VictoryEvent);
|
||||||
|
// 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_MissionComplete()
|
public void TwoCargoTypes_ParallelRoutes_Victory()
|
||||||
{
|
{
|
||||||
|
// 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)
|
||||||
|
|
@ -87,10 +110,12 @@ 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 MissionCompleteEvent);
|
Assert.Contains(allEvents, e => e is VictoryEvent);
|
||||||
|
// 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);
|
||||||
|
|
@ -99,8 +124,14 @@ public class SolvabilityTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Bishop_DiagonalRelay_MissionComplete()
|
public void Bishop_DiagonalRelay_Victory()
|
||||||
{
|
{
|
||||||
|
// 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)
|
||||||
|
|
@ -111,15 +142,23 @@ 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 MissionCompleteEvent);
|
Assert.Contains(allEvents, e => e is VictoryEvent);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Knight_JumpsWall_MissionComplete()
|
public void Knight_JumpsWall_Victory()
|
||||||
{
|
{
|
||||||
|
// 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)
|
||||||
|
|
@ -131,16 +170,43 @@ 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 MissionCompleteEvent);
|
Assert.Contains(allEvents, e => e is VictoryEvent);
|
||||||
|
// 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)
|
||||||
|
|
@ -150,15 +216,22 @@ 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 PieceReturnedToStockEvent);
|
Assert.DoesNotContain(allEvents, e => e is PieceDestroyedEvent);
|
||||||
}
|
}
|
||||||
|
|
||||||
[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)
|
||||||
|
|
@ -169,19 +242,24 @@ 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 MissionCompleteEvent);
|
Assert.Contains(allEvents, e => e is VictoryEvent);
|
||||||
}
|
}
|
||||||
|
|
||||||
[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)
|
||||||
|
|
@ -189,9 +267,9 @@ public class SolvabilityTests
|
||||||
.Build();
|
.Build();
|
||||||
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)); // adj to prod → Wood
|
||||||
sim.Place(PieceKind.Rook, (2, 0), (3, 0));
|
sim.Place(PieceKind.Rook, (2, 0), (3, 0)); // shares (2,0) → inherits Wood
|
||||||
sim.Place(PieceKind.Rook, (3, 0), (4, 0));
|
sim.Place(PieceKind.Rook, (3, 0), (4, 0)); // shares (3,0) → inherits Wood
|
||||||
|
|
||||||
var snapshot = sim.Snapshot;
|
var snapshot = sim.Snapshot;
|
||||||
Assert.Equal(CargoType.Wood, snapshot.Pieces[0].CargoFilter);
|
Assert.Equal(CargoType.Wood, snapshot.Pieces[0].CargoFilter);
|
||||||
|
|
@ -200,8 +278,9 @@ public class SolvabilityTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void StepFromPaused_Works()
|
public void StepFromEdit_AutoStartsSimulation()
|
||||||
{
|
{
|
||||||
|
// 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)
|
||||||
|
|
@ -210,109 +289,10 @@ 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));
|
||||||
// Step directly from Paused
|
// No Start() — step directly from Edit
|
||||||
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 MissionCompleteEvent);
|
Assert.Contains(allEvents, e => e is VictoryEvent);
|
||||||
}
|
|
||||||
|
|
||||||
/// <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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,219 +0,0 @@
|
||||||
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 +0,0 @@
|
||||||
uid://c7ifw7o8xahpv
|
|
||||||
|
|
@ -1,120 +0,0 @@
|
||||||
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,21 +16,17 @@ Chaque piece est un **maillon de convoyeur**. La strategie est dans la compositi
|
||||||
**Core loop** :
|
**Core loop** :
|
||||||
|
|
||||||
```
|
```
|
||||||
OBSERVER le reseau en fonctionnement
|
OBSERVER la situation (productions, demandes, terrain, pieces disponibles)
|
||||||
|
|
|
||||||
|
PLACER des pieces sur le plateau (point de depart + point d'arrivee)
|
||||||
|
|
|
|
||||||
IDENTIFIER un goulet, une mission non remplie, ou une collision
|
LANCER la simulation — les pieces font leurs allers-retours,
|
||||||
|
les colis se transmettent automatiquement entre pieces adjacentes
|
||||||
|
|
|
|
||||||
PLACER ou RETIRER des pieces (la simulation se met en pause automatiquement
|
+---> Le debit est insuffisant ? Observer les goulets, reorganiser
|
||||||
pendant le placement, puis reprend)
|
+---> Le debit est atteint ? Optimiser ou niveau suivant
|
||||||
|
|
|
||||||
OBSERVER le resultat — le reseau s'adapte immediatement
|
|
||||||
|
|
|
||||||
+---> Le debit est insuffisant ? Reorganiser les chaines
|
|
||||||
+---> La mission est remplie ? Avancer vers la mission suivante
|
|
||||||
```
|
```
|
||||||
|
|
||||||
La simulation tourne en continu. Le joueur ne "lance" jamais — il intervient sur un systeme vivant.
|
|
||||||
|
|
||||||
**Ce qui distingue Chessistics** :
|
**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
|
||||||
|
|
@ -55,7 +51,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 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. |
|
| **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. |
|
||||||
| **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
|
||||||
|
|
@ -175,8 +171,7 @@ 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 **retournent immediatement dans le stock** du joueur — il peut les replacer a tout moment.
|
- Les pieces detruites sont restaurees quand le joueur arrete la simulation (retour en mode edition).
|
||||||
- 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").
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -193,13 +188,11 @@ Les transferts se produisent entre :
|
||||||
|
|
||||||
### 4.2 Quand le transfert a lieu
|
### 4.2 Quand le transfert a lieu
|
||||||
|
|
||||||
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.
|
Un transfert se produit quand, a la **fin d'un coup** (une fois que toutes les pieces ont bouge) :
|
||||||
|
|
||||||
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** au sein du tour.
|
Le transfert est **instantane** : le colis passe d'une entite a l'autre entre deux coups.
|
||||||
|
|
||||||
### 4.3 Priorite et departage
|
### 4.3 Priorite et departage
|
||||||
|
|
||||||
|
|
@ -293,33 +286,36 @@ 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.
|
||||||
- Les pieces detruites **retournent immediatement dans le stock** du joueur.
|
- La simulation **continue** (pas de pause automatique)
|
||||||
- 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 completion de mission
|
### 5.3 Condition de victoire
|
||||||
|
|
||||||
La mission courante est completee quand **toutes ses demandes** ont ete satisfaites.
|
Le niveau est reussi quand **toutes les demandes** ont ete satisfaites selon leur objectif.
|
||||||
|
|
||||||
Chaque demande specifie : "recevoir N colis de type X".
|
Chaque demande specifie : "recevoir N colis de type X en Y coups ou moins".
|
||||||
|
|
||||||
Exemple : "Le Depot Royal demande 3 livraisons de Bois."
|
Exemple : "Le Depot Royal demande 3 livraisons de Bois en 30 coups."
|
||||||
|
|
||||||
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.
|
Le compteur de coups tourne en temps reel. Le joueur voit sa progression.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. Les metriques
|
## 6. Les metriques
|
||||||
|
|
||||||
A la completion d'une mission, 3 metriques sont affichees :
|
A la completion d'un niveau, 3 metriques sont affichees :
|
||||||
|
|
||||||
| Metrique | Description | Ce que ca mesure |
|
| Metrique | Description | Ce que ca mesure |
|
||||||
|----------|-------------|------------------|
|
|----------|-------------|------------------|
|
||||||
| **Pieces** | Nombre de pieces deployees pour cette mission | Economie de flotte |
|
| **Pieces** | Nombre de pieces deployees | 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
|
||||||
|
|
@ -327,7 +323,7 @@ A la completion d'une mission, 3 metriques sont affichees :
|
||||||
|
|
||||||
**Affichage en jeu** (pendant la simulation) :
|
**Affichage en jeu** (pendant la simulation) :
|
||||||
```
|
```
|
||||||
Tour: 12 Depot Royal: 2/3 Bois ✓ Forge: 0/2 Pierre ✗
|
Coup: 12/30 Depot Royal: 2/3 Bois ✓ Forge: 0/2 Pierre ✗
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -340,36 +336,34 @@ Le plateau est le centre. L'interface est minimale.
|
||||||
|
|
||||||
```
|
```
|
||||||
+---------------------------------------------------------------+
|
+---------------------------------------------------------------+
|
||||||
| CHESSISTICS La Quete du Roi [≡] [?] [←] |
|
| CHESSISTICS La Scierie Royale [≡] [?] [←] |
|
||||||
+---------------------------------------------------------------+
|
+---------------------------------------------------------------+
|
||||||
| | |
|
| | |
|
||||||
| | MISSION 3/8 |
|
| | OBJECTIF |
|
||||||
| | Forger les Tours |
|
| | Depot Royal |
|
||||||
| | Depot: 0/3 Bois |
|
| | 3x Bois / 30c |
|
||||||
| P L A T E A U | ✓ Mission 1 |
|
| P L A T E A U | |
|
||||||
| (damier interactif) | ✓ Mission 2 |
|
| (damier interactif) | ───────── |
|
||||||
| | ───────── |
|
| | |
|
||||||
| Les pieces et leurs trajets | |
|
| Les pieces et leurs trajets | PIECES |
|
||||||
| sont visibles sur le plateau | PIECES |
|
| sont visibles sur le plateau | [Tour II] x3 |
|
||||||
| | [Pion I] x4 |
|
| | [Fou II] x1 |
|
||||||
| | [Tour I] x3 |
|
| | [Cavalier] x1 |
|
||||||
| | |
|
| | |
|
||||||
+---------------------------------------------------------------+
|
+---------------------------------------------------------------+
|
||||||
| [⏸ / ▶] [x1] [x2] [x4] Tour: 42 Mission: 3/8 |
|
| [▶ PLAY] [⏩ x2] [⏸] [⏹ STOP] Coup: -- |
|
||||||
+---------------------------------------------------------------+
|
+---------------------------------------------------------------+
|
||||||
```
|
```
|
||||||
|
|
||||||
### 7.2 Placement d'une piece
|
### 7.2 Placement d'une piece
|
||||||
|
|
||||||
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.
|
Le flux de placement est en 2 clics :
|
||||||
|
|
||||||
1. Le joueur **selectionne un type de piece** dans le panneau de droite → **pause automatique**
|
1. Le joueur **selectionne un type de piece** dans le panneau de droite
|
||||||
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. → **la simulation reprend**
|
3. Il **clique une case en surbrillance** → c'est le point d'arrivee. La piece est placee.
|
||||||
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 :
|
||||||
|
|
||||||
|
|
@ -385,9 +379,9 @@ Si le joueur annule (Echap), la simulation reprend sans placement.
|
||||||
```
|
```
|
||||||
|
|
||||||
**Interactions** :
|
**Interactions** :
|
||||||
- **Clic gauche sur une piece placee** → la selectionne, affiche son trajet, panneau de detail
|
- **Clic sur une piece placee** → la selectionne, affiche son trajet, panneau de detail
|
||||||
- **Touche Suppr** (piece selectionnee) → la retire du plateau (retourne dans le stock)
|
- **Clic droit sur une piece** → la retire du plateau (retourne dans le stock)
|
||||||
- **Bouton [Retirer]** dans le panneau de detail → meme effet
|
- **Glisser une piece** → deplace son point de depart (recalcule les arrivees possibles)
|
||||||
|
|
||||||
### 7.3 Visualisation des trajets
|
### 7.3 Visualisation des trajets
|
||||||
|
|
||||||
|
|
@ -417,20 +411,21 @@ Quand une piece est selectionnee :
|
||||||
+---------------------------+
|
+---------------------------+
|
||||||
```
|
```
|
||||||
|
|
||||||
### 7.5 Simulation continue
|
### 7.5 Phases de jeu
|
||||||
|
|
||||||
La simulation tourne en continu — il n'y a pas de phases Edit/Exec separees. Le joueur modifie son reseau a tout moment, la simulation integre les changements au tour suivant.
|
**Phase EDIT** (temps arrete)
|
||||||
|
- Placer, deplacer, retirer des pieces
|
||||||
|
- Pas de limite de temps
|
||||||
|
- Les trajets sont visibles comme des traits sur le plateau
|
||||||
|
|
||||||
**Controles** :
|
**Phase EXEC** (simulation)
|
||||||
- **Espace** : pause / reprendre (le tour en cours se termine avant la pause)
|
- Les pieces font leurs allers-retours simultanement
|
||||||
- **Vitesse** : x1, x2, x4
|
- Les colis se transmettent automatiquement aux points de contact
|
||||||
- **Placement d'une piece** : met la simulation en pause automatiquement le temps du placement (voir §7.2)
|
- Compteur de coups et progression des objectifs en temps reel
|
||||||
|
- Controles : pause, step-by-step, vitesse x1/x2/x4, stop (retour a EDIT)
|
||||||
|
- En cas de collision → pause auto, pieces en erreur surlignees
|
||||||
|
|
||||||
**Pauses automatiques** :
|
Le joueur peut arreter la simulation a tout moment, reorganiser, et relancer.
|
||||||
- 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
|
||||||
|
|
||||||
|
|
@ -449,23 +444,13 @@ Le joueur peut placer, retirer et reorganiser ses pieces a tout moment, en pause
|
||||||
- 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.
|
||||||
|
|
||||||
**Collisions** :
|
**Erreurs** :
|
||||||
- Flash rouge + shake des deux pieces
|
- Collision : 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
|
|
||||||
|
|
||||||
**Completion de mission** :
|
**Victoire** :
|
||||||
- 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 de felicitations avec metriques de la mission
|
- Overlay des metriques + histogrammes
|
||||||
- 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
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -485,7 +470,7 @@ Le joueur peut placer, retirer et reorganiser ses pieces a tout moment, en pause
|
||||||
```
|
```
|
||||||
|
|
||||||
- Plateau : **4x4**
|
- Plateau : **4x4**
|
||||||
- S = Scierie (a1, produit du Bois 1 par tour)
|
- S = Scierie (a1, produit du Bois tous les 2 coups)
|
||||||
- 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**
|
||||||
|
|
||||||
|
|
@ -529,15 +514,17 @@ 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 4 . . . . . .
|
5 . . . . . [D2] Caserne — 2 Bois en 30 coups
|
||||||
|
4 . . . . . .
|
||||||
3 . . . . . .
|
3 . . . . . .
|
||||||
2 . . . . . .
|
2 . . . . . .
|
||||||
1 [S] . . . . [D1] Depot Royal — 2 Bois
|
1 [S] . . . . [D1] Depot Royal — 2 Bois en 30 coups
|
||||||
|
|
||||||
a b c d e f
|
a b c d e f
|
||||||
```
|
```
|
||||||
|
|
||||||
- Plateau : **6x6**
|
- Plateau : **6x6**
|
||||||
- S = Scierie (a1, produit du Bois 1 par tour)
|
- S = Scierie (a1, produit du Bois tous les 2 coups)
|
||||||
- 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**
|
||||||
|
|
@ -545,7 +532,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 1 par tour. Les deux chaines partagent la meme source.
|
- La Scierie ne produit qu'un colis tous les 2 coups. 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** :
|
||||||
|
|
@ -572,7 +559,9 @@ 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 5 . . # # # . Forge — 2 Pierre 4 . . # . . .
|
6 [D2] . . . . [D1] Depot Royal — 2 Bois en 40 coups
|
||||||
|
5 . . # # # . Forge — 2 Pierre en 40 coups
|
||||||
|
4 . . # . . .
|
||||||
3 . . # . . .
|
3 . . # . . .
|
||||||
2 . . . . . .
|
2 . . . . . .
|
||||||
1 [S1] . . . . [S2] Scierie (Bois)
|
1 [S1] . . . . [S2] Scierie (Bois)
|
||||||
|
|
@ -581,8 +570,8 @@ Le Fou peut couvrir des trajets diagonaux que les Tours ne peuvent pas. Pour att
|
||||||
```
|
```
|
||||||
|
|
||||||
- Plateau : **6x6**
|
- Plateau : **6x6**
|
||||||
- S1 = Scierie (a1, Bois, 1 par tour)
|
- S1 = Scierie (a1, Bois, tous les 2 coups)
|
||||||
- S2 = Carriere (f1, Pierre, 1 par tour)
|
- S2 = Carriere (f1, Pierre, tous les 2 coups)
|
||||||
- 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)
|
||||||
|
|
@ -624,19 +613,21 @@ Le Cavalier saute le mur en L. Il peut connecter les deux cotes du plateau la ou
|
||||||
|
|
||||||
```
|
```
|
||||||
8 . . . . . . . .
|
8 . . . . . . . .
|
||||||
7 [D2] . . . . . . . Forge — 3 Pierre 6 . . . . . . . .
|
7 [D2] . . . . . . . Forge — 3 Pierre en 40 coups
|
||||||
|
6 . . . . . . . .
|
||||||
5 . . . ## . . . .
|
5 . . . ## . . . .
|
||||||
4 . . . ## . . . .
|
4 . . . ## . . . .
|
||||||
3 . . . . . . . .
|
3 . . . . . . . .
|
||||||
2 . . . . . . . .
|
2 . . . . . . . .
|
||||||
1 [S1] . . . . . . [D1] Depot Royal — 3 Bois
|
1 [S1] . . . . . . [D1] Depot Royal — 3 Bois en 40 coups
|
||||||
|
|
||||||
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), D2 = Forge (a8, 3 Pierre)
|
- D1 = Depot Royal (h1, 3 Bois/40c), D2 = Forge (a8, 3 Pierre/40c)
|
||||||
- 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
|
||||||
|
|
||||||
|
|
@ -651,15 +642,17 @@ 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 3 . . # . . . # .
|
4 . . # . # . . [D1] Depot Royal — 3 Bois en 50 coups
|
||||||
|
3 . . # . . . # .
|
||||||
2 . . . . # . # .
|
2 . . . . # . # .
|
||||||
1 [S1] . . . # . . [D2] Forge — 3 Pierre
|
1 [S1] . . . # . . [D2] Forge — 3 Pierre en 50 coups
|
||||||
|
|
||||||
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), D2 = Forge (h1, 3 Pierre)
|
- D1 = Depot Royal (h6, 3 Bois/50c), D2 = Forge (h1, 3 Pierre/50c)
|
||||||
- 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
|
||||||
|
|
||||||
|
|
@ -672,19 +665,21 @@ 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 7 . . . # . . # . . .
|
8 [S2] . . # . . # . . [D2] Forge — 3 Pierre en 50 coups
|
||||||
|
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
|
1 [S1] . . . [D3] . . . . [D1] Depot Royal — 3 Bois en 50 coups
|
||||||
|
|
||||||
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), D2 = Forge (j8, 3 Pierre), D3 = Chantier (e8, 3 Bois)
|
- D1 = Depot Royal (j1, 3 Bois/50c), D2 = Forge (j8, 3 Pierre/50c), D3 = Chantier (e8, 3 Bois/50c)
|
||||||
- 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
|
||||||
|
|
||||||
|
|
@ -751,7 +746,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 — Pause / vitesse
|
ControlBar.tscn — Play / pause / stop / vitesse
|
||||||
MetricsOverlay.tscn — Resultats post-victoire
|
MetricsOverlay.tscn — Resultats post-victoire
|
||||||
LevelSelect.tscn — Selection de niveau
|
LevelSelect.tscn — Selection de niveau
|
||||||
scripts/
|
scripts/
|
||||||
|
|
@ -767,7 +762,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 — Pause/vitesse
|
ControlBar.cs — Play/pause/stop/vitesse
|
||||||
ProgressDisplay.cs — Compteur de coups + progression objectifs
|
ProgressDisplay.cs — Compteur de coups + progression objectifs
|
||||||
data/
|
data/
|
||||||
levels/
|
levels/
|
||||||
|
|
@ -786,10 +781,10 @@ Chessistics/
|
||||||
"width": 4,
|
"width": 4,
|
||||||
"height": 4,
|
"height": 4,
|
||||||
"productions": [
|
"productions": [
|
||||||
{ "x": 0, "y": 0, "name": "Scierie", "cargo": "wood", "amount": 1 }
|
{ "x": 0, "y": 0, "name": "Scierie", "cargo": "wood", "interval": 2 }
|
||||||
],
|
],
|
||||||
"demands": [
|
"demands": [
|
||||||
{ "x": 3, "y": 0, "name": "Depot Royal", "cargo": "wood", "amount": 3 }
|
{ "x": 3, "y": 0, "name": "Depot Royal", "cargo": "wood", "amount": 3, "deadline": 30 }
|
||||||
],
|
],
|
||||||
"walls": [],
|
"walls": [],
|
||||||
"pieces": [
|
"pieces": [
|
||||||
|
|
@ -806,7 +801,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 + 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. |
|
| 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. |
|
||||||
| 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
37
docs/PLAN.md
|
|
@ -1,37 +0,0 @@
|
||||||
# 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).
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
{
|
|
||||||
"sdk": {
|
|
||||||
"version": "9.0.312",
|
|
||||||
"rollForward": "latestMinor"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,96 +0,0 @@
|
||||||
# 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.
|
|
||||||
|
|
@ -1,320 +0,0 @@
|
||||||
"""
|
|
||||||
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))
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
"""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)
|
|
||||||
|
|
@ -1,114 +0,0 @@
|
||||||
"""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())
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
"""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()
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
"""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()
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
"""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()
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
"""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()
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
"""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()
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
"""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