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 \
|
||||
nano \
|
||||
vim \
|
||||
ca-certificates \
|
||||
curl \
|
||||
wget \
|
||||
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Chessistics: headless Godot + .NET SDK
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# 1. Xvfb + Mesa software GL + X/audio runtime deps for Godot's GL-compatibility renderer
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
xvfb \
|
||||
xauth \
|
||||
x11-utils \
|
||||
libx11-6 \
|
||||
libxcursor1 \
|
||||
libxinerama1 \
|
||||
libxrandr2 \
|
||||
libxi6 \
|
||||
libxext6 \
|
||||
libxrender1 \
|
||||
libxfixes3 \
|
||||
libxss1 \
|
||||
libxkbcommon0 \
|
||||
libxkbcommon-x11-0 \
|
||||
libgl1 \
|
||||
libglx-mesa0 \
|
||||
libgl1-mesa-dri \
|
||||
libglu1-mesa \
|
||||
libegl1 \
|
||||
libgles2 \
|
||||
libasound2 \
|
||||
libpulse0 \
|
||||
libfontconfig1 \
|
||||
libfreetype6 \
|
||||
libdbus-1-3 \
|
||||
libudev1 \
|
||||
fonts-dejavu-core \
|
||||
python3 \
|
||||
python3-pip \
|
||||
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 2. .NET SDK 9.0 via the upstream install script (arch-agnostic, no apt repo needed)
|
||||
RUN curl -fsSL https://dot.net/v1/dotnet-install.sh -o /tmp/dotnet-install.sh \
|
||||
&& bash /tmp/dotnet-install.sh --channel 9.0 --install-dir /usr/local/dotnet \
|
||||
&& ln -s /usr/local/dotnet/dotnet /usr/local/bin/dotnet \
|
||||
&& rm /tmp/dotnet-install.sh
|
||||
ENV DOTNET_ROOT=/usr/local/dotnet
|
||||
ENV DOTNET_CLI_TELEMETRY_OPTOUT=1
|
||||
ENV DOTNET_NOLOGO=1
|
||||
|
||||
# 3. Godot 4.6.2-stable Mono for Linux x86_64
|
||||
# The zip contains a directory like "Godot_v..._mono_linux_x86_64/" with
|
||||
# an executable whose exact filename has varied across releases
|
||||
# ("Godot_v..._mono_linux.x86_64" on 4.x). Locate it dynamically.
|
||||
ARG GODOT_VERSION=4.6.2-stable
|
||||
RUN mkdir -p /opt/godot \
|
||||
&& cd /tmp \
|
||||
&& wget -q "https://github.com/godotengine/godot/releases/download/${GODOT_VERSION}/Godot_v${GODOT_VERSION}_mono_linux_x86_64.zip" \
|
||||
&& unzip -q "Godot_v${GODOT_VERSION}_mono_linux_x86_64.zip" -d /opt/godot \
|
||||
&& GODOT_EXE="$(find /opt/godot -maxdepth 3 -type f \( -name 'Godot_v*mono_linux*x86_64' -o -name 'Godot_v*mono_linux*x86_64' \) | head -1)" \
|
||||
&& if [ -z "$GODOT_EXE" ]; then echo "Godot executable not found in zip" && ls -R /opt/godot && exit 1; fi \
|
||||
&& chmod +x "$GODOT_EXE" \
|
||||
&& ln -sf "$GODOT_EXE" /opt/godot/godot \
|
||||
&& rm "Godot_v${GODOT_VERSION}_mono_linux_x86_64.zip"
|
||||
ENV GODOT_BIN=/opt/godot/godot
|
||||
ENV PATH=$PATH:/opt/godot:/usr/local/dotnet
|
||||
|
||||
# 4. xvfb wrapper — any Godot invocation gets its own virtual 1280x720x24 display.
|
||||
# Usage: `godot-xvfb --path /workspace ...` or let tools/automation/harness.py
|
||||
# invoke it automatically on Linux.
|
||||
COPY godot-xvfb.sh /usr/local/bin/godot-xvfb
|
||||
RUN chmod +x /usr/local/bin/godot-xvfb
|
||||
|
||||
# Ensure default node user has access to /usr/local/share
|
||||
RUN mkdir -p /usr/local/share/npm-global && \
|
||||
chown -R node:node /usr/local/share
|
||||
|
|
|
|||
|
|
@ -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" \
|
||||
"sentry.io" \
|
||||
"statsig.anthropic.com" \
|
||||
"statsig.com" \
|
||||
"api.nuget.org"; do
|
||||
"statsig.com"; do
|
||||
echo "Resolving $domain..."
|
||||
ips=$(dig +noall +answer A "$domain" | awk '$4 == "A" {print $5}')
|
||||
if [ -z "$ips" ]; then
|
||||
echo "WARN: Failed to resolve $domain - skipping"
|
||||
continue
|
||||
echo "ERROR: Failed to resolve $domain"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
while read -r ip; do
|
||||
|
|
|
|||
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -21,8 +21,3 @@ Thumbs.db
|
|||
# Claude Code
|
||||
.claude/
|
||||
.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
|
||||
|
||||
Les plans vivants (travail restant, features a faire) vont dans
|
||||
[`docs/PLAN.md`](docs/PLAN.md). Pour un brouillon ad-hoc, ecrire a la racine
|
||||
du workspace (ex: `/workspace/PLAN_<sujet>.md`), **pas** dans
|
||||
`.claude/plans/` (taille limitee). Une fois implemente, supprimer le
|
||||
fichier ; si partiel, consolider le restant dans `docs/PLAN.md`.
|
||||
|
||||
### Boucle de developpement
|
||||
|
||||
Pour chaque sujet pris dans `docs/PLAN.md` :
|
||||
|
||||
1. **Prendre le sujet suivant** dans le plan (ordre de priorite).
|
||||
2. **Implementer** (moteur + presentation selon le cas).
|
||||
3. **Ajouter des tests unitaires** si applicable (`chessistics-tests/`).
|
||||
4. **Tester l'UI/UX** de la fonctionnalite dans le jeu si applicable
|
||||
(harness + quick save/load pour reprendre un checkpoint).
|
||||
5. **Mettre a jour la documentation** (README, CLAUDE.md, GDD) si
|
||||
necessaire et **retirer le sujet du plan** (ou annoter ce qui reste).
|
||||
6. **Commit** (un commit par sujet, message en anglais, sans co-author
|
||||
Claude).
|
||||
|
||||
## Harnais d'automatisation (Claude peut jouer tout seul)
|
||||
|
||||
Le jeu peut etre pilote de maniere autonome via le flag `--automation=<dir>`. Un
|
||||
`AutomationHarness` (`Scripts/Automation/`) s'active alors comme noeud au root de la
|
||||
scene, lit des commandes JSON dans `<dir>/inbox/`, ecrit les resultats dans
|
||||
`<dir>/outbox/`, et place les captures d'ecran dans `<dir>/screens/`. Sans le flag,
|
||||
comportement normal — overhead zero.
|
||||
|
||||
Cote agent, un wrapper Python stdlib (`tools/automation/harness.py`) expose une API
|
||||
simple. Le binaire Godot est detecte via `GODOT_BIN` (fallback Windows
|
||||
`C:\Apps\godot\Godot_v4.6.2-stable_mono_win64_console.exe`, Linux
|
||||
`/opt/godot/godot`). Sous Linux sans `DISPLAY`, la commande Godot est auto-wrappee
|
||||
dans `xvfb-run` (framebuffer virtuel 1280x720).
|
||||
|
||||
### Build + utilisation
|
||||
|
||||
```bash
|
||||
dotnet build Chessistics.csproj # compiler avant tout lancement
|
||||
python tools/automation/smoke.py # smoke test end-to-end
|
||||
python tools/automation/run_game.py # REPL interactif
|
||||
```
|
||||
|
||||
### API Python
|
||||
|
||||
```python
|
||||
from tools.automation.harness import Harness
|
||||
|
||||
with Harness.launch() as h:
|
||||
h.load_mission("campaign_01", 0) # charge la campagne + mission 0
|
||||
state = h.state() # snapshot complet (dict)
|
||||
h.screenshot("before") # -> .automation_runs/<ts>/screens/before.png
|
||||
h.place("Pawn", (0, 0), (0, 1)) # pose une piece
|
||||
h.step() # un tour (auto-wait animation)
|
||||
h.screenshot("after")
|
||||
h.set_speed(0.1); h.play() # auto-play rapide
|
||||
```
|
||||
|
||||
Methodes : `screenshot`, `state`, `select`, `place`, `click_cell`, `key`, `play`,
|
||||
`pause`, `step`, `wait_idle`, `set_speed`, `load_mission`, `back_to_menu`, `quit`.
|
||||
|
||||
Toutes les commandes non-query attendent `EventAnimator.IsAnimating == false` avant
|
||||
de retourner -> appels en serie toujours vus par le prochain `state()`.
|
||||
|
||||
### Validation visuelle par Claude
|
||||
|
||||
Les PNG 1280x720 ecrites dans `.automation_runs/<run>/screens/` peuvent etre lues
|
||||
directement par l'outil `Read` de Claude. Workflow type pour valider l'UI :
|
||||
|
||||
1. `h.load_mission("campaign_01", N)` + `h.screenshot("mission_N_start")`
|
||||
2. Lire le PNG -> verifier titre, flavor banner, board, panneau objectifs, stock
|
||||
3. Placer des pieces via `h.place(...)` et re-screenshot
|
||||
4. `h.step()` en boucle + screenshot a chaque etape
|
||||
5. Attendre `phase == "MissionComplete"` dans le snapshot
|
||||
|
||||
Cette boucle permet de valider que :
|
||||
- Les demandes affichent les bons compteurs
|
||||
- Les pieces bougent comme prevu
|
||||
- Le stock se met a jour
|
||||
- L'ecran `MissionComplete` apparait quand attendu
|
||||
|
||||
### Details importants
|
||||
|
||||
- `Place` passe par le signal `PlacementRequested` (meme chemin qu'un vrai clic) --
|
||||
ne pas appeler `GameSim.ProcessCommand(PlacePieceCommand)` directement dans le
|
||||
dispatcher, ca mute deux fois.
|
||||
- Les captures d'ecran sont prises apres `RenderingServer.frame_post_draw` -> le
|
||||
frame reflete l'etat final, animations incluses.
|
||||
- La facade (`AutomationFacade`) est la **seule** surface exposee au dispatcher.
|
||||
Elle ne touche que des methodes/signals publics de `GameSim`, `InputMapper`,
|
||||
`EventAnimator`, `ControlBar`, `PieceStockPanel`. La separation black-box tient.
|
||||
- Les fichiers IPC sont ecrits `.tmp` puis renommes (atomique sur Windows).
|
||||
- La campagne se charge via `load_mission("campaign_01", 0)`. Passer a une mission
|
||||
> 0 n'est pas supporte directement (il faut passer par `MissionComplete` reel).
|
||||
|
||||
## Mode autonome dans le devcontainer
|
||||
|
||||
Claude tourne dans un devcontainer Linux (`.devcontainer/`) qui embarque deja
|
||||
`.NET 9 SDK`, `Godot 4.6.2 mono Linux`, `Xvfb`, `Python 3`. Tout le workflow
|
||||
ci-dessous est executable sans sortir du container, sans display physique.
|
||||
|
||||
### Sanity check toolchain (une fois par session)
|
||||
|
||||
```bash
|
||||
dotnet --version # 9.0.x
|
||||
godot --version # 4.6.2.stable.mono.official.*
|
||||
which godot-xvfb # /usr/local/bin/godot-xvfb (auto xvfb-run wrapper)
|
||||
```
|
||||
|
||||
### Piege permissions `.godot/`
|
||||
|
||||
Si une etape precedente (build Docker, editeur…) a laisse `.godot/` detenu par
|
||||
`root`, le build dotnet echoue avec `MSB3374: Access to the path '...Up2Date'
|
||||
is denied`. Les perms sont a 777 -> **supprimer le cache suffit**, pas besoin de
|
||||
`sudo` :
|
||||
|
||||
```bash
|
||||
rm -rf /workspace/.godot
|
||||
dotnet build Chessistics.csproj
|
||||
```
|
||||
|
||||
### Recette pour verifier que Claude peut jouer tout seul
|
||||
|
||||
```bash
|
||||
dotnet build Chessistics.csproj # doit etre vert
|
||||
python3 tools/automation/smoke.py # charge mission 1, screenshots, determinisme
|
||||
ls .automation_runs/smoke/screens/ # PNG non-noirs
|
||||
```
|
||||
|
||||
Si `smoke.py` passe, tout le pipeline marche : Godot boot -> IPC inbox/outbox
|
||||
-> screenshots lisibles via l'outil `Read`.
|
||||
|
||||
### Driver le jeu en Python depuis Claude
|
||||
|
||||
```python
|
||||
import sys, time
|
||||
sys.path.insert(0, '/workspace')
|
||||
from tools.automation.harness import Harness
|
||||
|
||||
with Harness.launch(run_name="claude_drive") as h:
|
||||
h.load_mission("campaign_01", 0)
|
||||
s = h.state() # dict: phase, turn, width, height,
|
||||
# grid, productions, demands,
|
||||
# transformers, pieces, remainingStock
|
||||
h.screenshot("01_loaded")
|
||||
h.place("Pawn", (0, 0), (0, 1))
|
||||
h.screenshot("02_placed")
|
||||
h.set_speed(0.05); h.play()
|
||||
time.sleep(2); h.pause()
|
||||
h.screenshot("03_after_play")
|
||||
```
|
||||
|
||||
Les PNG atterrissent dans `/workspace/.automation_runs/<run_name>/screens/`.
|
||||
Claude les lit directement via `Read` (multimodal).
|
||||
|
||||
### Boucle typique de validation visuelle
|
||||
|
||||
1. `h.load_mission("campaign_01", N)` -> `h.screenshot(f"m{N}_start")` -> `Read`
|
||||
2. Verifier sur le PNG : titre, bandeau flavor, board, panneau OBJECTIFS,
|
||||
compteurs PIECES.
|
||||
3. Poser des pieces via `h.place(...)`, relire l'etat (`remainingStock`
|
||||
diminue, `pieces` grandit).
|
||||
4. `h.play()` + `sleep` + `h.pause()` (ou `h.step()` en boucle), screenshot
|
||||
a chaque palier.
|
||||
5. Boucler jusqu'a `state()["phase"] == "MissionComplete"` ou un objectif
|
||||
`demands[i].satisfied == True`.
|
||||
|
||||
### Details qui surprennent
|
||||
|
||||
- Le snapshot `state()` expose `width`/`height` au niveau racine (pas
|
||||
`board.width`).
|
||||
- `remainingStock` est un dict `{"Pawn": 4, ...}` ; verifier qu'il decremente
|
||||
apres un `place()` confirme que la commande a bien ete appliquee.
|
||||
- Les logs Godot affichent des warnings ALSA (pas de carte son) et V-Sync —
|
||||
inoffensifs en headless, les filtrer avant d'afficher a l'utilisateur.
|
||||
- Le firewall du container bloque tout sauf l'allowlist ; le runtime Godot
|
||||
n'a besoin d'aucun reseau, donc aucun probleme.
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
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 ProductionColor = new("#4A6E3A"); // deep forest
|
||||
private static readonly Color DemandColor = new("#B8942A"); // aged gold
|
||||
private static readonly Color TransformerColor = new("#8B4513"); // copper brown
|
||||
private static readonly Color HighlightColor = new("#44FF4444");
|
||||
private static readonly Color HoverOutlineColor = new("#FFFFFF88");
|
||||
|
||||
|
|
@ -48,7 +47,6 @@ public partial class CellView : Node2D
|
|||
CellType.Wall => WallColor,
|
||||
CellType.Production => ProductionColor,
|
||||
CellType.Demand => DemandColor,
|
||||
CellType.Transformer => TransformerColor,
|
||||
_ => baseColor
|
||||
};
|
||||
AddChild(_background);
|
||||
|
|
@ -134,24 +132,4 @@ public partial class CellView : Node2D
|
|||
.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Cubic);
|
||||
tween.TweenCallback(Callable.From(() => _highlight.Visible = false));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Transformer conversion flash: bright copper pulse + scale punch.
|
||||
/// Uses a distinct color from production so the two phases read apart.
|
||||
/// </summary>
|
||||
public void FlashTransform(float duration = 0.45f)
|
||||
{
|
||||
_highlight.Color = new Color(1f, 0.5f, 0.15f, 0.7f); // bright copper
|
||||
_highlight.Visible = true;
|
||||
|
||||
var tween = CreateTween();
|
||||
tween.SetParallel(true);
|
||||
tween.TweenProperty(_highlight, "color", new Color(1f, 0.5f, 0.15f, 0f), duration)
|
||||
.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Cubic);
|
||||
tween.TweenProperty(_background, "scale", new Vector2(1.08f, 1.08f), duration * 0.45f)
|
||||
.SetEase(Tween.EaseType.Out);
|
||||
tween.Chain().TweenProperty(_background, "scale", Vector2.One, duration * 0.55f)
|
||||
.SetEase(Tween.EaseType.InOut);
|
||||
tween.Chain().TweenCallback(Callable.From(() => _highlight.Visible = false));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,13 +16,9 @@ public partial class InputMapper : Node
|
|||
public delegate void CellClickedEventHandler(int col, int row);
|
||||
[Signal]
|
||||
public delegate void CancelledEventHandler();
|
||||
[Signal]
|
||||
public delegate void RelocateRequestedEventHandler(int pieceId, int newStartCol, int newStartRow, int newEndCol, int newEndRow);
|
||||
|
||||
public enum PlacementPhase { None, SelectingStart, SelectingEnd }
|
||||
|
||||
private const float DragThreshold = 8f;
|
||||
|
||||
private BoardView _boardView = null!;
|
||||
private PieceKind? _selectedKind;
|
||||
private Coords? _selectedStart;
|
||||
|
|
@ -30,11 +26,6 @@ public partial class InputMapper : Node
|
|||
private BoardSnapshot? _snapshot;
|
||||
private Coords? _hoverCoords;
|
||||
|
||||
// Drag & drop of a placed piece
|
||||
private int? _dragPieceId;
|
||||
private Vector2 _dragMouseStart;
|
||||
private bool _dragging;
|
||||
|
||||
public PlacementPhase CurrentPhase => _phase;
|
||||
|
||||
public void Initialize(BoardView boardView)
|
||||
|
|
@ -81,140 +72,28 @@ public partial class InputMapper : Node
|
|||
|
||||
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.Pressed)
|
||||
HandleLeftPress(localPos);
|
||||
else
|
||||
HandleLeftRelease(localPos);
|
||||
}
|
||||
if (mouseEvent.ButtonIndex == MouseButton.Right)
|
||||
{
|
||||
Cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
if (@event is InputEventMouseMotion && _dragPieceId != null)
|
||||
{
|
||||
var localPos = _boardView.GetLocalMousePosition();
|
||||
UpdateDrag(localPos);
|
||||
if (mouseEvent.ButtonIndex == MouseButton.Left)
|
||||
{
|
||||
var localPos = _boardView.GetLocalMousePosition();
|
||||
GD.Print($"[InputMapper] LEFT CLICK — localPos={localPos}, phase={_phase}");
|
||||
HandleLeftClick();
|
||||
}
|
||||
}
|
||||
|
||||
if (@event is InputEventKey keyEvent && keyEvent.Pressed && keyEvent.Keycode == Key.Escape)
|
||||
{
|
||||
CancelDrag();
|
||||
Cancel();
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleLeftPress(Vector2 localPos)
|
||||
{
|
||||
// In placement mode, ignore drag behavior — click advances placement
|
||||
if (_phase != PlacementPhase.None) return;
|
||||
|
||||
var coords = _boardView.PixelToCoords(localPos);
|
||||
if (coords == null || _snapshot == null) return;
|
||||
|
||||
var piece = _snapshot.Pieces.FirstOrDefault(
|
||||
p => p.StartCell == coords.Value || p.EndCell == coords.Value);
|
||||
if (piece != null)
|
||||
{
|
||||
_dragPieceId = piece.Id;
|
||||
_dragMouseStart = localPos;
|
||||
_dragging = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateDrag(Vector2 localPos)
|
||||
{
|
||||
if (_dragPieceId == null) return;
|
||||
|
||||
if (!_dragging && (localPos - _dragMouseStart).Length() > DragThreshold)
|
||||
{
|
||||
_dragging = true;
|
||||
HighlightLegalDropsFor(_dragPieceId.Value);
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleLeftRelease(Vector2 localPos)
|
||||
{
|
||||
if (_dragging && _dragPieceId != null)
|
||||
{
|
||||
var dropCoords = _boardView.PixelToCoords(localPos);
|
||||
TryRelocate(_dragPieceId.Value, dropCoords);
|
||||
CancelDrag();
|
||||
return;
|
||||
}
|
||||
|
||||
// Normal click flow
|
||||
CancelDrag();
|
||||
HandleLeftClick();
|
||||
}
|
||||
|
||||
private void CancelDrag()
|
||||
{
|
||||
_dragPieceId = null;
|
||||
_dragging = false;
|
||||
_boardView.ClearHighlights();
|
||||
}
|
||||
|
||||
private void HighlightLegalDropsFor(int pieceId)
|
||||
{
|
||||
var legal = ComputeLegalDrops(pieceId);
|
||||
_boardView.ClearHighlights();
|
||||
_boardView.HighlightCells(legal, new Color("#44FF88AA"));
|
||||
}
|
||||
|
||||
private List<Coords> ComputeLegalDrops(int pieceId)
|
||||
{
|
||||
var result = new List<Coords>();
|
||||
if (_snapshot == null) return result;
|
||||
|
||||
var piece = _snapshot.Pieces.FirstOrDefault(p => p.Id == pieceId);
|
||||
if (piece == null) return result;
|
||||
|
||||
var dc = piece.EndCell.Col - piece.StartCell.Col;
|
||||
var dr = piece.EndCell.Row - piece.StartCell.Row;
|
||||
|
||||
var boardState = GetBoardStateForValidation();
|
||||
if (boardState == null) return result;
|
||||
|
||||
for (int c = 0; c < _snapshot.Width; c++)
|
||||
{
|
||||
for (int r = 0; r < _snapshot.Height; r++)
|
||||
{
|
||||
var newStart = new Coords(c, r);
|
||||
var newEnd = new Coords(c + dc, r + dr);
|
||||
if (!newEnd.IsOnBoard(_snapshot.Width, _snapshot.Height)) continue;
|
||||
if (_snapshot.Grid[newStart.Col, newStart.Row] == CellType.Wall) continue;
|
||||
if (_snapshot.Grid[newEnd.Col, newEnd.Row] == CellType.Wall) continue;
|
||||
if (!MoveValidator.IsLegalPlacement(piece.Kind, newStart, newEnd, boardState)) continue;
|
||||
result.Add(newStart);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private void TryRelocate(int pieceId, Coords? dropCoords)
|
||||
{
|
||||
if (_snapshot == null || dropCoords == null) return;
|
||||
var piece = _snapshot.Pieces.FirstOrDefault(p => p.Id == pieceId);
|
||||
if (piece == null) return;
|
||||
|
||||
var dc = piece.EndCell.Col - piece.StartCell.Col;
|
||||
var dr = piece.EndCell.Row - piece.StartCell.Row;
|
||||
|
||||
var newStart = dropCoords.Value;
|
||||
var newEnd = new Coords(newStart.Col + dc, newStart.Row + dr);
|
||||
|
||||
if (!newEnd.IsOnBoard(_snapshot.Width, _snapshot.Height)) return;
|
||||
if (_snapshot.Grid[newStart.Col, newStart.Row] == CellType.Wall) return;
|
||||
if (_snapshot.Grid[newEnd.Col, newEnd.Row] == CellType.Wall) return;
|
||||
|
||||
var boardState = GetBoardStateForValidation();
|
||||
if (boardState == null) return;
|
||||
if (!MoveValidator.IsLegalPlacement(piece.Kind, newStart, newEnd, boardState)) return;
|
||||
|
||||
EmitSignal(SignalName.RelocateRequested, pieceId,
|
||||
newStart.Col, newStart.Row, newEnd.Col, newEnd.Row);
|
||||
}
|
||||
|
||||
private void HandleLeftClick()
|
||||
{
|
||||
var localPos = _boardView.GetLocalMousePosition();
|
||||
|
|
@ -228,41 +107,22 @@ public partial class InputMapper : Node
|
|||
return;
|
||||
}
|
||||
|
||||
HandleClickAt(coords.Value);
|
||||
}
|
||||
|
||||
private void HandleClickAt(Coords coords)
|
||||
{
|
||||
switch (_phase)
|
||||
{
|
||||
case PlacementPhase.SelectingStart:
|
||||
OnStartSelected(coords);
|
||||
OnStartSelected(coords.Value);
|
||||
break;
|
||||
|
||||
case PlacementPhase.SelectingEnd:
|
||||
OnEndSelected(coords);
|
||||
OnEndSelected(coords.Value);
|
||||
break;
|
||||
|
||||
default:
|
||||
EmitSignal(SignalName.CellClicked, coords.Col, coords.Row);
|
||||
EmitSignal(SignalName.CellClicked, coords.Value.Col, coords.Value.Row);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Same effect as a left/right click on a board cell, for automation.
|
||||
/// Runs the exact branch HandleLeftClick runs (no InputEvent synthesis).
|
||||
/// </summary>
|
||||
public void SimulateClick(Coords coords, MouseButton button)
|
||||
{
|
||||
if (button == MouseButton.Right)
|
||||
{
|
||||
Cancel();
|
||||
return;
|
||||
}
|
||||
HandleClickAt(coords);
|
||||
}
|
||||
|
||||
private void OnStartSelected(Coords start)
|
||||
{
|
||||
if (_selectedKind == null || _snapshot == null)
|
||||
|
|
|
|||
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 WoodCargoColor = new("#A67C32");
|
||||
private static readonly Color StoneCargoColor = new("#7A7A7A");
|
||||
private static readonly Color ToolsCargoColor = new("#C87533");
|
||||
private static readonly Color ArmsCargoColor = new("#8B0000");
|
||||
private static readonly Color GoldCargoColor = new("#FFD700");
|
||||
private static readonly Color ShadowColor = new Color(0, 0, 0, 0.18f);
|
||||
|
||||
public void Setup(int pieceId, PieceKind kind, Coords startCell, Coords endCell, BoardView boardView)
|
||||
|
|
@ -139,9 +136,6 @@ public partial class PieceView : Node2D
|
|||
{
|
||||
CargoType.Wood => WoodCargoColor,
|
||||
CargoType.Stone => StoneCargoColor,
|
||||
CargoType.Tools => ToolsCargoColor,
|
||||
CargoType.Arms => ArmsCargoColor,
|
||||
CargoType.Gold => GoldCargoColor,
|
||||
_ => Colors.White
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -5,54 +5,34 @@ namespace Chessistics.Scripts.Pieces;
|
|||
public partial class TrajectView : Line2D
|
||||
{
|
||||
public int PieceId { get; private set; }
|
||||
private Polygon2D? _arrowEnd;
|
||||
private Polygon2D? _arrowStart;
|
||||
private Tween? _pulseTween;
|
||||
private Color _baseColor;
|
||||
private Polygon2D? _arrow;
|
||||
|
||||
public void Setup(int pieceId, Vector2 from, Vector2 to, Color color)
|
||||
{
|
||||
PieceId = pieceId;
|
||||
_baseColor = color;
|
||||
Width = 3f;
|
||||
DefaultColor = new Color(color, 0.4f);
|
||||
Width = 2.5f;
|
||||
DefaultColor = new Color(color, 0.35f);
|
||||
Antialiased = true;
|
||||
ClearPoints();
|
||||
AddPoint(from);
|
||||
AddPoint(to);
|
||||
ZIndex = -1;
|
||||
|
||||
_arrowEnd = BuildArrow(from, to, color);
|
||||
_arrowStart = BuildArrow(to, from, color);
|
||||
AddChild(_arrowEnd);
|
||||
AddChild(_arrowStart);
|
||||
|
||||
StartPulse();
|
||||
}
|
||||
|
||||
private static Polygon2D BuildArrow(Vector2 from, Vector2 to, Color color)
|
||||
{
|
||||
// Arrowhead at the end point
|
||||
var dir = (to - from).Normalized();
|
||||
var perp = new Vector2(-dir.Y, dir.X);
|
||||
const float arrowSize = 9f;
|
||||
var tip = to - dir * 4f;
|
||||
float arrowSize = 8f;
|
||||
var tip = to - dir * 4f; // slightly inset from end
|
||||
var baseL = 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()
|
||||
{
|
||||
_pulseTween?.Kill();
|
||||
_pulseTween = CreateTween();
|
||||
_pulseTween.SetLoops();
|
||||
_pulseTween.TweenProperty(this, "default_color:a", 0.75f, 1.1f)
|
||||
.SetEase(Tween.EaseType.InOut).SetTrans(Tween.TransitionType.Sine);
|
||||
_pulseTween.TweenProperty(this, "default_color:a", 0.3f, 1.1f)
|
||||
.SetEase(Tween.EaseType.InOut).SetTrans(Tween.TransitionType.Sine);
|
||||
_arrow = new Polygon2D
|
||||
{
|
||||
Polygon = [tip - Position, baseL - Position, baseR - Position],
|
||||
Color = new Color(color, 0.4f),
|
||||
Position = Vector2.Zero
|
||||
};
|
||||
// Position relative to parent, not this Line2D
|
||||
AddChild(_arrow);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,9 +25,6 @@ public partial class EventAnimator : Node
|
|||
|
||||
private static readonly Color WoodCargoColor = new("#A67C32");
|
||||
private static readonly Color StoneCargoColor = new("#7A7A7A");
|
||||
private static readonly Color ToolsCargoColor = new("#C87533");
|
||||
private static readonly Color ArmsCargoColor = new("#8B0000");
|
||||
private static readonly Color GoldCargoColor = new("#FFD700");
|
||||
|
||||
private const float ProduceDuration = 0.35f;
|
||||
private const float TransferDuration = 0.28f;
|
||||
|
|
@ -39,10 +36,6 @@ public partial class EventAnimator : Node
|
|||
public delegate void TurnAnimationCompletedEventHandler();
|
||||
[Signal]
|
||||
public delegate void VictoryReachedEventHandler();
|
||||
[Signal]
|
||||
public delegate void MissionAdvancedEventHandler();
|
||||
[Signal]
|
||||
public delegate void CollisionOccurredEventHandler(int col, int row, string victimKind, int destroyerId);
|
||||
|
||||
public void Initialize(BoardView boardView, ObjectivePanel objectivePanel,
|
||||
ControlBar controlBar, MetricsOverlay metricsOverlay)
|
||||
|
|
@ -80,20 +73,16 @@ public partial class EventAnimator : Node
|
|||
tween.SetParallel(false);
|
||||
|
||||
var produceEvents = new List<CargoProducedEvent>();
|
||||
var transformerEvents = new List<CargoConvertedEvent>();
|
||||
var transferEvents = new List<IWorldEvent>();
|
||||
var moveEvents = new List<PieceMovedEvent>();
|
||||
var collisionEvents = new List<PieceReturnedToStockEvent>();
|
||||
|
||||
// Pre-scan: if MissionStartedEvent follows MissionCompleteEvent, it's an auto-advance (not last mission)
|
||||
bool hasAutoAdvance = events.Any(e => e is MissionStartedEvent);
|
||||
var collisionEvents = new List<PieceDestroyedEvent>();
|
||||
|
||||
foreach (var evt in events)
|
||||
{
|
||||
switch (evt)
|
||||
{
|
||||
case TurnStartedEvent ts:
|
||||
FlushPhases(tween, produceEvents, transformerEvents, transferEvents, moveEvents, collisionEvents);
|
||||
FlushPhases(tween, produceEvents, transferEvents, moveEvents, collisionEvents);
|
||||
tween.TweenCallback(Callable.From(() => _controlBar.UpdateTurn(ts.TurnNumber)));
|
||||
break;
|
||||
|
||||
|
|
@ -101,10 +90,6 @@ public partial class EventAnimator : Node
|
|||
produceEvents.Add(produced);
|
||||
break;
|
||||
|
||||
case CargoConvertedEvent converted:
|
||||
transformerEvents.Add(converted);
|
||||
break;
|
||||
|
||||
case CargoTransferredEvent:
|
||||
case DemandProgressEvent:
|
||||
transferEvents.Add(evt);
|
||||
|
|
@ -114,35 +99,23 @@ public partial class EventAnimator : Node
|
|||
moveEvents.Add(moved);
|
||||
break;
|
||||
|
||||
case PieceReturnedToStockEvent returned:
|
||||
collisionEvents.Add(returned);
|
||||
case PieceDestroyedEvent destroyed:
|
||||
collisionEvents.Add(destroyed);
|
||||
break;
|
||||
|
||||
case MissionCompleteEvent:
|
||||
FlushPhases(tween, produceEvents, transformerEvents, transferEvents, moveEvents, collisionEvents);
|
||||
case VictoryEvent victory:
|
||||
FlushPhases(tween, produceEvents, transferEvents, moveEvents, collisionEvents);
|
||||
tween.TweenCallback(Callable.From(() =>
|
||||
{
|
||||
SfxManager.Instance?.PlayVictory();
|
||||
SpawnConfetti();
|
||||
if (!hasAutoAdvance)
|
||||
EmitSignal(SignalName.VictoryReached);
|
||||
_metricsOverlay.ShowMetrics(victory.Metrics);
|
||||
EmitSignal(SignalName.VictoryReached);
|
||||
}));
|
||||
break;
|
||||
|
||||
case MissionStartedEvent:
|
||||
FlushPhases(tween, produceEvents, transformerEvents, transferEvents, moveEvents, collisionEvents);
|
||||
tween.TweenCallback(Callable.From(() =>
|
||||
{
|
||||
EmitSignal(SignalName.MissionAdvanced);
|
||||
}));
|
||||
break;
|
||||
|
||||
case SimulationPausedEvent:
|
||||
// Auto-pause from collision — handled by FlushPhases
|
||||
break;
|
||||
|
||||
case TurnEndedEvent:
|
||||
FlushPhases(tween, produceEvents, transformerEvents, transferEvents, moveEvents, collisionEvents);
|
||||
FlushPhases(tween, produceEvents, transferEvents, moveEvents, collisionEvents);
|
||||
break;
|
||||
|
||||
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(() =>
|
||||
{
|
||||
|
|
@ -162,29 +135,10 @@ public partial class EventAnimator : Node
|
|||
private void FlushPhases(
|
||||
Tween tween,
|
||||
List<CargoProducedEvent> produceEvents,
|
||||
List<CargoConvertedEvent> transformerEvents,
|
||||
List<IWorldEvent> transferEvents,
|
||||
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
|
||||
if (produceEvents.Count > 0)
|
||||
{
|
||||
|
|
@ -268,16 +222,16 @@ public partial class EventAnimator : Node
|
|||
moveEvents.Clear();
|
||||
}
|
||||
|
||||
// Phase 4: Collision — piece returned to stock (shrink + spin + particles)
|
||||
// Phase 4: Collision/Destruction — shrink + spin + particles
|
||||
if (collisionEvents.Count > 0)
|
||||
{
|
||||
var captured = collisionEvents.ToList();
|
||||
tween.TweenCallback(Callable.From(() =>
|
||||
{
|
||||
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);
|
||||
|
||||
|
|
@ -289,18 +243,12 @@ public partial class EventAnimator : Node
|
|||
dt.TweenProperty(pv, "modulate:a", 0f, DestroyDuration);
|
||||
}
|
||||
}
|
||||
|
||||
// Emit signal for Main to pan camera + show toast
|
||||
var first = captured[0];
|
||||
EmitSignal(SignalName.CollisionOccurred,
|
||||
first.Cell.Col, first.Cell.Row, first.Kind.ToString(),
|
||||
first.DestroyerPieceId ?? -1);
|
||||
}));
|
||||
tween.TweenInterval(DestroyDuration);
|
||||
tween.TweenCallback(Callable.From(() =>
|
||||
{
|
||||
foreach (var returned in captured)
|
||||
UnregisterPiece(returned.PieceId);
|
||||
foreach (var destroyed in captured)
|
||||
UnregisterPiece(destroyed.PieceId);
|
||||
}));
|
||||
collisionEvents.Clear();
|
||||
}
|
||||
|
|
@ -475,9 +423,6 @@ public partial class EventAnimator : Node
|
|||
{
|
||||
CargoType.Wood => WoodCargoColor,
|
||||
CargoType.Stone => StoneCargoColor,
|
||||
CargoType.Tools => ToolsCargoColor,
|
||||
CargoType.Arms => ArmsCargoColor,
|
||||
CargoType.Gold => GoldCargoColor,
|
||||
_ => Colors.White
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -12,14 +12,15 @@ public partial class ControlBar : HBoxContainer
|
|||
public delegate void PausePressedEventHandler();
|
||||
[Signal]
|
||||
public delegate void StepPressedEventHandler();
|
||||
// Stop removed in campaign mode
|
||||
[Signal]
|
||||
public delegate void StopPressedEventHandler();
|
||||
[Signal]
|
||||
public delegate void SpeedChangedEventHandler(float speed);
|
||||
|
||||
private Button _playButton = null!;
|
||||
private Button _pauseButton = null!;
|
||||
private Button _stepButton = null!;
|
||||
// _stopButton removed
|
||||
private Button _stopButton = null!;
|
||||
private OptionButton _speedSelect = null!;
|
||||
private Label _turnLabel = null!;
|
||||
|
||||
|
|
@ -45,7 +46,9 @@ public partial class ControlBar : HBoxContainer
|
|||
_stepButton.Pressed += () => EmitSignal(SignalName.StepPressed);
|
||||
AddChild(_stepButton);
|
||||
|
||||
// Stop button removed in campaign mode
|
||||
_stopButton = CreateStyledButton("STOP");
|
||||
_stopButton.Pressed += () => EmitSignal(SignalName.StopPressed);
|
||||
AddChild(_stopButton);
|
||||
|
||||
// Spacer
|
||||
AddChild(new Control { CustomMinimumSize = new Vector2(12, 0) });
|
||||
|
|
@ -65,7 +68,7 @@ public partial class ControlBar : HBoxContainer
|
|||
_turnLabel.AddThemeColorOverride("font_color", new Color("#999999"));
|
||||
AddChild(_turnLabel);
|
||||
|
||||
UpdateForPhase(SimPhase.Paused);
|
||||
UpdateForPhase(SimPhase.Edit);
|
||||
}
|
||||
|
||||
private static Button CreateStyledButton(string text)
|
||||
|
|
@ -73,8 +76,7 @@ public partial class ControlBar : HBoxContainer
|
|||
var btn = new Button
|
||||
{
|
||||
Text = text,
|
||||
CustomMinimumSize = new Vector2(70, 30),
|
||||
FocusMode = FocusModeEnum.None
|
||||
CustomMinimumSize = new Vector2(70, 30)
|
||||
};
|
||||
btn.AddThemeFontSizeOverride("font_size", 11);
|
||||
|
||||
|
|
@ -122,9 +124,10 @@ public partial class ControlBar : HBoxContainer
|
|||
|
||||
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;
|
||||
_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)
|
||||
|
|
|
|||
|
|
@ -79,6 +79,4 @@ public partial class DetailPanel : PanelContainer
|
|||
}
|
||||
|
||||
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
|
||||
{
|
||||
[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()
|
||||
{
|
||||
|
|
@ -18,79 +30,199 @@ public partial class LevelSelectScreen : Control
|
|||
bg.MouseFilter = MouseFilterEnum.Ignore;
|
||||
AddChild(bg);
|
||||
|
||||
// Center content
|
||||
var center = new CenterContainer();
|
||||
center.SetAnchorsPreset(LayoutPreset.FullRect);
|
||||
center.MouseFilter = MouseFilterEnum.Ignore;
|
||||
// Outer margin
|
||||
var margin = new MarginContainer();
|
||||
margin.SetAnchorsPreset(LayoutPreset.FullRect);
|
||||
margin.AddThemeConstantOverride("margin_left", 80);
|
||||
margin.AddThemeConstantOverride("margin_right", 80);
|
||||
margin.AddThemeConstantOverride("margin_top", 60);
|
||||
margin.AddThemeConstantOverride("margin_bottom", 60);
|
||||
margin.MouseFilter = MouseFilterEnum.Ignore;
|
||||
|
||||
var vbox = new VBoxContainer { Alignment = BoxContainer.AlignmentMode.Center };
|
||||
vbox.AddThemeConstantOverride("separation", 24);
|
||||
vbox.MouseFilter = MouseFilterEnum.Ignore;
|
||||
var outerVBox = new VBoxContainer();
|
||||
outerVBox.AddThemeConstantOverride("separation", 0);
|
||||
outerVBox.MouseFilter = MouseFilterEnum.Ignore;
|
||||
|
||||
// --- Header section ---
|
||||
var headerBox = new VBoxContainer();
|
||||
headerBox.AddThemeConstantOverride("separation", 4);
|
||||
headerBox.MouseFilter = MouseFilterEnum.Ignore;
|
||||
|
||||
// Title
|
||||
var title = new Label
|
||||
{
|
||||
Text = "CHESSISTICS",
|
||||
HorizontalAlignment = HorizontalAlignment.Center
|
||||
};
|
||||
title.AddThemeFontSizeOverride("font_size", 56);
|
||||
title.AddThemeFontSizeOverride("font_size", 48);
|
||||
title.AddThemeColorOverride("font_color", new Color("#FFD700"));
|
||||
vbox.AddChild(title);
|
||||
headerBox.AddChild(title);
|
||||
|
||||
// Subtitle
|
||||
var subtitle = new Label
|
||||
{
|
||||
Text = "La Quête du Roi",
|
||||
Text = "Selectionnez un niveau",
|
||||
HorizontalAlignment = HorizontalAlignment.Center
|
||||
};
|
||||
subtitle.AddThemeFontSizeOverride("font_size", 18);
|
||||
subtitle.AddThemeColorOverride("font_color", new Color("#999999"));
|
||||
vbox.AddChild(subtitle);
|
||||
subtitle.AddThemeFontSizeOverride("font_size", 15);
|
||||
subtitle.AddThemeColorOverride("font_color", new Color("#777777"));
|
||||
headerBox.AddChild(subtitle);
|
||||
|
||||
outerVBox.AddChild(headerBox);
|
||||
|
||||
// Spacer
|
||||
vbox.AddChild(new Control { CustomMinimumSize = new Vector2(0, 32) });
|
||||
outerVBox.AddChild(new Control { CustomMinimumSize = new Vector2(0, 48) });
|
||||
|
||||
// Start button
|
||||
var startBtn = new Button
|
||||
// --- Level cards in a scrollable grid ---
|
||||
var scroll = new ScrollContainer
|
||||
{
|
||||
Text = "Démarrer",
|
||||
CustomMinimumSize = new Vector2(200, 52),
|
||||
SizeFlagsVertical = SizeFlags.ExpandFill,
|
||||
HorizontalScrollMode = ScrollContainer.ScrollMode.Disabled
|
||||
};
|
||||
|
||||
var grid = new GridContainer
|
||||
{
|
||||
Columns = 3,
|
||||
SizeFlagsHorizontal = SizeFlags.ShrinkCenter,
|
||||
MouseFilter = MouseFilterEnum.Ignore
|
||||
};
|
||||
grid.AddThemeConstantOverride("h_separation", 28);
|
||||
grid.AddThemeConstantOverride("v_separation", 28);
|
||||
|
||||
for (int i = 0; i < _levels.Length; i++)
|
||||
{
|
||||
var (name, desc) = _levels[i];
|
||||
grid.AddChild(CreateLevelCard(i, name, desc));
|
||||
}
|
||||
|
||||
scroll.AddChild(grid);
|
||||
outerVBox.AddChild(scroll);
|
||||
|
||||
margin.AddChild(outerVBox);
|
||||
AddChild(margin);
|
||||
}
|
||||
|
||||
private Control CreateLevelCard(int index, string name, string description)
|
||||
{
|
||||
var card = new PanelContainer
|
||||
{
|
||||
CustomMinimumSize = new Vector2(300, 240),
|
||||
SizeFlagsVertical = SizeFlags.ShrinkCenter
|
||||
};
|
||||
|
||||
var cardStyle = new StyleBoxFlat
|
||||
{
|
||||
BgColor = new Color(0.17f, 0.17f, 0.19f),
|
||||
BorderColor = new Color(0.28f, 0.28f, 0.32f),
|
||||
BorderWidthBottom = 1,
|
||||
BorderWidthTop = 1,
|
||||
BorderWidthLeft = 1,
|
||||
BorderWidthRight = 1,
|
||||
CornerRadiusTopLeft = 8,
|
||||
CornerRadiusTopRight = 8,
|
||||
CornerRadiusBottomLeft = 8,
|
||||
CornerRadiusBottomRight = 8,
|
||||
ContentMarginLeft = 24,
|
||||
ContentMarginRight = 24,
|
||||
ContentMarginTop = 24,
|
||||
ContentMarginBottom = 24
|
||||
};
|
||||
card.AddThemeStyleboxOverride("panel", cardStyle);
|
||||
|
||||
var vbox = new VBoxContainer();
|
||||
vbox.AddThemeConstantOverride("separation", 10);
|
||||
|
||||
// Level number
|
||||
var numLabel = new Label
|
||||
{
|
||||
Text = $"Niveau {index + 1}",
|
||||
HorizontalAlignment = HorizontalAlignment.Center
|
||||
};
|
||||
numLabel.AddThemeFontSizeOverride("font_size", 12);
|
||||
numLabel.AddThemeColorOverride("font_color", new Color("#666666"));
|
||||
vbox.AddChild(numLabel);
|
||||
|
||||
// Level name
|
||||
var nameLabel = new Label
|
||||
{
|
||||
Text = name,
|
||||
HorizontalAlignment = HorizontalAlignment.Center
|
||||
};
|
||||
nameLabel.AddThemeFontSizeOverride("font_size", 22);
|
||||
nameLabel.AddThemeColorOverride("font_color", new Color("#EEEEEE"));
|
||||
vbox.AddChild(nameLabel);
|
||||
|
||||
// Thin separator
|
||||
var sep = new HSeparator { CustomMinimumSize = new Vector2(0, 4) };
|
||||
vbox.AddChild(sep);
|
||||
|
||||
// Description
|
||||
var descLabel = new Label
|
||||
{
|
||||
Text = description,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
AutowrapMode = TextServer.AutowrapMode.Word,
|
||||
CustomMinimumSize = new Vector2(240, 0)
|
||||
};
|
||||
descLabel.AddThemeFontSizeOverride("font_size", 13);
|
||||
descLabel.AddThemeColorOverride("font_color", new Color("#999999"));
|
||||
vbox.AddChild(descLabel);
|
||||
|
||||
// Flexible spacer
|
||||
vbox.AddChild(new Control { SizeFlagsVertical = SizeFlags.ExpandFill });
|
||||
|
||||
// Play button
|
||||
var playBtn = new Button
|
||||
{
|
||||
Text = "Jouer",
|
||||
CustomMinimumSize = new Vector2(120, 38),
|
||||
SizeFlagsHorizontal = SizeFlags.ShrinkCenter
|
||||
};
|
||||
|
||||
var btnNormal = new StyleBoxFlat
|
||||
{
|
||||
BgColor = new Color("#8B6914"),
|
||||
CornerRadiusTopLeft = 8, CornerRadiusTopRight = 8,
|
||||
CornerRadiusBottomLeft = 8, CornerRadiusBottomRight = 8,
|
||||
ContentMarginLeft = 32, ContentMarginRight = 32,
|
||||
ContentMarginTop = 12, ContentMarginBottom = 12
|
||||
CornerRadiusTopLeft = 6,
|
||||
CornerRadiusTopRight = 6,
|
||||
CornerRadiusBottomLeft = 6,
|
||||
CornerRadiusBottomRight = 6,
|
||||
ContentMarginLeft = 24,
|
||||
ContentMarginRight = 24,
|
||||
ContentMarginTop = 8,
|
||||
ContentMarginBottom = 8
|
||||
};
|
||||
var btnHover = new StyleBoxFlat
|
||||
{
|
||||
BgColor = new Color("#B8860B"),
|
||||
CornerRadiusTopLeft = 8, CornerRadiusTopRight = 8,
|
||||
CornerRadiusBottomLeft = 8, CornerRadiusBottomRight = 8,
|
||||
ContentMarginLeft = 32, ContentMarginRight = 32,
|
||||
ContentMarginTop = 12, ContentMarginBottom = 12
|
||||
CornerRadiusTopLeft = 6,
|
||||
CornerRadiusTopRight = 6,
|
||||
CornerRadiusBottomLeft = 6,
|
||||
CornerRadiusBottomRight = 6,
|
||||
ContentMarginLeft = 24,
|
||||
ContentMarginRight = 24,
|
||||
ContentMarginTop = 8,
|
||||
ContentMarginBottom = 8
|
||||
};
|
||||
var btnPressed = new StyleBoxFlat
|
||||
{
|
||||
BgColor = new Color("#6B5010"),
|
||||
CornerRadiusTopLeft = 8, CornerRadiusTopRight = 8,
|
||||
CornerRadiusBottomLeft = 8, CornerRadiusBottomRight = 8,
|
||||
ContentMarginLeft = 32, ContentMarginRight = 32,
|
||||
ContentMarginTop = 12, ContentMarginBottom = 12
|
||||
CornerRadiusTopLeft = 6,
|
||||
CornerRadiusTopRight = 6,
|
||||
CornerRadiusBottomLeft = 6,
|
||||
CornerRadiusBottomRight = 6,
|
||||
ContentMarginLeft = 24,
|
||||
ContentMarginRight = 24,
|
||||
ContentMarginTop = 8,
|
||||
ContentMarginBottom = 8
|
||||
};
|
||||
startBtn.AddThemeStyleboxOverride("normal", btnNormal);
|
||||
startBtn.AddThemeStyleboxOverride("hover", btnHover);
|
||||
startBtn.AddThemeStyleboxOverride("pressed", btnPressed);
|
||||
startBtn.AddThemeFontSizeOverride("font_size", 20);
|
||||
playBtn.AddThemeStyleboxOverride("normal", btnNormal);
|
||||
playBtn.AddThemeStyleboxOverride("hover", btnHover);
|
||||
playBtn.AddThemeStyleboxOverride("pressed", btnPressed);
|
||||
playBtn.AddThemeFontSizeOverride("font_size", 15);
|
||||
|
||||
startBtn.Pressed += () => EmitSignal(SignalName.StartCampaignPressed);
|
||||
vbox.AddChild(startBtn);
|
||||
var idx = index;
|
||||
playBtn.Pressed += () => EmitSignal(SignalName.LevelSelected, idx);
|
||||
vbox.AddChild(playBtn);
|
||||
|
||||
center.AddChild(vbox);
|
||||
AddChild(center);
|
||||
card.AddChild(vbox);
|
||||
return card;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,13 +7,14 @@ public partial class MetricsOverlay : PanelContainer
|
|||
{
|
||||
[Signal]
|
||||
public delegate void NextLevelPressedEventHandler();
|
||||
[Signal]
|
||||
public delegate void RetryPressedEventHandler();
|
||||
|
||||
private Label _titleLabel = null!;
|
||||
private Label _piecesLabel = null!;
|
||||
private Label _turnsLabel = null!;
|
||||
private Label _cellsLabel = null!;
|
||||
private HBoxContainer _buttons = null!;
|
||||
private Button _nextBtn = null!;
|
||||
|
||||
public override void _Ready()
|
||||
{
|
||||
|
|
@ -56,9 +57,13 @@ public partial class MetricsOverlay : PanelContainer
|
|||
_buttons = new HBoxContainer { Alignment = BoxContainer.AlignmentMode.Center };
|
||||
_buttons.AddThemeConstantOverride("separation", 16);
|
||||
|
||||
_nextBtn = CreateStyledButton("Mission suivante");
|
||||
_nextBtn.Pressed += () => EmitSignal(SignalName.NextLevelPressed);
|
||||
_buttons.AddChild(_nextBtn);
|
||||
var retryBtn = CreateStyledButton("Rejouer");
|
||||
retryBtn.Pressed += () => EmitSignal(SignalName.RetryPressed);
|
||||
_buttons.AddChild(retryBtn);
|
||||
|
||||
var nextBtn = CreateStyledButton("Niveau suivant");
|
||||
nextBtn.Pressed += () => EmitSignal(SignalName.NextLevelPressed);
|
||||
_buttons.AddChild(nextBtn);
|
||||
|
||||
vbox.AddChild(_buttons);
|
||||
AddChild(vbox);
|
||||
|
|
@ -104,37 +109,6 @@ public partial class MetricsOverlay : PanelContainer
|
|||
tween.TweenProperty(_buttons, "modulate:a", 1f, 0.2f);
|
||||
}
|
||||
|
||||
public void ShowMissionComplete(int missionNum, int turns, bool isLast)
|
||||
{
|
||||
_titleLabel.Text = $"MISSION {missionNum} TERMINÉE !";
|
||||
_piecesLabel.Text = "";
|
||||
_turnsLabel.Text = $"Coups: {turns}";
|
||||
_cellsLabel.Text = "";
|
||||
_nextBtn.Text = isLast ? "Campagne terminée" : "Mission suivante";
|
||||
|
||||
// Start invisible, fade + scale in
|
||||
Modulate = new Color(1, 1, 1, 0);
|
||||
Scale = new Vector2(0.85f, 0.85f);
|
||||
PivotOffset = Size / 2f;
|
||||
Visible = true;
|
||||
|
||||
_turnsLabel.Modulate = new Color(1, 1, 1, 0);
|
||||
_buttons.Modulate = new Color(1, 1, 1, 0);
|
||||
|
||||
var tween = CreateTween();
|
||||
tween.SetParallel(true);
|
||||
tween.TweenProperty(this, "modulate:a", 1f, 0.3f)
|
||||
.SetEase(Tween.EaseType.Out);
|
||||
tween.TweenProperty(this, "scale", Vector2.One, 0.35f)
|
||||
.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Back);
|
||||
tween.SetParallel(false);
|
||||
|
||||
tween.TweenInterval(0.15f);
|
||||
tween.TweenProperty(_turnsLabel, "modulate:a", 1f, 0.2f);
|
||||
tween.TweenInterval(0.15f);
|
||||
tween.TweenProperty(_buttons, "modulate:a", 1f, 0.2f);
|
||||
}
|
||||
|
||||
public new void Hide()
|
||||
{
|
||||
Visible = false;
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ namespace Chessistics.Scripts.UI;
|
|||
|
||||
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)
|
||||
{
|
||||
|
|
@ -57,8 +57,13 @@ public partial class ObjectivePanel : VBoxContainer
|
|||
bar.AddThemeStyleboxOverride("fill", fillStyle);
|
||||
vbox.AddChild(bar);
|
||||
|
||||
var deadline = new Label { Text = $"Deadline: {demand.Deadline} coups" };
|
||||
deadline.AddThemeFontSizeOverride("font_size", 10);
|
||||
deadline.AddThemeColorOverride("font_color", new Color("#777777"));
|
||||
vbox.AddChild(deadline);
|
||||
|
||||
AddChild(vbox);
|
||||
_entries[demand.Position] = (label, bar, false);
|
||||
_entries[demand.Position] = (label, bar, deadline);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -66,21 +71,15 @@ public partial class ObjectivePanel : VBoxContainer
|
|||
{
|
||||
if (!_entries.TryGetValue(demandCell, out var entry)) return;
|
||||
|
||||
// Once completed, stop updating
|
||||
if (entry.completed) return;
|
||||
|
||||
// Cap display at required value
|
||||
int displayCurrent = Math.Min(current, required);
|
||||
entry.label.Text = $"{name}: {displayCurrent}/{required}";
|
||||
entry.label.Text = $"{name}: {current}/{required}";
|
||||
|
||||
// Animate the progress bar value
|
||||
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);
|
||||
|
||||
if (current >= required)
|
||||
{
|
||||
entry.label.Text = $"{name}: {required}/{required}";
|
||||
entry.label.AddThemeColorOverride("font_color", new Color("#5AAC5A")); // warm green
|
||||
|
||||
// Flash the progress bar green
|
||||
|
|
@ -91,9 +90,6 @@ public partial class ObjectivePanel : VBoxContainer
|
|||
CornerRadiusBottomLeft = 3, CornerRadiusBottomRight = 3
|
||||
};
|
||||
entry.bar.AddThemeStyleboxOverride("fill", fillStyle);
|
||||
|
||||
// Mark as completed — no further updates
|
||||
_entries[demandCell] = (entry.label, entry.bar, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,8 +58,7 @@ public partial class PieceStockPanel : VBoxContainer
|
|||
{
|
||||
Text = GetPieceName(entry.Kind),
|
||||
CustomMinimumSize = new Vector2(120, 32),
|
||||
ToggleMode = false, // We manage selection state ourselves
|
||||
FocusMode = FocusModeEnum.None // Prevent spacebar from activating buttons
|
||||
ToggleMode = false // We manage selection state ourselves
|
||||
};
|
||||
ApplyButtonStyle(button, false);
|
||||
|
||||
|
|
@ -149,13 +148,6 @@ public partial class PieceStockPanel : VBoxContainer
|
|||
UpdateButtonStates();
|
||||
}
|
||||
|
||||
/// <summary>Automation hook — runs the same path as clicking a piece button.</summary>
|
||||
public void SimulateSelect(PieceKind kind)
|
||||
{
|
||||
if (!_entries.ContainsKey(kind)) return;
|
||||
OnPieceButtonPressed(kind);
|
||||
}
|
||||
|
||||
private static string GetPieceName(PieceKind kind) => kind switch
|
||||
{
|
||||
PieceKind.Pawn => "Pion",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Place a piece on the board. Works in any phase (Running or Paused).
|
||||
/// The placement takes effect between turns.
|
||||
/// </summary>
|
||||
public class PlacePieceCommand : WorldCommand
|
||||
{
|
||||
public PieceKind Kind { get; }
|
||||
|
|
@ -26,6 +22,10 @@ public class PlacePieceCommand : WorldCommand
|
|||
|
||||
public override void AssertApplicationConditions(BoardState state)
|
||||
{
|
||||
if (state.Phase != SimPhase.Edit)
|
||||
throw new CommandRejectedException(
|
||||
new CommandRejectedEvent(nameof(PlacePieceCommand), "Can only place pieces during Edit phase."));
|
||||
|
||||
if (!state.RemainingStock.TryGetValue(Kind, out var remaining) || remaining <= 0)
|
||||
throw new CommandRejectedException(
|
||||
new PlacementRejectedEvent(Kind, Start, End, "No pieces of this type remaining in stock."));
|
||||
|
|
@ -41,16 +41,6 @@ public class PlacePieceCommand : WorldCommand
|
|||
if (!MoveValidator.IsLegalPlacement(Kind, Start, End, state))
|
||||
throw new CommandRejectedException(
|
||||
new PlacementRejectedEvent(Kind, Start, End, "Illegal move for this piece type."));
|
||||
|
||||
// Check piece kind is unlocked (campaign mode)
|
||||
if (state.Campaign != null && !state.Campaign.IsPieceAvailable(Kind))
|
||||
throw new CommandRejectedException(
|
||||
new PlacementRejectedEvent(Kind, Start, End, $"Piece type {Kind} is not unlocked yet."));
|
||||
|
||||
// Check piece level is unlocked (campaign mode)
|
||||
if (state.Campaign != null && !state.Campaign.IsLevelAvailable(Kind, Level))
|
||||
throw new CommandRejectedException(
|
||||
new PlacementRejectedEvent(Kind, Start, End, $"Level {Level} for {Kind} is not unlocked yet."));
|
||||
}
|
||||
|
||||
protected override void DoApply(BoardState state, List<IWorldEvent> changeList)
|
||||
|
|
@ -68,21 +58,20 @@ public class PlacePieceCommand : WorldCommand
|
|||
changeList.Add(new PiecePlacedEvent(piece.Id, Kind, Start, End));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Auto-assign cargo filter by tracing the relay chain back to a production.
|
||||
/// Priority: direct adjacency to production, then shared relay with filtered piece.
|
||||
/// </summary>
|
||||
private static CargoType? InferCargoFilter(BoardState state, PieceState piece)
|
||||
{
|
||||
// Check if start or end cell is adjacent to a production
|
||||
foreach (var (prodPos, prod) in state.Productions)
|
||||
{
|
||||
if (piece.StartCell.IsAdjacent4(prodPos) || piece.EndCell.IsAdjacent4(prodPos))
|
||||
return prod.Cargo;
|
||||
}
|
||||
|
||||
// Transformer output acts like a production
|
||||
foreach (var (tPos, transformer) in state.Transformers)
|
||||
{
|
||||
if (piece.StartCell.IsAdjacent4(tPos) || piece.EndCell.IsAdjacent4(tPos))
|
||||
return transformer.OutputCargo;
|
||||
}
|
||||
|
||||
// Check if start or end shares a relay point with an existing piece that has a filter
|
||||
foreach (var existing in state.Pieces)
|
||||
{
|
||||
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 int PieceId { get; }
|
||||
|
|
@ -114,6 +100,10 @@ public class RemovePieceCommand : WorldCommand
|
|||
|
||||
public override void AssertApplicationConditions(BoardState state)
|
||||
{
|
||||
if (state.Phase != SimPhase.Edit)
|
||||
throw new CommandRejectedException(
|
||||
new CommandRejectedEvent(nameof(RemovePieceCommand), "Can only remove pieces during Edit phase."));
|
||||
|
||||
if (state.GetPieceById(PieceId) == null)
|
||||
throw new CommandRejectedException(
|
||||
new CommandRejectedEvent(nameof(RemovePieceCommand), $"Piece {PieceId} not found."));
|
||||
|
|
@ -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 override void AssertApplicationConditions(BoardState state)
|
||||
|
|
@ -149,9 +159,9 @@ public class ResumeSimulationCommand : WorldCommand
|
|||
{
|
||||
public override void AssertApplicationConditions(BoardState state)
|
||||
{
|
||||
if (state.Phase != SimPhase.Paused && state.Phase != SimPhase.MissionComplete)
|
||||
if (state.Phase != SimPhase.Paused)
|
||||
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)
|
||||
|
|
@ -165,20 +175,72 @@ public class StepSimulationCommand : WorldCommand
|
|||
{
|
||||
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(
|
||||
new CommandRejectedEvent(nameof(StepSimulationCommand), "Cannot step in current phase."));
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
// After a manual step (was Paused), remain Paused.
|
||||
// After an auto-play step (was Running), stay Running unless
|
||||
// TurnExecutor changed it (collision → Paused, last mission → MissionComplete).
|
||||
if (!wasRunning && state.Phase == SimPhase.Running)
|
||||
// After a step, remain in Paused unless victory/defeat occurred
|
||||
if (state.Phase == SimPhase.Running)
|
||||
state.Phase = SimPhase.Paused;
|
||||
}
|
||||
}
|
||||
|
||||
public class StopSimulationCommand : WorldCommand
|
||||
{
|
||||
public override void AssertApplicationConditions(BoardState state)
|
||||
{
|
||||
if (state.Phase == SimPhase.Edit)
|
||||
throw new CommandRejectedException(
|
||||
new CommandRejectedEvent(nameof(StopSimulationCommand), "Already in Edit phase."));
|
||||
}
|
||||
|
||||
protected override void DoApply(BoardState state, List<IWorldEvent> changeList)
|
||||
{
|
||||
// Restore destroyed pieces
|
||||
state.Pieces.AddRange(state.DestroyedPieces);
|
||||
state.DestroyedPieces.Clear();
|
||||
|
||||
foreach (var piece in state.Pieces)
|
||||
{
|
||||
piece.CurrentCell = piece.StartCell;
|
||||
piece.Cargo = null;
|
||||
}
|
||||
|
||||
foreach (var pos in state.ProductionBuffers.Keys.ToList())
|
||||
state.ProductionBuffers[pos] = 0;
|
||||
|
||||
foreach (var demand in state.Demands.Values)
|
||||
demand.ReceivedCount = 0;
|
||||
|
||||
state.TurnNumber = 0;
|
||||
state.Phase = SimPhase.Edit;
|
||||
|
||||
changeList.Add(new SimulationStoppedEvent());
|
||||
}
|
||||
}
|
||||
|
||||
public class ResetLevelCommand : WorldCommand
|
||||
{
|
||||
public override void AssertApplicationConditions(BoardState state)
|
||||
{
|
||||
// Reset is always valid
|
||||
}
|
||||
|
||||
protected override void DoApply(BoardState state, List<IWorldEvent> changeList)
|
||||
{
|
||||
state.ResetFromLevel();
|
||||
changeList.Add(new LevelResetEvent());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,42 +2,26 @@ using Chessistics.Engine.Model;
|
|||
|
||||
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 PieceRemovedEvent(int PieceId) : IWorldEvent;
|
||||
public record PlacementRejectedEvent(PieceKind Kind, Coords Start, Coords End, string Reason) : IWorldEvent;
|
||||
public record CommandRejectedEvent(string CommandType, string Reason) : IWorldEvent;
|
||||
|
||||
// Simulation lifecycle events
|
||||
public record SimulationStartedEvent : IWorldEvent;
|
||||
public record SimulationPausedEvent : IWorldEvent;
|
||||
public record SimulationResumedEvent : IWorldEvent;
|
||||
|
||||
// Campaign events
|
||||
public record CampaignLoadedEvent(string CampaignName, int MissionIndex) : IWorldEvent;
|
||||
public record MissionCompleteEvent(int TurnNumber, int MissionIndex) : IWorldEvent;
|
||||
public record MissionStartedEvent(int MissionIndex, int NewWidth, int NewHeight) : IWorldEvent;
|
||||
public record TerrainExpandedEvent(int NewWidth, int NewHeight, IReadOnlyList<PatchCell> NewCells) : IWorldEvent;
|
||||
public record PieceUnlockedEvent(PieceKind Kind, int Level) : IWorldEvent;
|
||||
|
||||
// Transformer events
|
||||
public record CargoConvertedEvent(int TurnNumber, Coords TransformerCell, CargoType InputCargo, CargoType OutputCargo, int OutputAmount) : IWorldEvent;
|
||||
public record SimulationStoppedEvent : IWorldEvent;
|
||||
public record LevelResetEvent : IWorldEvent;
|
||||
|
||||
// Turn events — all carry TurnNumber for animation grouping
|
||||
public record TurnStartedEvent(int TurnNumber) : IWorldEvent;
|
||||
public record PieceMovedEvent(int TurnNumber, int PieceId, Coords From, Coords To) : IWorldEvent;
|
||||
public record 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 CargoProducedEvent(int TurnNumber, Coords ProductionCell, CargoType Type) : IWorldEvent;
|
||||
public record DemandProgressEvent(int TurnNumber, Coords DemandCell, string Name, int Current, int Required) : IWorldEvent;
|
||||
public record VictoryEvent(int TurnNumber, Metrics Metrics) : IWorldEvent;
|
||||
public record DeadlineExpiredEvent(int TurnNumber, Coords DemandCell, string Name) : IWorldEvent;
|
||||
public record TurnEndedEvent(int TurnNumber) : IWorldEvent;
|
||||
|
||||
// Drag & drop
|
||||
public record PieceMovedByPlayerEvent(int PieceId, Coords OldStart, Coords OldEnd, Coords NewStart, Coords NewEnd) : IWorldEvent;
|
||||
|
||||
// QuickSave/QuickLoad — presentation must rebuild all visuals from Snapshot on Restored.
|
||||
public record StateSavedEvent(int TurnNumber, int? SlotId) : IWorldEvent;
|
||||
public record StateRestoredEvent(BoardSnapshot Snapshot, int? SlotId) : IWorldEvent;
|
||||
|
||||
// Recurring demands
|
||||
public record DemandShortageStartedEvent(int TurnNumber, Coords DemandCell, string Name) : IWorldEvent;
|
||||
public record DemandShortageClearedEvent(int TurnNumber, Coords DemandCell, string Name) : IWorldEvent;
|
||||
|
|
|
|||
|
|
@ -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<DemandSnapshot> Demands { get; }
|
||||
public IReadOnlyList<PieceSnapshot> Pieces { get; }
|
||||
public IReadOnlyList<TransformerSnapshot> Transformers { get; }
|
||||
public SimPhase Phase { get; }
|
||||
public int TurnNumber { get; }
|
||||
public IReadOnlyDictionary<PieceKind, int> RemainingStock { get; }
|
||||
|
||||
// Campaign info (null in legacy level mode)
|
||||
public CampaignSnapshot? Campaign { get; }
|
||||
|
||||
public BoardSnapshot(BoardState state)
|
||||
{
|
||||
Width = state.Width;
|
||||
|
|
@ -32,51 +28,17 @@ public class BoardSnapshot
|
|||
.ToList();
|
||||
|
||||
Demands = state.Demands.Values
|
||||
.Select(d => new DemandSnapshot(d.Position, d.Name, d.Cargo, d.Required, d.Deadline,
|
||||
d.ReceivedCount, d.IsSatisfied, d.MissionIndex,
|
||||
d.Definition.ConsumptionPerTurn, d.Definition.SustainTurns,
|
||||
d.Buffer, d.SustainedTurns, d.InShortage))
|
||||
.Select(d => new DemandSnapshot(d.Position, d.Name, d.Cargo, d.Required, d.Deadline, d.ReceivedCount, d.IsSatisfied))
|
||||
.ToList();
|
||||
|
||||
Pieces = state.Pieces
|
||||
.Select(p => new PieceSnapshot(p.Id, p.Kind, p.Level, p.StartCell, p.EndCell, p.CurrentCell, p.Cargo, p.CargoFilter, p.SocialStatus))
|
||||
.ToList();
|
||||
|
||||
Transformers = state.Transformers.Values
|
||||
.Select(t => new TransformerSnapshot(t.Position, t.Name, t.InputCargo, t.InputRequired, t.OutputCargo, t.OutputAmount,
|
||||
state.TransformerInputBuffers[t.Position], state.TransformerOutputBuffers[t.Position]))
|
||||
.ToList();
|
||||
|
||||
RemainingStock = new Dictionary<PieceKind, int>(state.RemainingStock);
|
||||
|
||||
if (state.Campaign != null)
|
||||
{
|
||||
Campaign = new CampaignSnapshot(
|
||||
state.Campaign.CampaignDef.Name,
|
||||
state.Campaign.CurrentMissionIndex,
|
||||
state.Campaign.CompletedMissions.ToList(),
|
||||
state.Campaign.AvailablePieceKinds.ToHashSet(),
|
||||
state.Campaign.AvailableLevels.ToHashSet()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public record ProductionSnapshot(Coords Position, string Name, CargoType Cargo, int Amount, int BufferCount);
|
||||
public record DemandSnapshot(
|
||||
Coords Position,
|
||||
string Name,
|
||||
CargoType Cargo,
|
||||
int Required,
|
||||
int Deadline,
|
||||
int ReceivedCount,
|
||||
bool IsSatisfied,
|
||||
int MissionIndex = 0,
|
||||
int ConsumptionPerTurn = 0,
|
||||
int SustainTurns = 0,
|
||||
int Buffer = 0,
|
||||
int SustainedTurns = 0,
|
||||
bool InShortage = false);
|
||||
public record DemandSnapshot(Coords Position, string Name, CargoType Cargo, int Required, int Deadline, int ReceivedCount, bool IsSatisfied);
|
||||
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 int Width { get; private set; }
|
||||
public int Height { get; private set; }
|
||||
public CellType[,] Grid { get; private set; }
|
||||
public int Width { get; }
|
||||
public int Height { get; }
|
||||
public CellType[,] Grid { get; }
|
||||
public Dictionary<Coords, ProductionDef> Productions { get; }
|
||||
public Dictionary<Coords, DemandState> Demands { get; }
|
||||
public List<PieceState> Pieces { get; }
|
||||
public List<PieceState> DestroyedPieces { get; } = new();
|
||||
public Dictionary<Coords, int> ProductionBuffers { get; }
|
||||
public Dictionary<Coords, TransformerDef> Transformers { get; }
|
||||
public Dictionary<Coords, int> TransformerInputBuffers { get; }
|
||||
public Dictionary<Coords, int> TransformerOutputBuffers { get; }
|
||||
public SimPhase Phase { get; set; }
|
||||
public int TurnNumber { get; set; }
|
||||
public int NextPieceId { get; set; }
|
||||
public Dictionary<PieceKind, int> RemainingStock { get; }
|
||||
|
||||
// Campaign state (null for legacy level mode)
|
||||
public CampaignState? Campaign { get; private set; }
|
||||
public int MaxDeadline { get; }
|
||||
|
||||
// Tracks all cells ever occupied by a piece (for metrics)
|
||||
public HashSet<Coords> OccupiedCells { get; }
|
||||
|
||||
private readonly LevelDef? _levelDef;
|
||||
private readonly LevelDef _levelDef;
|
||||
private bool _isApplyingCommand;
|
||||
|
||||
private BoardState(int width, int height)
|
||||
private BoardState(LevelDef level)
|
||||
{
|
||||
Width = width;
|
||||
Height = height;
|
||||
_levelDef = level;
|
||||
Width = level.Width;
|
||||
Height = level.Height;
|
||||
MaxDeadline = level.MaxDeadline;
|
||||
|
||||
Grid = new CellType[Width, Height];
|
||||
Productions = new Dictionary<Coords, ProductionDef>();
|
||||
Demands = new Dictionary<Coords, DemandState>();
|
||||
Pieces = new List<PieceState>();
|
||||
ProductionBuffers = new Dictionary<Coords, int>();
|
||||
Transformers = new Dictionary<Coords, TransformerDef>();
|
||||
TransformerInputBuffers = new Dictionary<Coords, int>();
|
||||
TransformerOutputBuffers = new Dictionary<Coords, int>();
|
||||
RemainingStock = new Dictionary<PieceKind, int>();
|
||||
OccupiedCells = new HashSet<Coords>();
|
||||
|
||||
Phase = SimPhase.Paused;
|
||||
Phase = SimPhase.Edit;
|
||||
TurnNumber = 0;
|
||||
NextPieceId = 1;
|
||||
|
||||
// Initialize grid as empty
|
||||
for (int c = 0; c < Width; c++)
|
||||
for (int r = 0; r < Height; r++)
|
||||
Grid[c, r] = CellType.Empty;
|
||||
}
|
||||
|
||||
private BoardState(LevelDef level) : this(level.Width, level.Height)
|
||||
{
|
||||
_levelDef = level;
|
||||
ApplyLevelDef(level);
|
||||
// Place walls
|
||||
foreach (var wall in level.Walls)
|
||||
Grid[wall.Col, wall.Row] = CellType.Wall;
|
||||
|
||||
// Place productions
|
||||
foreach (var prod in level.Productions)
|
||||
{
|
||||
Grid[prod.Position.Col, prod.Position.Row] = CellType.Production;
|
||||
Productions[prod.Position] = prod;
|
||||
ProductionBuffers[prod.Position] = 0;
|
||||
}
|
||||
|
||||
// Place demands
|
||||
foreach (var demand in level.Demands)
|
||||
{
|
||||
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 FromCampaign(CampaignDef campaignDef)
|
||||
{
|
||||
var state = new BoardState(campaignDef.InitialWidth, campaignDef.InitialHeight);
|
||||
state.Campaign = new CampaignState(campaignDef);
|
||||
return state;
|
||||
}
|
||||
|
||||
public CellType GetCell(Coords coords) => Grid[coords.Col, coords.Row];
|
||||
|
||||
public bool IsOnBoard(Coords coords) => coords.IsOnBoard(Width, Height);
|
||||
|
||||
/// <summary>
|
||||
/// Returns all cells currently occupied by any piece.
|
||||
/// In campaign mode (no Edit phase), always uses CurrentCell.
|
||||
/// Returns all cells currently occupied by any piece (both start and end during Edit, CurrentCell during sim).
|
||||
/// </summary>
|
||||
public HashSet<Coords> GetOccupiedCells()
|
||||
{
|
||||
var occupied = new HashSet<Coords>();
|
||||
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;
|
||||
}
|
||||
|
|
@ -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()
|
||||
{
|
||||
if (_levelDef == null)
|
||||
throw new InvalidOperationException("Cannot reset: no level definition.");
|
||||
|
||||
Pieces.Clear();
|
||||
DestroyedPieces.Clear();
|
||||
Productions.Clear();
|
||||
Demands.Clear();
|
||||
ProductionBuffers.Clear();
|
||||
Transformers.Clear();
|
||||
TransformerInputBuffers.Clear();
|
||||
TransformerOutputBuffers.Clear();
|
||||
RemainingStock.Clear();
|
||||
OccupiedCells.Clear();
|
||||
|
||||
Phase = SimPhase.Paused;
|
||||
Phase = SimPhase.Edit;
|
||||
TurnNumber = 0;
|
||||
NextPieceId = 1;
|
||||
|
||||
|
|
@ -202,149 +135,23 @@ public class BoardState
|
|||
for (int r = 0; r < Height; r++)
|
||||
Grid[c, r] = CellType.Empty;
|
||||
|
||||
ApplyLevelDef(_levelDef);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Remove pieces whose StartCell or EndCell is on the given cell (return to stock).
|
||||
/// Used when a wall overwrites an occupied cell during terrain patching.
|
||||
/// </summary>
|
||||
private void RemovePiecesOnCell(Coords coords)
|
||||
{
|
||||
var toRemove = Pieces
|
||||
.Where(p => p.StartCell == coords || p.EndCell == coords)
|
||||
.ToList();
|
||||
|
||||
foreach (var piece in toRemove)
|
||||
{
|
||||
Pieces.Remove(piece);
|
||||
RemainingStock[piece.Kind] = RemainingStock.GetValueOrDefault(piece.Kind) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Capture a deep copy of every mutable field (for QuickSave).
|
||||
/// Immutable defs and CampaignDef are shared by reference.
|
||||
/// </summary>
|
||||
public WorldSave CaptureSave()
|
||||
{
|
||||
var grid = new CellType[Width, Height];
|
||||
Array.Copy(Grid, grid, Grid.Length);
|
||||
|
||||
return new WorldSave
|
||||
{
|
||||
Width = Width,
|
||||
Height = Height,
|
||||
Grid = grid,
|
||||
Phase = Phase,
|
||||
TurnNumber = TurnNumber,
|
||||
NextPieceId = NextPieceId,
|
||||
Productions = new Dictionary<Coords, ProductionDef>(Productions),
|
||||
ProductionBuffers = new Dictionary<Coords, int>(ProductionBuffers),
|
||||
Demands = Demands.ToDictionary(kv => kv.Key, kv => kv.Value.Clone()),
|
||||
Transformers = new Dictionary<Coords, TransformerDef>(Transformers),
|
||||
TransformerInputBuffers = new Dictionary<Coords, int>(TransformerInputBuffers),
|
||||
TransformerOutputBuffers = new Dictionary<Coords, int>(TransformerOutputBuffers),
|
||||
Pieces = Pieces.Select(p => p.Clone()).ToList(),
|
||||
DestroyedPieces = DestroyedPieces.Select(p => p.Clone()).ToList(),
|
||||
RemainingStock = new Dictionary<PieceKind, int>(RemainingStock),
|
||||
OccupiedCells = new HashSet<Coords>(OccupiedCells),
|
||||
Campaign = Campaign == null ? null : new CampaignSaveData
|
||||
{
|
||||
CurrentMissionIndex = Campaign.CurrentMissionIndex,
|
||||
CompletedMissions = new List<int>(Campaign.CompletedMissions),
|
||||
AvailablePieceKinds = new HashSet<PieceKind>(Campaign.AvailablePieceKinds),
|
||||
AvailableLevels = new HashSet<PieceUpgrade>(Campaign.AvailableLevels)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restore every mutable field from a save. Board dimensions can grow
|
||||
/// or shrink; the grid is fully replaced.
|
||||
/// </summary>
|
||||
public void RestoreFromSave(WorldSave save)
|
||||
{
|
||||
Width = save.Width;
|
||||
Height = save.Height;
|
||||
Grid = new CellType[Width, Height];
|
||||
Array.Copy(save.Grid, Grid, save.Grid.Length);
|
||||
|
||||
Phase = save.Phase;
|
||||
TurnNumber = save.TurnNumber;
|
||||
NextPieceId = save.NextPieceId;
|
||||
|
||||
Productions.Clear();
|
||||
foreach (var kv in save.Productions) Productions[kv.Key] = kv.Value;
|
||||
|
||||
ProductionBuffers.Clear();
|
||||
foreach (var kv in save.ProductionBuffers) ProductionBuffers[kv.Key] = kv.Value;
|
||||
|
||||
Demands.Clear();
|
||||
foreach (var kv in save.Demands) Demands[kv.Key] = kv.Value.Clone();
|
||||
|
||||
Transformers.Clear();
|
||||
foreach (var kv in save.Transformers) Transformers[kv.Key] = kv.Value;
|
||||
|
||||
TransformerInputBuffers.Clear();
|
||||
foreach (var kv in save.TransformerInputBuffers) TransformerInputBuffers[kv.Key] = kv.Value;
|
||||
|
||||
TransformerOutputBuffers.Clear();
|
||||
foreach (var kv in save.TransformerOutputBuffers) TransformerOutputBuffers[kv.Key] = kv.Value;
|
||||
|
||||
Pieces.Clear();
|
||||
foreach (var p in save.Pieces) Pieces.Add(p.Clone());
|
||||
|
||||
DestroyedPieces.Clear();
|
||||
foreach (var p in save.DestroyedPieces) DestroyedPieces.Add(p.Clone());
|
||||
|
||||
RemainingStock.Clear();
|
||||
foreach (var kv in save.RemainingStock) RemainingStock[kv.Key] = kv.Value;
|
||||
|
||||
OccupiedCells.Clear();
|
||||
foreach (var c in save.OccupiedCells) OccupiedCells.Add(c);
|
||||
|
||||
if (save.Campaign != null && Campaign != null)
|
||||
{
|
||||
Campaign.CurrentMissionIndex = save.Campaign.CurrentMissionIndex;
|
||||
Campaign.CompletedMissions.Clear();
|
||||
Campaign.CompletedMissions.AddRange(save.Campaign.CompletedMissions);
|
||||
Campaign.AvailablePieceKinds.Clear();
|
||||
foreach (var k in save.Campaign.AvailablePieceKinds) Campaign.AvailablePieceKinds.Add(k);
|
||||
Campaign.AvailableLevels.Clear();
|
||||
foreach (var u in save.Campaign.AvailableLevels) Campaign.AvailableLevels.Add(u);
|
||||
}
|
||||
}
|
||||
|
||||
private void ClearBuildingAt(Coords coords)
|
||||
{
|
||||
Productions.Remove(coords);
|
||||
ProductionBuffers.Remove(coords);
|
||||
Demands.Remove(coords);
|
||||
Transformers.Remove(coords);
|
||||
TransformerInputBuffers.Remove(coords);
|
||||
TransformerOutputBuffers.Remove(coords);
|
||||
}
|
||||
|
||||
private void ApplyLevelDef(LevelDef level)
|
||||
{
|
||||
foreach (var wall in level.Walls)
|
||||
foreach (var wall in _levelDef.Walls)
|
||||
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;
|
||||
Productions[prod.Position] = prod;
|
||||
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;
|
||||
Demands[demand.Position] = new DemandState(demand);
|
||||
}
|
||||
|
||||
foreach (var stock in level.Stock)
|
||||
foreach (var stock in _levelDef.Stock)
|
||||
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
|
||||
{
|
||||
Wood,
|
||||
Stone,
|
||||
Tools,
|
||||
Arms,
|
||||
Gold
|
||||
Stone
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,5 @@ public enum CellType
|
|||
Empty,
|
||||
Wall,
|
||||
Production,
|
||||
Demand,
|
||||
Transformer
|
||||
Demand
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,22 +1,3 @@
|
|||
namespace Chessistics.Engine.Model;
|
||||
|
||||
/// <summary>
|
||||
/// A demand building.
|
||||
///
|
||||
/// Classic mode (default): counts deliveries up to <see cref="Amount"/>,
|
||||
/// then <see cref="DemandState.IsSatisfied"/> stays true forever.
|
||||
///
|
||||
/// Recurring mode: set <see cref="ConsumptionPerTurn"/> > 0. The demand
|
||||
/// holds a buffer of delivered cargo; each turn it consumes that many
|
||||
/// units. If the buffer runs dry it enters shortage. The demand is
|
||||
/// considered satisfied once it has spent <see cref="SustainTurns"/>
|
||||
/// consecutive turns without shortage.
|
||||
/// </summary>
|
||||
public record DemandDef(
|
||||
Coords Position,
|
||||
string Name,
|
||||
CargoType Cargo,
|
||||
int Amount,
|
||||
int Deadline = 0,
|
||||
int ConsumptionPerTurn = 0,
|
||||
int SustainTurns = 0);
|
||||
public record DemandDef(Coords Position, string Name, CargoType Cargo, int Amount, int Deadline);
|
||||
|
|
|
|||
|
|
@ -4,40 +4,17 @@ public class DemandState
|
|||
{
|
||||
public DemandDef Definition { get; }
|
||||
public int ReceivedCount { get; set; }
|
||||
public int MissionIndex { get; }
|
||||
|
||||
// Recurring demand tracking (only used when Definition.ConsumptionPerTurn > 0)
|
||||
public int Buffer { get; set; }
|
||||
public int SustainedTurns { get; set; }
|
||||
public bool InShortage { get; set; }
|
||||
|
||||
public DemandState(DemandDef definition, int missionIndex = 0)
|
||||
public DemandState(DemandDef definition)
|
||||
{
|
||||
Definition = definition;
|
||||
MissionIndex = missionIndex;
|
||||
ReceivedCount = 0;
|
||||
}
|
||||
|
||||
public bool IsRecurring => Definition.ConsumptionPerTurn > 0;
|
||||
|
||||
public bool IsSatisfied => IsRecurring
|
||||
? SustainedTurns >= Definition.SustainTurns
|
||||
: ReceivedCount >= Definition.Amount;
|
||||
|
||||
public bool IsSatisfied => ReceivedCount >= Definition.Amount;
|
||||
public Coords Position => Definition.Position;
|
||||
public string Name => Definition.Name;
|
||||
public CargoType Cargo => Definition.Cargo;
|
||||
public int Required => Definition.Amount;
|
||||
public int Deadline => Definition.Deadline;
|
||||
|
||||
public DemandState Clone()
|
||||
{
|
||||
return new DemandState(Definition, MissionIndex)
|
||||
{
|
||||
ReceivedCount = ReceivedCount,
|
||||
Buffer = Buffer,
|
||||
SustainedTurns = SustainedTurns,
|
||||
InShortage = InShortage
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 PieceKind Kind { get; }
|
||||
public int Level { get; }
|
||||
public Coords StartCell { get; private set; }
|
||||
public Coords EndCell { get; private set; }
|
||||
public Coords StartCell { get; }
|
||||
public Coords EndCell { get; }
|
||||
public Coords CurrentCell { get; set; }
|
||||
public CargoType? Cargo { get; set; }
|
||||
public CargoType? CargoFilter { get; set; }
|
||||
|
|
@ -30,25 +30,4 @@ public class PieceState
|
|||
/// Returns the cell this piece will move to next.
|
||||
/// </summary>
|
||||
public Coords TargetCell => CurrentCell == StartCell ? EndCell : StartCell;
|
||||
|
||||
/// <summary>
|
||||
/// Relocate this piece (drag & drop).
|
||||
/// </summary>
|
||||
public void SetPosition(Coords newStart, Coords newEnd)
|
||||
{
|
||||
StartCell = newStart;
|
||||
EndCell = newEnd;
|
||||
CurrentCell = newStart;
|
||||
}
|
||||
|
||||
public PieceState Clone()
|
||||
{
|
||||
var clone = new PieceState(Id, Kind, StartCell, EndCell, PlacementOrder, Level)
|
||||
{
|
||||
CurrentCell = CurrentCell,
|
||||
Cargo = Cargo,
|
||||
CargoFilter = CargoFilter
|
||||
};
|
||||
return clone;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
Edit,
|
||||
Running,
|
||||
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
|
||||
ResolveProductionTransfers(state, events, participated, productionGave);
|
||||
|
||||
// Phase A2: Transformer outputs give to adjacent pieces (like productions)
|
||||
ResolveTransformerOutputTransfers(state, events, participated);
|
||||
|
||||
// Phase B: Pieces give to demands, transformers, or other pieces
|
||||
// Phase B: Pieces give to demands or other pieces
|
||||
ResolvePieceTransfers(state, events, participated);
|
||||
|
||||
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(
|
||||
BoardState state, List<IWorldEvent> events, HashSet<int> participated)
|
||||
{
|
||||
|
|
@ -110,8 +78,6 @@ public static class TransferResolver
|
|||
{
|
||||
giver.Cargo = null;
|
||||
adjacentDemand.ReceivedCount++;
|
||||
if (adjacentDemand.IsRecurring)
|
||||
adjacentDemand.Buffer++;
|
||||
participated.Add(giver.Id);
|
||||
|
||||
events.Add(new CargoTransferredEvent(
|
||||
|
|
@ -125,22 +91,7 @@ public static class TransferResolver
|
|||
continue;
|
||||
}
|
||||
|
||||
// Priority 2: deliver to adjacent transformer (input side)
|
||||
var adjacentTransformer = GetAdjacentCompatibleTransformer(state, giver.CurrentCell, cargoType);
|
||||
if (adjacentTransformer != null)
|
||||
{
|
||||
giver.Cargo = null;
|
||||
state.TransformerInputBuffers[adjacentTransformer.Position]++;
|
||||
participated.Add(giver.Id);
|
||||
|
||||
events.Add(new CargoTransferredEvent(
|
||||
state.TurnNumber, giver.CurrentCell, adjacentTransformer.Position, cargoType,
|
||||
GivingPieceId: giver.Id, ReceivingPieceId: null));
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Priority 3: transfer to adjacent piece without cargo
|
||||
// Priority 2: transfer to adjacent piece without cargo
|
||||
var receivers = GetAdjacentPiecesWithoutCargo(state, giver.CurrentCell, participated,
|
||||
cargoType: cargoType);
|
||||
if (receivers.Count == 0) continue;
|
||||
|
|
@ -185,17 +136,6 @@ public static class TransferResolver
|
|||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
private static TransformerDef? GetAdjacentCompatibleTransformer(
|
||||
BoardState state, Coords position, CargoType cargoType)
|
||||
{
|
||||
var adjacent = position.GetAdjacent4(state.Width, state.Height);
|
||||
|
||||
return state.Transformers.Values
|
||||
.Where(t => t.InputCargo == cargoType
|
||||
&& adjacent.Contains(t.Position))
|
||||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a sort key (0-3) based on cardinal direction from center to piece.
|
||||
/// In y-up coordinates, clockwise from 0° (right):
|
||||
|
|
|
|||
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
|
||||
{
|
||||
private readonly BoardState _state;
|
||||
private readonly Dictionary<int, WorldSave> _saveSlots = new();
|
||||
private readonly LinkedList<WorldSave> _undoStack = new();
|
||||
private const int DefaultSlot = 0;
|
||||
private const int UndoStackLimit = 32;
|
||||
|
||||
public GameSim(LevelDef level)
|
||||
{
|
||||
_state = BoardState.FromLevel(level);
|
||||
}
|
||||
|
||||
public GameSim(CampaignDef campaign)
|
||||
{
|
||||
_state = BoardState.FromCampaign(campaign);
|
||||
}
|
||||
|
||||
public IReadOnlyList<IWorldEvent> ProcessCommand(IWorldCommand command)
|
||||
{
|
||||
WorldSave? undoCheckpoint = IsUndoable(command) ? _state.CaptureSave() : null;
|
||||
|
||||
var changeList = new List<IWorldEvent>();
|
||||
try
|
||||
{
|
||||
|
|
@ -35,73 +24,8 @@ public class GameSim
|
|||
{
|
||||
return [ex.RejectionEvent];
|
||||
}
|
||||
|
||||
if (undoCheckpoint != null && ContainsMutation(changeList))
|
||||
PushUndo(undoCheckpoint);
|
||||
|
||||
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);
|
||||
|
||||
/// <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
|
||||
ExecuteProduction(state, changeList);
|
||||
|
||||
// Sub-phase 2: TRANSFORMATION (convert accumulated input → output)
|
||||
ExecuteTransformation(state, changeList);
|
||||
|
||||
// Sub-phase 3: TRANSFERS
|
||||
// Sub-phase 2: TRANSFERS
|
||||
var transferEvents = TransferResolver.ResolveTransfers(state);
|
||||
changeList.AddRange(transferEvents);
|
||||
|
||||
// Sub-phase 4: MOVEMENT
|
||||
// Sub-phase 3: MOVEMENT
|
||||
ExecuteMovement(state, changeList);
|
||||
|
||||
// Sub-phase 4b: RECURRING DEMAND CONSUMPTION
|
||||
ExecuteRecurringConsumption(state, changeList);
|
||||
|
||||
// Sub-phase 5: COLLISION RESOLUTION
|
||||
// Sub-phase 4: COLLISION RESOLUTION
|
||||
var collisions = CollisionResolver.ResolveCollisions(state.Pieces);
|
||||
foreach (var (survivor, destroyed, cell) in collisions)
|
||||
{
|
||||
foreach (var victim in destroyed)
|
||||
{
|
||||
state.Pieces.Remove(victim);
|
||||
state.DestroyedPieces.Add(victim);
|
||||
victim.Cargo = null;
|
||||
|
||||
// Return piece to stock instead of destroying permanently
|
||||
state.RemainingStock[victim.Kind] = state.RemainingStock.GetValueOrDefault(victim.Kind) + 1;
|
||||
changeList.Add(new PieceReturnedToStockEvent(
|
||||
state.TurnNumber, victim.Id, victim.Kind, survivor?.Id, cell));
|
||||
changeList.Add(new PieceDestroyedEvent(
|
||||
state.TurnNumber, victim.Id, survivor?.Id, cell));
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-pause on collision
|
||||
if (collisions.Count > 0)
|
||||
// Check victory / defeat
|
||||
if (VictoryChecker.AllDemandsMet(state))
|
||||
{
|
||||
state.Phase = SimPhase.Paused;
|
||||
changeList.Add(new SimulationPausedEvent());
|
||||
state.Phase = SimPhase.Victory;
|
||||
changeList.Add(new VictoryEvent(state.TurnNumber, ComputeMetrics(state)));
|
||||
}
|
||||
|
||||
// Check mission completion
|
||||
if (MissionChecker.AllCurrentDemandsMet(state) && state.Demands.Count > 0)
|
||||
else if (VictoryChecker.AnyDeadlineExpired(state))
|
||||
{
|
||||
var campaign = state.Campaign;
|
||||
var missionIndex = campaign?.CurrentMissionIndex ?? 0;
|
||||
campaign?.CompletedMissions.Add(missionIndex);
|
||||
changeList.Add(new MissionCompleteEvent(state.TurnNumber, missionIndex));
|
||||
|
||||
// Auto-advance to next mission if available (campaign mode)
|
||||
if (campaign != null && !campaign.IsLastMission)
|
||||
{
|
||||
AdvanceToNextMission(state, campaign, changeList);
|
||||
// Phase stays Running — simulation continues
|
||||
}
|
||||
else
|
||||
{
|
||||
// Last mission or legacy mode — pause
|
||||
state.Phase = SimPhase.MissionComplete;
|
||||
}
|
||||
state.Phase = SimPhase.Defeat;
|
||||
foreach (var demand in VictoryChecker.GetExpiredDemands(state))
|
||||
changeList.Add(new DeadlineExpiredEvent(state.TurnNumber, demand.Position, demand.Name));
|
||||
}
|
||||
|
||||
changeList.Add(new TurnEndedEvent(state.TurnNumber));
|
||||
}
|
||||
|
||||
private static void AdvanceToNextMission(BoardState state, CampaignState campaign, List<IWorldEvent> changeList)
|
||||
{
|
||||
campaign.CurrentMissionIndex++;
|
||||
var mission = campaign.CurrentMission;
|
||||
|
||||
var oldWidth = state.Width;
|
||||
var oldHeight = state.Height;
|
||||
state.ApplyTerrainPatch(mission.TerrainPatch, campaign.CurrentMissionIndex);
|
||||
|
||||
if (state.Width != oldWidth || state.Height != oldHeight)
|
||||
changeList.Add(new TerrainExpandedEvent(state.Width, state.Height, mission.TerrainPatch.Cells));
|
||||
|
||||
state.AddStock(mission.Stock);
|
||||
|
||||
foreach (var kind in mission.UnlockedPieces)
|
||||
{
|
||||
campaign.AvailablePieceKinds.Add(kind);
|
||||
changeList.Add(new PieceUnlockedEvent(kind, 1));
|
||||
}
|
||||
foreach (var upgrade in mission.UnlockedLevels)
|
||||
{
|
||||
campaign.AvailableLevels.Add(upgrade);
|
||||
changeList.Add(new PieceUnlockedEvent(upgrade.Kind, upgrade.Level));
|
||||
}
|
||||
|
||||
changeList.Add(new MissionStartedEvent(campaign.CurrentMissionIndex, state.Width, state.Height));
|
||||
}
|
||||
|
||||
private static void ExecuteMovement(BoardState state, List<IWorldEvent> changeList)
|
||||
{
|
||||
// Compute all targets first (simultaneous movement)
|
||||
var moves = state.Pieces.Select(p => (piece: p, from: p.CurrentCell, to: p.TargetCell)).ToList();
|
||||
|
||||
// Apply all moves
|
||||
foreach (var (piece, from, to) in moves)
|
||||
{
|
||||
piece.CurrentCell = to;
|
||||
|
|
@ -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)
|
||||
{
|
||||
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 FromCampaign(CampaignDef campaign) => new(new GameSim(campaign));
|
||||
|
||||
public IReadOnlyList<IWorldEvent> Place(PieceKind kind, Coords start, Coords end)
|
||||
=> Sim.ProcessCommand(new PlacePieceCommand(kind, start, end));
|
||||
|
||||
|
|
@ -24,6 +22,9 @@ public class SimHelper
|
|||
public IReadOnlyList<IWorldEvent> Remove(int pieceId)
|
||||
=> Sim.ProcessCommand(new RemovePieceCommand(pieceId));
|
||||
|
||||
public IReadOnlyList<IWorldEvent> Start()
|
||||
=> Sim.ProcessCommand(new StartSimulationCommand());
|
||||
|
||||
public IReadOnlyList<IWorldEvent> Step()
|
||||
=> Sim.ProcessCommand(new StepSimulationCommand());
|
||||
|
||||
|
|
@ -33,20 +34,17 @@ public class SimHelper
|
|||
public IReadOnlyList<IWorldEvent> Resume()
|
||||
=> Sim.ProcessCommand(new ResumeSimulationCommand());
|
||||
|
||||
public IReadOnlyList<IWorldEvent> AdvanceMission()
|
||||
=> Sim.ProcessCommand(new AdvanceMissionCommand());
|
||||
public IReadOnlyList<IWorldEvent> Stop()
|
||||
=> Sim.ProcessCommand(new StopSimulationCommand());
|
||||
|
||||
public IReadOnlyList<IWorldEvent> Reset()
|
||||
=> Sim.ProcessCommand(new ResetLevelCommand());
|
||||
|
||||
public List<IWorldEvent> StepN(int n)
|
||||
{
|
||||
var allEvents = new List<IWorldEvent>();
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
var events = Step();
|
||||
allEvents.AddRange(events);
|
||||
// Stop stepping if simulation halted (last mission complete)
|
||||
if (Snapshot.Phase == SimPhase.MissionComplete)
|
||||
break;
|
||||
}
|
||||
allEvents.AddRange(Step());
|
||||
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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Production_Amount3_FeedsMultiplePieces()
|
||||
{
|
||||
var board = new BoardBuilder(4, 4)
|
||||
.WithProduction(0, 0, "P", CargoType.Wood, amount: 3)
|
||||
.WithDemand(3, 0, "D", CargoType.Wood, 10, 99)
|
||||
.WithStock(PieceKind.Rook, 5)
|
||||
.BuildState();
|
||||
|
||||
// Three pieces adjacent to production at (0,0): (1,0), (0,1)
|
||||
var p1 = new PieceState(1, PieceKind.Rook, new Coords(1, 0), new Coords(2, 0), 0);
|
||||
p1.CurrentCell = new Coords(1, 0);
|
||||
var p2 = new PieceState(2, PieceKind.Rook, new Coords(0, 1), new Coords(0, 2), 1);
|
||||
p2.CurrentCell = new Coords(0, 1);
|
||||
|
||||
board.Pieces.AddRange([p1, p2]);
|
||||
board.ProductionBuffers[new Coords(0, 0)] = 3; // amount=3
|
||||
|
||||
var events = TransferResolver.ResolveTransfers(board);
|
||||
|
||||
// Both pieces should receive cargo (buffer had 3, 2 pieces adjacent)
|
||||
Assert.Equal(CargoType.Wood, p1.Cargo);
|
||||
Assert.Equal(CargoType.Wood, p2.Cargo);
|
||||
Assert.Equal(1, board.ProductionBuffers[new Coords(0, 0)]); // 3 - 2 = 1 remaining
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DemandPriority_OverPieceReceiver()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
[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)
|
||||
.WithProduction(0, 0, "Scierie", CargoType.Wood)
|
||||
.WithDemand(3, 0, "Depot Royal", CargoType.Wood, 3, 30)
|
||||
|
|
@ -18,14 +20,23 @@ public class FullLevelTests
|
|||
var sim = SimHelper.FromLevel(level);
|
||||
|
||||
sim.Place(PieceKind.Rook, (1, 0), (2, 0));
|
||||
sim.Start();
|
||||
|
||||
var allEvents = sim.StepN(30);
|
||||
Assert.Contains(allEvents, e => e is MissionCompleteEvent);
|
||||
Assert.Contains(allEvents, e => e is VictoryEvent);
|
||||
}
|
||||
|
||||
[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)
|
||||
.WithProduction(0, 0, "Scierie", CargoType.Wood)
|
||||
.WithDemand(5, 0, "Depot Royal", CargoType.Wood, 2, 50)
|
||||
|
|
@ -42,21 +53,35 @@ public class FullLevelTests
|
|||
// Route 2: up then right → demand (5,4)
|
||||
sim.Place(PieceKind.Rook, (0, 1), (0, 2));
|
||||
sim.Place(PieceKind.Rook, (0, 2), (2, 2));
|
||||
// 5th rook — stock exhausted at 4!
|
||||
var events5 = sim.Place(PieceKind.Rook, (2, 2), (3, 2));
|
||||
Assert.DoesNotContain(events5, e => e is PlacementRejectedEvent);
|
||||
|
||||
sim.Place(PieceKind.Bishop, (3, 2), (4, 3));
|
||||
|
||||
// 6th rook needed but only 4 in stock
|
||||
var events6 = sim.Place(PieceKind.Rook, (4, 3), (5, 3));
|
||||
Assert.DoesNotContain(events6, e => e is PlacementRejectedEvent);
|
||||
|
||||
sim.Start();
|
||||
var allEvents = sim.StepN(60);
|
||||
Assert.Contains(allEvents, e => e is MissionCompleteEvent);
|
||||
Assert.Contains(allEvents, e => e is VictoryEvent);
|
||||
}
|
||||
|
||||
[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)
|
||||
.WithProduction(0, 0, "Scierie", CargoType.Wood)
|
||||
.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, 4), (0, 4));
|
||||
|
||||
sim.Start();
|
||||
var allEvents = sim.StepN(80);
|
||||
Assert.Contains(allEvents, e => e is MissionCompleteEvent);
|
||||
Assert.Contains(allEvents, e => e is VictoryEvent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Level1_InsufficientPieces_NoMissionComplete()
|
||||
public void Level1_InsufficientPieces_NoVictory()
|
||||
{
|
||||
var level = new BoardBuilder(4, 4)
|
||||
.WithProduction(0, 0, "Scierie", CargoType.Wood)
|
||||
|
|
@ -99,10 +125,11 @@ public class FullLevelTests
|
|||
var sim = SimHelper.FromLevel(level);
|
||||
|
||||
sim.Place(PieceKind.Rook, (1, 1), (2, 1));
|
||||
sim.Start();
|
||||
|
||||
var allEvents = sim.StepN(8);
|
||||
|
||||
// No deadline concept anymore — just no mission complete
|
||||
Assert.DoesNotContain(allEvents, e => e is MissionCompleteEvent);
|
||||
Assert.DoesNotContain(allEvents, e => e is VictoryEvent);
|
||||
Assert.Contains(allEvents, e => e is DeadlineExpiredEvent);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,27 +44,41 @@ public class GameSimTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void PlaceDuringRunning_Succeeds()
|
||||
public void PlaceDuringRunning_Rejected()
|
||||
{
|
||||
// In the new system, placement works in any phase
|
||||
var sim = CreateLevel1Sim();
|
||||
sim.Place(PieceKind.Rook, (0, 0), (1, 0));
|
||||
sim.Resume(); // Paused → Running
|
||||
sim.Start();
|
||||
|
||||
var events = sim.Place(PieceKind.Rook, (0, 1), (1, 1));
|
||||
Assert.IsType<PiecePlacedEvent>(events[0]);
|
||||
Assert.IsType<CommandRejectedEvent>(events[0]);
|
||||
}
|
||||
|
||||
[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();
|
||||
sim.Place(PieceKind.Rook, (0, 0), (1, 0));
|
||||
sim.Resume(); // Paused → Running
|
||||
sim.Start();
|
||||
|
||||
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]
|
||||
|
|
@ -72,6 +86,7 @@ public class GameSimTests
|
|||
{
|
||||
var sim = CreateLevel1Sim();
|
||||
sim.Place(PieceKind.Rook, (0, 0), (2, 0));
|
||||
sim.Start();
|
||||
|
||||
// Step 1: piece moves from (0,0) to (2,0)
|
||||
var events1 = sim.Step();
|
||||
|
|
@ -90,11 +105,16 @@ public class GameSimTests
|
|||
public void ChainedPieces_TransferCargo()
|
||||
{
|
||||
var sim = CreateLevel1Sim();
|
||||
// Piece A: (0,0) → (1,0), Piece B: (2,0) → (3,0)
|
||||
// Adjacent at (1,0)↔(2,0) when A is at end and B is at start
|
||||
sim.Place(PieceKind.Rook, (0, 0), (1, 0));
|
||||
sim.Place(PieceKind.Rook, (2, 0), (3, 0));
|
||||
sim.Start();
|
||||
|
||||
// Run until we see a cargo transfer between pieces
|
||||
var allEvents = sim.StepN(20);
|
||||
|
||||
// Should have production events and cargo transfers
|
||||
Assert.Contains(allEvents, e => e is CargoProducedEvent);
|
||||
Assert.Contains(allEvents, e => e is CargoTransferredEvent);
|
||||
}
|
||||
|
|
@ -105,15 +125,18 @@ public class GameSimTests
|
|||
var sim = CreateLevel1Sim();
|
||||
sim.Place(PieceKind.Rook, (0, 0), (1, 0));
|
||||
|
||||
sim.Start();
|
||||
var allEvents = sim.StepN(6);
|
||||
|
||||
var prodEvents = allEvents.OfType<CargoProducedEvent>().ToList();
|
||||
// Production fires every turn
|
||||
Assert.Equal(6, prodEvents.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissionComplete_WhenAllDemandsMet()
|
||||
public void Victory_WhenAllDemandsMet()
|
||||
{
|
||||
// Tiny level: prod adjacent to demand, just need one piece to relay
|
||||
var level = new BoardBuilder(3, 1)
|
||||
.WithProduction(0, 0, "P", CargoType.Wood)
|
||||
.WithDemand(2, 0, "D", CargoType.Wood, 1, 30)
|
||||
|
|
@ -122,28 +145,59 @@ public class GameSimTests
|
|||
var sim = SimHelper.FromLevel(level);
|
||||
|
||||
sim.Place(PieceKind.Rook, (1, 0), (2, 0));
|
||||
sim.Start();
|
||||
|
||||
// Run enough turns for production → piece → demand
|
||||
var allEvents = sim.StepN(10);
|
||||
|
||||
Assert.Contains(allEvents, e => e is MissionCompleteEvent);
|
||||
Assert.Contains(allEvents, e => e is VictoryEvent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InitialPhase_IsPaused()
|
||||
public void Defeat_WhenDeadlineExpires()
|
||||
{
|
||||
var sim = CreateLevel1Sim();
|
||||
var snap = sim.Snapshot;
|
||||
Assert.Equal(SimPhase.Paused, snap.Phase);
|
||||
}
|
||||
// Demand with very tight deadline, piece placed far from demand
|
||||
var level = new BoardBuilder(4, 4)
|
||||
.WithProduction(0, 0, "P", CargoType.Wood)
|
||||
.WithDemand(3, 3, "D", CargoType.Wood, 10, 3) // need 10 in 3 turns — impossible
|
||||
.WithStock(PieceKind.Rook, 3)
|
||||
.Build();
|
||||
var sim = SimHelper.FromLevel(level);
|
||||
|
||||
[Fact]
|
||||
public void StepFromPaused_Works()
|
||||
{
|
||||
var sim = CreateLevel1Sim();
|
||||
sim.Place(PieceKind.Rook, (1, 0), (2, 0));
|
||||
sim.Start();
|
||||
|
||||
// Step directly from Paused
|
||||
var events = sim.Step();
|
||||
Assert.Contains(events, e => e is TurnStartedEvent);
|
||||
var allEvents = sim.StepN(5);
|
||||
|
||||
Assert.Contains(allEvents, e => e is DeadlineExpiredEvent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StopResetsState()
|
||||
{
|
||||
var sim = CreateLevel1Sim();
|
||||
sim.Place(PieceKind.Rook, (0, 0), (1, 0));
|
||||
sim.Start();
|
||||
sim.StepN(5);
|
||||
sim.Stop();
|
||||
|
||||
var snap = sim.Snapshot;
|
||||
Assert.Equal(SimPhase.Edit, snap.Phase);
|
||||
Assert.Equal(0, snap.TurnNumber);
|
||||
// Pieces should be back at start cells
|
||||
Assert.All(snap.Pieces, p => Assert.Equal(p.StartCell, p.CurrentCell));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResetClearsEverything()
|
||||
{
|
||||
var sim = CreateLevel1Sim();
|
||||
sim.Place(PieceKind.Rook, (0, 0), (1, 0));
|
||||
sim.Reset();
|
||||
|
||||
var snap = sim.Snapshot;
|
||||
Assert.Equal(SimPhase.Edit, snap.Phase);
|
||||
Assert.Empty(snap.Pieces);
|
||||
Assert.Equal(3, snap.RemainingStock[PieceKind.Rook]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.Model;
|
||||
using Chessistics.Tests.Helpers;
|
||||
|
|
@ -8,13 +7,17 @@ namespace Chessistics.Tests.Simulation;
|
|||
|
||||
/// <summary>
|
||||
/// 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>
|
||||
public class SolvabilityTests
|
||||
{
|
||||
[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)
|
||||
.WithProduction(0, 0, "Scierie", CargoType.Wood)
|
||||
.WithDemand(2, 0, "Depot", CargoType.Wood, 2, 30)
|
||||
|
|
@ -23,15 +26,21 @@ public class SolvabilityTests
|
|||
var sim = SimHelper.FromLevel(level);
|
||||
|
||||
sim.Place(PieceKind.Rook, (1, 0), (2, 0));
|
||||
sim.Start();
|
||||
|
||||
var allEvents = sim.StepN(20);
|
||||
|
||||
Assert.Contains(allEvents, e => e is MissionCompleteEvent);
|
||||
Assert.Contains(allEvents, e => e is VictoryEvent);
|
||||
}
|
||||
|
||||
[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)
|
||||
.WithProduction(0, 0, "Scierie", CargoType.Wood)
|
||||
.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, (2, 0), (3, 0));
|
||||
sim.Place(PieceKind.Rook, (3, 0), (4, 0));
|
||||
sim.Start();
|
||||
|
||||
var allEvents = sim.StepN(30);
|
||||
|
||||
Assert.Contains(allEvents, e => e is MissionCompleteEvent);
|
||||
Assert.Contains(allEvents, e => e is VictoryEvent);
|
||||
// Verify cargo actually traversed the chain (not just a shortcut)
|
||||
Assert.True(
|
||||
allEvents.OfType<CargoTransferredEvent>().Count() >= 4,
|
||||
"Expected at least 4 cargo transfers across the 3-piece chain");
|
||||
|
|
@ -54,6 +65,12 @@ public class SolvabilityTests
|
|||
[Fact]
|
||||
public void TwoDemands_SingleSource_BothSatisfied()
|
||||
{
|
||||
// 4x3: one production feeds two demands via two rooks.
|
||||
// Prod(0,0) at origin.
|
||||
// D1(2,0) along row 0, D2(0,2) along col 0.
|
||||
// Rook A(1,0↔2,0): picks up at (1,0), delivers to D1 from (1,0).
|
||||
// Rook B(0,1↔0,2): picks up at (0,1), delivers to D2 from (0,1).
|
||||
// Both rooks compete for the same buffer; A gets priority (placed first).
|
||||
var level = new BoardBuilder(4, 3)
|
||||
.WithProduction(0, 0, "Scierie", CargoType.Wood)
|
||||
.WithDemand(2, 0, "Depot Royal", CargoType.Wood, 2, 30)
|
||||
|
|
@ -64,18 +81,24 @@ public class SolvabilityTests
|
|||
|
||||
sim.Place(PieceKind.Rook, (1, 0), (2, 0));
|
||||
sim.Place(PieceKind.Rook, (0, 1), (0, 2));
|
||||
sim.Start();
|
||||
|
||||
var allEvents = sim.StepN(20);
|
||||
|
||||
Assert.Contains(allEvents, e => e is MissionCompleteEvent);
|
||||
Assert.Contains(allEvents, e => e is VictoryEvent);
|
||||
// Both demands must have received progress events
|
||||
var demandProgress = allEvents.OfType<DemandProgressEvent>().ToList();
|
||||
Assert.Contains(demandProgress, dp => dp.DemandCell == new Coords(2, 0) && dp.Current == dp.Required);
|
||||
Assert.Contains(demandProgress, dp => dp.DemandCell == new Coords(0, 2) && dp.Current == dp.Required);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TwoCargoTypes_ParallelRoutes_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)
|
||||
.WithProduction(0, 0, "Scierie", CargoType.Wood)
|
||||
.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, 1), (2, 1));
|
||||
sim.Start();
|
||||
|
||||
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();
|
||||
foreach (var t in transfers.Where(t => t.To == new Coords(3, 0)))
|
||||
Assert.Equal(CargoType.Wood, t.Type);
|
||||
|
|
@ -99,8 +124,14 @@ public class SolvabilityTests
|
|||
}
|
||||
|
||||
[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)
|
||||
.WithProduction(0, 0, "Scierie", CargoType.Wood)
|
||||
.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.Bishop, (1, 1), (2, 2));
|
||||
sim.Start();
|
||||
|
||||
var allEvents = sim.StepN(20);
|
||||
|
||||
Assert.Contains(allEvents, e => e is MissionCompleteEvent);
|
||||
Assert.Contains(allEvents, e => e is VictoryEvent);
|
||||
}
|
||||
|
||||
[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)
|
||||
.WithProduction(0, 0, "Scierie", CargoType.Wood)
|
||||
.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.Knight, (1, 1), (3, 0));
|
||||
sim.Start();
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Victory_ReportsCorrectMetrics()
|
||||
{
|
||||
// 3x1: single rook relay, verify PiecesUsed, TurnsTaken, CellsOccupied.
|
||||
var level = new BoardBuilder(3, 1)
|
||||
.WithProduction(0, 0, "P", CargoType.Wood)
|
||||
.WithDemand(2, 0, "D", CargoType.Wood, 2, 30)
|
||||
.WithStock(PieceKind.Rook, 1)
|
||||
.Build();
|
||||
var sim = SimHelper.FromLevel(level);
|
||||
|
||||
sim.Place(PieceKind.Rook, (1, 0), (2, 0));
|
||||
sim.Start();
|
||||
|
||||
var allEvents = sim.StepN(20);
|
||||
|
||||
var victory = allEvents.OfType<VictoryEvent>().FirstOrDefault();
|
||||
Assert.NotNull(victory);
|
||||
Assert.Equal(1, victory.Metrics.PiecesUsed);
|
||||
Assert.True(victory.Metrics.TurnsTaken > 0);
|
||||
Assert.Equal(2, victory.Metrics.CellsOccupied); // cells (1,0) and (2,0)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoCollision_WithSharedRelayPoints()
|
||||
{
|
||||
// Two rooks sharing a relay point never collide.
|
||||
// A(1,0↔2,0), B(2,0↔3,0) — share cell (2,0) but occupy it on alternate turns.
|
||||
var level = new BoardBuilder(5, 2)
|
||||
.WithProduction(0, 0, "P", CargoType.Wood)
|
||||
.WithDemand(4, 0, "D", CargoType.Wood, 1, 40)
|
||||
|
|
@ -150,15 +216,22 @@ public class SolvabilityTests
|
|||
|
||||
sim.Place(PieceKind.Rook, (1, 0), (2, 0));
|
||||
sim.Place(PieceKind.Rook, (2, 0), (3, 0));
|
||||
sim.Start();
|
||||
|
||||
var allEvents = sim.StepN(20);
|
||||
|
||||
Assert.DoesNotContain(allEvents, e => e is PieceReturnedToStockEvent);
|
||||
Assert.DoesNotContain(allEvents, e => e is PieceDestroyedEvent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CargoFilter_AutoAssigned_PreventsContamination()
|
||||
{
|
||||
// 4x1: two productions side by side, two routes with adjacent pieces.
|
||||
// Prod_Wood(0,0), Prod_Stone(3,0)
|
||||
// Rook A(1,0↔2,0) — adjacent to both prods on alternating turns.
|
||||
// Without CargoFilter, A would pick up both types randomly.
|
||||
// With CargoFilter, A's start (1,0) is adjacent to prod_Wood(0,0),
|
||||
// so A is filtered to Wood and ignores Stone.
|
||||
var level = new BoardBuilder(4, 1)
|
||||
.WithProduction(0, 0, "Scierie", CargoType.Wood)
|
||||
.WithProduction(3, 0, "Carriere", CargoType.Stone)
|
||||
|
|
@ -169,19 +242,24 @@ public class SolvabilityTests
|
|||
|
||||
sim.Place(PieceKind.Rook, (1, 0), (2, 0));
|
||||
|
||||
// Verify CargoFilter was auto-assigned
|
||||
var snapshot = sim.Snapshot;
|
||||
Assert.Equal(CargoType.Wood, snapshot.Pieces[0].CargoFilter);
|
||||
|
||||
sim.Start();
|
||||
var allEvents = sim.StepN(20);
|
||||
|
||||
// Piece should only carry Wood — never Stone
|
||||
var transfers = allEvents.OfType<CargoTransferredEvent>().ToList();
|
||||
Assert.All(transfers, t => Assert.Equal(CargoType.Wood, t.Type));
|
||||
Assert.Contains(allEvents, e => e is MissionCompleteEvent);
|
||||
Assert.Contains(allEvents, e => e is VictoryEvent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CargoFilter_PropagatesThroughChain()
|
||||
{
|
||||
// 5x2: chain of 3 rooks, first adjacent to Wood production.
|
||||
// All should inherit Wood filter via relay chain propagation.
|
||||
var level = new BoardBuilder(5, 2)
|
||||
.WithProduction(0, 0, "Scierie", CargoType.Wood)
|
||||
.WithDemand(4, 0, "Depot", CargoType.Wood, 2, 40)
|
||||
|
|
@ -189,9 +267,9 @@ public class SolvabilityTests
|
|||
.Build();
|
||||
var sim = SimHelper.FromLevel(level);
|
||||
|
||||
sim.Place(PieceKind.Rook, (1, 0), (2, 0));
|
||||
sim.Place(PieceKind.Rook, (2, 0), (3, 0));
|
||||
sim.Place(PieceKind.Rook, (3, 0), (4, 0));
|
||||
sim.Place(PieceKind.Rook, (1, 0), (2, 0)); // adj to prod → Wood
|
||||
sim.Place(PieceKind.Rook, (2, 0), (3, 0)); // shares (2,0) → inherits Wood
|
||||
sim.Place(PieceKind.Rook, (3, 0), (4, 0)); // shares (3,0) → inherits Wood
|
||||
|
||||
var snapshot = sim.Snapshot;
|
||||
Assert.Equal(CargoType.Wood, snapshot.Pieces[0].CargoFilter);
|
||||
|
|
@ -200,8 +278,9 @@ public class SolvabilityTests
|
|||
}
|
||||
|
||||
[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)
|
||||
.WithProduction(0, 0, "P", CargoType.Wood)
|
||||
.WithDemand(2, 0, "D", CargoType.Wood, 1, 30)
|
||||
|
|
@ -210,109 +289,10 @@ public class SolvabilityTests
|
|||
var sim = SimHelper.FromLevel(level);
|
||||
|
||||
sim.Place(PieceKind.Rook, (1, 0), (2, 0));
|
||||
// Step directly from Paused
|
||||
// No Start() — step directly from Edit
|
||||
var allEvents = sim.StepN(20);
|
||||
|
||||
Assert.Contains(allEvents, e => e is TurnStartedEvent);
|
||||
Assert.Contains(allEvents, e => e is MissionCompleteEvent);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Full transformer chain: Production(Wood) → Piece → Forge(Wood→Tools) → Piece → Demand(Tools)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TransformerChain_WoodToTools_MissionComplete()
|
||||
{
|
||||
var campaign = new CampaignDef
|
||||
{
|
||||
Name = "Solvability: Transformer", InitialWidth = 5, InitialHeight = 1,
|
||||
Missions =
|
||||
[
|
||||
new MissionDef
|
||||
{
|
||||
Id = 1, Name = "Forge",
|
||||
TerrainPatch = new TerrainPatch
|
||||
{
|
||||
NewWidth = 5, NewHeight = 1,
|
||||
Cells =
|
||||
[
|
||||
new PatchCell { Col = 0, Row = 0, Type = CellType.Production,
|
||||
Production = new ProductionDef(new Coords(0, 0), "Scierie", CargoType.Wood, 4) },
|
||||
new PatchCell { Col = 2, Row = 0, Type = CellType.Transformer,
|
||||
Transformer = new TransformerDef(new Coords(2, 0), "Forge", CargoType.Wood, 2, CargoType.Tools, 1) },
|
||||
new PatchCell { Col = 4, Row = 0, Type = CellType.Demand,
|
||||
Demand = new DemandDef(new Coords(4, 0), "Atelier", CargoType.Tools, 2) }
|
||||
]
|
||||
},
|
||||
UnlockedPieces = [PieceKind.Rook],
|
||||
UnlockedLevels = [new PieceUpgrade(PieceKind.Rook, 1)],
|
||||
Stock = [new PieceStock(PieceKind.Rook, 3)]
|
||||
}
|
||||
]
|
||||
};
|
||||
var sim = SimHelper.FromCampaign(campaign);
|
||||
sim.Sim.ProcessCommand(new LoadCampaignCommand());
|
||||
|
||||
// Rook 1: delivers wood to forge input
|
||||
sim.Place(PieceKind.Rook, (0, 0), (1, 0));
|
||||
// Rook 2: picks up tools from forge output, delivers to demand
|
||||
sim.Place(PieceKind.Rook, (3, 0), (4, 0));
|
||||
|
||||
var allEvents = sim.StepN(50);
|
||||
|
||||
Assert.Contains(allEvents, e => e is CargoConvertedEvent);
|
||||
Assert.Contains(allEvents, e => e is MissionCompleteEvent);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Two-stage transformation: Wood → Forge → Tools → Comptoir → Gold
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void DoubleTransformerChain_WoodToToolsToGold_MissionComplete()
|
||||
{
|
||||
var campaign = new CampaignDef
|
||||
{
|
||||
Name = "Solvability: Double Transformer", InitialWidth = 7, InitialHeight = 1,
|
||||
Missions =
|
||||
[
|
||||
new MissionDef
|
||||
{
|
||||
Id = 1, Name = "Double Chain",
|
||||
TerrainPatch = new TerrainPatch
|
||||
{
|
||||
NewWidth = 7, NewHeight = 1,
|
||||
Cells =
|
||||
[
|
||||
new PatchCell { Col = 0, Row = 0, Type = CellType.Production,
|
||||
Production = new ProductionDef(new Coords(0, 0), "Scierie", CargoType.Wood, 4) },
|
||||
new PatchCell { Col = 2, Row = 0, Type = CellType.Transformer,
|
||||
Transformer = new TransformerDef(new Coords(2, 0), "Forge", CargoType.Wood, 2, CargoType.Tools, 1) },
|
||||
new PatchCell { Col = 4, Row = 0, Type = CellType.Transformer,
|
||||
Transformer = new TransformerDef(new Coords(4, 0), "Comptoir", CargoType.Tools, 2, CargoType.Gold, 1) },
|
||||
new PatchCell { Col = 6, Row = 0, Type = CellType.Demand,
|
||||
Demand = new DemandDef(new Coords(6, 0), "Tresor", CargoType.Gold, 1) }
|
||||
]
|
||||
},
|
||||
UnlockedPieces = [PieceKind.Rook],
|
||||
UnlockedLevels = [new PieceUpgrade(PieceKind.Rook, 1)],
|
||||
Stock = [new PieceStock(PieceKind.Rook, 4)]
|
||||
}
|
||||
]
|
||||
};
|
||||
var sim = SimHelper.FromCampaign(campaign);
|
||||
sim.Sim.ProcessCommand(new LoadCampaignCommand());
|
||||
|
||||
// Chain: Scierie → Rook1 → Forge → Rook2 → Comptoir → Rook3 → Tresor
|
||||
sim.Place(PieceKind.Rook, (0, 0), (1, 0)); // wood delivery
|
||||
sim.Place(PieceKind.Rook, (3, 0), (4, 0)); // tools delivery (picks from forge, delivers to comptoir)
|
||||
sim.Place(PieceKind.Rook, (5, 0), (6, 0)); // gold delivery
|
||||
|
||||
var allEvents = sim.StepN(80);
|
||||
|
||||
// Should see both transformations
|
||||
var conversions = allEvents.OfType<CargoConvertedEvent>().ToList();
|
||||
Assert.Contains(conversions, c => c.OutputCargo == CargoType.Tools);
|
||||
Assert.Contains(conversions, c => c.OutputCargo == CargoType.Gold);
|
||||
Assert.Contains(allEvents, e => e is MissionCompleteEvent);
|
||||
Assert.Contains(allEvents, e => e is VictoryEvent);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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** :
|
||||
|
||||
```
|
||||
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
|
||||
pendant le placement, puis reprend)
|
||||
|
|
||||
OBSERVER le resultat — le reseau s'adapte immediatement
|
||||
|
|
||||
+---> Le debit est insuffisant ? Reorganiser les chaines
|
||||
+---> La mission est remplie ? Avancer vers la mission suivante
|
||||
+---> Le debit est insuffisant ? Observer les goulets, reorganiser
|
||||
+---> Le debit est atteint ? Optimiser ou niveau suivant
|
||||
```
|
||||
|
||||
La simulation tourne en continu. Le joueur ne "lance" jamais — il intervient sur un systeme vivant.
|
||||
|
||||
**Ce qui distingue Chessistics** :
|
||||
- La logistique (macro) : le joueur compose des chaines, choisit sa flotte, gere l'espace
|
||||
- Le puzzle chess (micro) : les contraintes de mouvement creent des enigmes de couverture et d'espacement emergentes
|
||||
|
|
@ -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 sombre** | Carre sombre du damier | Traversable (les Fous sont limites a une couleur) |
|
||||
| **Mur** | Case barree, gris fonce | Bloque toutes les pieces sauf le Cavalier (qui saute) |
|
||||
| **Production** | Icone ressource + nom (ex: "Scierie") | Produit M cargaisons 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. |
|
||||
|
||||
### 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)
|
||||
- Si deux pieces arrivent sur la meme case apres le mouvement, la piece de **statut le plus eleve** detruit l'autre. A statut egal, le **niveau** departage. A egalite parfaite (meme statut ET meme niveau), **destruction mutuelle**.
|
||||
- La piece survivante reste sur la case avec sa cargaison intacte. La cargaison des pieces detruites est perdue.
|
||||
- Les pieces detruites **retournent immediatement dans le stock** du joueur — il peut les replacer a tout moment.
|
||||
- En cas de collision, la simulation se met en **pause automatique**. La camera effectue un pan et zoom vers la zone de collision pour montrer ce qui s'est passe. Une notification apparait dans un coin de l'ecran pour expliciter l'evenement (ex: "Tour II detruite par Dame — retournee au stock").
|
||||
- Les pieces detruites sont restaurees quand le joueur arrete la simulation (retour en mode edition).
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -193,13 +188,11 @@ Les transferts se produisent entre :
|
|||
|
||||
### 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.
|
||||
|
||||
Condition pour qu'un transfert ait lieu :
|
||||
Un transfert se produit quand, a la **fin d'un coup** (une fois que toutes les pieces ont bouge) :
|
||||
- Une entite avec colis et une entite sans colis (ou une demande) sont sur des **cases adjacentes** (4-connecte)
|
||||
- Le colis est compatible (la demande accepte ce type de cargaison)
|
||||
|
||||
Le transfert est **instantane** 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
|
||||
|
||||
|
|
@ -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 egalite parfaite (meme statut ET meme niveau), **destruction mutuelle** (aucun survivant)
|
||||
- La piece survivante conserve sa cargaison. Les cargaisons detruites sont perdues.
|
||||
- Les pieces detruites **retournent immediatement dans le stock** du joueur.
|
||||
- La simulation se met en **pause automatique**. La camera pan et zoom vers la zone de collision. Une notification explicite l'evenement.
|
||||
- La simulation **continue** (pas de pause automatique)
|
||||
|
||||
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
|
||||
|
||||
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 |
|
||||
|----------|-------------|------------------|
|
||||
| **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 |
|
||||
| **Espace** | Nombre de cases du plateau utilisees (occupees par une piece au moins 1 coup) | Compacite du reseau |
|
||||
|
||||
Chaque metrique a un **histogramme** montrant la distribution des solutions de tous les joueurs.
|
||||
|
||||
> **Proto** : histogrammes avec donnees fictives pour tester l'UI.
|
||||
|
||||
**Triangle d'optimisation** :
|
||||
- Moins de pieces = chaines courtes = couverture limitee → plus de coups
|
||||
- Moins de coups = pieces puissantes ou nombreuses → plus de pieces, plus d'espace
|
||||
|
|
@ -327,7 +323,7 @@ A la completion d'une mission, 3 metriques sont affichees :
|
|||
|
||||
**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 |
|
||||
| | Forger les Tours |
|
||||
| | Depot: 0/3 Bois |
|
||||
| P L A T E A U | ✓ Mission 1 |
|
||||
| (damier interactif) | ✓ Mission 2 |
|
||||
| | ───────── |
|
||||
| Les pieces et leurs trajets | |
|
||||
| sont visibles sur le plateau | PIECES |
|
||||
| | [Pion I] x4 |
|
||||
| | [Tour I] x3 |
|
||||
| | OBJECTIF |
|
||||
| | Depot Royal |
|
||||
| | 3x Bois / 30c |
|
||||
| P L A T E A U | |
|
||||
| (damier interactif) | ───────── |
|
||||
| | |
|
||||
| Les pieces et leurs trajets | PIECES |
|
||||
| sont visibles sur le plateau | [Tour II] x3 |
|
||||
| | [Fou II] x1 |
|
||||
| | [Cavalier] x1 |
|
||||
| | |
|
||||
+---------------------------------------------------------------+
|
||||
| [⏸ / ▶] [x1] [x2] [x4] Tour: 42 Mission: 3/8 |
|
||||
| [▶ PLAY] [⏩ x2] [⏸] [⏹ STOP] Coup: -- |
|
||||
+---------------------------------------------------------------+
|
||||
```
|
||||
|
||||
### 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.
|
||||
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.
|
||||
|
||||
Si le joueur annule (Echap), la simulation reprend sans placement.
|
||||
|
||||
```
|
||||
Placement d'une Tour II :
|
||||
|
||||
|
|
@ -385,9 +379,9 @@ Si le joueur annule (Echap), la simulation reprend sans placement.
|
|||
```
|
||||
|
||||
**Interactions** :
|
||||
- **Clic gauche sur une piece placee** → la selectionne, affiche son trajet, panneau de detail
|
||||
- **Touche Suppr** (piece selectionnee) → la retire du plateau (retourne dans le stock)
|
||||
- **Bouton [Retirer]** dans le panneau de detail → meme effet
|
||||
- **Clic sur une piece placee** → la selectionne, affiche son trajet, panneau de detail
|
||||
- **Clic droit sur une piece** → la retire du plateau (retourne dans le stock)
|
||||
- **Glisser une piece** → deplace son point de depart (recalcule les arrivees possibles)
|
||||
|
||||
### 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** :
|
||||
- **Espace** : pause / reprendre (le tour en cours se termine avant la pause)
|
||||
- **Vitesse** : x1, x2, x4
|
||||
- **Placement d'une piece** : met la simulation en pause automatiquement le temps du placement (voir §7.2)
|
||||
**Phase EXEC** (simulation)
|
||||
- Les pieces font leurs allers-retours simultanement
|
||||
- Les colis se transmettent automatiquement aux points de contact
|
||||
- Compteur de coups et progression des objectifs en temps reel
|
||||
- Controles : pause, step-by-step, vitesse x1/x2/x4, stop (retour a EDIT)
|
||||
- En cas de collision → pause auto, pieces en erreur surlignees
|
||||
|
||||
**Pauses automatiques** :
|
||||
- Quand le joueur selectionne une piece a placer → pause jusqu'a confirmation ou annulation
|
||||
- Quand une collision se produit → pause + pan/zoom camera vers la zone + notification (voir §5.2)
|
||||
|
||||
Le joueur peut placer, retirer et reorganiser ses pieces a tout moment, en pause ou pendant que la simulation tourne.
|
||||
Le joueur peut arreter la simulation a tout moment, reorganiser, et relancer.
|
||||
|
||||
### 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")
|
||||
- Jauge verte = objectif atteint. Jauge rouge = pas encore.
|
||||
|
||||
**Collisions** :
|
||||
- Flash rouge + shake des deux pieces
|
||||
**Erreurs** :
|
||||
- Collision : flash rouge + shake des deux pieces
|
||||
- Simulation en pause automatiquement
|
||||
- La camera pan et zoom vers la zone de collision
|
||||
- Notification dans un coin de l'ecran : "Tour II detruite par Dame — retournee au stock"
|
||||
- La piece detruite retourne dans le stock, le joueur peut la replacer
|
||||
|
||||
**Completion de mission** :
|
||||
**Victoire** :
|
||||
- Toutes les jauges au vert → animation sobre (les trajets scintillent en dore)
|
||||
- Overlay de felicitations avec metriques de la mission
|
||||
- Bouton "Mission suivante" pour avancer
|
||||
|
||||
**Transition de mission** :
|
||||
- Titre "Nouvelle mission" apparait en plein ecran en fade-in
|
||||
- La camera se lock (pan et zoom desactives) pour montrer la zone de la prochaine mission
|
||||
- Les nouvelles cases apparaissent sur le plateau avec une animation d'expansion
|
||||
- Le titre de mission se deplace vers la zone d'objectif dans le panneau lateral avant de disparaitre, emmenant l'oeil du joueur vers les nouveaux objectifs
|
||||
- Overlay des metriques + histogrammes
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -485,7 +470,7 @@ Le joueur peut placer, retirer et reorganiser ses pieces a tout moment, en pause
|
|||
```
|
||||
|
||||
- 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)
|
||||
- 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 . . . . . .
|
||||
5 . . . . . [D2] Caserne — 2 Bois 4 . . . . . .
|
||||
5 . . . . . [D2] Caserne — 2 Bois en 30 coups
|
||||
4 . . . . . .
|
||||
3 . . . . . .
|
||||
2 . . . . . .
|
||||
1 [S] . . . . [D1] Depot Royal — 2 Bois
|
||||
1 [S] . . . . [D1] Depot Royal — 2 Bois en 30 coups
|
||||
|
||||
a b c d e f
|
||||
```
|
||||
|
||||
- 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)
|
||||
- D2 = Caserne (f5, objectif : 2 Bois en 30 coups)
|
||||
- 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** :
|
||||
- S→D1 : 5 cases en ligne droite. Faisable avec une chaine de Tours.
|
||||
- S→D2 : trajet en angle (droite + haut). Plusieurs routes possibles.
|
||||
- La Scierie ne produit qu'un colis 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 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.
|
||||
|
||||
```
|
||||
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 . . # . . .
|
||||
2 . . . . . .
|
||||
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**
|
||||
- S1 = Scierie (a1, Bois, 1 par tour)
|
||||
- S2 = Carriere (f1, Pierre, 1 par tour)
|
||||
- S1 = Scierie (a1, Bois, tous les 2 coups)
|
||||
- S2 = Carriere (f1, Pierre, tous les 2 coups)
|
||||
- D1 = Depot Royal (f6, objectif : 2 Bois en 40 coups)
|
||||
- D2 = Forge (a6, objectif : 2 Pierre en 40 coups)
|
||||
- Murs : c3, c4, c5, d5, e5 (barriere en L)
|
||||
|
|
@ -624,19 +613,21 @@ Le Cavalier saute le mur en L. Il peut connecter les deux cotes du plateau la ou
|
|||
|
||||
```
|
||||
8 . . . . . . . .
|
||||
7 [D2] . . . . . . . Forge — 3 Pierre 6 . . . . . . . .
|
||||
7 [D2] . . . . . . . Forge — 3 Pierre en 40 coups
|
||||
6 . . . . . . . .
|
||||
5 . . . ## . . . .
|
||||
4 . . . ## . . . .
|
||||
3 . . . . . . . .
|
||||
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
|
||||
[S2] Carriere (h8)
|
||||
```
|
||||
|
||||
- Plateau : **8x8**
|
||||
- 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)
|
||||
- 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)
|
||||
5 . . # . # . # .
|
||||
4 . . # . # . . [D1] Depot Royal — 3 Bois 3 . . # . . . # .
|
||||
4 . . # . # . . [D1] Depot Royal — 3 Bois en 50 coups
|
||||
3 . . # . . . # .
|
||||
2 . . . . # . # .
|
||||
1 [S1] . . . # . . [D2] Forge — 3 Pierre
|
||||
1 [S1] . . . # . . [D2] Forge — 3 Pierre en 50 coups
|
||||
|
||||
a b c d e f g h
|
||||
```
|
||||
|
||||
- Plateau : **8x6**
|
||||
- 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
|
||||
- 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.
|
||||
|
||||
```
|
||||
8 [S2] . . # . . # . . [D2] Forge — 3 Pierre 7 . . . # . . # . . .
|
||||
8 [S2] . . # . . # . . [D2] Forge — 3 Pierre en 50 coups
|
||||
7 . . . # . . # . . .
|
||||
6 . . . # ## . # . . .
|
||||
5 . . . . . . . . . .
|
||||
4 . . . # ## . # . . [S3] Scierie Est (j4, Bois)
|
||||
3 . . . # . . # . . .
|
||||
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
|
||||
```
|
||||
|
||||
- Plateau : **10x8**
|
||||
- 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
|
||||
- Stock : 14 Pions, 6 Tours, 3 Fous, 4 Cavaliers
|
||||
|
||||
|
|
@ -751,7 +746,7 @@ Chessistics/
|
|||
UI/
|
||||
ObjectivePanel.tscn — Objectifs + stock de pieces
|
||||
DetailPanel.tscn — Detail piece selectionnee
|
||||
ControlBar.tscn — Pause / vitesse
|
||||
ControlBar.tscn — Play / pause / stop / vitesse
|
||||
MetricsOverlay.tscn — Resultats post-victoire
|
||||
LevelSelect.tscn — Selection de niveau
|
||||
scripts/
|
||||
|
|
@ -767,7 +762,7 @@ Chessistics/
|
|||
LevelLoader.cs — Chargement JSON
|
||||
UI/
|
||||
PiecePlacer.cs — Logique du placement 2 clics
|
||||
ControlBar.cs — Pause/vitesse
|
||||
ControlBar.cs — Play/pause/stop/vitesse
|
||||
ProgressDisplay.cs — Compteur de coups + progression objectifs
|
||||
data/
|
||||
levels/
|
||||
|
|
@ -786,10 +781,10 @@ Chessistics/
|
|||
"width": 4,
|
||||
"height": 4,
|
||||
"productions": [
|
||||
{ "x": 0, "y": 0, "name": "Scierie", "cargo": "wood", "amount": 1 }
|
||||
{ "x": 0, "y": 0, "name": "Scierie", "cargo": "wood", "interval": 2 }
|
||||
],
|
||||
"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": [],
|
||||
"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 |
|
||||
| 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 |
|
||||
| Pieces fixes par niveau ? | Fixes (catalogue impose) vs achat libre | **Fixes** — plus facile a designer. L'achat/fabrication est post-proto. |
|
||||
| Egalite de statut social ? | Niveau > direction horaire | **Niveau puis direction horaire alternee** (pairs: depuis droite, impairs: depuis gauche) — deterministe, pas de biais permanent |
|
||||
|
|
|
|||
37
docs/PLAN.md
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