Snapshot campaign system progress before automation harness

Bundles in-flight work on the campaign/missions system (CampaignDef,
MissionDef, TerrainPatch, TransformerDef, MissionChecker, CampaignLoader,
FlavorBanner, transformer rules), plan files, and matching tests. Baseline
commit so the upcoming automation testing harness lands on a clean tree.
This commit is contained in:
Samuel Bouchet 2026-04-16 21:22:49 +02:00
parent 358ab48d59
commit 2d1aea0a7a
71 changed files with 3749 additions and 924 deletions

View file

@ -0,0 +1,227 @@
{
"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 Couronnement",
"description": "Le Comptoir transforme les outils en or. Livrez l'or au Trésor Royal pour couronner 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 }
]
}
]
}

55
PLAN.md
View file

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

157
PLAN_leveldesign.md Normal file
View file

@ -0,0 +1,157 @@
# Plan : Level Design - Batiments de Transformation
## Concept
Les batiments de **transformation** consomment une ressource en entree et produisent une ressource differente en sortie. Le joueur doit construire des chaines logistiques multi-etapes : extraction → transformation → livraison.
Exemple : Scierie (produit bois) → Forge (consomme bois, produit outils) → Caserne (demande outils).
## Modele Engine
### Nouveau type de cellule : Transformer
```
CellType.Transformer
```
Un `TransformerDef` combine une demande (input) et une production (output) :
```csharp
public record TransformerDef(
Coords Position,
string Name,
CargoType InputCargo, // ce qu'il consomme
int InputRequired, // nb d'unites avant conversion
CargoType OutputCargo, // ce qu'il produit
int OutputAmount // nb d'unites produites par cycle
);
```
### Logique de production
Chaque tour, le `TransferResolver` livre du cargo au transformateur comme a une demande normale. Quand le buffer d'entree atteint `InputRequired`, le transformateur :
1. Vide son buffer d'entree
2. Remplit son buffer de sortie avec `OutputAmount` unites de `OutputCargo`
3. Le buffer de sortie est distribue aux pieces adjacentes (comme une production classique)
Cela cree un rythme : accumulation → conversion → distribution.
### Changements au TerrainPatch
Ajouter le type `"transformer"` dans le JSON :
```json
{
"col": 3, "row": 2,
"type": "transformer",
"transformer": {
"name": "Forge",
"inputCargo": "wood",
"inputRequired": 2,
"outputCargo": "tools",
"outputAmount": 1
}
}
```
### Nouveaux types de cargo
Ajouter au `CargoType` enum :
- `Tools` (outils) — bois transforme
- `Arms` (armes) — pierre transformee
- `Gold` (or) — ressource rare de fin de campagne
## Evolution de la campagne (10 missions)
### Mission 1 : Premier Convoi (4x4)
- **Deverrouille** : Pion
- **Batiments** : Scierie → Depot Royal
- **Objectif** : livrer 3 bois — apprendre le placement de base
### Mission 2 : Forger les Tours (6x6)
- **Deverrouille** : Tour
- **Batiments** : +Carriere, +Caserne (bois), +Forge (pierre)
- **Objectif** : livrer bois a la caserne ET pierre a la forge
- **Defi** : gerer deux routes independantes
### Mission 3 : Le Col (6x6)
- **Deverrouille** : Cavalier
- **Batiments** : mur bloquant + Depot (bois)
- **Objectif** : traverser les murs avec des cavaliers
- **Les demands precedentes restent actives**
### Mission 4 : Le Carrefour (8x8)
- **Deverrouille** : Fou
- **Batiments** : +Chateau (bois), +Forge Royale (pierre), murs
- **Objectif** : routes diagonales avec les fous
### Mission 5 : La Forge (8x8) -- TRANSFORMATION
- **Deverrouille** : aucun (nouveau type de cargo : Outils)
- **Batiments** : Forge *transformee* en transformateur (bois → outils)
- **Objectif** : livrer des outils a un nouveau batiment (Armurerie)
- **Defi** : la route bois existante doit continuer, PLUS une branche vers la forge qui produit des outils
### Mission 6 : La Dame Blanche (10x10)
- **Deverrouille** : Dame
- **Batiments** : +Scierie Nord, +Grand Chantier (bois), +Arsenal (pierre)
- **Objectif** : routes longue distance avec la dame
### Mission 7 : L'Armurerie (10x10) -- TRANSFORMATION
- **Nouveau cargo** : Armes
- **Batiments** : Armurerie (transforme pierre → armes)
- **Objectif** : livrer des armes a une Garnison
### Mission 8 : Le Comptoir (12x12)
- **Batiments** : Comptoir (transforme outils → or)
- **Objectif** : livrer de l'or au Tresor Royal
- **Defi** : chaine a 3 etapes : bois → outils → or
### Mission 9 : L'Expansion Finale (14x14)
- **Batiments** : multiples transformateurs, murs complexes
- **Objectif** : maintenir toutes les chaines tout en s'etendant
- **Defi** : gestion de la congestion (risque de collisions)
### Mission 10 : Le Couronnement (14x14)
- **Batiments** : Cathedrale (demande or + armes + outils)
- **Objectif** : livrer les 3 types de cargo transforme
- **Defi** : orchestrer l'ensemble des chaines logistiques simultanement
## Demands recurrentes (futur)
Pour que le joueur doive "preserver ses automatisations", les demands pourraient devenir recurrentes :
- Un batiment de demande **consomme** N unites par tour
- S'il n'est plus approvisionne, il passe en etat "en penurie"
- Condition de mission : **aucun** batiment en penurie pendant X tours consecutifs
Cela force le joueur a maintenir ses routes existantes quand le terrain s'agrandit, au lieu de tout reconstruire.
## Implementation par phases
### Phase 1 : Modele Transformer
- Ajouter `CellType.Transformer` et `TransformerDef`
- Ajouter `TransformerState` avec buffers input/output
- Integrer dans `BoardState`
### Phase 2 : Logique de conversion
- Modifier `TurnExecutor` : sous-phase "transformation" entre production et transferts
- Le transformateur agit comme une demande (recoit) ET une production (emet)
### Phase 3 : Nouveaux cargos
- Ajouter `Tools`, `Arms`, `Gold` a `CargoType`
- Couleurs visuelles pour chaque cargo
- Mise a jour du `CampaignLoader` pour parser les transformateurs
### Phase 4 : Visuels
- Couleur de cellule pour les transformateurs (ex: orange cuivre)
- Animation de conversion (flash input → flash output)
- Icones de cargo dans les pieces
### Phase 5 : Missions 5-10
- Ecrire les donnees JSON des missions 5 a 10
- Tester la solvabilite de chaque mission
- Equilibrer les quantites (input/output ratios)
### Phase 6 (optionnel) : Demands recurrentes
- Modifier `DemandState` pour tracker la consommation par tour
- Ajouter un flag "en penurie"
- Condition de victoire : pas de penurie pendant N tours

371
PLAN_missions.md Normal file
View file

@ -0,0 +1,371 @@
# Plan : Puzzle → Logistique incrémentale (Missions)
## Vision
Remplacer la série de niveaux indépendants par un plateau persistant où les **missions** s'enchaînent. Chaque mission complétée débloque une extension du terrain (nouvelles cases, productions, demandes, murs) sans casser la solution en place. Le jeu devient un jeu de **logistique** et non de **puzzle**.
### Fil narratif (lore GDD §12)
Les pions manquent d'un roi. Mission après mission, ils construisent des pièces de plus en plus puissantes pour atteindre des ressources plus lointaines, jusqu'à fabriquer le roi — qui exécute tout le monde.
Chaque mission débloque de nouveaux **types de pièces** : on commence avec les Pions seuls, puis les Tours, les Fous, les Cavaliers, et enfin la Dame. Le lore justifie la progression mécanique.
---
## Concepts clés
| Ancien | Nouveau |
|--------|---------|
| Level (plateau isolé) | **Campaign** : un plateau persistant avec N missions |
| Victoire → charger le niveau suivant | Victoire → le plateau **s'étend**, la mission suivante se débloque |
| Stock fixe par niveau | Stock cumulé : chaque mission donne un budget additionnel |
| Reset complet entre niveaux | Les pièces posées et les productions **restent actives** |
| Stop (retour en Edit) | **Supprimé** — la simulation tourne en continu, le joueur édite en temps réel |
| Toutes pièces disponibles dès le début | Pièces **débloquées progressivement** par mission (lore) |
| Pièces à niveau fixe | **Niveaux de pièces** (I, II, III) débloqués par la progression |
### Règle d'or : pas de régression
Les murs ne peuvent apparaître que sur les **nouvelles cases** débloquées par la mission. Le terrain existant ne change jamais de façon bloquante.
---
## Changements de game design
### Suppression du Stop — édition en temps réel
Il n'y a plus de phase Edit séparée. La simulation tourne en continu. Le joueur peut :
- **Pause** (barre espace) : l'animation termine le tour en cours puis se fige.
- **Placer/retirer des pièces** à tout moment (en pause ou pendant que ça tourne).
- **Réoptimiser** les missions précédentes — essentiel pour un jeu de logistique.
Conséquence engine : `PlacePieceCommand` et `RemovePieceCommand` fonctionnent quel que soit le `SimPhase` (plus de guard `phase == Edit`).
### Auto-pause pendant le placement
Sélectionner un type de pièce à placer met automatiquement la simulation en pause. La pause est levée quand le placement est confirmé (clic arrivée) ou annulé (Échap). Le joueur ne pense pas "pause" — il pense "placement". C'est transparent.
### Retirer une pièce — touche Suppr
Le clic droit est réservé au pan de caméra. Pour retirer une pièce :
- Clic gauche sur la pièce → sélection + panneau de détail
- **Touche Suppr** ou **bouton [Retirer]** dans le panneau de détail → retourne au stock
### Collisions → retour au stock + pause auto
Quand une pièce est détruite par collision, elle retourne dans le stock du joueur (au lieu d'être perdue). Cela évite un soft-lock où le joueur n'a plus assez de pièces pour résoudre la mission.
La simulation se met en **pause automatique** sur collision :
- La caméra effectue un **pan + zoom** vers la zone de collision.
- Une **notification** apparaît dans un coin de l'écran pour expliciter ce qui s'est passé (ex: "Tour II détruite par Dame — retournée au stock").
- Le joueur peut reprendre la simulation (Espace) après avoir pris connaissance de la situation.
### Undo (Ctrl+Z)
Annule le dernier placement ou retrait de pièce. L'architecture event-sourcing de l'engine rend l'implémentation naturelle : on conserve un historique de commandes et on les rejoue sans la dernière. Essentiel pour l'itération rapide sur un réseau en temps réel.
### Productions — pas d'intervalle, production variable
Chaque bâtiment de production a un champ `amount` (1 à 4) : il produit ce nombre de cargaisons **à chaque tour**. Le buffer max = `amount`. La surproduction non récupérée est écrasée au tour suivant (perdue). Pas de champ `interval`.
### Niveaux de pièces (I, II, III)
Chaque type de pièce a un niveau qui affecte :
- **Portée** : Tour I = 1 case, Tour II = 2 cases, Tour III = 3 cases
- **Priorité de transfert** : à statut social égal, le niveau départage
- **Collisions** : à statut égal, le niveau supérieur survit
Les missions débloquent des niveaux supérieurs progressivement.
### Drag & drop de pièces
Le joueur peut glisser une pièce déjà placée pour déplacer son point de départ (les arrivées possibles se recalculent). Quality-of-life essentiel quand on itère sur un réseau en temps réel.
---
## Architecture (Black-Box Sim)
### Nouveaux modèles (engine)
```
CampaignDef # remplace la liste de LevelDef
├── name: string
├── initialWidth / initialHeight
├── missions: MissionDef[]
MissionDef
├── id: int
├── name: string
├── description: string
├── terrainPatch: TerrainPatch # extension du plateau
├── stock: PieceStock[] # stock additionnel offert
├── demands: DemandDef[] # nouveaux objectifs
├── unlockedPieces: PieceKind[] # types de pièces débloqués par cette mission
├── unlockedLevels: PieceUpgrade[] # niveaux de pièces débloqués
PieceUpgrade
├── kind: PieceKind
├── level: int # ex: Tour niveau 2
TerrainPatch
├── newWidth / newHeight # nouvelle taille du plateau (>= précédente)
├── cells: PatchCell[] # cases ajoutées/modifiées
│ ├── col, row
│ └── type: Empty | Wall | Production(def) | Demand(def)
```
### Nouveau state (engine)
```
CampaignState
├── campaignDef: CampaignDef
├── currentMissionIndex: int
├── completedMissions: int[]
├── boardState: BoardState # persistant entre missions
├── availablePieces: Set<(PieceKind, int level)> # pièces débloquées
```
`BoardState` évolue :
- Ajout d'un champ `missionIndex` sur chaque `DemandState` pour savoir à quelle mission appartient une demande.
- Les demandes complétées restent dans le state (marquées `Satisfied`), les nouvelles s'ajoutent.
- `ProductionDef` reçoit un champ `amount` (1-4) au lieu de `interval`.
### Modification de SimPhase
```
Running ←→ Paused
↓ ↓
MissionComplete (la sim continue mais un overlay félicite)
AdvanceMission → Running (terrain étendu, nouvelles demandes)
```
Phases supprimées : `Edit`, `Victory`, `Defeat`.
- Plus de `Edit` : le joueur place des pièces à tout moment.
- Plus de `Victory` séparé : `MissionComplete` pour chaque mission, la dernière affiche un écran de fin.
- Plus de `Defeat` : pas de deadline punitive (jeu de logistique, pas de puzzle).
> **Note** : la suppression des deadlines est une conséquence de la suppression du Stop. Sans pouvoir reset, un deadline qui expire sans recours serait frustrant. Les demandes ont un `amount` cible mais pas de date limite. Les métriques (tours pour compléter) servent de score optionnel.
### Nouvelles Commands
| Command | Effet |
|---------|-------|
| `LoadCampaignCommand` | Charge la campagne, initialise le plateau avec la mission 0, démarre Running |
| `AdvanceMissionCommand` | Applique le `TerrainPatch` de la mission suivante, ajoute stock/demandes/pièces |
| `MovePieceCommand` | Drag & drop : déplace une pièce existante (nouveau start + end) |
Commands modifiées :
| Command | Changement |
|---------|------------|
| `PlacePieceCommand` | Fonctionne en Running et Paused (plus de guard Edit) |
| `RemovePieceCommand` | Idem |
| `PauseSimulationCommand` | Termine le tour en cours avant de figer |
Commands supprimées :
| Command | Raison |
|---------|--------|
| `StopSimulationCommand` | Plus de Stop |
| `StartSimulationCommand` | La sim démarre automatiquement au load |
| `ResetLevelCommand` | Plus de reset complet |
### Nouveaux Events
| Event | Données |
|-------|---------|
| `MissionCompleteEvent` | missionIndex |
| `MissionStartedEvent` | missionIndex, terrainPatch appliqué |
| `TerrainExpandedEvent` | nouvelles cases, nouvelle taille |
| `PieceUnlockedEvent` | kind, level |
| `PieceMovedByPlayerEvent` | pieceId, oldStart, oldEnd, newStart, newEnd |
| `PieceReturnedToStockEvent` | pieceId, kind, reason (collision) |
### Modifications du flow de victoire
1. `VictoryChecker` → renommé `MissionChecker`.
2. Vérifie les demandes **de la mission courante** uniquement.
3. Si toutes satisfaites → `MissionCompleteEvent`.
4. La sim continue de tourner (les pièces continuent de produire/livrer).
5. Le joueur déclenche `AdvanceMissionCommand` quand il est prêt.
6. Dernière mission complétée → écran de fin de campagne.
### Collisions — retour au stock + pause auto + caméra
`CollisionResolver` modifié :
- La pièce détruite émet `PieceReturnedToStockEvent` au lieu de `PieceDestroyedEvent`.
- Le stock dans `BoardState` est incrémenté.
- La pièce est retirée du plateau mais le joueur peut la replacer.
Côté présentation (Godot) :
- La simulation se met en **pause automatique** à la détection d'une collision.
- La caméra effectue un **pan + zoom** vers la zone de collision.
- Une **notification** apparaît dans un coin de l'écran (ex: "Tour II détruite par Dame — retournée au stock").
- Le joueur reprend avec Espace après avoir pris connaissance.
---
## Format JSON de campagne
```json
{
"name": "La Quête du Roi",
"initialWidth": 4,
"initialHeight": 4,
"missions": [
{
"id": 1,
"name": "Premier Convoi",
"description": "Les pions découvrent une scierie. Il faut acheminer le bois.",
"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": "Les pions ont forgé des Tours. De nouveaux territoires s'ouvrent à l'est.",
"terrainPatch": {
"newWidth": 6,
"newHeight": 4,
"cells": [
{ "col": 4, "row": 0, "type": "empty" },
{ "col": 4, "row": 1, "type": "wall" },
{ "col": 4, "row": 2, "type": "empty" },
{ "col": 4, "row": 3, "type": "empty" },
{ "col": 5, "row": 0, "type": "empty" },
{ "col": 5, "row": 1, "type": "empty" },
{ "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 }
]
}
]
}
```
---
## Phases d'implémentation
### Phase 1 — Modèles engine (pas de Godot)
1. Créer `CampaignDef`, `MissionDef`, `TerrainPatch`, `PatchCell`, `PieceUpgrade` dans `chessistics-engine/Model/`.
2. Créer `CampaignState` dans `chessistics-engine/Model/`.
3. Modifier `SimPhase` : supprimer `Edit`, `Victory`, `Defeat`. Ajouter `MissionComplete`.
4. Ajouter `missionIndex` à `DemandState`.
5. Ajouter `amount` à `ProductionDef` (remplace le concept d'intervalle). Buffer max = amount.
6. Ajouter `level` à `PieceStock` et aux modèles de pièces pour supporter les niveaux I/II/III.
### Phase 2 — Refactor SimPhase et édition en temps réel
1. Supprimer les guards `phase == Edit` dans `PlacePieceCommand` et `RemovePieceCommand`.
2. Supprimer `StopSimulationCommand`, `StartSimulationCommand`, `ResetLevelCommand`.
3. Modifier `PauseSimulationCommand` pour terminer le tour en cours avant de figer.
4. La sim démarre automatiquement au chargement (état initial = `Running` en pause).
### Phase 3 — Collisions → retour au stock + pause auto
1. Modifier `CollisionResolver` : la pièce détruite retourne au stock.
2. Créer `PieceReturnedToStockEvent` (avec coordonnées de la collision pour le pan caméra).
3. Incrémenter le stock dans `BoardState` quand une pièce est détruite.
4. L'engine émet un event de pause automatique après une collision.
5. Tests : vérifier qu'après collision la pièce réapparaît dans le stock et la sim est en pause.
### Phase 4 — Commands & Events campagne
1. Créer `LoadCampaignCommand` : initialise `BoardState` depuis mission 0, applique `unlockedPieces`.
2. Créer `AdvanceMissionCommand` : applique `TerrainPatch`, ajoute stock/demandes/pièces débloquées.
3. Créer `MissionCompleteEvent`, `MissionStartedEvent`, `TerrainExpandedEvent`, `PieceUnlockedEvent`.
4. Renommer `VictoryChecker``MissionChecker` : ne vérifie que les demandes de la mission courante.
### Phase 5 — Drag & drop engine
1. Créer `MovePieceCommand` : valide le nouveau placement, met à jour start/end.
2. Créer `PieceMovedByPlayerEvent`.
3. Tests : déplacer une pièce en Running, vérifier qu'elle reprend sa trajectoire.
### Phase 6 — Production variable
1. Modifier la production pour utiliser `amount` (1-4) au lieu de toujours produire 1.
2. Le buffer max = `amount`. La surproduction écrase le buffer.
3. Tests : production amount=3 remplit 3 slots, non récupérés → écrasés au tour suivant.
### Phase 7 — CampaignLoader
1. Créer `CampaignLoader` dans `chessistics-engine/Loading/` : parse le JSON campagne.
2. Valider la cohérence : pas de régression de taille, `unlockedPieces` cohérent.
3. Les anciens JSON de niveaux restent compatibles (mode puzzle legacy optionnel).
### Phase 8 — Tests engine intégration
1. Test : charger une campagne, compléter mission 1, avancer, vérifier terrain étendu.
2. Test : les pièces de la mission 1 restent en place après `AdvanceMissionCommand`.
3. Test : `MissionCompleteEvent` émis au bon moment.
4. Test : placer une pièce pendant Running fonctionne.
5. Test : collision retourne la pièce au stock.
6. Test : pièces non débloquées ne sont pas plaçables.
7. Test : niveaux de pièces affectent portée et priorité.
### Phase 9 — GameSim facade
1. `GameSim` accepte un `CampaignDef` (mode principal) ou un `LevelDef` (legacy).
2. Exposer `CampaignSnapshot` avec `currentMissionIndex`, `completedMissions`, `availablePieces`.
3. Plus de `StopSimulationCommand` dans la facade.
### Phase 10 — Présentation Godot
1. `Main.cs` : charger une campagne, supprimer le flow Stop/Start.
2. Contrôles : Espace = pause/resume, plus de bouton Stop. Barre de contrôle : `[⏸/▶] [x1] [x2] [x4] Tour: N Mission: M/T`.
3. **Auto-pause placement** : sélectionner un type de pièce dans le stock met la sim en pause. La pause est levée quand le placement est confirmé ou annulé (Échap).
4. **Retrait de pièce** : supprimer le clic droit pour retirer. Clic gauche sélectionne → touche Suppr ou bouton [Retirer] dans le panneau de détail.
5. **Collision — pause + caméra + notification** :
- Sur `PieceReturnedToStockEvent` : pause auto de la simulation.
- Pan + zoom caméra vers la zone de collision (tween animé).
- Notification dans un coin de l'écran (ex: "Tour II détruite par Dame — retournée au stock").
- Le joueur reprend avec Espace.
6. **Transition de mission** (cinématique) :
- Titre "Nouvelle mission" en plein écran, fade-in.
- Lock du pan/zoom joueur — la caméra se déplace pour montrer la zone de la prochaine mission.
- Les nouvelles cases apparaissent sur le plateau avec animation d'expansion.
- Le titre de mission se déplace vers la zone d'objectif du panneau latéral avant de disparaître (guide l'œil).
- Unlock pan/zoom, la simulation reprend.
7. `ObjectivePanel` : objectifs de la mission courante + badge ✓ pour les missions complétées.
8. `PieceStockPanel` : stock cumulatif, n'affiche que les pièces débloquées.
9. Notification `PieceUnlockedEvent` : animation de déblocage d'une nouvelle pièce.
10. Drag & drop visuel : le joueur glisse une pièce, les destinations légales s'affichent.
11. **Undo (Ctrl+Z)** : annule le dernier placement ou retrait. Conserver un historique de commandes côté présentation, rejouer l'état sans la dernière.
### Phase 11 — Données de campagne
1. Créer `Data/campaigns/campaign_01.json` — "La Quête du Roi".
2. Concevoir 6-8 missions avec progression de pièces (Pion → Tour → Fou → Cavalier → Dame).
3. Chaque mission étend le terrain sans casser les solutions précédentes.
4. Équilibrer les stocks cumulatifs et les `amount` de production.
---
## Risques et mitigations
| Risque | Mitigation |
|--------|------------|
| Un patch de terrain casse une solution existante | Validation loader : les patches ne peuvent que **ajouter** des cases ou modifier des cases hors de la zone initiale |
| Le stock cumulé rend les missions triviales | Playtesting + stock additionnel calibré mission par mission |
| Placer des pièces en Running cause des bugs de timing | La commande s'applique **entre deux tours** (fin du tour courant → placement → tour suivant) |
| Collision = perte de pièce frustrante | Retour au stock — le joueur peut replacer immédiatement |
| Drag & drop complexe pendant la simulation | Le drag ne modifie la pièce qu'au **relâchement** (atomique), appliqué entre deux tours |
| Niveaux de pièces explosent la complexité | Introduction graduelle : mission 1-3 = niveau I seul, niveaux II/III arrivent plus tard |

41
PLAN_playtest.md Normal file
View file

@ -0,0 +1,41 @@
# Plan Playtest Fixes
## Problèmes identifiés
### P1 — Superposition production/demand (Bug moteur)
**Cause racine**: `ApplyTerrainPatch` ne nettoie pas les bâtiments existants avant d'ajouter un nouveau (Production, Demand, Transformer). Mission 3 place un demand sur (5,5) où Mission 2 avait une production → les deux coexistent dans les dictionnaires.
**Fix**: Appeler `ClearBuildingAt(coords)` pour TOUS les types de cellules dans `ApplyTerrainPatch`.
### P2 — Murs sur cases existantes / pièces traversent les murs
**Cause racine**: Mission 3 ajoute des murs sur des cases déjà jouables (2,2), (2,3), etc. Si des pièces y sont placées, elles restent et traversent le mur.
**Fix moteur**: Quand un mur apparaît via terrain patch, retirer les pièces dont StartCell ou EndCell est sur ce mur (retour au stock).
**Fix level design**: Redessiner mission 3 pour que les murs soient sur des cases nouvellement révélées (agrandir le plateau).
### P3 — Noms de bâtiments dupliqués
**Cause**: Deux "Dépôt Royal" (mission 1 et mission 3).
**Fix**: Renommer dans campaign_01.json. Mission 3 demand → "Avant-Poste du Col".
### P4 — Compteurs d'objectifs qui montent à l'infini
**Cause**: `ObjectivePanel.UpdateProgress` affiche le current réel même quand il dépasse le required.
**Fix**: Afficher `min(current, required)/required` et marquer visuellement les objectifs complétés. Les objectifs des missions précédentes complétées: afficher "✓" et ne plus mettre à jour.
### P5 — Espace sélectionne un pion au lieu de lancer la simulation
**Cause**: Les boutons Godot ont `FocusMode = All` par défaut et capturent la touche Espace.
**Fix**: Mettre `FocusMode = None` sur les boutons du PieceStockPanel. Ajouter un handler Espace dans Main pour toggle play/pause.
### P6 — Centrage du plateau
**Cause**: Le calcul de centrage ne prend pas en compte la barre de titre (~36px en haut).
**Fix**: Ajouter `TitleBarHeight` au calcul de l'offset caméra.
### P7 — Pas de narration/lore
**Fix**: Ajouter un champ `flavor` dans MissionDef + campaign JSON. Afficher un encart narratif au démarrage de chaque mission. Un personnage parle en une phrase, ton léger et enjoué.
## Ordre d'implémentation
1. P1 + P2: Fixes moteur (ApplyTerrainPatch) + tests
2. P3: Redesign campaign_01.json (missions 2-7, noms uniques, pas de superposition, murs sur nouvelles cases)
3. P4: Test automatisé de validation du campaign (pas de superposition, niveaux finissables)
4. P5: Spacebar play/pause
5. P6: Centrage caméra
6. P4: Objectifs capped
7. P7: Narration

View file

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

View file

@ -24,6 +24,7 @@ public partial class CellView : Node2D
private static readonly Color WallColor = new("#3A3A3A"); // charcoal
private static readonly Color ProductionColor = new("#4A6E3A"); // deep forest
private static readonly Color DemandColor = new("#B8942A"); // aged gold
private static readonly Color TransformerColor = new("#8B4513"); // copper brown
private static readonly Color HighlightColor = new("#44FF4444");
private static readonly Color HoverOutlineColor = new("#FFFFFF88");
@ -47,6 +48,7 @@ public partial class CellView : Node2D
CellType.Wall => WallColor,
CellType.Production => ProductionColor,
CellType.Demand => DemandColor,
CellType.Transformer => TransformerColor,
_ => baseColor
};
AddChild(_background);

View file

@ -74,12 +74,6 @@ public partial class InputMapper : Node
{
if (@event is InputEventMouseButton mouseEvent && mouseEvent.Pressed)
{
if (mouseEvent.ButtonIndex == MouseButton.Right)
{
Cancel();
return;
}
if (mouseEvent.ButtonIndex == MouseButton.Left)
{
var localPos = _boardView.GetLocalMousePosition();

View file

@ -18,8 +18,7 @@ namespace Chessistics.Scripts;
public partial class Main : Node2D
{
private GameSim? _sim;
private LevelDef? _currentLevel;
private int _currentLevelIndex;
private CampaignDef? _campaignDef;
// Views
private BoardView _boardView = null!;
@ -33,23 +32,26 @@ public partial class Main : Node2D
private DetailPanel _detailPanel = null!;
private ControlBar _controlBar = null!;
private MetricsOverlay _metricsOverlay = null!;
private LevelSelectScreen _levelSelectScreen = null!;
private LevelSelectScreen _titleScreen = null!;
private Label _levelTitle = null!;
private PanelContainer _sidePanel = null!;
private PanelContainer _controlBarWrapper = null!;
private Camera2D _camera = null!;
private ColorRect _fadeOverlay = null!;
private FlavorBanner _flavorBanner = null!;
// Simulation timer
private Godot.Timer _simTimer = null!;
private float _simInterval = 1.0f;
private bool _running;
private bool _panning;
private static readonly string[] LevelFiles = ["level_01.json", "level_02.json", "level_03.json", "level_04.json", "level_05.json", "level_06.json", "level_07.json", "level_08.json"];
private bool _rightDragged;
private bool _collisionPauseOccurred;
private const float CameraKeyboardSpeed = 400f;
private const float SidePanelWidth = 280f;
private const float ControlBarHeight = 48f;
private const float TitleBarHeight = 40f;
private static readonly Color BackgroundColor = new("#2D2D2D");
@ -59,9 +61,8 @@ public partial class Main : Node2D
BuildSceneTree();
ConnectSignals();
ShowLevelSelect();
ShowTitleScreen();
// Fade in from black on startup
FadeIn(0.5f);
}
@ -70,7 +71,26 @@ public partial class Main : Node2D
if (@event is InputEventMouseButton mb)
{
if (mb.ButtonIndex == MouseButton.Middle)
{
_panning = mb.Pressed;
}
else if (mb.ButtonIndex == MouseButton.Right)
{
if (mb.Pressed)
{
_panning = true;
_rightDragged = false;
}
else
{
_panning = false;
if (!_rightDragged)
{
_inputMapper.Cancel();
_pieceStockPanel.ClearSelection();
}
}
}
else if (mb.Pressed && mb.ButtonIndex == MouseButton.WheelUp)
ZoomCamera(1.1f);
else if (mb.Pressed && mb.ButtonIndex == MouseButton.WheelDown)
@ -78,8 +98,30 @@ public partial class Main : Node2D
}
else if (@event is InputEventMouseMotion motion && _panning)
{
_rightDragged = true;
_camera.Position -= motion.Relative / _camera.Zoom;
}
else if (@event is InputEventKey key && key.Pressed && !key.Echo && key.Keycode == Key.Space)
{
TogglePlayPause();
GetViewport().SetInputAsHandled();
}
}
public override void _Process(double delta)
{
var dir = Vector2.Zero;
if (Godot.Input.IsKeyPressed(Key.Z) || Godot.Input.IsKeyPressed(Key.W))
dir.Y -= 1;
if (Godot.Input.IsKeyPressed(Key.S))
dir.Y += 1;
if (Godot.Input.IsKeyPressed(Key.Q) || Godot.Input.IsKeyPressed(Key.A))
dir.X -= 1;
if (Godot.Input.IsKeyPressed(Key.D))
dir.X += 1;
if (dir != Vector2.Zero)
_camera.Position += dir.Normalized() * CameraKeyboardSpeed * (float)delta / _camera.Zoom.X;
}
private void ZoomCamera(float factor)
@ -108,28 +150,22 @@ public partial class Main : Node2D
private void BuildSceneTree()
{
// Camera
_camera = new Camera2D { Enabled = true };
AddChild(_camera);
// SFX
var sfx = new SfxManager();
AddChild(sfx);
// Board
_boardView = new BoardView();
AddChild(_boardView);
// Input
_inputMapper = new InputMapper();
_inputMapper.Initialize(_boardView);
AddChild(_inputMapper);
// Animator
_eventAnimator = new EventAnimator();
AddChild(_eventAnimator);
// Sim timer
_simTimer = new Godot.Timer { OneShot = false, WaitTime = _simInterval };
_simTimer.Timeout += OnSimTimerTick;
AddChild(_simTimer);
@ -138,13 +174,12 @@ public partial class Main : Node2D
_uiLayer = new CanvasLayer();
AddChild(_uiLayer);
// Root control anchored to viewport (required for child anchoring)
var uiRoot = new Control();
uiRoot.SetAnchorsPreset(Control.LayoutPreset.FullRect);
uiRoot.MouseFilter = Control.MouseFilterEnum.Ignore;
_uiLayer.AddChild(uiRoot);
// Level title bar (top-left)
// Title bar
var titleBar = new HBoxContainer();
titleBar.SetAnchorsPreset(Control.LayoutPreset.TopLeft);
titleBar.OffsetLeft = 12;
@ -174,7 +209,7 @@ public partial class Main : Node2D
uiRoot.AddChild(titleBar);
// --- Side Panel (anchored to right edge) ---
// --- Side Panel ---
_sidePanel = new PanelContainer();
_sidePanel.AnchorLeft = 1.0f;
_sidePanel.AnchorRight = 1.0f;
@ -190,10 +225,8 @@ public partial class Main : Node2D
BgColor = new Color(0.14f, 0.14f, 0.16f, 0.97f),
BorderColor = new Color(0.25f, 0.25f, 0.28f),
BorderWidthLeft = 1,
ContentMarginLeft = 16,
ContentMarginRight = 16,
ContentMarginTop = 16,
ContentMarginBottom = 16
ContentMarginLeft = 16, ContentMarginRight = 16,
ContentMarginTop = 16, ContentMarginBottom = 16
};
_sidePanel.AddThemeStyleboxOverride("panel", sidePanelStyle);
@ -220,7 +253,7 @@ public partial class Main : Node2D
_sidePanel.AddChild(sideScroll);
uiRoot.AddChild(_sidePanel);
// --- Control Bar (anchored to bottom, left of side panel) ---
// --- Control Bar ---
_controlBarWrapper = new PanelContainer();
_controlBarWrapper.AnchorLeft = 0.0f;
_controlBarWrapper.AnchorRight = 1.0f;
@ -234,10 +267,8 @@ public partial class Main : Node2D
BgColor = new Color(0.11f, 0.11f, 0.13f, 0.97f),
BorderColor = new Color(0.25f, 0.25f, 0.28f),
BorderWidthTop = 1,
ContentMarginLeft = 12,
ContentMarginRight = 12,
ContentMarginTop = 4,
ContentMarginBottom = 4
ContentMarginLeft = 12, ContentMarginRight = 12,
ContentMarginTop = 4, ContentMarginBottom = 4
};
_controlBarWrapper.AddThemeStyleboxOverride("panel", controlBarStyle);
@ -245,7 +276,7 @@ public partial class Main : Node2D
_controlBarWrapper.AddChild(_controlBar);
uiRoot.AddChild(_controlBarWrapper);
// --- Metrics Overlay (centered in board area) ---
// --- Metrics Overlay ---
var metricsCenter = new CenterContainer();
metricsCenter.SetAnchorsPreset(Control.LayoutPreset.FullRect);
metricsCenter.OffsetRight = -SidePanelWidth;
@ -257,12 +288,22 @@ public partial class Main : Node2D
metricsCenter.AddChild(_metricsOverlay);
uiRoot.AddChild(metricsCenter);
// --- Level Select Screen (full viewport) ---
_levelSelectScreen = new LevelSelectScreen();
_levelSelectScreen.SetAnchorsPreset(Control.LayoutPreset.FullRect);
uiRoot.AddChild(_levelSelectScreen);
// --- Title Screen ---
_titleScreen = new LevelSelectScreen();
_titleScreen.SetAnchorsPreset(Control.LayoutPreset.FullRect);
uiRoot.AddChild(_titleScreen);
// --- Fade overlay (on top of everything) ---
// --- Flavor Banner (narrative text) ---
_flavorBanner = new FlavorBanner();
_flavorBanner.AnchorLeft = 0.1f;
_flavorBanner.AnchorRight = 0.7f;
_flavorBanner.AnchorTop = 0.0f;
_flavorBanner.AnchorBottom = 0.0f;
_flavorBanner.OffsetTop = 44; // Below title bar
_flavorBanner.OffsetBottom = 100;
uiRoot.AddChild(_flavorBanner);
// --- Fade overlay ---
_fadeOverlay = new ColorRect
{
Color = new Color(0, 0, 0, 1),
@ -271,25 +312,23 @@ public partial class Main : Node2D
_fadeOverlay.SetAnchorsPreset(Control.LayoutPreset.FullRect);
uiRoot.AddChild(_fadeOverlay);
// Initialize animator
_eventAnimator.Initialize(_boardView, _objectivePanel, _controlBar, _metricsOverlay);
}
private void ConnectSignals()
{
_levelSelectScreen.LevelSelected += OnLevelSelected;
_titleScreen.StartCampaignPressed += OnStartCampaign;
_pieceStockPanel.PieceSelected += OnPieceKindSelected;
_inputMapper.PlacementRequested += OnPlacementRequested;
_inputMapper.Cancelled += OnPlacementCancelled;
_controlBar.PlayPressed += OnPlay;
_controlBar.PausePressed += OnPause;
_controlBar.StepPressed += OnStep;
_controlBar.StopPressed += OnStop;
_controlBar.SpeedChanged += OnSpeedChanged;
_eventAnimator.TurnAnimationCompleted += OnTurnAnimationCompleted;
_eventAnimator.VictoryReached += OnVictory;
_metricsOverlay.RetryPressed += OnRetry;
_metricsOverlay.NextLevelPressed += OnNextLevel;
_eventAnimator.VictoryReached += OnCampaignComplete;
_eventAnimator.MissionAdvanced += OnMissionAdvanced;
_metricsOverlay.NextLevelPressed += OnBackToMenu;
_detailPanel.RemoveRequested += OnRemoveRequested;
_inputMapper.CellClicked += OnCellClicked;
}
@ -298,7 +337,6 @@ public partial class Main : Node2D
{
if (_sim == null) return;
var snap = _sim.GetSnapshot();
if (snap.Phase != SimPhase.Edit) return;
_boardView.ClearHighlights();
@ -307,8 +345,6 @@ public partial class Main : Node2D
if (piece != null)
{
_detailPanel.ShowPiece(piece);
// Highlight start and end cells to show trajectory
var pieceColor = PieceView.GetPieceColor(piece.Kind);
var highlightColor = new Color(pieceColor, 0.3f);
_boardView.HighlightCells([piece.StartCell, piece.EndCell], highlightColor);
@ -319,79 +355,140 @@ public partial class Main : Node2D
}
}
// --- Level Management ---
// --- Campaign Management ---
private void ShowLevelSelect()
private void ShowTitleScreen()
{
_levelSelectScreen.Visible = true;
_titleScreen.Visible = true;
_boardView.Visible = false;
_sidePanel.Visible = false;
_controlBarWrapper.Visible = false;
_levelTitle.Visible = false;
}
private void OnLevelSelected(int levelIndex)
private void OnStartCampaign()
{
SfxManager.Instance?.PlayClick();
_currentLevelIndex = levelIndex;
// Fade out, load, fade in
FadeOut(0.25f, () =>
{
LoadLevel(levelIndex);
LoadCampaign();
FadeIn(0.3f);
});
}
private void LoadLevel(int index)
private void LoadCampaign()
{
if (index < 0 || index >= LevelFiles.Length) return;
var path = $"res://Data/levels/{LevelFiles[index]}";
var path = "res://Data/campaigns/campaign_01.json";
var file = Godot.FileAccess.Open(path, Godot.FileAccess.ModeFlags.Read);
if (file == null)
{
GD.PrintErr($"Cannot open level file: {path}");
GD.PrintErr($"Cannot open campaign file: {path}");
return;
}
var json = file.GetAsText();
file.Close();
_currentLevel = LevelLoader.Load(json);
_sim = new GameSim(_currentLevel);
_campaignDef = CampaignLoader.Load(json);
_sim = new GameSim(_campaignDef);
_levelSelectScreen.Visible = false;
// Load campaign: applies mission 0 terrain, stock, unlocked pieces
var loadEvents = _sim.ProcessCommand(new LoadCampaignCommand());
foreach (var evt in loadEvents)
{
if (evt is CommandRejectedEvent r)
{
GD.PrintErr($"Cannot load campaign: {r.Reason}");
return;
}
}
_titleScreen.Visible = false;
_boardView.Visible = true;
_sidePanel.Visible = true;
_controlBarWrapper.Visible = true;
_levelTitle.Visible = true;
_boardView.BuildBoard(_currentLevel);
_objectivePanel.Setup(_currentLevel.Demands);
_pieceStockPanel.Setup(_currentLevel.Stock);
_controlBar.UpdateForPhase(SimPhase.Edit);
var snap = _sim.GetSnapshot();
var mission = _campaignDef.Missions[snap.Campaign!.CurrentMissionIndex];
BuildBoardFromSnapshot(snap);
SetupUIForMission(snap, mission);
CenterCameraOnBoard(snap.Width, snap.Height);
_inputMapper.SetSnapshot(snap);
}
private void BuildBoardFromSnapshot(BoardSnapshot snap)
{
_eventAnimator.ClearAll();
_boardView.BuildBoardFromSnapshot(snap);
// Recreate piece visuals if any exist
foreach (var ps in snap.Pieces)
{
var pieceView = new PieceView();
pieceView.Setup(ps.Id, ps.Kind, ps.StartCell, ps.EndCell, _boardView);
_boardView.AddChild(pieceView);
var color = PieceView.GetPieceColor(ps.Kind);
var trajectView = new TrajectView();
trajectView.Setup(ps.Id,
_boardView.CoordsToPixel(ps.StartCell),
_boardView.CoordsToPixel(ps.EndCell),
color);
_boardView.AddChild(trajectView);
_eventAnimator.RegisterPiece(ps.Id, pieceView, trajectView);
}
}
private void SetupUIForMission(BoardSnapshot snap, MissionDef mission)
{
// Show all demands (current + previous missions)
var allDemands = snap.Demands
.Select(d => new DemandDef(d.Position, d.Name, d.Cargo, d.Required))
.ToList();
_objectivePanel.Setup(allDemands);
// Setup stock panel with only unlocked piece kinds
var availableStock = new List<PieceStock>();
foreach (var (kind, remaining) in snap.RemainingStock)
{
if (snap.Campaign != null && !snap.Campaign.AvailablePieceKinds.Contains(kind))
continue;
availableStock.Add(new PieceStock(kind, remaining));
}
_pieceStockPanel.Setup(availableStock);
_controlBar.UpdateForPhase(snap.Phase);
_controlBar.ResetTurn();
_metricsOverlay.Hide();
_detailPanel.Hide();
_eventAnimator.ClearAll();
_levelTitle.Text = $"CHESSISTICS — {_currentLevel.Name}";
var missionNum = snap.Campaign!.CurrentMissionIndex + 1;
var totalMissions = _campaignDef!.Missions.Count;
_levelTitle.Text = $"CHESSISTICS — Mission {missionNum}/{totalMissions}: {mission.Name}";
// Center camera on board
// Board X spans 0..W*Cell, Y spans -(H-1)*Cell .. Cell
_camera.Position = new Vector2(
_currentLevel.Width * BoardView.CellSize / 2f,
-_currentLevel.Height * BoardView.CellSize / 2f + BoardView.CellSize
);
_camera.Offset = new Vector2(-SidePanelWidth / 2f, -ControlBarHeight / 2f);
var snapshot = _sim.GetSnapshot();
GD.Print($"[Main] LoadLevel — snapshot null? {snapshot == null}, width={snapshot?.Width}, height={snapshot?.Height}");
_inputMapper.SetSnapshot(snapshot);
// Show narrative flavor text
_flavorBanner.ShowFlavor(mission.Flavor);
}
// --- Edit Phase ---
private void CenterCameraOnBoard(int width, int height)
{
_camera.Position = new Vector2(
width * BoardView.CellSize / 2f,
-height * BoardView.CellSize / 2f + BoardView.CellSize
);
// Offset: shift view to account for side panel (right) and title/control bars
// Positive X → camera looks right → board appears left (compensates right panel)
// Positive Y → camera looks down → board appears up (compensates bottom bar)
_camera.Offset = new Vector2(SidePanelWidth / 2f, (ControlBarHeight - TitleBarHeight) / 2f);
}
// --- Placement ---
private void OnPieceKindSelected(int kindIndex)
{
@ -409,6 +506,7 @@ public partial class Main : Node2D
var events = _sim.ProcessCommand(new PlacePieceCommand(kind, start, end));
HandleEditEvents(events);
_inputMapper.SetSnapshot(_sim.GetSnapshot());
_pieceStockPanel.ClearSelection();
}
private void OnPlacementCancelled()
@ -462,7 +560,6 @@ public partial class Main : Node2D
_boardView.AddChild(pieceView);
var color = PieceView.GetPieceColor(placed.Kind);
var trajectView = new TrajectView();
trajectView.Setup(placed.PieceId,
_boardView.CoordsToPixel(placed.Start),
@ -481,26 +578,24 @@ public partial class Main : Node2D
_pieceStockPanel.UpdateCount(kind, remaining);
}
// --- Exec Phase ---
// --- Simulation Control ---
private void TogglePlayPause()
{
if (_sim == null) return;
var phase = _sim.GetSnapshot().Phase;
if (phase == SimPhase.Running)
OnPause();
else if (phase == SimPhase.Paused)
OnPlay();
}
private void OnPlay()
{
if (_sim == null) return;
var snap = _sim.GetSnapshot();
if (snap.Phase == SimPhase.Edit)
{
var events = _sim.ProcessCommand(new StartSimulationCommand());
foreach (var evt in events)
{
if (evt is CommandRejectedEvent r)
{
GD.Print($"Cannot start: {r.Reason}");
return;
}
}
}
else if (snap.Phase == SimPhase.Paused)
if (snap.Phase == SimPhase.Paused || snap.Phase == SimPhase.MissionComplete)
{
_sim.ProcessCommand(new ResumeSimulationCommand());
}
@ -523,47 +618,17 @@ public partial class Main : Node2D
private void OnStep()
{
if (_sim == null || _eventAnimator.IsAnimating) return;
_collisionPauseOccurred = false;
var events = _sim.ProcessCommand(new StepSimulationCommand());
// Detect if a collision auto-pause happened this step
_collisionPauseOccurred = events.Any(e => e is PieceReturnedToStockEvent);
_eventAnimator.ProcessEvents(events);
_controlBar.UpdateForPhase(_sim.GetSnapshot().Phase);
}
private void OnStop()
{
if (_sim == null) return;
_running = false;
_simTimer.Stop();
_sim.ProcessCommand(new StopSimulationCommand());
// Full visual rebuild: clear everything and recreate from snapshot
_eventAnimator.ClearAll();
var snap = _sim.GetSnapshot();
foreach (var ps in snap.Pieces)
{
var pieceView = new PieceView();
pieceView.Setup(ps.Id, ps.Kind, ps.StartCell, ps.EndCell, _boardView);
_boardView.AddChild(pieceView);
var color = PieceView.GetPieceColor(ps.Kind);
var trajectView = new TrajectView();
trajectView.Setup(ps.Id,
_boardView.CoordsToPixel(ps.StartCell),
_boardView.CoordsToPixel(ps.EndCell),
color);
_boardView.AddChild(trajectView);
_eventAnimator.RegisterPiece(ps.Id, pieceView, trajectView);
}
_controlBar.UpdateForPhase(SimPhase.Edit);
_controlBar.ResetTurn();
_metricsOverlay.Hide();
_inputMapper.SetSnapshot(snap);
if (_currentLevel != null)
_objectivePanel.Setup(_currentLevel.Demands);
}
private void OnSpeedChanged(float interval)
{
_simInterval = interval;
@ -575,7 +640,11 @@ public partial class Main : Node2D
{
if (_sim == null || _eventAnimator.IsAnimating) return;
_collisionPauseOccurred = false;
var events = _sim.ProcessCommand(new StepSimulationCommand());
_collisionPauseOccurred = events.Any(e => e is PieceReturnedToStockEvent);
_eventAnimator.ProcessEvents(events);
}
@ -585,34 +654,55 @@ public partial class Main : Node2D
var phase = _sim.GetSnapshot().Phase;
_controlBar.UpdateForPhase(phase);
if (phase == SimPhase.Victory || phase == SimPhase.Defeat)
if (phase == SimPhase.MissionComplete)
{
// Stop auto-running, show mission complete overlay
_running = false;
_simTimer.Stop();
}
else if (_collisionPauseOccurred && _running)
{
// Collision caused auto-pause — stop the timer
_running = false;
_simTimer.Stop();
_collisionPauseOccurred = false;
}
// Otherwise: if _running, the timer will fire next step automatically
}
private void OnVictory()
private void OnCampaignComplete()
{
// Last mission complete — show victory overlay
_running = false;
_simTimer.Stop();
if (_sim == null || _campaignDef == null) return;
var snap = _sim.GetSnapshot();
_metricsOverlay.ShowMissionComplete(
snap.Campaign!.CurrentMissionIndex + 1,
snap.TurnNumber,
true
);
}
private void OnMissionAdvanced()
{
// Auto-advance happened during simulation — rebuild board seamlessly
if (_sim == null || _campaignDef == null) return;
var snap = _sim.GetSnapshot();
var mission = _campaignDef.Missions[snap.Campaign!.CurrentMissionIndex];
BuildBoardFromSnapshot(snap);
SetupUIForMission(snap, mission);
CenterCameraOnBoard(snap.Width, snap.Height);
_inputMapper.SetSnapshot(snap);
}
// --- Navigation ---
private void OnRetry()
{
LoadLevel(_currentLevelIndex);
}
private void OnNextLevel()
{
if (_currentLevelIndex + 1 < LevelFiles.Length)
LoadLevel(_currentLevelIndex + 1);
else
ShowLevelSelect();
}
private void OnBackToMenu()
{
SfxManager.Instance?.PlayClick();
@ -622,7 +712,7 @@ public partial class Main : Node2D
FadeOut(0.2f, () =>
{
ShowLevelSelect();
ShowTitleScreen();
FadeIn(0.3f);
});
}

View file

@ -25,6 +25,9 @@ public partial class EventAnimator : Node
private static readonly Color WoodCargoColor = new("#A67C32");
private static readonly Color StoneCargoColor = new("#7A7A7A");
private static readonly Color ToolsCargoColor = new("#C87533");
private static readonly Color ArmsCargoColor = new("#8B0000");
private static readonly Color GoldCargoColor = new("#FFD700");
private const float ProduceDuration = 0.35f;
private const float TransferDuration = 0.28f;
@ -36,6 +39,8 @@ public partial class EventAnimator : Node
public delegate void TurnAnimationCompletedEventHandler();
[Signal]
public delegate void VictoryReachedEventHandler();
[Signal]
public delegate void MissionAdvancedEventHandler();
public void Initialize(BoardView boardView, ObjectivePanel objectivePanel,
ControlBar controlBar, MetricsOverlay metricsOverlay)
@ -75,7 +80,10 @@ public partial class EventAnimator : Node
var produceEvents = new List<CargoProducedEvent>();
var transferEvents = new List<IWorldEvent>();
var moveEvents = new List<PieceMovedEvent>();
var collisionEvents = new List<PieceDestroyedEvent>();
var collisionEvents = new List<PieceReturnedToStockEvent>();
// Pre-scan: if MissionStartedEvent follows MissionCompleteEvent, it's an auto-advance (not last mission)
bool hasAutoAdvance = events.Any(e => e is MissionStartedEvent);
foreach (var evt in events)
{
@ -90,6 +98,11 @@ public partial class EventAnimator : Node
produceEvents.Add(produced);
break;
case CargoConvertedEvent converted:
// Visual flash on transformer cell (treat like a produce event for animation)
produceEvents.Add(new CargoProducedEvent(converted.TurnNumber, converted.TransformerCell, converted.OutputCargo));
break;
case CargoTransferredEvent:
case DemandProgressEvent:
transferEvents.Add(evt);
@ -99,21 +112,33 @@ public partial class EventAnimator : Node
moveEvents.Add(moved);
break;
case PieceDestroyedEvent destroyed:
collisionEvents.Add(destroyed);
case PieceReturnedToStockEvent returned:
collisionEvents.Add(returned);
break;
case VictoryEvent victory:
case MissionCompleteEvent:
FlushPhases(tween, produceEvents, transferEvents, moveEvents, collisionEvents);
tween.TweenCallback(Callable.From(() =>
{
SfxManager.Instance?.PlayVictory();
SpawnConfetti();
_metricsOverlay.ShowMetrics(victory.Metrics);
EmitSignal(SignalName.VictoryReached);
if (!hasAutoAdvance)
EmitSignal(SignalName.VictoryReached);
}));
break;
case MissionStartedEvent:
FlushPhases(tween, produceEvents, transferEvents, moveEvents, collisionEvents);
tween.TweenCallback(Callable.From(() =>
{
EmitSignal(SignalName.MissionAdvanced);
}));
break;
case SimulationPausedEvent:
// Auto-pause from collision — handled by FlushPhases
break;
case TurnEndedEvent:
FlushPhases(tween, produceEvents, transferEvents, moveEvents, collisionEvents);
break;
@ -137,7 +162,7 @@ public partial class EventAnimator : Node
List<CargoProducedEvent> produceEvents,
List<IWorldEvent> transferEvents,
List<PieceMovedEvent> moveEvents,
List<PieceDestroyedEvent> collisionEvents)
List<PieceReturnedToStockEvent> collisionEvents)
{
// Phase 1: Produce — warm golden flash + particle burst
if (produceEvents.Count > 0)
@ -222,16 +247,16 @@ public partial class EventAnimator : Node
moveEvents.Clear();
}
// Phase 4: Collision/Destruction — shrink + spin + particles
// Phase 4: Collision — piece returned to stock (shrink + spin + particles)
if (collisionEvents.Count > 0)
{
var captured = collisionEvents.ToList();
tween.TweenCallback(Callable.From(() =>
{
SfxManager.Instance?.PlayDestroy();
foreach (var destroyed in captured)
foreach (var returned in captured)
{
if (_pieceViews.TryGetValue(destroyed.PieceId, out var pv))
if (_pieceViews.TryGetValue(returned.PieceId, out var pv))
{
SpawnDestroyParticles(pv.Position);
@ -247,8 +272,8 @@ public partial class EventAnimator : Node
tween.TweenInterval(DestroyDuration);
tween.TweenCallback(Callable.From(() =>
{
foreach (var destroyed in captured)
UnregisterPiece(destroyed.PieceId);
foreach (var returned in captured)
UnregisterPiece(returned.PieceId);
}));
collisionEvents.Clear();
}
@ -423,6 +448,9 @@ public partial class EventAnimator : Node
{
CargoType.Wood => WoodCargoColor,
CargoType.Stone => StoneCargoColor,
CargoType.Tools => ToolsCargoColor,
CargoType.Arms => ArmsCargoColor,
CargoType.Gold => GoldCargoColor,
_ => Colors.White
};

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -58,7 +58,8 @@ public partial class PieceStockPanel : VBoxContainer
{
Text = GetPieceName(entry.Kind),
CustomMinimumSize = new Vector2(120, 32),
ToggleMode = false // We manage selection state ourselves
ToggleMode = false, // We manage selection state ourselves
FocusMode = FocusModeEnum.None // Prevent spacebar from activating buttons
};
ApplyButtonStyle(button, false);

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,216 @@
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);
}
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; }
}
private class TransformerDto
{
public string Name { get; set; } = "";
public string InputCargo { get; set; } = "";
public int InputRequired { get; set; } = 1;
public string OutputCargo { get; set; } = "";
public int OutputAmount { get; set; } = 1;
}
private class StockDto
{
public string Kind { get; set; } = "";
public int Count { get; set; }
public int Level { get; set; } = 1;
}
private class UpgradeDto
{
public string Kind { get; set; } = "";
public int Level { get; set; }
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,3 +1,3 @@
namespace Chessistics.Engine.Model;
public record DemandDef(Coords Position, string Name, CargoType Cargo, int Amount, int Deadline);
public record DemandDef(Coords Position, string Name, CargoType Cargo, int Amount, int Deadline = 0);

View file

@ -4,10 +4,12 @@ public class DemandState
{
public DemandDef Definition { get; }
public int ReceivedCount { get; set; }
public int MissionIndex { get; }
public DemandState(DemandDef definition)
public DemandState(DemandDef definition, int missionIndex = 0)
{
Definition = definition;
MissionIndex = missionIndex;
ReceivedCount = 0;
}

View file

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

View file

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

View file

@ -5,8 +5,8 @@ public class PieceState
public int Id { get; }
public PieceKind Kind { get; }
public int Level { get; }
public Coords StartCell { get; }
public Coords EndCell { get; }
public Coords StartCell { get; private set; }
public Coords EndCell { get; private set; }
public Coords CurrentCell { get; set; }
public CargoType? Cargo { get; set; }
public CargoType? CargoFilter { get; set; }
@ -30,4 +30,14 @@ 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;
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -13,6 +13,11 @@ public class GameSim
_state = BoardState.FromLevel(level);
}
public GameSim(CampaignDef campaign)
{
_state = BoardState.FromCampaign(campaign);
}
public IReadOnlyList<IWorldEvent> ProcessCommand(IWorldCommand command)
{
var changeList = new List<IWorldEvent>();

View file

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

View file

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

View file

@ -0,0 +1,63 @@
using Chessistics.Engine.Loading;
using Chessistics.Engine.Model;
using Xunit;
namespace Chessistics.Tests.Loading;
public class CampaignFileTests
{
[Fact]
public void Campaign01_LoadsSuccessfully()
{
var campaign = CampaignLoader.LoadFromFile("../../../../Data/campaigns/campaign_01.json");
Assert.Equal("La Quête du Roi", campaign.Name);
Assert.Equal(7, 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 Couronnement", m7.Name);
var transformerCell = m7.TerrainPatch.Cells
.FirstOrDefault(c => c.Type == CellType.Transformer);
Assert.NotNull(transformerCell);
Assert.Equal(CargoType.Tools, transformerCell.Transformer!.InputCargo);
Assert.Equal(CargoType.Gold, transformerCell.Transformer.OutputCargo);
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

6
global.json Normal file
View file

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