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:
parent
358ab48d59
commit
2d1aea0a7a
71 changed files with 3749 additions and 924 deletions
227
Data/campaigns/campaign_01.json
Normal file
227
Data/campaigns/campaign_01.json
Normal 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
55
PLAN.md
|
|
@ -1,55 +0,0 @@
|
||||||
# Chessistics — Prototype Roadmap
|
|
||||||
|
|
||||||
## Phase 1: Core solvability (DONE)
|
|
||||||
|
|
||||||
- Black Box Sim pattern: commands self-apply via `DoApply()`/`AssertApplicationConditions()`
|
|
||||||
- 3 piece types: Rook (orthogonal range 2), Bishop (diagonal range 2), Knight (L-jump)
|
|
||||||
- Relay chain mechanic with shared relay points (collision-free alternating)
|
|
||||||
- Transfer system: production → pieces → demands, 4-adjacency, participation tracking
|
|
||||||
- Victory/defeat: all demands met vs deadline expired
|
|
||||||
|
|
||||||
## Phase 2: Cargo-type aware transfers (DONE)
|
|
||||||
|
|
||||||
- `CargoFilter` property on `PieceState`: optional `CargoType?` restricting accepted cargo
|
|
||||||
- Auto-assigned at placement via relay chain tracing (adjacency to production, then shared
|
|
||||||
relay points with filtered pieces)
|
|
||||||
- `TransferResolver` enforces filter: receivers with mismatched `CargoFilter` are skipped
|
|
||||||
- Forward-direction sorting uses cargo-type-aware `MinDistanceToProduction` to avoid
|
|
||||||
wrong sorting when multiple productions exist
|
|
||||||
- Level 3 restored to dual-cargo (Wood+Stone) with 10R+2K stock
|
|
||||||
- GDD stock corrections: Level 2 = 6R+1B, Level 3 = 10R+2K
|
|
||||||
- 60 tests passing including 2 new CargoFilter tests
|
|
||||||
|
|
||||||
## Phase 3: Pion, surplus stock, levels 4-6 (DONE)
|
|
||||||
|
|
||||||
- Pion: orthogonal range 1, status 1 (lowest), cheap relay maillon
|
|
||||||
- Surplus stock on all levels (more pieces than minimum solution)
|
|
||||||
- Levels 4-6: Le Carrefour (8x8), Le Labyrinthe (8x6), Trois Royaumes (10x8)
|
|
||||||
- Production interval removed: all productions fire every turn
|
|
||||||
- GDD updated with Pion, 6 levels
|
|
||||||
|
|
||||||
## Phase 5: Dame, network levels, juice pass (DONE)
|
|
||||||
|
|
||||||
- Dame (Queen): 8 directions, range 2, status 7 (highest)
|
|
||||||
- Levels 7-8: La Dame Blanche (10x10), Le Grand Reseau (12x10)
|
|
||||||
- Procedural SFX, particles, polished animations, fade transitions
|
|
||||||
- UI bugfixes: stop reset, piece selection visuals, back-to-menu button
|
|
||||||
- Trajectory preview on piece click
|
|
||||||
|
|
||||||
## Phase 6: Godot integration (DONE)
|
|
||||||
|
|
||||||
- Board renderer, piece placement, step/play/pause controls
|
|
||||||
- Event visualization with simultaneous animations per phase
|
|
||||||
- Victory/defeat screens with animated metrics
|
|
||||||
- Production flash, cargo slide trails, destruction particles, confetti
|
|
||||||
|
|
||||||
## Phase 7: Zoom, scroll wheel, and camera polish
|
|
||||||
|
|
||||||
- Mouse scroll wheel to zoom in/out on board
|
|
||||||
- Zoom limits (min/max) to prevent getting lost
|
|
||||||
- Double-click to center on a piece
|
|
||||||
|
|
||||||
## Phase 8: Level editor (future)
|
|
||||||
|
|
||||||
- Player-designed levels via JSON export
|
|
||||||
- In-game editing of board size, walls, productions, demands, stock
|
|
||||||
157
PLAN_leveldesign.md
Normal file
157
PLAN_leveldesign.md
Normal 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
371
PLAN_missions.md
Normal 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
41
PLAN_playtest.md
Normal 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
|
||||||
|
|
@ -50,6 +50,48 @@ public partial class BoardView : Node2D
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void BuildBoardFromSnapshot(BoardSnapshot snap)
|
||||||
|
{
|
||||||
|
// Clear existing children
|
||||||
|
foreach (var child in GetChildren())
|
||||||
|
child.QueueFree();
|
||||||
|
_cells.Clear();
|
||||||
|
|
||||||
|
_width = snap.Width;
|
||||||
|
_height = snap.Height;
|
||||||
|
|
||||||
|
for (int col = 0; col < snap.Width; col++)
|
||||||
|
{
|
||||||
|
for (int row = 0; row < snap.Height; row++)
|
||||||
|
{
|
||||||
|
var coords = new Coords(col, row);
|
||||||
|
var cellView = new CellView();
|
||||||
|
cellView.Setup(coords, snap.Grid[col, row], CellSize);
|
||||||
|
AddChild(cellView);
|
||||||
|
_cells[coords] = cellView;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Label productions and demands
|
||||||
|
foreach (var prod in snap.Productions)
|
||||||
|
{
|
||||||
|
if (_cells.TryGetValue(prod.Position, out var cell))
|
||||||
|
cell.SetLabel(prod.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var demand in snap.Demands)
|
||||||
|
{
|
||||||
|
if (_cells.TryGetValue(demand.Position, out var cell))
|
||||||
|
cell.SetLabel(demand.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var transformer in snap.Transformers)
|
||||||
|
{
|
||||||
|
if (_cells.TryGetValue(transformer.Position, out var cell))
|
||||||
|
cell.SetLabel(transformer.Name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public Coords? PixelToCoords(Vector2 localPos)
|
public Coords? PixelToCoords(Vector2 localPos)
|
||||||
{
|
{
|
||||||
int col = Mathf.FloorToInt(localPos.X / CellSize);
|
int col = Mathf.FloorToInt(localPos.X / CellSize);
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ public partial class CellView : Node2D
|
||||||
private static readonly Color WallColor = new("#3A3A3A"); // charcoal
|
private static readonly Color WallColor = new("#3A3A3A"); // charcoal
|
||||||
private static readonly Color ProductionColor = new("#4A6E3A"); // deep forest
|
private static readonly Color ProductionColor = new("#4A6E3A"); // deep forest
|
||||||
private static readonly Color DemandColor = new("#B8942A"); // aged gold
|
private static readonly Color DemandColor = new("#B8942A"); // aged gold
|
||||||
|
private static readonly Color TransformerColor = new("#8B4513"); // copper brown
|
||||||
private static readonly Color HighlightColor = new("#44FF4444");
|
private static readonly Color HighlightColor = new("#44FF4444");
|
||||||
private static readonly Color HoverOutlineColor = new("#FFFFFF88");
|
private static readonly Color HoverOutlineColor = new("#FFFFFF88");
|
||||||
|
|
||||||
|
|
@ -47,6 +48,7 @@ public partial class CellView : Node2D
|
||||||
CellType.Wall => WallColor,
|
CellType.Wall => WallColor,
|
||||||
CellType.Production => ProductionColor,
|
CellType.Production => ProductionColor,
|
||||||
CellType.Demand => DemandColor,
|
CellType.Demand => DemandColor,
|
||||||
|
CellType.Transformer => TransformerColor,
|
||||||
_ => baseColor
|
_ => baseColor
|
||||||
};
|
};
|
||||||
AddChild(_background);
|
AddChild(_background);
|
||||||
|
|
|
||||||
|
|
@ -74,12 +74,6 @@ public partial class InputMapper : Node
|
||||||
{
|
{
|
||||||
if (@event is InputEventMouseButton mouseEvent && mouseEvent.Pressed)
|
if (@event is InputEventMouseButton mouseEvent && mouseEvent.Pressed)
|
||||||
{
|
{
|
||||||
if (mouseEvent.ButtonIndex == MouseButton.Right)
|
|
||||||
{
|
|
||||||
Cancel();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mouseEvent.ButtonIndex == MouseButton.Left)
|
if (mouseEvent.ButtonIndex == MouseButton.Left)
|
||||||
{
|
{
|
||||||
var localPos = _boardView.GetLocalMousePosition();
|
var localPos = _boardView.GetLocalMousePosition();
|
||||||
|
|
|
||||||
370
Scripts/Main.cs
370
Scripts/Main.cs
|
|
@ -18,8 +18,7 @@ namespace Chessistics.Scripts;
|
||||||
public partial class Main : Node2D
|
public partial class Main : Node2D
|
||||||
{
|
{
|
||||||
private GameSim? _sim;
|
private GameSim? _sim;
|
||||||
private LevelDef? _currentLevel;
|
private CampaignDef? _campaignDef;
|
||||||
private int _currentLevelIndex;
|
|
||||||
|
|
||||||
// Views
|
// Views
|
||||||
private BoardView _boardView = null!;
|
private BoardView _boardView = null!;
|
||||||
|
|
@ -33,23 +32,26 @@ public partial class Main : Node2D
|
||||||
private DetailPanel _detailPanel = null!;
|
private DetailPanel _detailPanel = null!;
|
||||||
private ControlBar _controlBar = null!;
|
private ControlBar _controlBar = null!;
|
||||||
private MetricsOverlay _metricsOverlay = null!;
|
private MetricsOverlay _metricsOverlay = null!;
|
||||||
private LevelSelectScreen _levelSelectScreen = null!;
|
private LevelSelectScreen _titleScreen = null!;
|
||||||
private Label _levelTitle = null!;
|
private Label _levelTitle = null!;
|
||||||
private PanelContainer _sidePanel = null!;
|
private PanelContainer _sidePanel = null!;
|
||||||
private PanelContainer _controlBarWrapper = null!;
|
private PanelContainer _controlBarWrapper = null!;
|
||||||
private Camera2D _camera = null!;
|
private Camera2D _camera = null!;
|
||||||
private ColorRect _fadeOverlay = null!;
|
private ColorRect _fadeOverlay = null!;
|
||||||
|
private FlavorBanner _flavorBanner = null!;
|
||||||
|
|
||||||
// Simulation timer
|
// Simulation timer
|
||||||
private Godot.Timer _simTimer = null!;
|
private Godot.Timer _simTimer = null!;
|
||||||
private float _simInterval = 1.0f;
|
private float _simInterval = 1.0f;
|
||||||
private bool _running;
|
private bool _running;
|
||||||
private bool _panning;
|
private bool _panning;
|
||||||
|
private bool _rightDragged;
|
||||||
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 _collisionPauseOccurred;
|
||||||
|
private const float CameraKeyboardSpeed = 400f;
|
||||||
|
|
||||||
private const float SidePanelWidth = 280f;
|
private const float SidePanelWidth = 280f;
|
||||||
private const float ControlBarHeight = 48f;
|
private const float ControlBarHeight = 48f;
|
||||||
|
private const float TitleBarHeight = 40f;
|
||||||
|
|
||||||
private static readonly Color BackgroundColor = new("#2D2D2D");
|
private static readonly Color BackgroundColor = new("#2D2D2D");
|
||||||
|
|
||||||
|
|
@ -59,9 +61,8 @@ public partial class Main : Node2D
|
||||||
|
|
||||||
BuildSceneTree();
|
BuildSceneTree();
|
||||||
ConnectSignals();
|
ConnectSignals();
|
||||||
ShowLevelSelect();
|
ShowTitleScreen();
|
||||||
|
|
||||||
// Fade in from black on startup
|
|
||||||
FadeIn(0.5f);
|
FadeIn(0.5f);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -70,7 +71,26 @@ public partial class Main : Node2D
|
||||||
if (@event is InputEventMouseButton mb)
|
if (@event is InputEventMouseButton mb)
|
||||||
{
|
{
|
||||||
if (mb.ButtonIndex == MouseButton.Middle)
|
if (mb.ButtonIndex == MouseButton.Middle)
|
||||||
|
{
|
||||||
_panning = mb.Pressed;
|
_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)
|
else if (mb.Pressed && mb.ButtonIndex == MouseButton.WheelUp)
|
||||||
ZoomCamera(1.1f);
|
ZoomCamera(1.1f);
|
||||||
else if (mb.Pressed && mb.ButtonIndex == MouseButton.WheelDown)
|
else if (mb.Pressed && mb.ButtonIndex == MouseButton.WheelDown)
|
||||||
|
|
@ -78,8 +98,30 @@ public partial class Main : Node2D
|
||||||
}
|
}
|
||||||
else if (@event is InputEventMouseMotion motion && _panning)
|
else if (@event is InputEventMouseMotion motion && _panning)
|
||||||
{
|
{
|
||||||
|
_rightDragged = true;
|
||||||
_camera.Position -= motion.Relative / _camera.Zoom;
|
_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)
|
private void ZoomCamera(float factor)
|
||||||
|
|
@ -108,28 +150,22 @@ public partial class Main : Node2D
|
||||||
|
|
||||||
private void BuildSceneTree()
|
private void BuildSceneTree()
|
||||||
{
|
{
|
||||||
// Camera
|
|
||||||
_camera = new Camera2D { Enabled = true };
|
_camera = new Camera2D { Enabled = true };
|
||||||
AddChild(_camera);
|
AddChild(_camera);
|
||||||
|
|
||||||
// SFX
|
|
||||||
var sfx = new SfxManager();
|
var sfx = new SfxManager();
|
||||||
AddChild(sfx);
|
AddChild(sfx);
|
||||||
|
|
||||||
// Board
|
|
||||||
_boardView = new BoardView();
|
_boardView = new BoardView();
|
||||||
AddChild(_boardView);
|
AddChild(_boardView);
|
||||||
|
|
||||||
// Input
|
|
||||||
_inputMapper = new InputMapper();
|
_inputMapper = new InputMapper();
|
||||||
_inputMapper.Initialize(_boardView);
|
_inputMapper.Initialize(_boardView);
|
||||||
AddChild(_inputMapper);
|
AddChild(_inputMapper);
|
||||||
|
|
||||||
// Animator
|
|
||||||
_eventAnimator = new EventAnimator();
|
_eventAnimator = new EventAnimator();
|
||||||
AddChild(_eventAnimator);
|
AddChild(_eventAnimator);
|
||||||
|
|
||||||
// Sim timer
|
|
||||||
_simTimer = new Godot.Timer { OneShot = false, WaitTime = _simInterval };
|
_simTimer = new Godot.Timer { OneShot = false, WaitTime = _simInterval };
|
||||||
_simTimer.Timeout += OnSimTimerTick;
|
_simTimer.Timeout += OnSimTimerTick;
|
||||||
AddChild(_simTimer);
|
AddChild(_simTimer);
|
||||||
|
|
@ -138,13 +174,12 @@ public partial class Main : Node2D
|
||||||
_uiLayer = new CanvasLayer();
|
_uiLayer = new CanvasLayer();
|
||||||
AddChild(_uiLayer);
|
AddChild(_uiLayer);
|
||||||
|
|
||||||
// Root control anchored to viewport (required for child anchoring)
|
|
||||||
var uiRoot = new Control();
|
var uiRoot = new Control();
|
||||||
uiRoot.SetAnchorsPreset(Control.LayoutPreset.FullRect);
|
uiRoot.SetAnchorsPreset(Control.LayoutPreset.FullRect);
|
||||||
uiRoot.MouseFilter = Control.MouseFilterEnum.Ignore;
|
uiRoot.MouseFilter = Control.MouseFilterEnum.Ignore;
|
||||||
_uiLayer.AddChild(uiRoot);
|
_uiLayer.AddChild(uiRoot);
|
||||||
|
|
||||||
// Level title bar (top-left)
|
// Title bar
|
||||||
var titleBar = new HBoxContainer();
|
var titleBar = new HBoxContainer();
|
||||||
titleBar.SetAnchorsPreset(Control.LayoutPreset.TopLeft);
|
titleBar.SetAnchorsPreset(Control.LayoutPreset.TopLeft);
|
||||||
titleBar.OffsetLeft = 12;
|
titleBar.OffsetLeft = 12;
|
||||||
|
|
@ -174,7 +209,7 @@ public partial class Main : Node2D
|
||||||
|
|
||||||
uiRoot.AddChild(titleBar);
|
uiRoot.AddChild(titleBar);
|
||||||
|
|
||||||
// --- Side Panel (anchored to right edge) ---
|
// --- Side Panel ---
|
||||||
_sidePanel = new PanelContainer();
|
_sidePanel = new PanelContainer();
|
||||||
_sidePanel.AnchorLeft = 1.0f;
|
_sidePanel.AnchorLeft = 1.0f;
|
||||||
_sidePanel.AnchorRight = 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),
|
BgColor = new Color(0.14f, 0.14f, 0.16f, 0.97f),
|
||||||
BorderColor = new Color(0.25f, 0.25f, 0.28f),
|
BorderColor = new Color(0.25f, 0.25f, 0.28f),
|
||||||
BorderWidthLeft = 1,
|
BorderWidthLeft = 1,
|
||||||
ContentMarginLeft = 16,
|
ContentMarginLeft = 16, ContentMarginRight = 16,
|
||||||
ContentMarginRight = 16,
|
ContentMarginTop = 16, ContentMarginBottom = 16
|
||||||
ContentMarginTop = 16,
|
|
||||||
ContentMarginBottom = 16
|
|
||||||
};
|
};
|
||||||
_sidePanel.AddThemeStyleboxOverride("panel", sidePanelStyle);
|
_sidePanel.AddThemeStyleboxOverride("panel", sidePanelStyle);
|
||||||
|
|
||||||
|
|
@ -220,7 +253,7 @@ public partial class Main : Node2D
|
||||||
_sidePanel.AddChild(sideScroll);
|
_sidePanel.AddChild(sideScroll);
|
||||||
uiRoot.AddChild(_sidePanel);
|
uiRoot.AddChild(_sidePanel);
|
||||||
|
|
||||||
// --- Control Bar (anchored to bottom, left of side panel) ---
|
// --- Control Bar ---
|
||||||
_controlBarWrapper = new PanelContainer();
|
_controlBarWrapper = new PanelContainer();
|
||||||
_controlBarWrapper.AnchorLeft = 0.0f;
|
_controlBarWrapper.AnchorLeft = 0.0f;
|
||||||
_controlBarWrapper.AnchorRight = 1.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),
|
BgColor = new Color(0.11f, 0.11f, 0.13f, 0.97f),
|
||||||
BorderColor = new Color(0.25f, 0.25f, 0.28f),
|
BorderColor = new Color(0.25f, 0.25f, 0.28f),
|
||||||
BorderWidthTop = 1,
|
BorderWidthTop = 1,
|
||||||
ContentMarginLeft = 12,
|
ContentMarginLeft = 12, ContentMarginRight = 12,
|
||||||
ContentMarginRight = 12,
|
ContentMarginTop = 4, ContentMarginBottom = 4
|
||||||
ContentMarginTop = 4,
|
|
||||||
ContentMarginBottom = 4
|
|
||||||
};
|
};
|
||||||
_controlBarWrapper.AddThemeStyleboxOverride("panel", controlBarStyle);
|
_controlBarWrapper.AddThemeStyleboxOverride("panel", controlBarStyle);
|
||||||
|
|
||||||
|
|
@ -245,7 +276,7 @@ public partial class Main : Node2D
|
||||||
_controlBarWrapper.AddChild(_controlBar);
|
_controlBarWrapper.AddChild(_controlBar);
|
||||||
uiRoot.AddChild(_controlBarWrapper);
|
uiRoot.AddChild(_controlBarWrapper);
|
||||||
|
|
||||||
// --- Metrics Overlay (centered in board area) ---
|
// --- Metrics Overlay ---
|
||||||
var metricsCenter = new CenterContainer();
|
var metricsCenter = new CenterContainer();
|
||||||
metricsCenter.SetAnchorsPreset(Control.LayoutPreset.FullRect);
|
metricsCenter.SetAnchorsPreset(Control.LayoutPreset.FullRect);
|
||||||
metricsCenter.OffsetRight = -SidePanelWidth;
|
metricsCenter.OffsetRight = -SidePanelWidth;
|
||||||
|
|
@ -257,12 +288,22 @@ public partial class Main : Node2D
|
||||||
metricsCenter.AddChild(_metricsOverlay);
|
metricsCenter.AddChild(_metricsOverlay);
|
||||||
uiRoot.AddChild(metricsCenter);
|
uiRoot.AddChild(metricsCenter);
|
||||||
|
|
||||||
// --- Level Select Screen (full viewport) ---
|
// --- Title Screen ---
|
||||||
_levelSelectScreen = new LevelSelectScreen();
|
_titleScreen = new LevelSelectScreen();
|
||||||
_levelSelectScreen.SetAnchorsPreset(Control.LayoutPreset.FullRect);
|
_titleScreen.SetAnchorsPreset(Control.LayoutPreset.FullRect);
|
||||||
uiRoot.AddChild(_levelSelectScreen);
|
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
|
_fadeOverlay = new ColorRect
|
||||||
{
|
{
|
||||||
Color = new Color(0, 0, 0, 1),
|
Color = new Color(0, 0, 0, 1),
|
||||||
|
|
@ -271,25 +312,23 @@ public partial class Main : Node2D
|
||||||
_fadeOverlay.SetAnchorsPreset(Control.LayoutPreset.FullRect);
|
_fadeOverlay.SetAnchorsPreset(Control.LayoutPreset.FullRect);
|
||||||
uiRoot.AddChild(_fadeOverlay);
|
uiRoot.AddChild(_fadeOverlay);
|
||||||
|
|
||||||
// Initialize animator
|
|
||||||
_eventAnimator.Initialize(_boardView, _objectivePanel, _controlBar, _metricsOverlay);
|
_eventAnimator.Initialize(_boardView, _objectivePanel, _controlBar, _metricsOverlay);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ConnectSignals()
|
private void ConnectSignals()
|
||||||
{
|
{
|
||||||
_levelSelectScreen.LevelSelected += OnLevelSelected;
|
_titleScreen.StartCampaignPressed += OnStartCampaign;
|
||||||
_pieceStockPanel.PieceSelected += OnPieceKindSelected;
|
_pieceStockPanel.PieceSelected += OnPieceKindSelected;
|
||||||
_inputMapper.PlacementRequested += OnPlacementRequested;
|
_inputMapper.PlacementRequested += OnPlacementRequested;
|
||||||
_inputMapper.Cancelled += OnPlacementCancelled;
|
_inputMapper.Cancelled += OnPlacementCancelled;
|
||||||
_controlBar.PlayPressed += OnPlay;
|
_controlBar.PlayPressed += OnPlay;
|
||||||
_controlBar.PausePressed += OnPause;
|
_controlBar.PausePressed += OnPause;
|
||||||
_controlBar.StepPressed += OnStep;
|
_controlBar.StepPressed += OnStep;
|
||||||
_controlBar.StopPressed += OnStop;
|
|
||||||
_controlBar.SpeedChanged += OnSpeedChanged;
|
_controlBar.SpeedChanged += OnSpeedChanged;
|
||||||
_eventAnimator.TurnAnimationCompleted += OnTurnAnimationCompleted;
|
_eventAnimator.TurnAnimationCompleted += OnTurnAnimationCompleted;
|
||||||
_eventAnimator.VictoryReached += OnVictory;
|
_eventAnimator.VictoryReached += OnCampaignComplete;
|
||||||
_metricsOverlay.RetryPressed += OnRetry;
|
_eventAnimator.MissionAdvanced += OnMissionAdvanced;
|
||||||
_metricsOverlay.NextLevelPressed += OnNextLevel;
|
_metricsOverlay.NextLevelPressed += OnBackToMenu;
|
||||||
_detailPanel.RemoveRequested += OnRemoveRequested;
|
_detailPanel.RemoveRequested += OnRemoveRequested;
|
||||||
_inputMapper.CellClicked += OnCellClicked;
|
_inputMapper.CellClicked += OnCellClicked;
|
||||||
}
|
}
|
||||||
|
|
@ -298,7 +337,6 @@ public partial class Main : Node2D
|
||||||
{
|
{
|
||||||
if (_sim == null) return;
|
if (_sim == null) return;
|
||||||
var snap = _sim.GetSnapshot();
|
var snap = _sim.GetSnapshot();
|
||||||
if (snap.Phase != SimPhase.Edit) return;
|
|
||||||
|
|
||||||
_boardView.ClearHighlights();
|
_boardView.ClearHighlights();
|
||||||
|
|
||||||
|
|
@ -307,8 +345,6 @@ public partial class Main : Node2D
|
||||||
if (piece != null)
|
if (piece != null)
|
||||||
{
|
{
|
||||||
_detailPanel.ShowPiece(piece);
|
_detailPanel.ShowPiece(piece);
|
||||||
|
|
||||||
// Highlight start and end cells to show trajectory
|
|
||||||
var pieceColor = PieceView.GetPieceColor(piece.Kind);
|
var pieceColor = PieceView.GetPieceColor(piece.Kind);
|
||||||
var highlightColor = new Color(pieceColor, 0.3f);
|
var highlightColor = new Color(pieceColor, 0.3f);
|
||||||
_boardView.HighlightCells([piece.StartCell, piece.EndCell], highlightColor);
|
_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;
|
_boardView.Visible = false;
|
||||||
_sidePanel.Visible = false;
|
_sidePanel.Visible = false;
|
||||||
_controlBarWrapper.Visible = false;
|
_controlBarWrapper.Visible = false;
|
||||||
_levelTitle.Visible = false;
|
_levelTitle.Visible = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnLevelSelected(int levelIndex)
|
private void OnStartCampaign()
|
||||||
{
|
{
|
||||||
SfxManager.Instance?.PlayClick();
|
SfxManager.Instance?.PlayClick();
|
||||||
_currentLevelIndex = levelIndex;
|
|
||||||
|
|
||||||
// Fade out, load, fade in
|
|
||||||
FadeOut(0.25f, () =>
|
FadeOut(0.25f, () =>
|
||||||
{
|
{
|
||||||
LoadLevel(levelIndex);
|
LoadCampaign();
|
||||||
FadeIn(0.3f);
|
FadeIn(0.3f);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private void LoadLevel(int index)
|
private void LoadCampaign()
|
||||||
{
|
{
|
||||||
if (index < 0 || index >= LevelFiles.Length) return;
|
var path = "res://Data/campaigns/campaign_01.json";
|
||||||
|
|
||||||
var path = $"res://Data/levels/{LevelFiles[index]}";
|
|
||||||
var file = Godot.FileAccess.Open(path, Godot.FileAccess.ModeFlags.Read);
|
var file = Godot.FileAccess.Open(path, Godot.FileAccess.ModeFlags.Read);
|
||||||
if (file == null)
|
if (file == null)
|
||||||
{
|
{
|
||||||
GD.PrintErr($"Cannot open level file: {path}");
|
GD.PrintErr($"Cannot open campaign file: {path}");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var json = file.GetAsText();
|
var json = file.GetAsText();
|
||||||
file.Close();
|
file.Close();
|
||||||
|
|
||||||
_currentLevel = LevelLoader.Load(json);
|
_campaignDef = CampaignLoader.Load(json);
|
||||||
_sim = new GameSim(_currentLevel);
|
_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;
|
_boardView.Visible = true;
|
||||||
_sidePanel.Visible = true;
|
_sidePanel.Visible = true;
|
||||||
_controlBarWrapper.Visible = true;
|
_controlBarWrapper.Visible = true;
|
||||||
_levelTitle.Visible = true;
|
_levelTitle.Visible = true;
|
||||||
|
|
||||||
_boardView.BuildBoard(_currentLevel);
|
var snap = _sim.GetSnapshot();
|
||||||
_objectivePanel.Setup(_currentLevel.Demands);
|
var mission = _campaignDef.Missions[snap.Campaign!.CurrentMissionIndex];
|
||||||
_pieceStockPanel.Setup(_currentLevel.Stock);
|
|
||||||
_controlBar.UpdateForPhase(SimPhase.Edit);
|
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();
|
_controlBar.ResetTurn();
|
||||||
_metricsOverlay.Hide();
|
_metricsOverlay.Hide();
|
||||||
_detailPanel.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
|
// Show narrative flavor text
|
||||||
// Board X spans 0..W*Cell, Y spans -(H-1)*Cell .. Cell
|
_flavorBanner.ShowFlavor(mission.Flavor);
|
||||||
_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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 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)
|
private void OnPieceKindSelected(int kindIndex)
|
||||||
{
|
{
|
||||||
|
|
@ -409,6 +506,7 @@ public partial class Main : Node2D
|
||||||
var events = _sim.ProcessCommand(new PlacePieceCommand(kind, start, end));
|
var events = _sim.ProcessCommand(new PlacePieceCommand(kind, start, end));
|
||||||
HandleEditEvents(events);
|
HandleEditEvents(events);
|
||||||
_inputMapper.SetSnapshot(_sim.GetSnapshot());
|
_inputMapper.SetSnapshot(_sim.GetSnapshot());
|
||||||
|
_pieceStockPanel.ClearSelection();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnPlacementCancelled()
|
private void OnPlacementCancelled()
|
||||||
|
|
@ -462,7 +560,6 @@ public partial class Main : Node2D
|
||||||
_boardView.AddChild(pieceView);
|
_boardView.AddChild(pieceView);
|
||||||
|
|
||||||
var color = PieceView.GetPieceColor(placed.Kind);
|
var color = PieceView.GetPieceColor(placed.Kind);
|
||||||
|
|
||||||
var trajectView = new TrajectView();
|
var trajectView = new TrajectView();
|
||||||
trajectView.Setup(placed.PieceId,
|
trajectView.Setup(placed.PieceId,
|
||||||
_boardView.CoordsToPixel(placed.Start),
|
_boardView.CoordsToPixel(placed.Start),
|
||||||
|
|
@ -481,26 +578,24 @@ public partial class Main : Node2D
|
||||||
_pieceStockPanel.UpdateCount(kind, remaining);
|
_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()
|
private void OnPlay()
|
||||||
{
|
{
|
||||||
if (_sim == null) return;
|
if (_sim == null) return;
|
||||||
|
|
||||||
var snap = _sim.GetSnapshot();
|
var snap = _sim.GetSnapshot();
|
||||||
if (snap.Phase == SimPhase.Edit)
|
if (snap.Phase == SimPhase.Paused || snap.Phase == SimPhase.MissionComplete)
|
||||||
{
|
|
||||||
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)
|
|
||||||
{
|
{
|
||||||
_sim.ProcessCommand(new ResumeSimulationCommand());
|
_sim.ProcessCommand(new ResumeSimulationCommand());
|
||||||
}
|
}
|
||||||
|
|
@ -523,47 +618,17 @@ public partial class Main : Node2D
|
||||||
private void OnStep()
|
private void OnStep()
|
||||||
{
|
{
|
||||||
if (_sim == null || _eventAnimator.IsAnimating) return;
|
if (_sim == null || _eventAnimator.IsAnimating) return;
|
||||||
|
|
||||||
|
_collisionPauseOccurred = false;
|
||||||
var events = _sim.ProcessCommand(new StepSimulationCommand());
|
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);
|
_eventAnimator.ProcessEvents(events);
|
||||||
_controlBar.UpdateForPhase(_sim.GetSnapshot().Phase);
|
_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)
|
private void OnSpeedChanged(float interval)
|
||||||
{
|
{
|
||||||
_simInterval = interval;
|
_simInterval = interval;
|
||||||
|
|
@ -575,7 +640,11 @@ public partial class Main : Node2D
|
||||||
{
|
{
|
||||||
if (_sim == null || _eventAnimator.IsAnimating) return;
|
if (_sim == null || _eventAnimator.IsAnimating) return;
|
||||||
|
|
||||||
|
_collisionPauseOccurred = false;
|
||||||
var events = _sim.ProcessCommand(new StepSimulationCommand());
|
var events = _sim.ProcessCommand(new StepSimulationCommand());
|
||||||
|
|
||||||
|
_collisionPauseOccurred = events.Any(e => e is PieceReturnedToStockEvent);
|
||||||
|
|
||||||
_eventAnimator.ProcessEvents(events);
|
_eventAnimator.ProcessEvents(events);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -585,34 +654,55 @@ public partial class Main : Node2D
|
||||||
var phase = _sim.GetSnapshot().Phase;
|
var phase = _sim.GetSnapshot().Phase;
|
||||||
_controlBar.UpdateForPhase(phase);
|
_controlBar.UpdateForPhase(phase);
|
||||||
|
|
||||||
if (phase == SimPhase.Victory || phase == SimPhase.Defeat)
|
if (phase == SimPhase.MissionComplete)
|
||||||
{
|
{
|
||||||
|
// Stop auto-running, show mission complete overlay
|
||||||
_running = false;
|
_running = false;
|
||||||
_simTimer.Stop();
|
_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;
|
_running = false;
|
||||||
_simTimer.Stop();
|
_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 ---
|
// --- Navigation ---
|
||||||
|
|
||||||
private void OnRetry()
|
|
||||||
{
|
|
||||||
LoadLevel(_currentLevelIndex);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnNextLevel()
|
|
||||||
{
|
|
||||||
if (_currentLevelIndex + 1 < LevelFiles.Length)
|
|
||||||
LoadLevel(_currentLevelIndex + 1);
|
|
||||||
else
|
|
||||||
ShowLevelSelect();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnBackToMenu()
|
private void OnBackToMenu()
|
||||||
{
|
{
|
||||||
SfxManager.Instance?.PlayClick();
|
SfxManager.Instance?.PlayClick();
|
||||||
|
|
@ -622,7 +712,7 @@ public partial class Main : Node2D
|
||||||
|
|
||||||
FadeOut(0.2f, () =>
|
FadeOut(0.2f, () =>
|
||||||
{
|
{
|
||||||
ShowLevelSelect();
|
ShowTitleScreen();
|
||||||
FadeIn(0.3f);
|
FadeIn(0.3f);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,9 @@ public partial class EventAnimator : Node
|
||||||
|
|
||||||
private static readonly Color WoodCargoColor = new("#A67C32");
|
private static readonly Color WoodCargoColor = new("#A67C32");
|
||||||
private static readonly Color StoneCargoColor = new("#7A7A7A");
|
private static readonly Color StoneCargoColor = new("#7A7A7A");
|
||||||
|
private static readonly Color ToolsCargoColor = new("#C87533");
|
||||||
|
private static readonly Color ArmsCargoColor = new("#8B0000");
|
||||||
|
private static readonly Color GoldCargoColor = new("#FFD700");
|
||||||
|
|
||||||
private const float ProduceDuration = 0.35f;
|
private const float ProduceDuration = 0.35f;
|
||||||
private const float TransferDuration = 0.28f;
|
private const float TransferDuration = 0.28f;
|
||||||
|
|
@ -36,6 +39,8 @@ public partial class EventAnimator : Node
|
||||||
public delegate void TurnAnimationCompletedEventHandler();
|
public delegate void TurnAnimationCompletedEventHandler();
|
||||||
[Signal]
|
[Signal]
|
||||||
public delegate void VictoryReachedEventHandler();
|
public delegate void VictoryReachedEventHandler();
|
||||||
|
[Signal]
|
||||||
|
public delegate void MissionAdvancedEventHandler();
|
||||||
|
|
||||||
public void Initialize(BoardView boardView, ObjectivePanel objectivePanel,
|
public void Initialize(BoardView boardView, ObjectivePanel objectivePanel,
|
||||||
ControlBar controlBar, MetricsOverlay metricsOverlay)
|
ControlBar controlBar, MetricsOverlay metricsOverlay)
|
||||||
|
|
@ -75,7 +80,10 @@ public partial class EventAnimator : Node
|
||||||
var produceEvents = new List<CargoProducedEvent>();
|
var produceEvents = new List<CargoProducedEvent>();
|
||||||
var transferEvents = new List<IWorldEvent>();
|
var transferEvents = new List<IWorldEvent>();
|
||||||
var moveEvents = new List<PieceMovedEvent>();
|
var moveEvents = new List<PieceMovedEvent>();
|
||||||
var collisionEvents = new List<PieceDestroyedEvent>();
|
var collisionEvents = new List<PieceReturnedToStockEvent>();
|
||||||
|
|
||||||
|
// Pre-scan: if MissionStartedEvent follows MissionCompleteEvent, it's an auto-advance (not last mission)
|
||||||
|
bool hasAutoAdvance = events.Any(e => e is MissionStartedEvent);
|
||||||
|
|
||||||
foreach (var evt in events)
|
foreach (var evt in events)
|
||||||
{
|
{
|
||||||
|
|
@ -90,6 +98,11 @@ public partial class EventAnimator : Node
|
||||||
produceEvents.Add(produced);
|
produceEvents.Add(produced);
|
||||||
break;
|
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 CargoTransferredEvent:
|
||||||
case DemandProgressEvent:
|
case DemandProgressEvent:
|
||||||
transferEvents.Add(evt);
|
transferEvents.Add(evt);
|
||||||
|
|
@ -99,21 +112,33 @@ public partial class EventAnimator : Node
|
||||||
moveEvents.Add(moved);
|
moveEvents.Add(moved);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case PieceDestroyedEvent destroyed:
|
case PieceReturnedToStockEvent returned:
|
||||||
collisionEvents.Add(destroyed);
|
collisionEvents.Add(returned);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case VictoryEvent victory:
|
case MissionCompleteEvent:
|
||||||
FlushPhases(tween, produceEvents, transferEvents, moveEvents, collisionEvents);
|
FlushPhases(tween, produceEvents, transferEvents, moveEvents, collisionEvents);
|
||||||
tween.TweenCallback(Callable.From(() =>
|
tween.TweenCallback(Callable.From(() =>
|
||||||
{
|
{
|
||||||
SfxManager.Instance?.PlayVictory();
|
SfxManager.Instance?.PlayVictory();
|
||||||
SpawnConfetti();
|
SpawnConfetti();
|
||||||
_metricsOverlay.ShowMetrics(victory.Metrics);
|
if (!hasAutoAdvance)
|
||||||
EmitSignal(SignalName.VictoryReached);
|
EmitSignal(SignalName.VictoryReached);
|
||||||
}));
|
}));
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case MissionStartedEvent:
|
||||||
|
FlushPhases(tween, produceEvents, transferEvents, moveEvents, collisionEvents);
|
||||||
|
tween.TweenCallback(Callable.From(() =>
|
||||||
|
{
|
||||||
|
EmitSignal(SignalName.MissionAdvanced);
|
||||||
|
}));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case SimulationPausedEvent:
|
||||||
|
// Auto-pause from collision — handled by FlushPhases
|
||||||
|
break;
|
||||||
|
|
||||||
case TurnEndedEvent:
|
case TurnEndedEvent:
|
||||||
FlushPhases(tween, produceEvents, transferEvents, moveEvents, collisionEvents);
|
FlushPhases(tween, produceEvents, transferEvents, moveEvents, collisionEvents);
|
||||||
break;
|
break;
|
||||||
|
|
@ -137,7 +162,7 @@ public partial class EventAnimator : Node
|
||||||
List<CargoProducedEvent> produceEvents,
|
List<CargoProducedEvent> produceEvents,
|
||||||
List<IWorldEvent> transferEvents,
|
List<IWorldEvent> transferEvents,
|
||||||
List<PieceMovedEvent> moveEvents,
|
List<PieceMovedEvent> moveEvents,
|
||||||
List<PieceDestroyedEvent> collisionEvents)
|
List<PieceReturnedToStockEvent> collisionEvents)
|
||||||
{
|
{
|
||||||
// Phase 1: Produce — warm golden flash + particle burst
|
// Phase 1: Produce — warm golden flash + particle burst
|
||||||
if (produceEvents.Count > 0)
|
if (produceEvents.Count > 0)
|
||||||
|
|
@ -222,16 +247,16 @@ public partial class EventAnimator : Node
|
||||||
moveEvents.Clear();
|
moveEvents.Clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 4: Collision/Destruction — shrink + spin + particles
|
// Phase 4: Collision — piece returned to stock (shrink + spin + particles)
|
||||||
if (collisionEvents.Count > 0)
|
if (collisionEvents.Count > 0)
|
||||||
{
|
{
|
||||||
var captured = collisionEvents.ToList();
|
var captured = collisionEvents.ToList();
|
||||||
tween.TweenCallback(Callable.From(() =>
|
tween.TweenCallback(Callable.From(() =>
|
||||||
{
|
{
|
||||||
SfxManager.Instance?.PlayDestroy();
|
SfxManager.Instance?.PlayDestroy();
|
||||||
foreach (var destroyed in captured)
|
foreach (var returned in captured)
|
||||||
{
|
{
|
||||||
if (_pieceViews.TryGetValue(destroyed.PieceId, out var pv))
|
if (_pieceViews.TryGetValue(returned.PieceId, out var pv))
|
||||||
{
|
{
|
||||||
SpawnDestroyParticles(pv.Position);
|
SpawnDestroyParticles(pv.Position);
|
||||||
|
|
||||||
|
|
@ -247,8 +272,8 @@ public partial class EventAnimator : Node
|
||||||
tween.TweenInterval(DestroyDuration);
|
tween.TweenInterval(DestroyDuration);
|
||||||
tween.TweenCallback(Callable.From(() =>
|
tween.TweenCallback(Callable.From(() =>
|
||||||
{
|
{
|
||||||
foreach (var destroyed in captured)
|
foreach (var returned in captured)
|
||||||
UnregisterPiece(destroyed.PieceId);
|
UnregisterPiece(returned.PieceId);
|
||||||
}));
|
}));
|
||||||
collisionEvents.Clear();
|
collisionEvents.Clear();
|
||||||
}
|
}
|
||||||
|
|
@ -423,6 +448,9 @@ public partial class EventAnimator : Node
|
||||||
{
|
{
|
||||||
CargoType.Wood => WoodCargoColor,
|
CargoType.Wood => WoodCargoColor,
|
||||||
CargoType.Stone => StoneCargoColor,
|
CargoType.Stone => StoneCargoColor,
|
||||||
|
CargoType.Tools => ToolsCargoColor,
|
||||||
|
CargoType.Arms => ArmsCargoColor,
|
||||||
|
CargoType.Gold => GoldCargoColor,
|
||||||
_ => Colors.White
|
_ => Colors.White
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,15 +12,14 @@ public partial class ControlBar : HBoxContainer
|
||||||
public delegate void PausePressedEventHandler();
|
public delegate void PausePressedEventHandler();
|
||||||
[Signal]
|
[Signal]
|
||||||
public delegate void StepPressedEventHandler();
|
public delegate void StepPressedEventHandler();
|
||||||
[Signal]
|
// Stop removed in campaign mode
|
||||||
public delegate void StopPressedEventHandler();
|
|
||||||
[Signal]
|
[Signal]
|
||||||
public delegate void SpeedChangedEventHandler(float speed);
|
public delegate void SpeedChangedEventHandler(float speed);
|
||||||
|
|
||||||
private Button _playButton = null!;
|
private Button _playButton = null!;
|
||||||
private Button _pauseButton = null!;
|
private Button _pauseButton = null!;
|
||||||
private Button _stepButton = null!;
|
private Button _stepButton = null!;
|
||||||
private Button _stopButton = null!;
|
// _stopButton removed
|
||||||
private OptionButton _speedSelect = null!;
|
private OptionButton _speedSelect = null!;
|
||||||
private Label _turnLabel = null!;
|
private Label _turnLabel = null!;
|
||||||
|
|
||||||
|
|
@ -46,9 +45,7 @@ public partial class ControlBar : HBoxContainer
|
||||||
_stepButton.Pressed += () => EmitSignal(SignalName.StepPressed);
|
_stepButton.Pressed += () => EmitSignal(SignalName.StepPressed);
|
||||||
AddChild(_stepButton);
|
AddChild(_stepButton);
|
||||||
|
|
||||||
_stopButton = CreateStyledButton("STOP");
|
// Stop button removed in campaign mode
|
||||||
_stopButton.Pressed += () => EmitSignal(SignalName.StopPressed);
|
|
||||||
AddChild(_stopButton);
|
|
||||||
|
|
||||||
// Spacer
|
// Spacer
|
||||||
AddChild(new Control { CustomMinimumSize = new Vector2(12, 0) });
|
AddChild(new Control { CustomMinimumSize = new Vector2(12, 0) });
|
||||||
|
|
@ -68,7 +65,7 @@ public partial class ControlBar : HBoxContainer
|
||||||
_turnLabel.AddThemeColorOverride("font_color", new Color("#999999"));
|
_turnLabel.AddThemeColorOverride("font_color", new Color("#999999"));
|
||||||
AddChild(_turnLabel);
|
AddChild(_turnLabel);
|
||||||
|
|
||||||
UpdateForPhase(SimPhase.Edit);
|
UpdateForPhase(SimPhase.Paused);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Button CreateStyledButton(string text)
|
private static Button CreateStyledButton(string text)
|
||||||
|
|
@ -76,7 +73,8 @@ public partial class ControlBar : HBoxContainer
|
||||||
var btn = new Button
|
var btn = new Button
|
||||||
{
|
{
|
||||||
Text = text,
|
Text = text,
|
||||||
CustomMinimumSize = new Vector2(70, 30)
|
CustomMinimumSize = new Vector2(70, 30),
|
||||||
|
FocusMode = FocusModeEnum.None
|
||||||
};
|
};
|
||||||
btn.AddThemeFontSizeOverride("font_size", 11);
|
btn.AddThemeFontSizeOverride("font_size", 11);
|
||||||
|
|
||||||
|
|
@ -124,10 +122,9 @@ public partial class ControlBar : HBoxContainer
|
||||||
|
|
||||||
public void UpdateForPhase(SimPhase phase)
|
public void UpdateForPhase(SimPhase phase)
|
||||||
{
|
{
|
||||||
_playButton.Disabled = phase != SimPhase.Edit && phase != SimPhase.Paused;
|
_playButton.Disabled = phase != SimPhase.Paused && phase != SimPhase.MissionComplete;
|
||||||
_pauseButton.Disabled = phase != SimPhase.Running;
|
_pauseButton.Disabled = phase != SimPhase.Running;
|
||||||
_stepButton.Disabled = phase == SimPhase.Running || phase == SimPhase.Victory || phase == SimPhase.Defeat;
|
_stepButton.Disabled = phase == SimPhase.Running;
|
||||||
_stopButton.Disabled = phase == SimPhase.Edit;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void UpdateTurn(int turn)
|
public void UpdateTurn(int turn)
|
||||||
|
|
|
||||||
73
Scripts/UI/FlavorBanner.cs
Normal file
73
Scripts/UI/FlavorBanner.cs
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
using Godot;
|
||||||
|
|
||||||
|
namespace Chessistics.Scripts.UI;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Displays a one-line narrative blurb at the top of the screen when a mission starts.
|
||||||
|
/// Auto-fades out after a few seconds.
|
||||||
|
/// </summary>
|
||||||
|
public partial class FlavorBanner : PanelContainer
|
||||||
|
{
|
||||||
|
private Label _label = null!;
|
||||||
|
private Tween? _activeTween;
|
||||||
|
|
||||||
|
private static readonly Color BannerBg = new(0.12f, 0.10f, 0.08f, 0.92f);
|
||||||
|
private static readonly Color BorderColor = new("#B8942A");
|
||||||
|
private static readonly Color TextColor = new("#E8D4A0");
|
||||||
|
|
||||||
|
public override void _Ready()
|
||||||
|
{
|
||||||
|
// Style the panel
|
||||||
|
var style = new StyleBoxFlat
|
||||||
|
{
|
||||||
|
BgColor = BannerBg,
|
||||||
|
BorderColor = BorderColor,
|
||||||
|
BorderWidthBottom = 2,
|
||||||
|
ContentMarginLeft = 24,
|
||||||
|
ContentMarginRight = 24,
|
||||||
|
ContentMarginTop = 10,
|
||||||
|
ContentMarginBottom = 10,
|
||||||
|
CornerRadiusBottomLeft = 6,
|
||||||
|
CornerRadiusBottomRight = 6
|
||||||
|
};
|
||||||
|
AddThemeStyleboxOverride("panel", style);
|
||||||
|
|
||||||
|
_label = new Label
|
||||||
|
{
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Center,
|
||||||
|
AutowrapMode = TextServer.AutowrapMode.WordSmart
|
||||||
|
};
|
||||||
|
_label.AddThemeFontSizeOverride("font_size", 13);
|
||||||
|
_label.AddThemeColorOverride("font_color", TextColor);
|
||||||
|
AddChild(_label);
|
||||||
|
|
||||||
|
MouseFilter = MouseFilterEnum.Ignore;
|
||||||
|
Visible = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ShowFlavor(string text)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(text))
|
||||||
|
{
|
||||||
|
Visible = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_activeTween?.Kill();
|
||||||
|
|
||||||
|
_label.Text = text;
|
||||||
|
Visible = true;
|
||||||
|
Modulate = new Color(1, 1, 1, 0);
|
||||||
|
|
||||||
|
_activeTween = CreateTween();
|
||||||
|
// Fade in
|
||||||
|
_activeTween.TweenProperty(this, "modulate:a", 1f, 0.4f)
|
||||||
|
.SetEase(Tween.EaseType.Out);
|
||||||
|
// Hold
|
||||||
|
_activeTween.TweenInterval(5.0f);
|
||||||
|
// Fade out
|
||||||
|
_activeTween.TweenProperty(this, "modulate:a", 0f, 1.0f)
|
||||||
|
.SetEase(Tween.EaseType.In);
|
||||||
|
_activeTween.TweenCallback(Callable.From(() => Visible = false));
|
||||||
|
}
|
||||||
|
}
|
||||||
1
Scripts/UI/FlavorBanner.cs.uid
Normal file
1
Scripts/UI/FlavorBanner.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://bcapogap6qff2
|
||||||
|
|
@ -6,19 +6,7 @@ namespace Chessistics.Scripts.UI;
|
||||||
public partial class LevelSelectScreen : Control
|
public partial class LevelSelectScreen : Control
|
||||||
{
|
{
|
||||||
[Signal]
|
[Signal]
|
||||||
public delegate void LevelSelectedEventHandler(int levelIndex);
|
public delegate void StartCampaignPressedEventHandler();
|
||||||
|
|
||||||
private readonly (string name, string desc)[] _levels =
|
|
||||||
[
|
|
||||||
("Premier Convoi", "Acheminez du bois de la scierie au depot."),
|
|
||||||
("Deux Clients", "Fournissez deux destinations depuis une seule scierie."),
|
|
||||||
("Le Col", "Franchissez le mur et gerez deux types de cargaison."),
|
|
||||||
("Le Carrefour", "Deux productions, deux demandes, et un carrefour au centre."),
|
|
||||||
("Le Labyrinthe", "Un couloir etroit serpente a travers les murs."),
|
|
||||||
("Trois Royaumes", "Trois productions, trois demandes. Gerez un reseau complet."),
|
|
||||||
("La Dame Blanche", "La Dame entre en jeu. Portee supreme sur 8 directions."),
|
|
||||||
("Le Grand Reseau", "Quatre productions, quatre demandes. Reseau complet.")
|
|
||||||
];
|
|
||||||
|
|
||||||
public override void _Ready()
|
public override void _Ready()
|
||||||
{
|
{
|
||||||
|
|
@ -30,199 +18,79 @@ public partial class LevelSelectScreen : Control
|
||||||
bg.MouseFilter = MouseFilterEnum.Ignore;
|
bg.MouseFilter = MouseFilterEnum.Ignore;
|
||||||
AddChild(bg);
|
AddChild(bg);
|
||||||
|
|
||||||
// Outer margin
|
// Center content
|
||||||
var margin = new MarginContainer();
|
var center = new CenterContainer();
|
||||||
margin.SetAnchorsPreset(LayoutPreset.FullRect);
|
center.SetAnchorsPreset(LayoutPreset.FullRect);
|
||||||
margin.AddThemeConstantOverride("margin_left", 80);
|
center.MouseFilter = MouseFilterEnum.Ignore;
|
||||||
margin.AddThemeConstantOverride("margin_right", 80);
|
|
||||||
margin.AddThemeConstantOverride("margin_top", 60);
|
|
||||||
margin.AddThemeConstantOverride("margin_bottom", 60);
|
|
||||||
margin.MouseFilter = MouseFilterEnum.Ignore;
|
|
||||||
|
|
||||||
var outerVBox = new VBoxContainer();
|
var vbox = new VBoxContainer { Alignment = BoxContainer.AlignmentMode.Center };
|
||||||
outerVBox.AddThemeConstantOverride("separation", 0);
|
vbox.AddThemeConstantOverride("separation", 24);
|
||||||
outerVBox.MouseFilter = MouseFilterEnum.Ignore;
|
vbox.MouseFilter = MouseFilterEnum.Ignore;
|
||||||
|
|
||||||
// --- Header section ---
|
|
||||||
var headerBox = new VBoxContainer();
|
|
||||||
headerBox.AddThemeConstantOverride("separation", 4);
|
|
||||||
headerBox.MouseFilter = MouseFilterEnum.Ignore;
|
|
||||||
|
|
||||||
|
// Title
|
||||||
var title = new Label
|
var title = new Label
|
||||||
{
|
{
|
||||||
Text = "CHESSISTICS",
|
Text = "CHESSISTICS",
|
||||||
HorizontalAlignment = HorizontalAlignment.Center
|
HorizontalAlignment = HorizontalAlignment.Center
|
||||||
};
|
};
|
||||||
title.AddThemeFontSizeOverride("font_size", 48);
|
title.AddThemeFontSizeOverride("font_size", 56);
|
||||||
title.AddThemeColorOverride("font_color", new Color("#FFD700"));
|
title.AddThemeColorOverride("font_color", new Color("#FFD700"));
|
||||||
headerBox.AddChild(title);
|
vbox.AddChild(title);
|
||||||
|
|
||||||
|
// Subtitle
|
||||||
var subtitle = new Label
|
var subtitle = new Label
|
||||||
{
|
{
|
||||||
Text = "Selectionnez un niveau",
|
Text = "La Quête du Roi",
|
||||||
HorizontalAlignment = HorizontalAlignment.Center
|
HorizontalAlignment = HorizontalAlignment.Center
|
||||||
};
|
};
|
||||||
subtitle.AddThemeFontSizeOverride("font_size", 15);
|
subtitle.AddThemeFontSizeOverride("font_size", 18);
|
||||||
subtitle.AddThemeColorOverride("font_color", new Color("#777777"));
|
subtitle.AddThemeColorOverride("font_color", new Color("#999999"));
|
||||||
headerBox.AddChild(subtitle);
|
vbox.AddChild(subtitle);
|
||||||
|
|
||||||
outerVBox.AddChild(headerBox);
|
|
||||||
|
|
||||||
// Spacer
|
// Spacer
|
||||||
outerVBox.AddChild(new Control { CustomMinimumSize = new Vector2(0, 48) });
|
vbox.AddChild(new Control { CustomMinimumSize = new Vector2(0, 32) });
|
||||||
|
|
||||||
// --- Level cards in a scrollable grid ---
|
// Start button
|
||||||
var scroll = new ScrollContainer
|
var startBtn = new Button
|
||||||
{
|
{
|
||||||
SizeFlagsVertical = SizeFlags.ExpandFill,
|
Text = "Démarrer",
|
||||||
HorizontalScrollMode = ScrollContainer.ScrollMode.Disabled
|
CustomMinimumSize = new Vector2(200, 52),
|
||||||
};
|
|
||||||
|
|
||||||
var grid = new GridContainer
|
|
||||||
{
|
|
||||||
Columns = 3,
|
|
||||||
SizeFlagsHorizontal = SizeFlags.ShrinkCenter,
|
|
||||||
MouseFilter = MouseFilterEnum.Ignore
|
|
||||||
};
|
|
||||||
grid.AddThemeConstantOverride("h_separation", 28);
|
|
||||||
grid.AddThemeConstantOverride("v_separation", 28);
|
|
||||||
|
|
||||||
for (int i = 0; i < _levels.Length; i++)
|
|
||||||
{
|
|
||||||
var (name, desc) = _levels[i];
|
|
||||||
grid.AddChild(CreateLevelCard(i, name, desc));
|
|
||||||
}
|
|
||||||
|
|
||||||
scroll.AddChild(grid);
|
|
||||||
outerVBox.AddChild(scroll);
|
|
||||||
|
|
||||||
margin.AddChild(outerVBox);
|
|
||||||
AddChild(margin);
|
|
||||||
}
|
|
||||||
|
|
||||||
private Control CreateLevelCard(int index, string name, string description)
|
|
||||||
{
|
|
||||||
var card = new PanelContainer
|
|
||||||
{
|
|
||||||
CustomMinimumSize = new Vector2(300, 240),
|
|
||||||
SizeFlagsVertical = SizeFlags.ShrinkCenter
|
|
||||||
};
|
|
||||||
|
|
||||||
var cardStyle = new StyleBoxFlat
|
|
||||||
{
|
|
||||||
BgColor = new Color(0.17f, 0.17f, 0.19f),
|
|
||||||
BorderColor = new Color(0.28f, 0.28f, 0.32f),
|
|
||||||
BorderWidthBottom = 1,
|
|
||||||
BorderWidthTop = 1,
|
|
||||||
BorderWidthLeft = 1,
|
|
||||||
BorderWidthRight = 1,
|
|
||||||
CornerRadiusTopLeft = 8,
|
|
||||||
CornerRadiusTopRight = 8,
|
|
||||||
CornerRadiusBottomLeft = 8,
|
|
||||||
CornerRadiusBottomRight = 8,
|
|
||||||
ContentMarginLeft = 24,
|
|
||||||
ContentMarginRight = 24,
|
|
||||||
ContentMarginTop = 24,
|
|
||||||
ContentMarginBottom = 24
|
|
||||||
};
|
|
||||||
card.AddThemeStyleboxOverride("panel", cardStyle);
|
|
||||||
|
|
||||||
var vbox = new VBoxContainer();
|
|
||||||
vbox.AddThemeConstantOverride("separation", 10);
|
|
||||||
|
|
||||||
// Level number
|
|
||||||
var numLabel = new Label
|
|
||||||
{
|
|
||||||
Text = $"Niveau {index + 1}",
|
|
||||||
HorizontalAlignment = HorizontalAlignment.Center
|
|
||||||
};
|
|
||||||
numLabel.AddThemeFontSizeOverride("font_size", 12);
|
|
||||||
numLabel.AddThemeColorOverride("font_color", new Color("#666666"));
|
|
||||||
vbox.AddChild(numLabel);
|
|
||||||
|
|
||||||
// Level name
|
|
||||||
var nameLabel = new Label
|
|
||||||
{
|
|
||||||
Text = name,
|
|
||||||
HorizontalAlignment = HorizontalAlignment.Center
|
|
||||||
};
|
|
||||||
nameLabel.AddThemeFontSizeOverride("font_size", 22);
|
|
||||||
nameLabel.AddThemeColorOverride("font_color", new Color("#EEEEEE"));
|
|
||||||
vbox.AddChild(nameLabel);
|
|
||||||
|
|
||||||
// Thin separator
|
|
||||||
var sep = new HSeparator { CustomMinimumSize = new Vector2(0, 4) };
|
|
||||||
vbox.AddChild(sep);
|
|
||||||
|
|
||||||
// Description
|
|
||||||
var descLabel = new Label
|
|
||||||
{
|
|
||||||
Text = description,
|
|
||||||
HorizontalAlignment = HorizontalAlignment.Center,
|
|
||||||
AutowrapMode = TextServer.AutowrapMode.Word,
|
|
||||||
CustomMinimumSize = new Vector2(240, 0)
|
|
||||||
};
|
|
||||||
descLabel.AddThemeFontSizeOverride("font_size", 13);
|
|
||||||
descLabel.AddThemeColorOverride("font_color", new Color("#999999"));
|
|
||||||
vbox.AddChild(descLabel);
|
|
||||||
|
|
||||||
// Flexible spacer
|
|
||||||
vbox.AddChild(new Control { SizeFlagsVertical = SizeFlags.ExpandFill });
|
|
||||||
|
|
||||||
// Play button
|
|
||||||
var playBtn = new Button
|
|
||||||
{
|
|
||||||
Text = "Jouer",
|
|
||||||
CustomMinimumSize = new Vector2(120, 38),
|
|
||||||
SizeFlagsHorizontal = SizeFlags.ShrinkCenter
|
SizeFlagsHorizontal = SizeFlags.ShrinkCenter
|
||||||
};
|
};
|
||||||
|
|
||||||
var btnNormal = new StyleBoxFlat
|
var btnNormal = new StyleBoxFlat
|
||||||
{
|
{
|
||||||
BgColor = new Color("#8B6914"),
|
BgColor = new Color("#8B6914"),
|
||||||
CornerRadiusTopLeft = 6,
|
CornerRadiusTopLeft = 8, CornerRadiusTopRight = 8,
|
||||||
CornerRadiusTopRight = 6,
|
CornerRadiusBottomLeft = 8, CornerRadiusBottomRight = 8,
|
||||||
CornerRadiusBottomLeft = 6,
|
ContentMarginLeft = 32, ContentMarginRight = 32,
|
||||||
CornerRadiusBottomRight = 6,
|
ContentMarginTop = 12, ContentMarginBottom = 12
|
||||||
ContentMarginLeft = 24,
|
|
||||||
ContentMarginRight = 24,
|
|
||||||
ContentMarginTop = 8,
|
|
||||||
ContentMarginBottom = 8
|
|
||||||
};
|
};
|
||||||
var btnHover = new StyleBoxFlat
|
var btnHover = new StyleBoxFlat
|
||||||
{
|
{
|
||||||
BgColor = new Color("#B8860B"),
|
BgColor = new Color("#B8860B"),
|
||||||
CornerRadiusTopLeft = 6,
|
CornerRadiusTopLeft = 8, CornerRadiusTopRight = 8,
|
||||||
CornerRadiusTopRight = 6,
|
CornerRadiusBottomLeft = 8, CornerRadiusBottomRight = 8,
|
||||||
CornerRadiusBottomLeft = 6,
|
ContentMarginLeft = 32, ContentMarginRight = 32,
|
||||||
CornerRadiusBottomRight = 6,
|
ContentMarginTop = 12, ContentMarginBottom = 12
|
||||||
ContentMarginLeft = 24,
|
|
||||||
ContentMarginRight = 24,
|
|
||||||
ContentMarginTop = 8,
|
|
||||||
ContentMarginBottom = 8
|
|
||||||
};
|
};
|
||||||
var btnPressed = new StyleBoxFlat
|
var btnPressed = new StyleBoxFlat
|
||||||
{
|
{
|
||||||
BgColor = new Color("#6B5010"),
|
BgColor = new Color("#6B5010"),
|
||||||
CornerRadiusTopLeft = 6,
|
CornerRadiusTopLeft = 8, CornerRadiusTopRight = 8,
|
||||||
CornerRadiusTopRight = 6,
|
CornerRadiusBottomLeft = 8, CornerRadiusBottomRight = 8,
|
||||||
CornerRadiusBottomLeft = 6,
|
ContentMarginLeft = 32, ContentMarginRight = 32,
|
||||||
CornerRadiusBottomRight = 6,
|
ContentMarginTop = 12, ContentMarginBottom = 12
|
||||||
ContentMarginLeft = 24,
|
|
||||||
ContentMarginRight = 24,
|
|
||||||
ContentMarginTop = 8,
|
|
||||||
ContentMarginBottom = 8
|
|
||||||
};
|
};
|
||||||
playBtn.AddThemeStyleboxOverride("normal", btnNormal);
|
startBtn.AddThemeStyleboxOverride("normal", btnNormal);
|
||||||
playBtn.AddThemeStyleboxOverride("hover", btnHover);
|
startBtn.AddThemeStyleboxOverride("hover", btnHover);
|
||||||
playBtn.AddThemeStyleboxOverride("pressed", btnPressed);
|
startBtn.AddThemeStyleboxOverride("pressed", btnPressed);
|
||||||
playBtn.AddThemeFontSizeOverride("font_size", 15);
|
startBtn.AddThemeFontSizeOverride("font_size", 20);
|
||||||
|
|
||||||
var idx = index;
|
startBtn.Pressed += () => EmitSignal(SignalName.StartCampaignPressed);
|
||||||
playBtn.Pressed += () => EmitSignal(SignalName.LevelSelected, idx);
|
vbox.AddChild(startBtn);
|
||||||
vbox.AddChild(playBtn);
|
|
||||||
|
|
||||||
card.AddChild(vbox);
|
center.AddChild(vbox);
|
||||||
return card;
|
AddChild(center);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,14 +7,13 @@ public partial class MetricsOverlay : PanelContainer
|
||||||
{
|
{
|
||||||
[Signal]
|
[Signal]
|
||||||
public delegate void NextLevelPressedEventHandler();
|
public delegate void NextLevelPressedEventHandler();
|
||||||
[Signal]
|
|
||||||
public delegate void RetryPressedEventHandler();
|
|
||||||
|
|
||||||
private Label _titleLabel = null!;
|
private Label _titleLabel = null!;
|
||||||
private Label _piecesLabel = null!;
|
private Label _piecesLabel = null!;
|
||||||
private Label _turnsLabel = null!;
|
private Label _turnsLabel = null!;
|
||||||
private Label _cellsLabel = null!;
|
private Label _cellsLabel = null!;
|
||||||
private HBoxContainer _buttons = null!;
|
private HBoxContainer _buttons = null!;
|
||||||
|
private Button _nextBtn = null!;
|
||||||
|
|
||||||
public override void _Ready()
|
public override void _Ready()
|
||||||
{
|
{
|
||||||
|
|
@ -57,13 +56,9 @@ public partial class MetricsOverlay : PanelContainer
|
||||||
_buttons = new HBoxContainer { Alignment = BoxContainer.AlignmentMode.Center };
|
_buttons = new HBoxContainer { Alignment = BoxContainer.AlignmentMode.Center };
|
||||||
_buttons.AddThemeConstantOverride("separation", 16);
|
_buttons.AddThemeConstantOverride("separation", 16);
|
||||||
|
|
||||||
var retryBtn = CreateStyledButton("Rejouer");
|
_nextBtn = CreateStyledButton("Mission suivante");
|
||||||
retryBtn.Pressed += () => EmitSignal(SignalName.RetryPressed);
|
_nextBtn.Pressed += () => EmitSignal(SignalName.NextLevelPressed);
|
||||||
_buttons.AddChild(retryBtn);
|
_buttons.AddChild(_nextBtn);
|
||||||
|
|
||||||
var nextBtn = CreateStyledButton("Niveau suivant");
|
|
||||||
nextBtn.Pressed += () => EmitSignal(SignalName.NextLevelPressed);
|
|
||||||
_buttons.AddChild(nextBtn);
|
|
||||||
|
|
||||||
vbox.AddChild(_buttons);
|
vbox.AddChild(_buttons);
|
||||||
AddChild(vbox);
|
AddChild(vbox);
|
||||||
|
|
@ -109,6 +104,37 @@ public partial class MetricsOverlay : PanelContainer
|
||||||
tween.TweenProperty(_buttons, "modulate:a", 1f, 0.2f);
|
tween.TweenProperty(_buttons, "modulate:a", 1f, 0.2f);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void ShowMissionComplete(int missionNum, int turns, bool isLast)
|
||||||
|
{
|
||||||
|
_titleLabel.Text = $"MISSION {missionNum} TERMINÉE !";
|
||||||
|
_piecesLabel.Text = "";
|
||||||
|
_turnsLabel.Text = $"Coups: {turns}";
|
||||||
|
_cellsLabel.Text = "";
|
||||||
|
_nextBtn.Text = isLast ? "Campagne terminée" : "Mission suivante";
|
||||||
|
|
||||||
|
// Start invisible, fade + scale in
|
||||||
|
Modulate = new Color(1, 1, 1, 0);
|
||||||
|
Scale = new Vector2(0.85f, 0.85f);
|
||||||
|
PivotOffset = Size / 2f;
|
||||||
|
Visible = true;
|
||||||
|
|
||||||
|
_turnsLabel.Modulate = new Color(1, 1, 1, 0);
|
||||||
|
_buttons.Modulate = new Color(1, 1, 1, 0);
|
||||||
|
|
||||||
|
var tween = CreateTween();
|
||||||
|
tween.SetParallel(true);
|
||||||
|
tween.TweenProperty(this, "modulate:a", 1f, 0.3f)
|
||||||
|
.SetEase(Tween.EaseType.Out);
|
||||||
|
tween.TweenProperty(this, "scale", Vector2.One, 0.35f)
|
||||||
|
.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Back);
|
||||||
|
tween.SetParallel(false);
|
||||||
|
|
||||||
|
tween.TweenInterval(0.15f);
|
||||||
|
tween.TweenProperty(_turnsLabel, "modulate:a", 1f, 0.2f);
|
||||||
|
tween.TweenInterval(0.15f);
|
||||||
|
tween.TweenProperty(_buttons, "modulate:a", 1f, 0.2f);
|
||||||
|
}
|
||||||
|
|
||||||
public new void Hide()
|
public new void Hide()
|
||||||
{
|
{
|
||||||
Visible = false;
|
Visible = false;
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ namespace Chessistics.Scripts.UI;
|
||||||
|
|
||||||
public partial class ObjectivePanel : VBoxContainer
|
public partial class ObjectivePanel : VBoxContainer
|
||||||
{
|
{
|
||||||
private readonly Dictionary<Coords, (Label label, ProgressBar bar, Label deadline)> _entries = new();
|
private readonly Dictionary<Coords, (Label label, ProgressBar bar, bool completed)> _entries = new();
|
||||||
|
|
||||||
public void Setup(IReadOnlyList<DemandDef> demands)
|
public void Setup(IReadOnlyList<DemandDef> demands)
|
||||||
{
|
{
|
||||||
|
|
@ -57,13 +57,8 @@ public partial class ObjectivePanel : VBoxContainer
|
||||||
bar.AddThemeStyleboxOverride("fill", fillStyle);
|
bar.AddThemeStyleboxOverride("fill", fillStyle);
|
||||||
vbox.AddChild(bar);
|
vbox.AddChild(bar);
|
||||||
|
|
||||||
var deadline = new Label { Text = $"Deadline: {demand.Deadline} coups" };
|
|
||||||
deadline.AddThemeFontSizeOverride("font_size", 10);
|
|
||||||
deadline.AddThemeColorOverride("font_color", new Color("#777777"));
|
|
||||||
vbox.AddChild(deadline);
|
|
||||||
|
|
||||||
AddChild(vbox);
|
AddChild(vbox);
|
||||||
_entries[demand.Position] = (label, bar, deadline);
|
_entries[demand.Position] = (label, bar, false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -71,15 +66,21 @@ public partial class ObjectivePanel : VBoxContainer
|
||||||
{
|
{
|
||||||
if (!_entries.TryGetValue(demandCell, out var entry)) return;
|
if (!_entries.TryGetValue(demandCell, out var entry)) return;
|
||||||
|
|
||||||
entry.label.Text = $"{name}: {current}/{required}";
|
// Once completed, stop updating
|
||||||
|
if (entry.completed) return;
|
||||||
|
|
||||||
|
// Cap display at required value
|
||||||
|
int displayCurrent = Math.Min(current, required);
|
||||||
|
entry.label.Text = $"{name}: {displayCurrent}/{required}";
|
||||||
|
|
||||||
// Animate the progress bar value
|
// Animate the progress bar value
|
||||||
var tween = entry.bar.CreateTween();
|
var tween = entry.bar.CreateTween();
|
||||||
tween.TweenProperty(entry.bar, "value", (double)current, 0.2f)
|
tween.TweenProperty(entry.bar, "value", (double)displayCurrent, 0.2f)
|
||||||
.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Cubic);
|
.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Cubic);
|
||||||
|
|
||||||
if (current >= required)
|
if (current >= required)
|
||||||
{
|
{
|
||||||
|
entry.label.Text = $"{name}: {required}/{required}";
|
||||||
entry.label.AddThemeColorOverride("font_color", new Color("#5AAC5A")); // warm green
|
entry.label.AddThemeColorOverride("font_color", new Color("#5AAC5A")); // warm green
|
||||||
|
|
||||||
// Flash the progress bar green
|
// Flash the progress bar green
|
||||||
|
|
@ -90,6 +91,9 @@ public partial class ObjectivePanel : VBoxContainer
|
||||||
CornerRadiusBottomLeft = 3, CornerRadiusBottomRight = 3
|
CornerRadiusBottomLeft = 3, CornerRadiusBottomRight = 3
|
||||||
};
|
};
|
||||||
entry.bar.AddThemeStyleboxOverride("fill", fillStyle);
|
entry.bar.AddThemeStyleboxOverride("fill", fillStyle);
|
||||||
|
|
||||||
|
// Mark as completed — no further updates
|
||||||
|
_entries[demandCell] = (entry.label, entry.bar, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,8 @@ public partial class PieceStockPanel : VBoxContainer
|
||||||
{
|
{
|
||||||
Text = GetPieceName(entry.Kind),
|
Text = GetPieceName(entry.Kind),
|
||||||
CustomMinimumSize = new Vector2(120, 32),
|
CustomMinimumSize = new Vector2(120, 32),
|
||||||
ToggleMode = false // We manage selection state ourselves
|
ToggleMode = false, // We manage selection state ourselves
|
||||||
|
FocusMode = FocusModeEnum.None // Prevent spacebar from activating buttons
|
||||||
};
|
};
|
||||||
ApplyButtonStyle(button, false);
|
ApplyButtonStyle(button, false);
|
||||||
|
|
||||||
|
|
|
||||||
156
chessistics-engine/Commands/CampaignCommands.cs
Normal file
156
chessistics-engine/Commands/CampaignCommands.cs
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
using Chessistics.Engine.Events;
|
||||||
|
using Chessistics.Engine.Model;
|
||||||
|
|
||||||
|
namespace Chessistics.Engine.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Load a campaign: initializes the board with mission 0's terrain, stock, and unlocked pieces.
|
||||||
|
/// </summary>
|
||||||
|
public class LoadCampaignCommand : WorldCommand
|
||||||
|
{
|
||||||
|
public override void AssertApplicationConditions(BoardState state)
|
||||||
|
{
|
||||||
|
if (state.Campaign == null)
|
||||||
|
throw new CommandRejectedException(
|
||||||
|
new CommandRejectedEvent(nameof(LoadCampaignCommand), "No campaign defined."));
|
||||||
|
|
||||||
|
if (state.Campaign.CampaignDef.Missions.Count == 0)
|
||||||
|
throw new CommandRejectedException(
|
||||||
|
new CommandRejectedEvent(nameof(LoadCampaignCommand), "Campaign has no missions."));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void DoApply(BoardState state, List<IWorldEvent> changeList)
|
||||||
|
{
|
||||||
|
var campaign = state.Campaign!;
|
||||||
|
var mission = campaign.CurrentMission;
|
||||||
|
|
||||||
|
// Apply terrain patch for mission 0
|
||||||
|
state.ApplyTerrainPatch(mission.TerrainPatch, campaign.CurrentMissionIndex);
|
||||||
|
|
||||||
|
// Add stock
|
||||||
|
state.AddStock(mission.Stock);
|
||||||
|
|
||||||
|
// Unlock pieces and levels
|
||||||
|
foreach (var kind in mission.UnlockedPieces)
|
||||||
|
{
|
||||||
|
campaign.AvailablePieceKinds.Add(kind);
|
||||||
|
changeList.Add(new PieceUnlockedEvent(kind, 1));
|
||||||
|
}
|
||||||
|
foreach (var upgrade in mission.UnlockedLevels)
|
||||||
|
{
|
||||||
|
campaign.AvailableLevels.Add(upgrade);
|
||||||
|
changeList.Add(new PieceUnlockedEvent(upgrade.Kind, upgrade.Level));
|
||||||
|
}
|
||||||
|
|
||||||
|
state.Phase = SimPhase.Paused;
|
||||||
|
|
||||||
|
changeList.Add(new CampaignLoadedEvent(campaign.CampaignDef.Name, 0));
|
||||||
|
changeList.Add(new MissionStartedEvent(0, state.Width, state.Height));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Advance to the next mission: applies the terrain patch, adds stock, unlocks pieces.
|
||||||
|
/// </summary>
|
||||||
|
public class AdvanceMissionCommand : WorldCommand
|
||||||
|
{
|
||||||
|
public override void AssertApplicationConditions(BoardState state)
|
||||||
|
{
|
||||||
|
if (state.Campaign == null)
|
||||||
|
throw new CommandRejectedException(
|
||||||
|
new CommandRejectedEvent(nameof(AdvanceMissionCommand), "No campaign defined."));
|
||||||
|
|
||||||
|
if (state.Phase != SimPhase.MissionComplete)
|
||||||
|
throw new CommandRejectedException(
|
||||||
|
new CommandRejectedEvent(nameof(AdvanceMissionCommand), "Current mission is not complete."));
|
||||||
|
|
||||||
|
if (state.Campaign.IsLastMission)
|
||||||
|
throw new CommandRejectedException(
|
||||||
|
new CommandRejectedEvent(nameof(AdvanceMissionCommand), "No more missions."));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void DoApply(BoardState state, List<IWorldEvent> changeList)
|
||||||
|
{
|
||||||
|
var campaign = state.Campaign!;
|
||||||
|
campaign.CurrentMissionIndex++;
|
||||||
|
var mission = campaign.CurrentMission;
|
||||||
|
|
||||||
|
// Apply terrain expansion
|
||||||
|
var oldWidth = state.Width;
|
||||||
|
var oldHeight = state.Height;
|
||||||
|
state.ApplyTerrainPatch(mission.TerrainPatch, campaign.CurrentMissionIndex);
|
||||||
|
|
||||||
|
if (state.Width != oldWidth || state.Height != oldHeight)
|
||||||
|
changeList.Add(new TerrainExpandedEvent(state.Width, state.Height, mission.TerrainPatch.Cells));
|
||||||
|
|
||||||
|
// Add stock
|
||||||
|
state.AddStock(mission.Stock);
|
||||||
|
|
||||||
|
// Unlock pieces and levels
|
||||||
|
foreach (var kind in mission.UnlockedPieces)
|
||||||
|
{
|
||||||
|
campaign.AvailablePieceKinds.Add(kind);
|
||||||
|
changeList.Add(new PieceUnlockedEvent(kind, 1));
|
||||||
|
}
|
||||||
|
foreach (var upgrade in mission.UnlockedLevels)
|
||||||
|
{
|
||||||
|
campaign.AvailableLevels.Add(upgrade);
|
||||||
|
changeList.Add(new PieceUnlockedEvent(upgrade.Kind, upgrade.Level));
|
||||||
|
}
|
||||||
|
|
||||||
|
state.Phase = SimPhase.Paused;
|
||||||
|
changeList.Add(new MissionStartedEvent(campaign.CurrentMissionIndex, state.Width, state.Height));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Move a piece already on the board (drag & drop). Validates the new placement.
|
||||||
|
/// </summary>
|
||||||
|
public class MovePieceCommand : WorldCommand
|
||||||
|
{
|
||||||
|
public int PieceId { get; }
|
||||||
|
public Coords NewStart { get; }
|
||||||
|
public Coords NewEnd { get; }
|
||||||
|
|
||||||
|
public MovePieceCommand(int pieceId, Coords newStart, Coords newEnd)
|
||||||
|
{
|
||||||
|
PieceId = pieceId;
|
||||||
|
NewStart = newStart;
|
||||||
|
NewEnd = newEnd;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void AssertApplicationConditions(BoardState state)
|
||||||
|
{
|
||||||
|
var piece = state.GetPieceById(PieceId);
|
||||||
|
if (piece == null)
|
||||||
|
throw new CommandRejectedException(
|
||||||
|
new CommandRejectedEvent(nameof(MovePieceCommand), $"Piece {PieceId} not found."));
|
||||||
|
|
||||||
|
if (!state.IsOnBoard(NewStart) || !state.IsOnBoard(NewEnd))
|
||||||
|
throw new CommandRejectedException(
|
||||||
|
new CommandRejectedEvent(nameof(MovePieceCommand), "Position is off the board."));
|
||||||
|
|
||||||
|
if (state.GetCell(NewStart) == CellType.Wall)
|
||||||
|
throw new CommandRejectedException(
|
||||||
|
new CommandRejectedEvent(nameof(MovePieceCommand), "Cannot place on a wall."));
|
||||||
|
|
||||||
|
if (!Rules.MoveValidator.IsLegalPlacement(piece.Kind, NewStart, NewEnd, state))
|
||||||
|
throw new CommandRejectedException(
|
||||||
|
new CommandRejectedEvent(nameof(MovePieceCommand), "Illegal move for this piece type."));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void DoApply(BoardState state, List<IWorldEvent> changeList)
|
||||||
|
{
|
||||||
|
var piece = state.GetPieceById(PieceId)!;
|
||||||
|
var oldStart = piece.StartCell;
|
||||||
|
var oldEnd = piece.EndCell;
|
||||||
|
|
||||||
|
piece.SetPosition(NewStart, NewEnd);
|
||||||
|
piece.Cargo = null;
|
||||||
|
|
||||||
|
state.OccupiedCells.Add(NewStart);
|
||||||
|
state.OccupiedCells.Add(NewEnd);
|
||||||
|
|
||||||
|
changeList.Add(new PieceMovedByPlayerEvent(PieceId, oldStart, oldEnd, NewStart, NewEnd));
|
||||||
|
}
|
||||||
|
}
|
||||||
1
chessistics-engine/Commands/CampaignCommands.cs.uid
Normal file
1
chessistics-engine/Commands/CampaignCommands.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://b2103p4uf8f3t
|
||||||
|
|
@ -5,6 +5,10 @@ using Chessistics.Engine.Simulation;
|
||||||
|
|
||||||
namespace Chessistics.Engine.Commands;
|
namespace Chessistics.Engine.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Place a piece on the board. Works in any phase (Running or Paused).
|
||||||
|
/// The placement takes effect between turns.
|
||||||
|
/// </summary>
|
||||||
public class PlacePieceCommand : WorldCommand
|
public class PlacePieceCommand : WorldCommand
|
||||||
{
|
{
|
||||||
public PieceKind Kind { get; }
|
public PieceKind Kind { get; }
|
||||||
|
|
@ -22,10 +26,6 @@ public class PlacePieceCommand : WorldCommand
|
||||||
|
|
||||||
public override void AssertApplicationConditions(BoardState state)
|
public override void AssertApplicationConditions(BoardState state)
|
||||||
{
|
{
|
||||||
if (state.Phase != SimPhase.Edit)
|
|
||||||
throw new CommandRejectedException(
|
|
||||||
new CommandRejectedEvent(nameof(PlacePieceCommand), "Can only place pieces during Edit phase."));
|
|
||||||
|
|
||||||
if (!state.RemainingStock.TryGetValue(Kind, out var remaining) || remaining <= 0)
|
if (!state.RemainingStock.TryGetValue(Kind, out var remaining) || remaining <= 0)
|
||||||
throw new CommandRejectedException(
|
throw new CommandRejectedException(
|
||||||
new PlacementRejectedEvent(Kind, Start, End, "No pieces of this type remaining in stock."));
|
new PlacementRejectedEvent(Kind, Start, End, "No pieces of this type remaining in stock."));
|
||||||
|
|
@ -41,6 +41,16 @@ public class PlacePieceCommand : WorldCommand
|
||||||
if (!MoveValidator.IsLegalPlacement(Kind, Start, End, state))
|
if (!MoveValidator.IsLegalPlacement(Kind, Start, End, state))
|
||||||
throw new CommandRejectedException(
|
throw new CommandRejectedException(
|
||||||
new PlacementRejectedEvent(Kind, Start, End, "Illegal move for this piece type."));
|
new PlacementRejectedEvent(Kind, Start, End, "Illegal move for this piece type."));
|
||||||
|
|
||||||
|
// Check piece kind is unlocked (campaign mode)
|
||||||
|
if (state.Campaign != null && !state.Campaign.IsPieceAvailable(Kind))
|
||||||
|
throw new CommandRejectedException(
|
||||||
|
new PlacementRejectedEvent(Kind, Start, End, $"Piece type {Kind} is not unlocked yet."));
|
||||||
|
|
||||||
|
// Check piece level is unlocked (campaign mode)
|
||||||
|
if (state.Campaign != null && !state.Campaign.IsLevelAvailable(Kind, Level))
|
||||||
|
throw new CommandRejectedException(
|
||||||
|
new PlacementRejectedEvent(Kind, Start, End, $"Level {Level} for {Kind} is not unlocked yet."));
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void DoApply(BoardState state, List<IWorldEvent> changeList)
|
protected override void DoApply(BoardState state, List<IWorldEvent> changeList)
|
||||||
|
|
@ -58,20 +68,21 @@ public class PlacePieceCommand : WorldCommand
|
||||||
changeList.Add(new PiecePlacedEvent(piece.Id, Kind, Start, End));
|
changeList.Add(new PiecePlacedEvent(piece.Id, Kind, Start, End));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Auto-assign cargo filter by tracing the relay chain back to a production.
|
|
||||||
/// Priority: direct adjacency to production, then shared relay with filtered piece.
|
|
||||||
/// </summary>
|
|
||||||
private static CargoType? InferCargoFilter(BoardState state, PieceState piece)
|
private static CargoType? InferCargoFilter(BoardState state, PieceState piece)
|
||||||
{
|
{
|
||||||
// Check if start or end cell is adjacent to a production
|
|
||||||
foreach (var (prodPos, prod) in state.Productions)
|
foreach (var (prodPos, prod) in state.Productions)
|
||||||
{
|
{
|
||||||
if (piece.StartCell.IsAdjacent4(prodPos) || piece.EndCell.IsAdjacent4(prodPos))
|
if (piece.StartCell.IsAdjacent4(prodPos) || piece.EndCell.IsAdjacent4(prodPos))
|
||||||
return prod.Cargo;
|
return prod.Cargo;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if start or end shares a relay point with an existing piece that has a filter
|
// Transformer output acts like a production
|
||||||
|
foreach (var (tPos, transformer) in state.Transformers)
|
||||||
|
{
|
||||||
|
if (piece.StartCell.IsAdjacent4(tPos) || piece.EndCell.IsAdjacent4(tPos))
|
||||||
|
return transformer.OutputCargo;
|
||||||
|
}
|
||||||
|
|
||||||
foreach (var existing in state.Pieces)
|
foreach (var existing in state.Pieces)
|
||||||
{
|
{
|
||||||
if (existing.CargoFilter == null) continue;
|
if (existing.CargoFilter == null) continue;
|
||||||
|
|
@ -89,6 +100,9 @@ public class PlacePieceCommand : WorldCommand
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Remove a piece from the board. Works in any phase.
|
||||||
|
/// </summary>
|
||||||
public class RemovePieceCommand : WorldCommand
|
public class RemovePieceCommand : WorldCommand
|
||||||
{
|
{
|
||||||
public int PieceId { get; }
|
public int PieceId { get; }
|
||||||
|
|
@ -100,10 +114,6 @@ public class RemovePieceCommand : WorldCommand
|
||||||
|
|
||||||
public override void AssertApplicationConditions(BoardState state)
|
public override void AssertApplicationConditions(BoardState state)
|
||||||
{
|
{
|
||||||
if (state.Phase != SimPhase.Edit)
|
|
||||||
throw new CommandRejectedException(
|
|
||||||
new CommandRejectedEvent(nameof(RemovePieceCommand), "Can only remove pieces during Edit phase."));
|
|
||||||
|
|
||||||
if (state.GetPieceById(PieceId) == null)
|
if (state.GetPieceById(PieceId) == null)
|
||||||
throw new CommandRejectedException(
|
throw new CommandRejectedException(
|
||||||
new CommandRejectedEvent(nameof(RemovePieceCommand), $"Piece {PieceId} not found."));
|
new CommandRejectedEvent(nameof(RemovePieceCommand), $"Piece {PieceId} not found."));
|
||||||
|
|
@ -119,26 +129,6 @@ public class RemovePieceCommand : WorldCommand
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public class StartSimulationCommand : WorldCommand
|
|
||||||
{
|
|
||||||
public override void AssertApplicationConditions(BoardState state)
|
|
||||||
{
|
|
||||||
if (state.Phase != SimPhase.Edit)
|
|
||||||
throw new CommandRejectedException(
|
|
||||||
new CommandRejectedEvent(nameof(StartSimulationCommand), "Can only start from Edit phase."));
|
|
||||||
|
|
||||||
if (state.Pieces.Count == 0)
|
|
||||||
throw new CommandRejectedException(
|
|
||||||
new CommandRejectedEvent(nameof(StartSimulationCommand), "Place at least one piece before starting."));
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override void DoApply(BoardState state, List<IWorldEvent> changeList)
|
|
||||||
{
|
|
||||||
state.Phase = SimPhase.Running;
|
|
||||||
changeList.Add(new SimulationStartedEvent());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public class PauseSimulationCommand : WorldCommand
|
public class PauseSimulationCommand : WorldCommand
|
||||||
{
|
{
|
||||||
public override void AssertApplicationConditions(BoardState state)
|
public override void AssertApplicationConditions(BoardState state)
|
||||||
|
|
@ -159,9 +149,9 @@ public class ResumeSimulationCommand : WorldCommand
|
||||||
{
|
{
|
||||||
public override void AssertApplicationConditions(BoardState state)
|
public override void AssertApplicationConditions(BoardState state)
|
||||||
{
|
{
|
||||||
if (state.Phase != SimPhase.Paused)
|
if (state.Phase != SimPhase.Paused && state.Phase != SimPhase.MissionComplete)
|
||||||
throw new CommandRejectedException(
|
throw new CommandRejectedException(
|
||||||
new CommandRejectedEvent(nameof(ResumeSimulationCommand), "Can only resume from Paused phase."));
|
new CommandRejectedEvent(nameof(ResumeSimulationCommand), "Can only resume from Paused or MissionComplete phase."));
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void DoApply(BoardState state, List<IWorldEvent> changeList)
|
protected override void DoApply(BoardState state, List<IWorldEvent> changeList)
|
||||||
|
|
@ -175,72 +165,20 @@ public class StepSimulationCommand : WorldCommand
|
||||||
{
|
{
|
||||||
public override void AssertApplicationConditions(BoardState state)
|
public override void AssertApplicationConditions(BoardState state)
|
||||||
{
|
{
|
||||||
if (state.Phase == SimPhase.Edit && state.Pieces.Count == 0)
|
if (state.Phase != SimPhase.Running && state.Phase != SimPhase.Paused)
|
||||||
throw new CommandRejectedException(
|
|
||||||
new CommandRejectedEvent(nameof(StepSimulationCommand), "Place at least one piece before stepping."));
|
|
||||||
|
|
||||||
if (state.Phase != SimPhase.Edit && state.Phase != SimPhase.Running && state.Phase != SimPhase.Paused)
|
|
||||||
throw new CommandRejectedException(
|
throw new CommandRejectedException(
|
||||||
new CommandRejectedEvent(nameof(StepSimulationCommand), "Cannot step in current phase."));
|
new CommandRejectedEvent(nameof(StepSimulationCommand), "Cannot step in current phase."));
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void DoApply(BoardState state, List<IWorldEvent> changeList)
|
protected override void DoApply(BoardState state, List<IWorldEvent> changeList)
|
||||||
{
|
{
|
||||||
if (state.Phase == SimPhase.Edit)
|
var wasRunning = state.Phase == SimPhase.Running;
|
||||||
state.Phase = SimPhase.Paused;
|
|
||||||
|
|
||||||
TurnExecutor.ExecuteTurn(state, changeList);
|
TurnExecutor.ExecuteTurn(state, changeList);
|
||||||
|
|
||||||
// After a step, remain in Paused unless victory/defeat occurred
|
// After a manual step (was Paused), remain Paused.
|
||||||
if (state.Phase == SimPhase.Running)
|
// After an auto-play step (was Running), stay Running unless
|
||||||
|
// TurnExecutor changed it (collision → Paused, last mission → MissionComplete).
|
||||||
|
if (!wasRunning && state.Phase == SimPhase.Running)
|
||||||
state.Phase = SimPhase.Paused;
|
state.Phase = SimPhase.Paused;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public class StopSimulationCommand : WorldCommand
|
|
||||||
{
|
|
||||||
public override void AssertApplicationConditions(BoardState state)
|
|
||||||
{
|
|
||||||
if (state.Phase == SimPhase.Edit)
|
|
||||||
throw new CommandRejectedException(
|
|
||||||
new CommandRejectedEvent(nameof(StopSimulationCommand), "Already in Edit phase."));
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override void DoApply(BoardState state, List<IWorldEvent> changeList)
|
|
||||||
{
|
|
||||||
// Restore destroyed pieces
|
|
||||||
state.Pieces.AddRange(state.DestroyedPieces);
|
|
||||||
state.DestroyedPieces.Clear();
|
|
||||||
|
|
||||||
foreach (var piece in state.Pieces)
|
|
||||||
{
|
|
||||||
piece.CurrentCell = piece.StartCell;
|
|
||||||
piece.Cargo = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var pos in state.ProductionBuffers.Keys.ToList())
|
|
||||||
state.ProductionBuffers[pos] = 0;
|
|
||||||
|
|
||||||
foreach (var demand in state.Demands.Values)
|
|
||||||
demand.ReceivedCount = 0;
|
|
||||||
|
|
||||||
state.TurnNumber = 0;
|
|
||||||
state.Phase = SimPhase.Edit;
|
|
||||||
|
|
||||||
changeList.Add(new SimulationStoppedEvent());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public class ResetLevelCommand : WorldCommand
|
|
||||||
{
|
|
||||||
public override void AssertApplicationConditions(BoardState state)
|
|
||||||
{
|
|
||||||
// Reset is always valid
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override void DoApply(BoardState state, List<IWorldEvent> changeList)
|
|
||||||
{
|
|
||||||
state.ResetFromLevel();
|
|
||||||
changeList.Add(new LevelResetEvent());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -2,26 +2,34 @@ using Chessistics.Engine.Model;
|
||||||
|
|
||||||
namespace Chessistics.Engine.Events;
|
namespace Chessistics.Engine.Events;
|
||||||
|
|
||||||
// Edit phase events
|
// Placement events (work in any phase)
|
||||||
public record PiecePlacedEvent(int PieceId, PieceKind Kind, Coords Start, Coords End) : IWorldEvent;
|
public record PiecePlacedEvent(int PieceId, PieceKind Kind, Coords Start, Coords End) : IWorldEvent;
|
||||||
public record PieceRemovedEvent(int PieceId) : IWorldEvent;
|
public record PieceRemovedEvent(int PieceId) : IWorldEvent;
|
||||||
public record PlacementRejectedEvent(PieceKind Kind, Coords Start, Coords End, string Reason) : IWorldEvent;
|
public record PlacementRejectedEvent(PieceKind Kind, Coords Start, Coords End, string Reason) : IWorldEvent;
|
||||||
public record CommandRejectedEvent(string CommandType, string Reason) : IWorldEvent;
|
public record CommandRejectedEvent(string CommandType, string Reason) : IWorldEvent;
|
||||||
|
|
||||||
// Simulation lifecycle events
|
// Simulation lifecycle events
|
||||||
public record SimulationStartedEvent : IWorldEvent;
|
|
||||||
public record SimulationPausedEvent : IWorldEvent;
|
public record SimulationPausedEvent : IWorldEvent;
|
||||||
public record SimulationResumedEvent : IWorldEvent;
|
public record SimulationResumedEvent : IWorldEvent;
|
||||||
public record SimulationStoppedEvent : IWorldEvent;
|
|
||||||
public record LevelResetEvent : IWorldEvent;
|
// Campaign events
|
||||||
|
public record CampaignLoadedEvent(string CampaignName, int MissionIndex) : IWorldEvent;
|
||||||
|
public record MissionCompleteEvent(int TurnNumber, int MissionIndex) : IWorldEvent;
|
||||||
|
public record MissionStartedEvent(int MissionIndex, int NewWidth, int NewHeight) : IWorldEvent;
|
||||||
|
public record TerrainExpandedEvent(int NewWidth, int NewHeight, IReadOnlyList<PatchCell> NewCells) : IWorldEvent;
|
||||||
|
public record PieceUnlockedEvent(PieceKind Kind, int Level) : IWorldEvent;
|
||||||
|
|
||||||
|
// Transformer events
|
||||||
|
public record CargoConvertedEvent(int TurnNumber, Coords TransformerCell, CargoType InputCargo, CargoType OutputCargo, int OutputAmount) : IWorldEvent;
|
||||||
|
|
||||||
// Turn events — all carry TurnNumber for animation grouping
|
// Turn events — all carry TurnNumber for animation grouping
|
||||||
public record TurnStartedEvent(int TurnNumber) : IWorldEvent;
|
public record TurnStartedEvent(int TurnNumber) : IWorldEvent;
|
||||||
public record PieceMovedEvent(int TurnNumber, int PieceId, Coords From, Coords To) : IWorldEvent;
|
public record PieceMovedEvent(int TurnNumber, int PieceId, Coords From, Coords To) : IWorldEvent;
|
||||||
public record PieceDestroyedEvent(int TurnNumber, int PieceId, int? DestroyerPieceId, Coords Cell) : IWorldEvent;
|
public record PieceReturnedToStockEvent(int TurnNumber, int PieceId, PieceKind Kind, int? DestroyerPieceId, Coords Cell) : IWorldEvent;
|
||||||
public record CargoTransferredEvent(int TurnNumber, Coords From, Coords To, CargoType Type, int? GivingPieceId, int? ReceivingPieceId) : IWorldEvent;
|
public record CargoTransferredEvent(int TurnNumber, Coords From, Coords To, CargoType Type, int? GivingPieceId, int? ReceivingPieceId) : IWorldEvent;
|
||||||
public record CargoProducedEvent(int TurnNumber, Coords ProductionCell, CargoType Type) : IWorldEvent;
|
public record CargoProducedEvent(int TurnNumber, Coords ProductionCell, CargoType Type) : IWorldEvent;
|
||||||
public record DemandProgressEvent(int TurnNumber, Coords DemandCell, string Name, int Current, int Required) : IWorldEvent;
|
public record DemandProgressEvent(int TurnNumber, Coords DemandCell, string Name, int Current, int Required) : IWorldEvent;
|
||||||
public record VictoryEvent(int TurnNumber, Metrics Metrics) : IWorldEvent;
|
|
||||||
public record DeadlineExpiredEvent(int TurnNumber, Coords DemandCell, string Name) : IWorldEvent;
|
|
||||||
public record TurnEndedEvent(int TurnNumber) : IWorldEvent;
|
public record TurnEndedEvent(int TurnNumber) : IWorldEvent;
|
||||||
|
|
||||||
|
// Drag & drop
|
||||||
|
public record PieceMovedByPlayerEvent(int PieceId, Coords OldStart, Coords OldEnd, Coords NewStart, Coords NewEnd) : IWorldEvent;
|
||||||
|
|
|
||||||
216
chessistics-engine/Loading/CampaignLoader.cs
Normal file
216
chessistics-engine/Loading/CampaignLoader.cs
Normal 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
1
chessistics-engine/Loading/CampaignLoader.cs.uid
Normal file
1
chessistics-engine/Loading/CampaignLoader.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://dq4ycsj6oc1nh
|
||||||
|
|
@ -8,10 +8,14 @@ public class BoardSnapshot
|
||||||
public IReadOnlyList<ProductionSnapshot> Productions { get; }
|
public IReadOnlyList<ProductionSnapshot> Productions { get; }
|
||||||
public IReadOnlyList<DemandSnapshot> Demands { get; }
|
public IReadOnlyList<DemandSnapshot> Demands { get; }
|
||||||
public IReadOnlyList<PieceSnapshot> Pieces { get; }
|
public IReadOnlyList<PieceSnapshot> Pieces { get; }
|
||||||
|
public IReadOnlyList<TransformerSnapshot> Transformers { get; }
|
||||||
public SimPhase Phase { get; }
|
public SimPhase Phase { get; }
|
||||||
public int TurnNumber { get; }
|
public int TurnNumber { get; }
|
||||||
public IReadOnlyDictionary<PieceKind, int> RemainingStock { get; }
|
public IReadOnlyDictionary<PieceKind, int> RemainingStock { get; }
|
||||||
|
|
||||||
|
// Campaign info (null in legacy level mode)
|
||||||
|
public CampaignSnapshot? Campaign { get; }
|
||||||
|
|
||||||
public BoardSnapshot(BoardState state)
|
public BoardSnapshot(BoardState state)
|
||||||
{
|
{
|
||||||
Width = state.Width;
|
Width = state.Width;
|
||||||
|
|
@ -28,17 +32,35 @@ public class BoardSnapshot
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
Demands = state.Demands.Values
|
Demands = state.Demands.Values
|
||||||
.Select(d => new DemandSnapshot(d.Position, d.Name, d.Cargo, d.Required, d.Deadline, d.ReceivedCount, d.IsSatisfied))
|
.Select(d => new DemandSnapshot(d.Position, d.Name, d.Cargo, d.Required, d.Deadline, d.ReceivedCount, d.IsSatisfied, d.MissionIndex))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
Pieces = state.Pieces
|
Pieces = state.Pieces
|
||||||
.Select(p => new PieceSnapshot(p.Id, p.Kind, p.Level, p.StartCell, p.EndCell, p.CurrentCell, p.Cargo, p.CargoFilter, p.SocialStatus))
|
.Select(p => new PieceSnapshot(p.Id, p.Kind, p.Level, p.StartCell, p.EndCell, p.CurrentCell, p.Cargo, p.CargoFilter, p.SocialStatus))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
|
Transformers = state.Transformers.Values
|
||||||
|
.Select(t => new TransformerSnapshot(t.Position, t.Name, t.InputCargo, t.InputRequired, t.OutputCargo, t.OutputAmount,
|
||||||
|
state.TransformerInputBuffers[t.Position], state.TransformerOutputBuffers[t.Position]))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
RemainingStock = new Dictionary<PieceKind, int>(state.RemainingStock);
|
RemainingStock = new Dictionary<PieceKind, int>(state.RemainingStock);
|
||||||
|
|
||||||
|
if (state.Campaign != null)
|
||||||
|
{
|
||||||
|
Campaign = new CampaignSnapshot(
|
||||||
|
state.Campaign.CampaignDef.Name,
|
||||||
|
state.Campaign.CurrentMissionIndex,
|
||||||
|
state.Campaign.CompletedMissions.ToList(),
|
||||||
|
state.Campaign.AvailablePieceKinds.ToHashSet(),
|
||||||
|
state.Campaign.AvailableLevels.ToHashSet()
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public record ProductionSnapshot(Coords Position, string Name, CargoType Cargo, int Amount, int BufferCount);
|
public record ProductionSnapshot(Coords Position, string Name, CargoType Cargo, int Amount, int BufferCount);
|
||||||
public record DemandSnapshot(Coords Position, string Name, CargoType Cargo, int Required, int Deadline, int ReceivedCount, bool IsSatisfied);
|
public record DemandSnapshot(Coords Position, string Name, CargoType Cargo, int Required, int Deadline, int ReceivedCount, bool IsSatisfied, int MissionIndex = 0);
|
||||||
public record PieceSnapshot(int Id, PieceKind Kind, int Level, Coords StartCell, Coords EndCell, Coords CurrentCell, CargoType? Cargo, CargoType? CargoFilter, int SocialStatus);
|
public record PieceSnapshot(int Id, PieceKind Kind, int Level, Coords StartCell, Coords EndCell, Coords CurrentCell, CargoType? Cargo, CargoType? CargoFilter, int SocialStatus);
|
||||||
|
public record TransformerSnapshot(Coords Position, string Name, CargoType InputCargo, int InputRequired, CargoType OutputCargo, int OutputAmount, int InputBufferCount, int OutputBufferCount);
|
||||||
|
public record CampaignSnapshot(string Name, int CurrentMissionIndex, IReadOnlyList<int> CompletedMissions, IReadOnlySet<PieceKind> AvailablePieceKinds, IReadOnlySet<PieceUpgrade> AvailableLevels);
|
||||||
|
|
|
||||||
|
|
@ -4,98 +4,86 @@ namespace Chessistics.Engine.Model;
|
||||||
|
|
||||||
public class BoardState
|
public class BoardState
|
||||||
{
|
{
|
||||||
public int Width { get; }
|
public int Width { get; private set; }
|
||||||
public int Height { get; }
|
public int Height { get; private set; }
|
||||||
public CellType[,] Grid { get; }
|
public CellType[,] Grid { get; private set; }
|
||||||
public Dictionary<Coords, ProductionDef> Productions { get; }
|
public Dictionary<Coords, ProductionDef> Productions { get; }
|
||||||
public Dictionary<Coords, DemandState> Demands { get; }
|
public Dictionary<Coords, DemandState> Demands { get; }
|
||||||
public List<PieceState> Pieces { get; }
|
public List<PieceState> Pieces { get; }
|
||||||
public List<PieceState> DestroyedPieces { get; } = new();
|
public List<PieceState> DestroyedPieces { get; } = new();
|
||||||
public Dictionary<Coords, int> ProductionBuffers { get; }
|
public Dictionary<Coords, int> ProductionBuffers { get; }
|
||||||
|
public Dictionary<Coords, TransformerDef> Transformers { get; }
|
||||||
|
public Dictionary<Coords, int> TransformerInputBuffers { get; }
|
||||||
|
public Dictionary<Coords, int> TransformerOutputBuffers { get; }
|
||||||
public SimPhase Phase { get; set; }
|
public SimPhase Phase { get; set; }
|
||||||
public int TurnNumber { get; set; }
|
public int TurnNumber { get; set; }
|
||||||
public int NextPieceId { get; set; }
|
public int NextPieceId { get; set; }
|
||||||
public Dictionary<PieceKind, int> RemainingStock { get; }
|
public Dictionary<PieceKind, int> RemainingStock { get; }
|
||||||
public int MaxDeadline { get; }
|
|
||||||
|
// Campaign state (null for legacy level mode)
|
||||||
|
public CampaignState? Campaign { get; private set; }
|
||||||
|
|
||||||
// Tracks all cells ever occupied by a piece (for metrics)
|
// Tracks all cells ever occupied by a piece (for metrics)
|
||||||
public HashSet<Coords> OccupiedCells { get; }
|
public HashSet<Coords> OccupiedCells { get; }
|
||||||
|
|
||||||
private readonly LevelDef _levelDef;
|
private readonly LevelDef? _levelDef;
|
||||||
private bool _isApplyingCommand;
|
private bool _isApplyingCommand;
|
||||||
|
|
||||||
private BoardState(LevelDef level)
|
private BoardState(int width, int height)
|
||||||
{
|
{
|
||||||
_levelDef = level;
|
Width = width;
|
||||||
Width = level.Width;
|
Height = height;
|
||||||
Height = level.Height;
|
|
||||||
MaxDeadline = level.MaxDeadline;
|
|
||||||
|
|
||||||
Grid = new CellType[Width, Height];
|
Grid = new CellType[Width, Height];
|
||||||
Productions = new Dictionary<Coords, ProductionDef>();
|
Productions = new Dictionary<Coords, ProductionDef>();
|
||||||
Demands = new Dictionary<Coords, DemandState>();
|
Demands = new Dictionary<Coords, DemandState>();
|
||||||
Pieces = new List<PieceState>();
|
Pieces = new List<PieceState>();
|
||||||
ProductionBuffers = new Dictionary<Coords, int>();
|
ProductionBuffers = new Dictionary<Coords, int>();
|
||||||
|
Transformers = new Dictionary<Coords, TransformerDef>();
|
||||||
|
TransformerInputBuffers = new Dictionary<Coords, int>();
|
||||||
|
TransformerOutputBuffers = new Dictionary<Coords, int>();
|
||||||
RemainingStock = new Dictionary<PieceKind, int>();
|
RemainingStock = new Dictionary<PieceKind, int>();
|
||||||
OccupiedCells = new HashSet<Coords>();
|
OccupiedCells = new HashSet<Coords>();
|
||||||
|
|
||||||
Phase = SimPhase.Edit;
|
Phase = SimPhase.Paused;
|
||||||
TurnNumber = 0;
|
TurnNumber = 0;
|
||||||
NextPieceId = 1;
|
NextPieceId = 1;
|
||||||
|
|
||||||
// Initialize grid as empty
|
|
||||||
for (int c = 0; c < Width; c++)
|
for (int c = 0; c < Width; c++)
|
||||||
for (int r = 0; r < Height; r++)
|
for (int r = 0; r < Height; r++)
|
||||||
Grid[c, r] = CellType.Empty;
|
Grid[c, r] = CellType.Empty;
|
||||||
|
|
||||||
// Place walls
|
|
||||||
foreach (var wall in level.Walls)
|
|
||||||
Grid[wall.Col, wall.Row] = CellType.Wall;
|
|
||||||
|
|
||||||
// Place productions
|
|
||||||
foreach (var prod in level.Productions)
|
|
||||||
{
|
|
||||||
Grid[prod.Position.Col, prod.Position.Row] = CellType.Production;
|
|
||||||
Productions[prod.Position] = prod;
|
|
||||||
ProductionBuffers[prod.Position] = 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Place demands
|
private BoardState(LevelDef level) : this(level.Width, level.Height)
|
||||||
foreach (var demand in level.Demands)
|
|
||||||
{
|
{
|
||||||
Grid[demand.Position.Col, demand.Position.Row] = CellType.Demand;
|
_levelDef = level;
|
||||||
Demands[demand.Position] = new DemandState(demand);
|
ApplyLevelDef(level);
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize stock
|
|
||||||
foreach (var stock in level.Stock)
|
|
||||||
RemainingStock[stock.Kind] = stock.Count;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static BoardState FromLevel(LevelDef level) => new(level);
|
public static BoardState FromLevel(LevelDef level) => new(level);
|
||||||
|
|
||||||
|
public static BoardState FromCampaign(CampaignDef campaignDef)
|
||||||
|
{
|
||||||
|
var state = new BoardState(campaignDef.InitialWidth, campaignDef.InitialHeight);
|
||||||
|
state.Campaign = new CampaignState(campaignDef);
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
public CellType GetCell(Coords coords) => Grid[coords.Col, coords.Row];
|
public CellType GetCell(Coords coords) => Grid[coords.Col, coords.Row];
|
||||||
|
|
||||||
public bool IsOnBoard(Coords coords) => coords.IsOnBoard(Width, Height);
|
public bool IsOnBoard(Coords coords) => coords.IsOnBoard(Width, Height);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns all cells currently occupied by any piece (both start and end during Edit, CurrentCell during sim).
|
/// Returns all cells currently occupied by any piece.
|
||||||
|
/// In campaign mode (no Edit phase), always uses CurrentCell.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public HashSet<Coords> GetOccupiedCells()
|
public HashSet<Coords> GetOccupiedCells()
|
||||||
{
|
{
|
||||||
var occupied = new HashSet<Coords>();
|
var occupied = new HashSet<Coords>();
|
||||||
foreach (var piece in Pieces)
|
foreach (var piece in Pieces)
|
||||||
{
|
|
||||||
if (Phase == SimPhase.Edit)
|
|
||||||
{
|
|
||||||
occupied.Add(piece.StartCell);
|
|
||||||
occupied.Add(piece.EndCell);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
{
|
||||||
occupied.Add(piece.CurrentCell);
|
occupied.Add(piece.CurrentCell);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return occupied;
|
return occupied;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -117,17 +105,96 @@ public class BoardState
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Expand the board to new dimensions, preserving existing state.
|
||||||
|
/// </summary>
|
||||||
|
public void Resize(int newWidth, int newHeight)
|
||||||
|
{
|
||||||
|
if (newWidth < Width || newHeight < Height)
|
||||||
|
throw new InvalidOperationException("Cannot shrink the board.");
|
||||||
|
|
||||||
|
if (newWidth == Width && newHeight == Height)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var newGrid = new CellType[newWidth, newHeight];
|
||||||
|
for (int c = 0; c < newWidth; c++)
|
||||||
|
for (int r = 0; r < newHeight; r++)
|
||||||
|
newGrid[c, r] = CellType.Empty;
|
||||||
|
|
||||||
|
// Copy existing grid
|
||||||
|
for (int c = 0; c < Width; c++)
|
||||||
|
for (int r = 0; r < Height; r++)
|
||||||
|
newGrid[c, r] = Grid[c, r];
|
||||||
|
|
||||||
|
Grid = newGrid;
|
||||||
|
Width = newWidth;
|
||||||
|
Height = newHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Apply a terrain patch (new cells, productions, demands, walls).
|
||||||
|
/// </summary>
|
||||||
|
public void ApplyTerrainPatch(TerrainPatch patch, int missionIndex)
|
||||||
|
{
|
||||||
|
if (patch.NewWidth > Width || patch.NewHeight > Height)
|
||||||
|
Resize(patch.NewWidth, patch.NewHeight);
|
||||||
|
|
||||||
|
foreach (var cell in patch.Cells)
|
||||||
|
{
|
||||||
|
var coords = new Coords(cell.Col, cell.Row);
|
||||||
|
|
||||||
|
// Always clear existing buildings before overwriting
|
||||||
|
ClearBuildingAt(coords);
|
||||||
|
Grid[cell.Col, cell.Row] = cell.Type;
|
||||||
|
|
||||||
|
switch (cell.Type)
|
||||||
|
{
|
||||||
|
case CellType.Production when cell.Production != null:
|
||||||
|
Productions[coords] = cell.Production;
|
||||||
|
ProductionBuffers[coords] = 0;
|
||||||
|
break;
|
||||||
|
case CellType.Demand when cell.Demand != null:
|
||||||
|
Demands[coords] = new DemandState(cell.Demand, missionIndex);
|
||||||
|
break;
|
||||||
|
case CellType.Transformer when cell.Transformer != null:
|
||||||
|
Transformers[coords] = cell.Transformer;
|
||||||
|
TransformerInputBuffers[coords] = 0;
|
||||||
|
TransformerOutputBuffers[coords] = 0;
|
||||||
|
break;
|
||||||
|
case CellType.Wall:
|
||||||
|
// Remove pieces whose start or end cell is on this wall
|
||||||
|
RemovePiecesOnCell(coords);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Add stock to the remaining stock (cumulative for campaigns).
|
||||||
|
/// </summary>
|
||||||
|
public void AddStock(IReadOnlyList<PieceStock> stock)
|
||||||
|
{
|
||||||
|
foreach (var s in stock)
|
||||||
|
RemainingStock[s.Kind] = RemainingStock.GetValueOrDefault(s.Kind) + s.Count;
|
||||||
|
}
|
||||||
|
|
||||||
public void ResetFromLevel()
|
public void ResetFromLevel()
|
||||||
{
|
{
|
||||||
|
if (_levelDef == null)
|
||||||
|
throw new InvalidOperationException("Cannot reset: no level definition.");
|
||||||
|
|
||||||
Pieces.Clear();
|
Pieces.Clear();
|
||||||
DestroyedPieces.Clear();
|
DestroyedPieces.Clear();
|
||||||
Productions.Clear();
|
Productions.Clear();
|
||||||
Demands.Clear();
|
Demands.Clear();
|
||||||
ProductionBuffers.Clear();
|
ProductionBuffers.Clear();
|
||||||
|
Transformers.Clear();
|
||||||
|
TransformerInputBuffers.Clear();
|
||||||
|
TransformerOutputBuffers.Clear();
|
||||||
RemainingStock.Clear();
|
RemainingStock.Clear();
|
||||||
OccupiedCells.Clear();
|
OccupiedCells.Clear();
|
||||||
|
|
||||||
Phase = SimPhase.Edit;
|
Phase = SimPhase.Paused;
|
||||||
TurnNumber = 0;
|
TurnNumber = 0;
|
||||||
NextPieceId = 1;
|
NextPieceId = 1;
|
||||||
|
|
||||||
|
|
@ -135,23 +202,55 @@ public class BoardState
|
||||||
for (int r = 0; r < Height; r++)
|
for (int r = 0; r < Height; r++)
|
||||||
Grid[c, r] = CellType.Empty;
|
Grid[c, r] = CellType.Empty;
|
||||||
|
|
||||||
foreach (var wall in _levelDef.Walls)
|
ApplyLevelDef(_levelDef);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Remove pieces whose StartCell or EndCell is on the given cell (return to stock).
|
||||||
|
/// Used when a wall overwrites an occupied cell during terrain patching.
|
||||||
|
/// </summary>
|
||||||
|
private void RemovePiecesOnCell(Coords coords)
|
||||||
|
{
|
||||||
|
var toRemove = Pieces
|
||||||
|
.Where(p => p.StartCell == coords || p.EndCell == coords)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
foreach (var piece in toRemove)
|
||||||
|
{
|
||||||
|
Pieces.Remove(piece);
|
||||||
|
RemainingStock[piece.Kind] = RemainingStock.GetValueOrDefault(piece.Kind) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ClearBuildingAt(Coords coords)
|
||||||
|
{
|
||||||
|
Productions.Remove(coords);
|
||||||
|
ProductionBuffers.Remove(coords);
|
||||||
|
Demands.Remove(coords);
|
||||||
|
Transformers.Remove(coords);
|
||||||
|
TransformerInputBuffers.Remove(coords);
|
||||||
|
TransformerOutputBuffers.Remove(coords);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyLevelDef(LevelDef level)
|
||||||
|
{
|
||||||
|
foreach (var wall in level.Walls)
|
||||||
Grid[wall.Col, wall.Row] = CellType.Wall;
|
Grid[wall.Col, wall.Row] = CellType.Wall;
|
||||||
|
|
||||||
foreach (var prod in _levelDef.Productions)
|
foreach (var prod in level.Productions)
|
||||||
{
|
{
|
||||||
Grid[prod.Position.Col, prod.Position.Row] = CellType.Production;
|
Grid[prod.Position.Col, prod.Position.Row] = CellType.Production;
|
||||||
Productions[prod.Position] = prod;
|
Productions[prod.Position] = prod;
|
||||||
ProductionBuffers[prod.Position] = 0;
|
ProductionBuffers[prod.Position] = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var demand in _levelDef.Demands)
|
foreach (var demand in level.Demands)
|
||||||
{
|
{
|
||||||
Grid[demand.Position.Col, demand.Position.Row] = CellType.Demand;
|
Grid[demand.Position.Col, demand.Position.Row] = CellType.Demand;
|
||||||
Demands[demand.Position] = new DemandState(demand);
|
Demands[demand.Position] = new DemandState(demand);
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var stock in _levelDef.Stock)
|
foreach (var stock in level.Stock)
|
||||||
RemainingStock[stock.Kind] = stock.Count;
|
RemainingStock[stock.Kind] = stock.Count;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
9
chessistics-engine/Model/CampaignDef.cs
Normal file
9
chessistics-engine/Model/CampaignDef.cs
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
namespace Chessistics.Engine.Model;
|
||||||
|
|
||||||
|
public class CampaignDef
|
||||||
|
{
|
||||||
|
public string Name { get; init; } = "";
|
||||||
|
public int InitialWidth { get; init; }
|
||||||
|
public int InitialHeight { get; init; }
|
||||||
|
public IReadOnlyList<MissionDef> Missions { get; init; } = [];
|
||||||
|
}
|
||||||
1
chessistics-engine/Model/CampaignDef.cs.uid
Normal file
1
chessistics-engine/Model/CampaignDef.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://cpyjhyp308ybb
|
||||||
22
chessistics-engine/Model/CampaignState.cs
Normal file
22
chessistics-engine/Model/CampaignState.cs
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
namespace Chessistics.Engine.Model;
|
||||||
|
|
||||||
|
public class CampaignState
|
||||||
|
{
|
||||||
|
public CampaignDef CampaignDef { get; }
|
||||||
|
public int CurrentMissionIndex { get; set; }
|
||||||
|
public List<int> CompletedMissions { get; } = new();
|
||||||
|
public HashSet<PieceKind> AvailablePieceKinds { get; } = new();
|
||||||
|
public HashSet<PieceUpgrade> AvailableLevels { get; } = new();
|
||||||
|
|
||||||
|
public CampaignState(CampaignDef campaignDef)
|
||||||
|
{
|
||||||
|
CampaignDef = campaignDef;
|
||||||
|
CurrentMissionIndex = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public MissionDef CurrentMission => CampaignDef.Missions[CurrentMissionIndex];
|
||||||
|
public bool IsLastMission => CurrentMissionIndex >= CampaignDef.Missions.Count - 1;
|
||||||
|
|
||||||
|
public bool IsPieceAvailable(PieceKind kind) => AvailablePieceKinds.Contains(kind);
|
||||||
|
public bool IsLevelAvailable(PieceKind kind, int level) => AvailableLevels.Contains(new PieceUpgrade(kind, level));
|
||||||
|
}
|
||||||
1
chessistics-engine/Model/CampaignState.cs.uid
Normal file
1
chessistics-engine/Model/CampaignState.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://bxmuxyxroua54
|
||||||
|
|
@ -3,5 +3,8 @@ namespace Chessistics.Engine.Model;
|
||||||
public enum CargoType
|
public enum CargoType
|
||||||
{
|
{
|
||||||
Wood,
|
Wood,
|
||||||
Stone
|
Stone,
|
||||||
|
Tools,
|
||||||
|
Arms,
|
||||||
|
Gold
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,5 +5,6 @@ public enum CellType
|
||||||
Empty,
|
Empty,
|
||||||
Wall,
|
Wall,
|
||||||
Production,
|
Production,
|
||||||
Demand
|
Demand,
|
||||||
|
Transformer
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
namespace Chessistics.Engine.Model;
|
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);
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,12 @@ public class DemandState
|
||||||
{
|
{
|
||||||
public DemandDef Definition { get; }
|
public DemandDef Definition { get; }
|
||||||
public int ReceivedCount { get; set; }
|
public int ReceivedCount { get; set; }
|
||||||
|
public int MissionIndex { get; }
|
||||||
|
|
||||||
public DemandState(DemandDef definition)
|
public DemandState(DemandDef definition, int missionIndex = 0)
|
||||||
{
|
{
|
||||||
Definition = definition;
|
Definition = definition;
|
||||||
|
MissionIndex = missionIndex;
|
||||||
ReceivedCount = 0;
|
ReceivedCount = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
14
chessistics-engine/Model/MissionDef.cs
Normal file
14
chessistics-engine/Model/MissionDef.cs
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
namespace Chessistics.Engine.Model;
|
||||||
|
|
||||||
|
public class MissionDef
|
||||||
|
{
|
||||||
|
public int Id { get; init; }
|
||||||
|
public string Name { get; init; } = "";
|
||||||
|
public string Description { get; init; } = "";
|
||||||
|
public string Flavor { get; init; } = "";
|
||||||
|
public TerrainPatch TerrainPatch { get; init; } = new();
|
||||||
|
public IReadOnlyList<PieceStock> Stock { get; init; } = [];
|
||||||
|
public IReadOnlyList<DemandDef> Demands { get; init; } = [];
|
||||||
|
public IReadOnlyList<PieceKind> UnlockedPieces { get; init; } = [];
|
||||||
|
public IReadOnlyList<PieceUpgrade> UnlockedLevels { get; init; } = [];
|
||||||
|
}
|
||||||
1
chessistics-engine/Model/MissionDef.cs.uid
Normal file
1
chessistics-engine/Model/MissionDef.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://bh4mvmkqeohqj
|
||||||
|
|
@ -5,8 +5,8 @@ public class PieceState
|
||||||
public int Id { get; }
|
public int Id { get; }
|
||||||
public PieceKind Kind { get; }
|
public PieceKind Kind { get; }
|
||||||
public int Level { get; }
|
public int Level { get; }
|
||||||
public Coords StartCell { get; }
|
public Coords StartCell { get; private set; }
|
||||||
public Coords EndCell { get; }
|
public Coords EndCell { get; private set; }
|
||||||
public Coords CurrentCell { get; set; }
|
public Coords CurrentCell { get; set; }
|
||||||
public CargoType? Cargo { get; set; }
|
public CargoType? Cargo { get; set; }
|
||||||
public CargoType? CargoFilter { get; set; }
|
public CargoType? CargoFilter { get; set; }
|
||||||
|
|
@ -30,4 +30,14 @@ public class PieceState
|
||||||
/// Returns the cell this piece will move to next.
|
/// Returns the cell this piece will move to next.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Coords TargetCell => CurrentCell == StartCell ? EndCell : StartCell;
|
public Coords TargetCell => CurrentCell == StartCell ? EndCell : StartCell;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Relocate this piece (drag & drop).
|
||||||
|
/// </summary>
|
||||||
|
public void SetPosition(Coords newStart, Coords newEnd)
|
||||||
|
{
|
||||||
|
StartCell = newStart;
|
||||||
|
EndCell = newEnd;
|
||||||
|
CurrentCell = newStart;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
3
chessistics-engine/Model/PieceUpgrade.cs
Normal file
3
chessistics-engine/Model/PieceUpgrade.cs
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
namespace Chessistics.Engine.Model;
|
||||||
|
|
||||||
|
public record PieceUpgrade(PieceKind Kind, int Level);
|
||||||
1
chessistics-engine/Model/PieceUpgrade.cs.uid
Normal file
1
chessistics-engine/Model/PieceUpgrade.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://broa1hmowlt7
|
||||||
|
|
@ -2,9 +2,7 @@ namespace Chessistics.Engine.Model;
|
||||||
|
|
||||||
public enum SimPhase
|
public enum SimPhase
|
||||||
{
|
{
|
||||||
Edit,
|
|
||||||
Running,
|
Running,
|
||||||
Paused,
|
Paused,
|
||||||
Victory,
|
MissionComplete
|
||||||
Defeat
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
18
chessistics-engine/Model/TerrainPatch.cs
Normal file
18
chessistics-engine/Model/TerrainPatch.cs
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
namespace Chessistics.Engine.Model;
|
||||||
|
|
||||||
|
public class TerrainPatch
|
||||||
|
{
|
||||||
|
public int NewWidth { get; init; }
|
||||||
|
public int NewHeight { get; init; }
|
||||||
|
public IReadOnlyList<PatchCell> Cells { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PatchCell
|
||||||
|
{
|
||||||
|
public int Col { get; init; }
|
||||||
|
public int Row { get; init; }
|
||||||
|
public CellType Type { get; init; }
|
||||||
|
public ProductionDef? Production { get; init; }
|
||||||
|
public DemandDef? Demand { get; init; }
|
||||||
|
public TransformerDef? Transformer { get; init; }
|
||||||
|
}
|
||||||
1
chessistics-engine/Model/TerrainPatch.cs.uid
Normal file
1
chessistics-engine/Model/TerrainPatch.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://c51en0egfstje
|
||||||
10
chessistics-engine/Model/TransformerDef.cs
Normal file
10
chessistics-engine/Model/TransformerDef.cs
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
namespace Chessistics.Engine.Model;
|
||||||
|
|
||||||
|
public record TransformerDef(
|
||||||
|
Coords Position,
|
||||||
|
string Name,
|
||||||
|
CargoType InputCargo,
|
||||||
|
int InputRequired,
|
||||||
|
CargoType OutputCargo,
|
||||||
|
int OutputAmount
|
||||||
|
);
|
||||||
1
chessistics-engine/Model/TransformerDef.cs.uid
Normal file
1
chessistics-engine/Model/TransformerDef.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://cu7cpt1u5mtxd
|
||||||
25
chessistics-engine/Rules/MissionChecker.cs
Normal file
25
chessistics-engine/Rules/MissionChecker.cs
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
using Chessistics.Engine.Model;
|
||||||
|
|
||||||
|
namespace Chessistics.Engine.Rules;
|
||||||
|
|
||||||
|
public static class MissionChecker
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Check if all demands for the current mission are satisfied.
|
||||||
|
/// In campaign mode, only checks the current mission's demands.
|
||||||
|
/// In legacy level mode, checks all demands.
|
||||||
|
/// </summary>
|
||||||
|
public static bool AllCurrentDemandsMet(BoardState state)
|
||||||
|
{
|
||||||
|
var missionIndex = state.Campaign?.CurrentMissionIndex ?? 0;
|
||||||
|
return state.Demands.Values
|
||||||
|
.Where(d => d.MissionIndex == missionIndex)
|
||||||
|
.All(d => d.IsSatisfied);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Check if all demands on the board are satisfied (all missions).
|
||||||
|
/// </summary>
|
||||||
|
public static bool AllDemandsMet(BoardState state)
|
||||||
|
=> state.Demands.Values.All(d => d.IsSatisfied);
|
||||||
|
}
|
||||||
1
chessistics-engine/Rules/MissionChecker.cs.uid
Normal file
1
chessistics-engine/Rules/MissionChecker.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://b3vg5keyv2aj6
|
||||||
|
|
@ -14,7 +14,10 @@ public static class TransferResolver
|
||||||
// Phase A: Productions give to adjacent pieces
|
// Phase A: Productions give to adjacent pieces
|
||||||
ResolveProductionTransfers(state, events, participated, productionGave);
|
ResolveProductionTransfers(state, events, participated, productionGave);
|
||||||
|
|
||||||
// Phase B: Pieces give to demands or other pieces
|
// Phase A2: Transformer outputs give to adjacent pieces (like productions)
|
||||||
|
ResolveTransformerOutputTransfers(state, events, participated);
|
||||||
|
|
||||||
|
// Phase B: Pieces give to demands, transformers, or other pieces
|
||||||
ResolvePieceTransfers(state, events, participated);
|
ResolvePieceTransfers(state, events, participated);
|
||||||
|
|
||||||
return events;
|
return events;
|
||||||
|
|
@ -56,6 +59,35 @@ public static class TransferResolver
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void ResolveTransformerOutputTransfers(
|
||||||
|
BoardState state, List<IWorldEvent> events, HashSet<int> participated)
|
||||||
|
{
|
||||||
|
var transformers = state.Transformers.Values
|
||||||
|
.Where(t => state.TransformerOutputBuffers[t.Position] > 0)
|
||||||
|
.OrderBy(t => t.Position.Col).ThenBy(t => t.Position.Row)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
foreach (var transformer in transformers)
|
||||||
|
{
|
||||||
|
var cargoType = transformer.OutputCargo;
|
||||||
|
var receivers = GetAdjacentPiecesWithoutCargo(state, transformer.Position, participated,
|
||||||
|
cargoType: cargoType);
|
||||||
|
|
||||||
|
foreach (var receiver in receivers)
|
||||||
|
{
|
||||||
|
if (state.TransformerOutputBuffers[transformer.Position] <= 0) break;
|
||||||
|
|
||||||
|
receiver.Cargo = cargoType;
|
||||||
|
state.TransformerOutputBuffers[transformer.Position]--;
|
||||||
|
participated.Add(receiver.Id);
|
||||||
|
|
||||||
|
events.Add(new CargoTransferredEvent(
|
||||||
|
state.TurnNumber, transformer.Position, receiver.CurrentCell, cargoType,
|
||||||
|
GivingPieceId: null, ReceivingPieceId: receiver.Id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static void ResolvePieceTransfers(
|
private static void ResolvePieceTransfers(
|
||||||
BoardState state, List<IWorldEvent> events, HashSet<int> participated)
|
BoardState state, List<IWorldEvent> events, HashSet<int> participated)
|
||||||
{
|
{
|
||||||
|
|
@ -91,7 +123,22 @@ public static class TransferResolver
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Priority 2: transfer to adjacent piece without cargo
|
// Priority 2: deliver to adjacent transformer (input side)
|
||||||
|
var adjacentTransformer = GetAdjacentCompatibleTransformer(state, giver.CurrentCell, cargoType);
|
||||||
|
if (adjacentTransformer != null)
|
||||||
|
{
|
||||||
|
giver.Cargo = null;
|
||||||
|
state.TransformerInputBuffers[adjacentTransformer.Position]++;
|
||||||
|
participated.Add(giver.Id);
|
||||||
|
|
||||||
|
events.Add(new CargoTransferredEvent(
|
||||||
|
state.TurnNumber, giver.CurrentCell, adjacentTransformer.Position, cargoType,
|
||||||
|
GivingPieceId: giver.Id, ReceivingPieceId: null));
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority 3: transfer to adjacent piece without cargo
|
||||||
var receivers = GetAdjacentPiecesWithoutCargo(state, giver.CurrentCell, participated,
|
var receivers = GetAdjacentPiecesWithoutCargo(state, giver.CurrentCell, participated,
|
||||||
cargoType: cargoType);
|
cargoType: cargoType);
|
||||||
if (receivers.Count == 0) continue;
|
if (receivers.Count == 0) continue;
|
||||||
|
|
@ -136,6 +183,17 @@ public static class TransferResolver
|
||||||
.FirstOrDefault();
|
.FirstOrDefault();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static TransformerDef? GetAdjacentCompatibleTransformer(
|
||||||
|
BoardState state, Coords position, CargoType cargoType)
|
||||||
|
{
|
||||||
|
var adjacent = position.GetAdjacent4(state.Width, state.Height);
|
||||||
|
|
||||||
|
return state.Transformers.Values
|
||||||
|
.Where(t => t.InputCargo == cargoType
|
||||||
|
&& adjacent.Contains(t.Position))
|
||||||
|
.FirstOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns a sort key (0-3) based on cardinal direction from center to piece.
|
/// Returns a sort key (0-3) based on cardinal direction from center to piece.
|
||||||
/// In y-up coordinates, clockwise from 0° (right):
|
/// In y-up coordinates, clockwise from 0° (right):
|
||||||
|
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
using Chessistics.Engine.Model;
|
|
||||||
|
|
||||||
namespace Chessistics.Engine.Rules;
|
|
||||||
|
|
||||||
public static class VictoryChecker
|
|
||||||
{
|
|
||||||
public static bool AllDemandsMet(BoardState state)
|
|
||||||
=> state.Demands.Values.All(d => d.IsSatisfied);
|
|
||||||
|
|
||||||
public static bool AnyDeadlineExpired(BoardState state)
|
|
||||||
=> state.TurnNumber > state.MaxDeadline && !AllDemandsMet(state);
|
|
||||||
|
|
||||||
public static IReadOnlyList<DemandState> GetExpiredDemands(BoardState state)
|
|
||||||
=> state.Demands.Values
|
|
||||||
.Where(d => !d.IsSatisfied && state.TurnNumber > d.Deadline)
|
|
||||||
.ToList();
|
|
||||||
}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
uid://uh7qhohnsxpa
|
|
||||||
|
|
@ -13,6 +13,11 @@ public class GameSim
|
||||||
_state = BoardState.FromLevel(level);
|
_state = BoardState.FromLevel(level);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public GameSim(CampaignDef campaign)
|
||||||
|
{
|
||||||
|
_state = BoardState.FromCampaign(campaign);
|
||||||
|
}
|
||||||
|
|
||||||
public IReadOnlyList<IWorldEvent> ProcessCommand(IWorldCommand command)
|
public IReadOnlyList<IWorldEvent> ProcessCommand(IWorldCommand command)
|
||||||
{
|
{
|
||||||
var changeList = new List<IWorldEvent>();
|
var changeList = new List<IWorldEvent>();
|
||||||
|
|
|
||||||
|
|
@ -14,49 +14,95 @@ public static class TurnExecutor
|
||||||
// Sub-phase 1: PRODUCTION
|
// Sub-phase 1: PRODUCTION
|
||||||
ExecuteProduction(state, changeList);
|
ExecuteProduction(state, changeList);
|
||||||
|
|
||||||
// Sub-phase 2: TRANSFERS
|
// Sub-phase 2: TRANSFORMATION (convert accumulated input → output)
|
||||||
|
ExecuteTransformation(state, changeList);
|
||||||
|
|
||||||
|
// Sub-phase 3: TRANSFERS
|
||||||
var transferEvents = TransferResolver.ResolveTransfers(state);
|
var transferEvents = TransferResolver.ResolveTransfers(state);
|
||||||
changeList.AddRange(transferEvents);
|
changeList.AddRange(transferEvents);
|
||||||
|
|
||||||
// Sub-phase 3: MOVEMENT
|
// Sub-phase 4: MOVEMENT
|
||||||
ExecuteMovement(state, changeList);
|
ExecuteMovement(state, changeList);
|
||||||
|
|
||||||
// Sub-phase 4: COLLISION RESOLUTION
|
// Sub-phase 5: COLLISION RESOLUTION
|
||||||
var collisions = CollisionResolver.ResolveCollisions(state.Pieces);
|
var collisions = CollisionResolver.ResolveCollisions(state.Pieces);
|
||||||
foreach (var (survivor, destroyed, cell) in collisions)
|
foreach (var (survivor, destroyed, cell) in collisions)
|
||||||
{
|
{
|
||||||
foreach (var victim in destroyed)
|
foreach (var victim in destroyed)
|
||||||
{
|
{
|
||||||
state.Pieces.Remove(victim);
|
state.Pieces.Remove(victim);
|
||||||
state.DestroyedPieces.Add(victim);
|
|
||||||
victim.Cargo = null;
|
victim.Cargo = null;
|
||||||
changeList.Add(new PieceDestroyedEvent(
|
|
||||||
state.TurnNumber, victim.Id, survivor?.Id, cell));
|
// Return piece to stock instead of destroying permanently
|
||||||
|
state.RemainingStock[victim.Kind] = state.RemainingStock.GetValueOrDefault(victim.Kind) + 1;
|
||||||
|
changeList.Add(new PieceReturnedToStockEvent(
|
||||||
|
state.TurnNumber, victim.Id, victim.Kind, survivor?.Id, cell));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check victory / defeat
|
// Auto-pause on collision
|
||||||
if (VictoryChecker.AllDemandsMet(state))
|
if (collisions.Count > 0)
|
||||||
{
|
{
|
||||||
state.Phase = SimPhase.Victory;
|
state.Phase = SimPhase.Paused;
|
||||||
changeList.Add(new VictoryEvent(state.TurnNumber, ComputeMetrics(state)));
|
changeList.Add(new SimulationPausedEvent());
|
||||||
}
|
}
|
||||||
else if (VictoryChecker.AnyDeadlineExpired(state))
|
|
||||||
|
// Check mission completion
|
||||||
|
if (MissionChecker.AllCurrentDemandsMet(state) && state.Demands.Count > 0)
|
||||||
{
|
{
|
||||||
state.Phase = SimPhase.Defeat;
|
var campaign = state.Campaign;
|
||||||
foreach (var demand in VictoryChecker.GetExpiredDemands(state))
|
var missionIndex = campaign?.CurrentMissionIndex ?? 0;
|
||||||
changeList.Add(new DeadlineExpiredEvent(state.TurnNumber, demand.Position, demand.Name));
|
campaign?.CompletedMissions.Add(missionIndex);
|
||||||
|
changeList.Add(new MissionCompleteEvent(state.TurnNumber, missionIndex));
|
||||||
|
|
||||||
|
// Auto-advance to next mission if available (campaign mode)
|
||||||
|
if (campaign != null && !campaign.IsLastMission)
|
||||||
|
{
|
||||||
|
AdvanceToNextMission(state, campaign, changeList);
|
||||||
|
// Phase stays Running — simulation continues
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Last mission or legacy mode — pause
|
||||||
|
state.Phase = SimPhase.MissionComplete;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
changeList.Add(new TurnEndedEvent(state.TurnNumber));
|
changeList.Add(new TurnEndedEvent(state.TurnNumber));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void AdvanceToNextMission(BoardState state, CampaignState campaign, List<IWorldEvent> changeList)
|
||||||
|
{
|
||||||
|
campaign.CurrentMissionIndex++;
|
||||||
|
var mission = campaign.CurrentMission;
|
||||||
|
|
||||||
|
var oldWidth = state.Width;
|
||||||
|
var oldHeight = state.Height;
|
||||||
|
state.ApplyTerrainPatch(mission.TerrainPatch, campaign.CurrentMissionIndex);
|
||||||
|
|
||||||
|
if (state.Width != oldWidth || state.Height != oldHeight)
|
||||||
|
changeList.Add(new TerrainExpandedEvent(state.Width, state.Height, mission.TerrainPatch.Cells));
|
||||||
|
|
||||||
|
state.AddStock(mission.Stock);
|
||||||
|
|
||||||
|
foreach (var kind in mission.UnlockedPieces)
|
||||||
|
{
|
||||||
|
campaign.AvailablePieceKinds.Add(kind);
|
||||||
|
changeList.Add(new PieceUnlockedEvent(kind, 1));
|
||||||
|
}
|
||||||
|
foreach (var upgrade in mission.UnlockedLevels)
|
||||||
|
{
|
||||||
|
campaign.AvailableLevels.Add(upgrade);
|
||||||
|
changeList.Add(new PieceUnlockedEvent(upgrade.Kind, upgrade.Level));
|
||||||
|
}
|
||||||
|
|
||||||
|
changeList.Add(new MissionStartedEvent(campaign.CurrentMissionIndex, state.Width, state.Height));
|
||||||
|
}
|
||||||
|
|
||||||
private static void ExecuteMovement(BoardState state, List<IWorldEvent> changeList)
|
private static void ExecuteMovement(BoardState state, List<IWorldEvent> changeList)
|
||||||
{
|
{
|
||||||
// Compute all targets first (simultaneous movement)
|
|
||||||
var moves = state.Pieces.Select(p => (piece: p, from: p.CurrentCell, to: p.TargetCell)).ToList();
|
var moves = state.Pieces.Select(p => (piece: p, from: p.CurrentCell, to: p.TargetCell)).ToList();
|
||||||
|
|
||||||
// Apply all moves
|
|
||||||
foreach (var (piece, from, to) in moves)
|
foreach (var (piece, from, to) in moves)
|
||||||
{
|
{
|
||||||
piece.CurrentCell = to;
|
piece.CurrentCell = to;
|
||||||
|
|
@ -65,6 +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)
|
private static void ExecuteProduction(BoardState state, List<IWorldEvent> changeList)
|
||||||
{
|
{
|
||||||
foreach (var (pos, prod) in state.Productions)
|
foreach (var (pos, prod) in state.Productions)
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@ public class SimHelper
|
||||||
|
|
||||||
public static SimHelper FromLevel(LevelDef level) => new(new GameSim(level));
|
public static SimHelper FromLevel(LevelDef level) => new(new GameSim(level));
|
||||||
|
|
||||||
|
public static SimHelper FromCampaign(CampaignDef campaign) => new(new GameSim(campaign));
|
||||||
|
|
||||||
public IReadOnlyList<IWorldEvent> Place(PieceKind kind, Coords start, Coords end)
|
public IReadOnlyList<IWorldEvent> Place(PieceKind kind, Coords start, Coords end)
|
||||||
=> Sim.ProcessCommand(new PlacePieceCommand(kind, start, end));
|
=> Sim.ProcessCommand(new PlacePieceCommand(kind, start, end));
|
||||||
|
|
||||||
|
|
@ -22,9 +24,6 @@ public class SimHelper
|
||||||
public IReadOnlyList<IWorldEvent> Remove(int pieceId)
|
public IReadOnlyList<IWorldEvent> Remove(int pieceId)
|
||||||
=> Sim.ProcessCommand(new RemovePieceCommand(pieceId));
|
=> Sim.ProcessCommand(new RemovePieceCommand(pieceId));
|
||||||
|
|
||||||
public IReadOnlyList<IWorldEvent> Start()
|
|
||||||
=> Sim.ProcessCommand(new StartSimulationCommand());
|
|
||||||
|
|
||||||
public IReadOnlyList<IWorldEvent> Step()
|
public IReadOnlyList<IWorldEvent> Step()
|
||||||
=> Sim.ProcessCommand(new StepSimulationCommand());
|
=> Sim.ProcessCommand(new StepSimulationCommand());
|
||||||
|
|
||||||
|
|
@ -34,17 +33,20 @@ public class SimHelper
|
||||||
public IReadOnlyList<IWorldEvent> Resume()
|
public IReadOnlyList<IWorldEvent> Resume()
|
||||||
=> Sim.ProcessCommand(new ResumeSimulationCommand());
|
=> Sim.ProcessCommand(new ResumeSimulationCommand());
|
||||||
|
|
||||||
public IReadOnlyList<IWorldEvent> Stop()
|
public IReadOnlyList<IWorldEvent> AdvanceMission()
|
||||||
=> Sim.ProcessCommand(new StopSimulationCommand());
|
=> Sim.ProcessCommand(new AdvanceMissionCommand());
|
||||||
|
|
||||||
public IReadOnlyList<IWorldEvent> Reset()
|
|
||||||
=> Sim.ProcessCommand(new ResetLevelCommand());
|
|
||||||
|
|
||||||
public List<IWorldEvent> StepN(int n)
|
public List<IWorldEvent> StepN(int n)
|
||||||
{
|
{
|
||||||
var allEvents = new List<IWorldEvent>();
|
var allEvents = new List<IWorldEvent>();
|
||||||
for (int i = 0; i < n; i++)
|
for (int i = 0; i < n; i++)
|
||||||
allEvents.AddRange(Step());
|
{
|
||||||
|
var events = Step();
|
||||||
|
allEvents.AddRange(events);
|
||||||
|
// Stop stepping if simulation halted (last mission complete)
|
||||||
|
if (Snapshot.Phase == SimPhase.MissionComplete)
|
||||||
|
break;
|
||||||
|
}
|
||||||
return allEvents;
|
return allEvents;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
63
chessistics-tests/Loading/CampaignFileTests.cs
Normal file
63
chessistics-tests/Loading/CampaignFileTests.cs
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
using Chessistics.Engine.Loading;
|
||||||
|
using Chessistics.Engine.Model;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Chessistics.Tests.Loading;
|
||||||
|
|
||||||
|
public class CampaignFileTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Campaign01_LoadsSuccessfully()
|
||||||
|
{
|
||||||
|
var campaign = CampaignLoader.LoadFromFile("../../../../Data/campaigns/campaign_01.json");
|
||||||
|
|
||||||
|
Assert.Equal("La Quête du Roi", campaign.Name);
|
||||||
|
Assert.Equal(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
1
chessistics-tests/Loading/CampaignFileTests.cs.uid
Normal file
1
chessistics-tests/Loading/CampaignFileTests.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://c6sab8mq5a201
|
||||||
220
chessistics-tests/Loading/CampaignLoaderTests.cs
Normal file
220
chessistics-tests/Loading/CampaignLoaderTests.cs
Normal file
|
|
@ -0,0 +1,220 @@
|
||||||
|
using System.Text.Json;
|
||||||
|
using Chessistics.Engine.Loading;
|
||||||
|
using Chessistics.Engine.Model;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Chessistics.Tests.Loading;
|
||||||
|
|
||||||
|
public class CampaignLoaderTests
|
||||||
|
{
|
||||||
|
private const string ValidCampaignJson = """
|
||||||
|
{
|
||||||
|
"name": "La Quête du Roi",
|
||||||
|
"initialWidth": 4,
|
||||||
|
"initialHeight": 4,
|
||||||
|
"missions": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "Premier Convoi",
|
||||||
|
"description": "Les pions découvrent une scierie.",
|
||||||
|
"terrainPatch": {
|
||||||
|
"newWidth": 4,
|
||||||
|
"newHeight": 4,
|
||||||
|
"cells": [
|
||||||
|
{ "col": 0, "row": 0, "type": "production", "production": { "name": "Scierie", "cargo": "wood", "amount": 1 } },
|
||||||
|
{ "col": 3, "row": 0, "type": "demand", "demand": { "name": "Dépôt", "cargo": "wood", "amount": 3 } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"unlockedPieces": ["pawn"],
|
||||||
|
"unlockedLevels": [{ "kind": "pawn", "level": 1 }],
|
||||||
|
"stock": [{ "kind": "pawn", "count": 6 }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"name": "Forger les Tours",
|
||||||
|
"description": "De nouveaux territoires s'ouvrent.",
|
||||||
|
"terrainPatch": {
|
||||||
|
"newWidth": 6,
|
||||||
|
"newHeight": 4,
|
||||||
|
"cells": [
|
||||||
|
{ "col": 4, "row": 0, "type": "empty" },
|
||||||
|
{ "col": 4, "row": 1, "type": "wall" },
|
||||||
|
{ "col": 5, "row": 2, "type": "production", "production": { "name": "Carrière", "cargo": "stone", "amount": 1 } },
|
||||||
|
{ "col": 5, "row": 3, "type": "demand", "demand": { "name": "Chantier", "cargo": "stone", "amount": 5 } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"unlockedPieces": ["rook"],
|
||||||
|
"unlockedLevels": [{ "kind": "rook", "level": 1 }],
|
||||||
|
"stock": [
|
||||||
|
{ "kind": "pawn", "count": 2 },
|
||||||
|
{ "kind": "rook", "count": 3 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LoadValidCampaign_ParsesCorrectly()
|
||||||
|
{
|
||||||
|
var campaign = CampaignLoader.Load(ValidCampaignJson);
|
||||||
|
|
||||||
|
Assert.Equal("La Quête du Roi", campaign.Name);
|
||||||
|
Assert.Equal(4, campaign.InitialWidth);
|
||||||
|
Assert.Equal(4, campaign.InitialHeight);
|
||||||
|
Assert.Equal(2, campaign.Missions.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LoadValidCampaign_Mission1_Correct()
|
||||||
|
{
|
||||||
|
var campaign = CampaignLoader.Load(ValidCampaignJson);
|
||||||
|
var m1 = campaign.Missions[0];
|
||||||
|
|
||||||
|
Assert.Equal(1, m1.Id);
|
||||||
|
Assert.Equal("Premier Convoi", m1.Name);
|
||||||
|
Assert.Equal(4, m1.TerrainPatch.NewWidth);
|
||||||
|
Assert.Equal(2, m1.TerrainPatch.Cells.Count);
|
||||||
|
Assert.Single(m1.UnlockedPieces);
|
||||||
|
Assert.Equal(PieceKind.Pawn, m1.UnlockedPieces[0]);
|
||||||
|
Assert.Single(m1.Stock);
|
||||||
|
Assert.Equal(6, m1.Stock[0].Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LoadValidCampaign_Mission2_TerrainExpands()
|
||||||
|
{
|
||||||
|
var campaign = CampaignLoader.Load(ValidCampaignJson);
|
||||||
|
var m2 = campaign.Missions[1];
|
||||||
|
|
||||||
|
Assert.Equal(6, m2.TerrainPatch.NewWidth);
|
||||||
|
Assert.Equal(4, m2.TerrainPatch.NewHeight);
|
||||||
|
|
||||||
|
var wallCell = m2.TerrainPatch.Cells.First(c => c.Type == CellType.Wall);
|
||||||
|
Assert.Equal(4, wallCell.Col);
|
||||||
|
Assert.Equal(1, wallCell.Row);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LoadCampaign_ShrinkingTerrain_Throws()
|
||||||
|
{
|
||||||
|
var badJson = """
|
||||||
|
{
|
||||||
|
"name": "Bad",
|
||||||
|
"initialWidth": 6,
|
||||||
|
"initialHeight": 6,
|
||||||
|
"missions": [
|
||||||
|
{
|
||||||
|
"id": 1, "name": "M1",
|
||||||
|
"terrainPatch": { "newWidth": 4, "newHeight": 4, "cells": [] },
|
||||||
|
"stock": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
Assert.Throws<JsonException>(() => CampaignLoader.Load(badJson));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LoadCampaign_EmptyName_Throws()
|
||||||
|
{
|
||||||
|
var badJson = """
|
||||||
|
{
|
||||||
|
"name": "",
|
||||||
|
"initialWidth": 4,
|
||||||
|
"initialHeight": 4,
|
||||||
|
"missions": [{ "id": 1, "name": "M", "terrainPatch": { "newWidth": 4, "newHeight": 4, "cells": [] } }]
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
Assert.Throws<JsonException>(() => CampaignLoader.Load(badJson));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LoadCampaign_NoMissions_Throws()
|
||||||
|
{
|
||||||
|
var badJson = """
|
||||||
|
{
|
||||||
|
"name": "Empty",
|
||||||
|
"initialWidth": 4,
|
||||||
|
"initialHeight": 4,
|
||||||
|
"missions": []
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
Assert.Throws<JsonException>(() => CampaignLoader.Load(badJson));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LoadCampaign_TransformerCell_ParsesCorrectly()
|
||||||
|
{
|
||||||
|
var json = """
|
||||||
|
{
|
||||||
|
"name": "Forge Test",
|
||||||
|
"initialWidth": 5,
|
||||||
|
"initialHeight": 1,
|
||||||
|
"missions": [
|
||||||
|
{
|
||||||
|
"id": 1, "name": "M1",
|
||||||
|
"terrainPatch": {
|
||||||
|
"newWidth": 5, "newHeight": 1,
|
||||||
|
"cells": [
|
||||||
|
{ "col": 0, "row": 0, "type": "production", "production": { "name": "Scierie", "cargo": "wood", "amount": 4 } },
|
||||||
|
{ "col": 2, "row": 0, "type": "transformer", "transformer": { "name": "Forge", "inputCargo": "wood", "inputRequired": 2, "outputCargo": "tools", "outputAmount": 1 } },
|
||||||
|
{ "col": 4, "row": 0, "type": "demand", "demand": { "name": "Depot", "cargo": "tools", "amount": 3 } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"stock": [{ "kind": "rook", "count": 3 }],
|
||||||
|
"unlockedPieces": ["rook"],
|
||||||
|
"unlockedLevels": [{ "kind": "rook", "level": 1 }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
var campaign = CampaignLoader.Load(json);
|
||||||
|
var cells = campaign.Missions[0].TerrainPatch.Cells;
|
||||||
|
|
||||||
|
var transformerCell = cells.First(c => c.Type == CellType.Transformer);
|
||||||
|
Assert.NotNull(transformerCell.Transformer);
|
||||||
|
Assert.Equal("Forge", transformerCell.Transformer.Name);
|
||||||
|
Assert.Equal(CargoType.Wood, transformerCell.Transformer.InputCargo);
|
||||||
|
Assert.Equal(2, transformerCell.Transformer.InputRequired);
|
||||||
|
Assert.Equal(CargoType.Tools, transformerCell.Transformer.OutputCargo);
|
||||||
|
Assert.Equal(1, transformerCell.Transformer.OutputAmount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LoadCampaign_NewCargoTypes_ParseCorrectly()
|
||||||
|
{
|
||||||
|
var json = """
|
||||||
|
{
|
||||||
|
"name": "Cargo Test",
|
||||||
|
"initialWidth": 3,
|
||||||
|
"initialHeight": 1,
|
||||||
|
"missions": [
|
||||||
|
{
|
||||||
|
"id": 1, "name": "M1",
|
||||||
|
"terrainPatch": {
|
||||||
|
"newWidth": 3, "newHeight": 1,
|
||||||
|
"cells": [
|
||||||
|
{ "col": 0, "row": 0, "type": "demand", "demand": { "name": "D1", "cargo": "arms", "amount": 1 } },
|
||||||
|
{ "col": 1, "row": 0, "type": "demand", "demand": { "name": "D2", "cargo": "gold", "amount": 1 } },
|
||||||
|
{ "col": 2, "row": 0, "type": "demand", "demand": { "name": "D3", "cargo": "tools", "amount": 1 } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"stock": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
var campaign = CampaignLoader.Load(json);
|
||||||
|
var cells = campaign.Missions[0].TerrainPatch.Cells;
|
||||||
|
|
||||||
|
Assert.Equal(CargoType.Arms, cells[0].Demand!.Cargo);
|
||||||
|
Assert.Equal(CargoType.Gold, cells[1].Demand!.Cargo);
|
||||||
|
Assert.Equal(CargoType.Tools, cells[2].Demand!.Cargo);
|
||||||
|
}
|
||||||
|
}
|
||||||
1
chessistics-tests/Loading/CampaignLoaderTests.cs.uid
Normal file
1
chessistics-tests/Loading/CampaignLoaderTests.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://boxlkyt1rnb6l
|
||||||
135
chessistics-tests/Loading/CampaignValidationTests.cs
Normal file
135
chessistics-tests/Loading/CampaignValidationTests.cs
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
using Chessistics.Engine.Commands;
|
||||||
|
using Chessistics.Engine.Loading;
|
||||||
|
using Chessistics.Engine.Model;
|
||||||
|
using Chessistics.Engine.Simulation;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Chessistics.Tests.Loading;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates campaign_01.json structural integrity:
|
||||||
|
/// no cell overlaps, unique building names, walls only on new cells.
|
||||||
|
/// </summary>
|
||||||
|
public class CampaignValidationTests
|
||||||
|
{
|
||||||
|
private static CampaignDef LoadCampaign()
|
||||||
|
=> CampaignLoader.LoadFromFile("../../../../Data/campaigns/campaign_01.json");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Campaign01_NoBuildingOverlaps()
|
||||||
|
{
|
||||||
|
var campaign = LoadCampaign();
|
||||||
|
var sim = new GameSim(campaign);
|
||||||
|
sim.ProcessCommand(new LoadCampaignCommand());
|
||||||
|
|
||||||
|
// Step through missions by manually applying patches and checking for conflicts
|
||||||
|
var state = BoardState.FromCampaign(campaign);
|
||||||
|
|
||||||
|
// Track all active buildings after each mission
|
||||||
|
for (int i = 0; i < campaign.Missions.Count; i++)
|
||||||
|
{
|
||||||
|
var mission = campaign.Missions[i];
|
||||||
|
state.ApplyTerrainPatch(mission.TerrainPatch, i);
|
||||||
|
|
||||||
|
// After each mission, verify no cell has multiple building types
|
||||||
|
foreach (var pos in state.Productions.Keys)
|
||||||
|
{
|
||||||
|
Assert.False(state.Demands.ContainsKey(pos),
|
||||||
|
$"Mission {i + 1}: Production and Demand overlap at {pos}");
|
||||||
|
Assert.False(state.Transformers.ContainsKey(pos),
|
||||||
|
$"Mission {i + 1}: Production and Transformer overlap at {pos}");
|
||||||
|
Assert.NotEqual(CellType.Wall, state.GetCell(pos));
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var pos in state.Demands.Keys)
|
||||||
|
{
|
||||||
|
Assert.False(state.Transformers.ContainsKey(pos),
|
||||||
|
$"Mission {i + 1}: Demand and Transformer overlap at {pos}");
|
||||||
|
Assert.NotEqual(CellType.Wall, state.GetCell(pos));
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var pos in state.Transformers.Keys)
|
||||||
|
{
|
||||||
|
Assert.NotEqual(CellType.Wall, state.GetCell(pos));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Campaign01_UniqueBuildingNames()
|
||||||
|
{
|
||||||
|
var campaign = LoadCampaign();
|
||||||
|
var state = BoardState.FromCampaign(campaign);
|
||||||
|
|
||||||
|
for (int i = 0; i < campaign.Missions.Count; i++)
|
||||||
|
{
|
||||||
|
state.ApplyTerrainPatch(campaign.Missions[i].TerrainPatch, i);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect all building names
|
||||||
|
var names = new List<string>();
|
||||||
|
names.AddRange(state.Productions.Values.Select(p => p.Name));
|
||||||
|
names.AddRange(state.Demands.Values.Select(d => d.Name));
|
||||||
|
names.AddRange(state.Transformers.Values.Select(t => t.Name));
|
||||||
|
|
||||||
|
var duplicates = names.GroupBy(n => n).Where(g => g.Count() > 1).Select(g => g.Key).ToList();
|
||||||
|
Assert.Empty(duplicates);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Campaign01_WallsOnlyOnNewOrEmptyCells()
|
||||||
|
{
|
||||||
|
var campaign = LoadCampaign();
|
||||||
|
|
||||||
|
// Track which cells have buildings before each mission
|
||||||
|
var buildingCells = new HashSet<Coords>();
|
||||||
|
int prevWidth = campaign.InitialWidth;
|
||||||
|
int prevHeight = campaign.InitialHeight;
|
||||||
|
|
||||||
|
for (int i = 0; i < campaign.Missions.Count; i++)
|
||||||
|
{
|
||||||
|
var mission = campaign.Missions[i];
|
||||||
|
|
||||||
|
// Check walls in this mission's patch
|
||||||
|
foreach (var cell in mission.TerrainPatch.Cells)
|
||||||
|
{
|
||||||
|
if (cell.Type == CellType.Wall)
|
||||||
|
{
|
||||||
|
// Wall should be on a cell that was either:
|
||||||
|
// - Outside the previous board dimensions (new cell)
|
||||||
|
// - Not a building cell
|
||||||
|
bool isNewCell = cell.Col >= prevWidth || cell.Row >= prevHeight;
|
||||||
|
bool isBuilding = buildingCells.Contains(new Coords(cell.Col, cell.Row));
|
||||||
|
|
||||||
|
Assert.True(isNewCell || !isBuilding,
|
||||||
|
$"Mission {i + 1}: Wall at ({cell.Col},{cell.Row}) overwrites an existing building");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update building tracking
|
||||||
|
foreach (var cell in mission.TerrainPatch.Cells)
|
||||||
|
{
|
||||||
|
var coords = new Coords(cell.Col, cell.Row);
|
||||||
|
if (cell.Type is CellType.Production or CellType.Demand or CellType.Transformer)
|
||||||
|
buildingCells.Add(coords);
|
||||||
|
else
|
||||||
|
buildingCells.Remove(coords);
|
||||||
|
}
|
||||||
|
|
||||||
|
prevWidth = mission.TerrainPatch.NewWidth;
|
||||||
|
prevHeight = mission.TerrainPatch.NewHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Campaign01_AllMissionsHaveFlavor()
|
||||||
|
{
|
||||||
|
var campaign = LoadCampaign();
|
||||||
|
|
||||||
|
for (int i = 0; i < campaign.Missions.Count; i++)
|
||||||
|
{
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(campaign.Missions[i].Flavor),
|
||||||
|
$"Mission {i + 1} ({campaign.Missions[i].Name}) has no flavor text");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
chessistics-tests/Loading/CampaignValidationTests.cs.uid
Normal file
1
chessistics-tests/Loading/CampaignValidationTests.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://dle5bi0rtya8x
|
||||||
112
chessistics-tests/Model/TerrainPatchTests.cs
Normal file
112
chessistics-tests/Model/TerrainPatchTests.cs
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
using Chessistics.Engine.Model;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Chessistics.Tests.Model;
|
||||||
|
|
||||||
|
public class TerrainPatchTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void TerrainPatch_DemandOverwritesProduction_ClearsProduction()
|
||||||
|
{
|
||||||
|
// Setup: board with a production at (2,0)
|
||||||
|
var state = BoardState.FromCampaign(new CampaignDef
|
||||||
|
{
|
||||||
|
Name = "Test", InitialWidth = 4, InitialHeight = 1,
|
||||||
|
Missions = [new MissionDef
|
||||||
|
{
|
||||||
|
Id = 1, Name = "M1",
|
||||||
|
TerrainPatch = new TerrainPatch
|
||||||
|
{
|
||||||
|
NewWidth = 4, NewHeight = 1,
|
||||||
|
Cells = [new PatchCell { Col = 2, Row = 0, Type = CellType.Production,
|
||||||
|
Production = new ProductionDef(new Coords(2, 0), "Prod", CargoType.Wood, 4) }]
|
||||||
|
},
|
||||||
|
Stock = []
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Apply mission 0 terrain
|
||||||
|
state.ApplyTerrainPatch(state.Campaign!.CurrentMission.TerrainPatch, 0);
|
||||||
|
Assert.True(state.Productions.ContainsKey(new Coords(2, 0)));
|
||||||
|
|
||||||
|
// Now overwrite with a demand at same position
|
||||||
|
var patch = new TerrainPatch
|
||||||
|
{
|
||||||
|
NewWidth = 4, NewHeight = 1,
|
||||||
|
Cells = [new PatchCell { Col = 2, Row = 0, Type = CellType.Demand,
|
||||||
|
Demand = new DemandDef(new Coords(2, 0), "Demand", CargoType.Wood, 2) }]
|
||||||
|
};
|
||||||
|
state.ApplyTerrainPatch(patch, 1);
|
||||||
|
|
||||||
|
// Production should be gone, demand should exist
|
||||||
|
Assert.False(state.Productions.ContainsKey(new Coords(2, 0)));
|
||||||
|
Assert.True(state.Demands.ContainsKey(new Coords(2, 0)));
|
||||||
|
Assert.Equal(CellType.Demand, state.GetCell(new Coords(2, 0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TerrainPatch_WallRemovesPiecesOnCell()
|
||||||
|
{
|
||||||
|
var state = BoardState.FromCampaign(new CampaignDef
|
||||||
|
{
|
||||||
|
Name = "Test", InitialWidth = 4, InitialHeight = 2,
|
||||||
|
Missions = [new MissionDef
|
||||||
|
{
|
||||||
|
Id = 1, Name = "M1",
|
||||||
|
TerrainPatch = new TerrainPatch { NewWidth = 4, NewHeight = 2, Cells = [] },
|
||||||
|
Stock = [new PieceStock(PieceKind.Rook, 2)]
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
state.ApplyTerrainPatch(state.Campaign!.CurrentMission.TerrainPatch, 0);
|
||||||
|
state.AddStock(state.Campaign.CurrentMission.Stock);
|
||||||
|
|
||||||
|
// Place a piece with start=(2,0), end=(3,0)
|
||||||
|
state.Pieces.Add(new PieceState(1, PieceKind.Rook, new Coords(2, 0), new Coords(3, 0), 1));
|
||||||
|
state.RemainingStock[PieceKind.Rook] = 1;
|
||||||
|
|
||||||
|
// Apply wall on (2,0) — should remove the piece
|
||||||
|
var patch = new TerrainPatch
|
||||||
|
{
|
||||||
|
NewWidth = 4, NewHeight = 2,
|
||||||
|
Cells = [new PatchCell { Col = 2, Row = 0, Type = CellType.Wall }]
|
||||||
|
};
|
||||||
|
state.ApplyTerrainPatch(patch, 1);
|
||||||
|
|
||||||
|
Assert.Empty(state.Pieces);
|
||||||
|
Assert.Equal(2, state.RemainingStock[PieceKind.Rook]); // returned to stock
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TerrainPatch_ProductionOverwritesDemand_ClearsDemand()
|
||||||
|
{
|
||||||
|
var state = BoardState.FromCampaign(new CampaignDef
|
||||||
|
{
|
||||||
|
Name = "Test", InitialWidth = 4, InitialHeight = 1,
|
||||||
|
Missions = [new MissionDef
|
||||||
|
{
|
||||||
|
Id = 1, Name = "M1",
|
||||||
|
TerrainPatch = new TerrainPatch
|
||||||
|
{
|
||||||
|
NewWidth = 4, NewHeight = 1,
|
||||||
|
Cells = [new PatchCell { Col = 1, Row = 0, Type = CellType.Demand,
|
||||||
|
Demand = new DemandDef(new Coords(1, 0), "D", CargoType.Wood, 2) }]
|
||||||
|
},
|
||||||
|
Stock = []
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
state.ApplyTerrainPatch(state.Campaign!.CurrentMission.TerrainPatch, 0);
|
||||||
|
Assert.True(state.Demands.ContainsKey(new Coords(1, 0)));
|
||||||
|
|
||||||
|
// Overwrite with production
|
||||||
|
var patch = new TerrainPatch
|
||||||
|
{
|
||||||
|
NewWidth = 4, NewHeight = 1,
|
||||||
|
Cells = [new PatchCell { Col = 1, Row = 0, Type = CellType.Production,
|
||||||
|
Production = new ProductionDef(new Coords(1, 0), "P", CargoType.Stone, 4) }]
|
||||||
|
};
|
||||||
|
state.ApplyTerrainPatch(patch, 1);
|
||||||
|
|
||||||
|
Assert.False(state.Demands.ContainsKey(new Coords(1, 0)));
|
||||||
|
Assert.True(state.Productions.ContainsKey(new Coords(1, 0)));
|
||||||
|
}
|
||||||
|
}
|
||||||
1
chessistics-tests/Model/TerrainPatchTests.cs.uid
Normal file
1
chessistics-tests/Model/TerrainPatchTests.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://kujovcfoy6j2
|
||||||
|
|
@ -319,6 +319,32 @@ public class TransferResolverTests
|
||||||
Assert.DoesNotContain(events, e => e is CargoTransferredEvent ct && ct.GivingPieceId == 1 && ct.ReceivingPieceId == 2);
|
Assert.DoesNotContain(events, e => e is CargoTransferredEvent ct && ct.GivingPieceId == 1 && ct.ReceivingPieceId == 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Production_Amount3_FeedsMultiplePieces()
|
||||||
|
{
|
||||||
|
var board = new BoardBuilder(4, 4)
|
||||||
|
.WithProduction(0, 0, "P", CargoType.Wood, amount: 3)
|
||||||
|
.WithDemand(3, 0, "D", CargoType.Wood, 10, 99)
|
||||||
|
.WithStock(PieceKind.Rook, 5)
|
||||||
|
.BuildState();
|
||||||
|
|
||||||
|
// Three pieces adjacent to production at (0,0): (1,0), (0,1)
|
||||||
|
var p1 = new PieceState(1, PieceKind.Rook, new Coords(1, 0), new Coords(2, 0), 0);
|
||||||
|
p1.CurrentCell = new Coords(1, 0);
|
||||||
|
var p2 = new PieceState(2, PieceKind.Rook, new Coords(0, 1), new Coords(0, 2), 1);
|
||||||
|
p2.CurrentCell = new Coords(0, 1);
|
||||||
|
|
||||||
|
board.Pieces.AddRange([p1, p2]);
|
||||||
|
board.ProductionBuffers[new Coords(0, 0)] = 3; // amount=3
|
||||||
|
|
||||||
|
var events = TransferResolver.ResolveTransfers(board);
|
||||||
|
|
||||||
|
// Both pieces should receive cargo (buffer had 3, 2 pieces adjacent)
|
||||||
|
Assert.Equal(CargoType.Wood, p1.Cargo);
|
||||||
|
Assert.Equal(CargoType.Wood, p2.Cargo);
|
||||||
|
Assert.Equal(1, board.ProductionBuffers[new Coords(0, 0)]); // 3 - 2 = 1 remaining
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void DemandPriority_OverPieceReceiver()
|
public void DemandPriority_OverPieceReceiver()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
308
chessistics-tests/Simulation/CampaignTests.cs
Normal file
308
chessistics-tests/Simulation/CampaignTests.cs
Normal file
|
|
@ -0,0 +1,308 @@
|
||||||
|
using Chessistics.Engine.Commands;
|
||||||
|
using Chessistics.Engine.Events;
|
||||||
|
using Chessistics.Engine.Model;
|
||||||
|
using Chessistics.Tests.Helpers;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Chessistics.Tests.Simulation;
|
||||||
|
|
||||||
|
public class CampaignTests
|
||||||
|
{
|
||||||
|
private static CampaignDef CreateTwOMissionCampaign()
|
||||||
|
{
|
||||||
|
return new CampaignDef
|
||||||
|
{
|
||||||
|
Name = "Test Campaign",
|
||||||
|
InitialWidth = 4,
|
||||||
|
InitialHeight = 4,
|
||||||
|
Missions =
|
||||||
|
[
|
||||||
|
new MissionDef
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
Name = "Mission 1",
|
||||||
|
Description = "First mission",
|
||||||
|
TerrainPatch = new TerrainPatch
|
||||||
|
{
|
||||||
|
NewWidth = 4,
|
||||||
|
NewHeight = 4,
|
||||||
|
Cells =
|
||||||
|
[
|
||||||
|
new PatchCell { Col = 0, Row = 0, Type = CellType.Production,
|
||||||
|
Production = new ProductionDef(new Coords(0, 0), "Scierie", CargoType.Wood) },
|
||||||
|
new PatchCell { Col = 3, Row = 0, Type = CellType.Demand,
|
||||||
|
Demand = new DemandDef(new Coords(3, 0), "Depot", CargoType.Wood, 2) }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
UnlockedPieces = [PieceKind.Pawn, PieceKind.Rook],
|
||||||
|
UnlockedLevels = [new PieceUpgrade(PieceKind.Pawn, 1), new PieceUpgrade(PieceKind.Rook, 1)],
|
||||||
|
Stock = [new PieceStock(PieceKind.Rook, 3)]
|
||||||
|
},
|
||||||
|
new MissionDef
|
||||||
|
{
|
||||||
|
Id = 2,
|
||||||
|
Name = "Mission 2",
|
||||||
|
Description = "Second mission — terrain expands east",
|
||||||
|
TerrainPatch = new TerrainPatch
|
||||||
|
{
|
||||||
|
NewWidth = 6,
|
||||||
|
NewHeight = 4,
|
||||||
|
Cells =
|
||||||
|
[
|
||||||
|
new PatchCell { Col = 4, Row = 0, Type = CellType.Empty },
|
||||||
|
new PatchCell { Col = 4, Row = 1, Type = CellType.Wall },
|
||||||
|
new PatchCell { Col = 5, Row = 0, Type = CellType.Production,
|
||||||
|
Production = new ProductionDef(new Coords(5, 0), "Carriere", CargoType.Stone) },
|
||||||
|
new PatchCell { Col = 5, Row = 3, Type = CellType.Demand,
|
||||||
|
Demand = new DemandDef(new Coords(5, 3), "Chantier", CargoType.Stone, 3) }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
UnlockedPieces = [],
|
||||||
|
UnlockedLevels = [],
|
||||||
|
Stock = [new PieceStock(PieceKind.Rook, 2)]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LoadCampaign_InitializesBoard()
|
||||||
|
{
|
||||||
|
var campaign = CreateTwOMissionCampaign();
|
||||||
|
var sim = SimHelper.FromCampaign(campaign);
|
||||||
|
|
||||||
|
var events = sim.Sim.ProcessCommand(new LoadCampaignCommand());
|
||||||
|
|
||||||
|
Assert.Contains(events, e => e is CampaignLoadedEvent);
|
||||||
|
Assert.Contains(events, e => e is MissionStartedEvent ms && ms.MissionIndex == 0);
|
||||||
|
Assert.Contains(events, e => e is PieceUnlockedEvent pu && pu.Kind == PieceKind.Rook);
|
||||||
|
|
||||||
|
var snap = sim.Snapshot;
|
||||||
|
Assert.Equal(4, snap.Width);
|
||||||
|
Assert.Equal(4, snap.Height);
|
||||||
|
Assert.Equal(3, snap.RemainingStock[PieceKind.Rook]);
|
||||||
|
Assert.Equal(SimPhase.Paused, snap.Phase);
|
||||||
|
Assert.NotNull(snap.Campaign);
|
||||||
|
Assert.Equal(0, snap.Campaign.CurrentMissionIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Campaign_CompleteMission1_AutoAdvances()
|
||||||
|
{
|
||||||
|
var campaign = CreateTwOMissionCampaign();
|
||||||
|
var sim = SimHelper.FromCampaign(campaign);
|
||||||
|
sim.Sim.ProcessCommand(new LoadCampaignCommand());
|
||||||
|
|
||||||
|
// Place rook to relay wood
|
||||||
|
sim.Place(PieceKind.Rook, (1, 0), (2, 0));
|
||||||
|
|
||||||
|
// Run — mission 1 completes and auto-advances to mission 2
|
||||||
|
var allEvents = sim.StepN(30);
|
||||||
|
Assert.Contains(allEvents, e => e is MissionCompleteEvent mc && mc.MissionIndex == 0);
|
||||||
|
Assert.Contains(allEvents, e => e is MissionStartedEvent ms && ms.MissionIndex == 1);
|
||||||
|
Assert.Contains(allEvents, e => e is TerrainExpandedEvent te && te.NewWidth == 6);
|
||||||
|
|
||||||
|
var snap = sim.Snapshot;
|
||||||
|
Assert.Equal(6, snap.Width);
|
||||||
|
Assert.Equal(4, snap.Height);
|
||||||
|
// Phase stays Paused (from StepSimulationCommand), NOT MissionComplete
|
||||||
|
Assert.Equal(SimPhase.Paused, snap.Phase);
|
||||||
|
Assert.Equal(1, snap.Campaign!.CurrentMissionIndex);
|
||||||
|
|
||||||
|
// Original rook is still in place
|
||||||
|
Assert.Single(snap.Pieces);
|
||||||
|
|
||||||
|
// Additional stock from mission 2 added
|
||||||
|
Assert.Equal(2 + 2, snap.RemainingStock[PieceKind.Rook]); // 2 leftover from M1 + 2 new
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Campaign_PiecesRemainAfterAutoAdvance()
|
||||||
|
{
|
||||||
|
var campaign = CreateTwOMissionCampaign();
|
||||||
|
var sim = SimHelper.FromCampaign(campaign);
|
||||||
|
sim.Sim.ProcessCommand(new LoadCampaignCommand());
|
||||||
|
|
||||||
|
sim.Place(PieceKind.Rook, (1, 0), (2, 0));
|
||||||
|
|
||||||
|
// Run until auto-advance
|
||||||
|
sim.StepN(30);
|
||||||
|
|
||||||
|
// Piece is STILL on the board after auto-advancing
|
||||||
|
Assert.Single(sim.Snapshot.Pieces);
|
||||||
|
Assert.Equal(new Coords(1, 0), sim.Snapshot.Pieces[0].StartCell);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Campaign_PlacePieceDuringRunning()
|
||||||
|
{
|
||||||
|
var campaign = CreateTwOMissionCampaign();
|
||||||
|
var sim = SimHelper.FromCampaign(campaign);
|
||||||
|
sim.Sim.ProcessCommand(new LoadCampaignCommand());
|
||||||
|
|
||||||
|
sim.Resume(); // Paused → Running
|
||||||
|
var events = sim.Place(PieceKind.Rook, (1, 0), (2, 0));
|
||||||
|
|
||||||
|
Assert.IsType<PiecePlacedEvent>(events[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Campaign_RemovePieceDuringRunning()
|
||||||
|
{
|
||||||
|
var campaign = CreateTwOMissionCampaign();
|
||||||
|
var sim = SimHelper.FromCampaign(campaign);
|
||||||
|
sim.Sim.ProcessCommand(new LoadCampaignCommand());
|
||||||
|
|
||||||
|
sim.Place(PieceKind.Rook, (1, 0), (2, 0));
|
||||||
|
sim.Resume(); // Running
|
||||||
|
|
||||||
|
var events = sim.Remove(1);
|
||||||
|
Assert.IsType<PieceRemovedEvent>(events[0]);
|
||||||
|
Assert.Equal(3, sim.Snapshot.RemainingStock[PieceKind.Rook]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Campaign_CollisionReturnsPieceToStock()
|
||||||
|
{
|
||||||
|
// Two rooks heading to same cell → collision
|
||||||
|
var campaign = new CampaignDef
|
||||||
|
{
|
||||||
|
Name = "Collision Test",
|
||||||
|
InitialWidth = 4,
|
||||||
|
InitialHeight = 4,
|
||||||
|
Missions =
|
||||||
|
[
|
||||||
|
new MissionDef
|
||||||
|
{
|
||||||
|
Id = 1, Name = "M1",
|
||||||
|
TerrainPatch = new TerrainPatch
|
||||||
|
{
|
||||||
|
NewWidth = 4, NewHeight = 4,
|
||||||
|
Cells =
|
||||||
|
[
|
||||||
|
new PatchCell { Col = 0, Row = 0, Type = CellType.Production,
|
||||||
|
Production = new ProductionDef(new Coords(0, 0), "P", CargoType.Wood) },
|
||||||
|
new PatchCell { Col = 3, Row = 0, Type = CellType.Demand,
|
||||||
|
Demand = new DemandDef(new Coords(3, 0), "D", CargoType.Wood, 5) }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
UnlockedPieces = [PieceKind.Rook, PieceKind.Queen],
|
||||||
|
UnlockedLevels = [new PieceUpgrade(PieceKind.Rook, 1), new PieceUpgrade(PieceKind.Queen, 1)],
|
||||||
|
Stock = [new PieceStock(PieceKind.Rook, 2), new PieceStock(PieceKind.Queen, 1)]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
var sim = SimHelper.FromCampaign(campaign);
|
||||||
|
sim.Sim.ProcessCommand(new LoadCampaignCommand());
|
||||||
|
|
||||||
|
// Rook and Queen both pass through (1,0): collision!
|
||||||
|
// Rook: (0,0) ↔ (2,0), Queen: (2,0) ↔ (0,0) — they swap cells each turn, meeting at...
|
||||||
|
// Actually let's make them collide: same end cell
|
||||||
|
sim.Place(PieceKind.Rook, (0, 1), (1, 1));
|
||||||
|
sim.Place(PieceKind.Queen, (2, 1), (1, 1)); // same end cell → collision after first step
|
||||||
|
|
||||||
|
var stockBefore = sim.Snapshot.RemainingStock[PieceKind.Rook];
|
||||||
|
var events = sim.Step();
|
||||||
|
|
||||||
|
// Queen wins (status 7 vs 5), rook returns to stock
|
||||||
|
Assert.Contains(events, e => e is PieceReturnedToStockEvent ret && ret.Kind == PieceKind.Rook);
|
||||||
|
|
||||||
|
// Rook returned to stock
|
||||||
|
var stockAfter = sim.Snapshot.RemainingStock[PieceKind.Rook];
|
||||||
|
Assert.Equal(stockBefore + 1, stockAfter);
|
||||||
|
|
||||||
|
// Auto-pause on collision
|
||||||
|
Assert.Equal(SimPhase.Paused, sim.Snapshot.Phase);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Campaign_ManualAdvanceWithoutComplete_Rejected()
|
||||||
|
{
|
||||||
|
var campaign = CreateTwOMissionCampaign();
|
||||||
|
var sim = SimHelper.FromCampaign(campaign);
|
||||||
|
sim.Sim.ProcessCommand(new LoadCampaignCommand());
|
||||||
|
|
||||||
|
// AdvanceMissionCommand requires MissionComplete phase
|
||||||
|
var events = sim.AdvanceMission();
|
||||||
|
Assert.IsType<CommandRejectedEvent>(events[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Campaign_LastMission_SetsMissionCompletePhase()
|
||||||
|
{
|
||||||
|
// Single-mission campaign — completing it should set MissionComplete phase
|
||||||
|
var campaign = new CampaignDef
|
||||||
|
{
|
||||||
|
Name = "Short", InitialWidth = 3, InitialHeight = 1,
|
||||||
|
Missions =
|
||||||
|
[
|
||||||
|
new MissionDef
|
||||||
|
{
|
||||||
|
Id = 1, Name = "Only",
|
||||||
|
TerrainPatch = new TerrainPatch
|
||||||
|
{
|
||||||
|
NewWidth = 3, NewHeight = 1,
|
||||||
|
Cells =
|
||||||
|
[
|
||||||
|
new PatchCell { Col = 0, Row = 0, Type = CellType.Production,
|
||||||
|
Production = new ProductionDef(new Coords(0, 0), "P", CargoType.Wood) },
|
||||||
|
new PatchCell { Col = 2, Row = 0, Type = CellType.Demand,
|
||||||
|
Demand = new DemandDef(new Coords(2, 0), "D", CargoType.Wood, 1) }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
UnlockedPieces = [PieceKind.Rook],
|
||||||
|
UnlockedLevels = [new PieceUpgrade(PieceKind.Rook, 1)],
|
||||||
|
Stock = [new PieceStock(PieceKind.Rook, 1)]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
var sim = SimHelper.FromCampaign(campaign);
|
||||||
|
sim.Sim.ProcessCommand(new LoadCampaignCommand());
|
||||||
|
|
||||||
|
sim.Place(PieceKind.Rook, (1, 0), (2, 0));
|
||||||
|
var allEvents = sim.StepN(10);
|
||||||
|
|
||||||
|
// Last mission → MissionComplete phase (no auto-advance)
|
||||||
|
Assert.Contains(allEvents, e => e is MissionCompleteEvent);
|
||||||
|
Assert.DoesNotContain(allEvents, e => e is MissionStartedEvent);
|
||||||
|
Assert.Equal(SimPhase.MissionComplete, sim.Snapshot.Phase);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Campaign_UnlockedPiecesEnforced()
|
||||||
|
{
|
||||||
|
var campaign = CreateTwOMissionCampaign();
|
||||||
|
var sim = SimHelper.FromCampaign(campaign);
|
||||||
|
sim.Sim.ProcessCommand(new LoadCampaignCommand());
|
||||||
|
|
||||||
|
// Bishop not unlocked — placement rejected
|
||||||
|
// First, add bishop to stock manually for this test
|
||||||
|
// Actually, the stock doesn't have bishops. Let's check that even if stock existed, unlock blocks it.
|
||||||
|
// The campaign only unlocks Pawn and Rook. No bishop stock either.
|
||||||
|
// Let's just verify the unlock set is correct.
|
||||||
|
var snap = sim.Snapshot;
|
||||||
|
Assert.Contains(PieceKind.Rook, snap.Campaign!.AvailablePieceKinds);
|
||||||
|
Assert.Contains(PieceKind.Pawn, snap.Campaign!.AvailablePieceKinds);
|
||||||
|
Assert.DoesNotContain(PieceKind.Bishop, snap.Campaign!.AvailablePieceKinds);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MovePiece_UpdatesPosition()
|
||||||
|
{
|
||||||
|
var campaign = CreateTwOMissionCampaign();
|
||||||
|
var sim = SimHelper.FromCampaign(campaign);
|
||||||
|
sim.Sim.ProcessCommand(new LoadCampaignCommand());
|
||||||
|
|
||||||
|
sim.Place(PieceKind.Rook, (1, 0), (2, 0));
|
||||||
|
|
||||||
|
var events = sim.Sim.ProcessCommand(new MovePieceCommand(1, new Coords(1, 1), new Coords(2, 1)));
|
||||||
|
|
||||||
|
Assert.Contains(events, e => e is PieceMovedByPlayerEvent mv
|
||||||
|
&& mv.OldStart == new Coords(1, 0) && mv.NewStart == new Coords(1, 1));
|
||||||
|
|
||||||
|
var snap = sim.Snapshot;
|
||||||
|
Assert.Equal(new Coords(1, 1), snap.Pieces[0].StartCell);
|
||||||
|
Assert.Equal(new Coords(2, 1), snap.Pieces[0].EndCell);
|
||||||
|
Assert.Equal(new Coords(1, 1), snap.Pieces[0].CurrentCell);
|
||||||
|
}
|
||||||
|
}
|
||||||
1
chessistics-tests/Simulation/CampaignTests.cs.uid
Normal file
1
chessistics-tests/Simulation/CampaignTests.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://bxt85xb77h4jn
|
||||||
|
|
@ -8,10 +8,8 @@ namespace Chessistics.Tests.Simulation;
|
||||||
public class FullLevelTests
|
public class FullLevelTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Level1_PremierConvoi_Victory()
|
public void Level1_PremierConvoi_MissionComplete()
|
||||||
{
|
{
|
||||||
// GDD Level 1: 4x4, Scierie(0,0) → Depot(3,0), 3 Rooks
|
|
||||||
// Solution: single rook relay at (1,0)↔(2,0)
|
|
||||||
var level = new BoardBuilder(4, 4)
|
var level = new BoardBuilder(4, 4)
|
||||||
.WithProduction(0, 0, "Scierie", CargoType.Wood)
|
.WithProduction(0, 0, "Scierie", CargoType.Wood)
|
||||||
.WithDemand(3, 0, "Depot Royal", CargoType.Wood, 3, 30)
|
.WithDemand(3, 0, "Depot Royal", CargoType.Wood, 3, 30)
|
||||||
|
|
@ -20,23 +18,14 @@ public class FullLevelTests
|
||||||
var sim = SimHelper.FromLevel(level);
|
var sim = SimHelper.FromLevel(level);
|
||||||
|
|
||||||
sim.Place(PieceKind.Rook, (1, 0), (2, 0));
|
sim.Place(PieceKind.Rook, (1, 0), (2, 0));
|
||||||
sim.Start();
|
|
||||||
|
|
||||||
var allEvents = sim.StepN(30);
|
var allEvents = sim.StepN(30);
|
||||||
Assert.Contains(allEvents, e => e is VictoryEvent);
|
Assert.Contains(allEvents, e => e is MissionCompleteEvent);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Level2_DeuxClients_Victory()
|
public void Level2_DeuxClients_MissionComplete()
|
||||||
{
|
{
|
||||||
// GDD Level 2: 6x6, Scierie(0,0), Depot Royal(5,0), Caserne(5,4)
|
|
||||||
// Stock: 6 Rooks + 1 Bishop (fixed from GDD's 4R+1B — insufficient)
|
|
||||||
//
|
|
||||||
// Solution requires two routes from single source:
|
|
||||||
// Route 1 → (5,0): A(1,0↔2,0), B(2,0↔4,0)
|
|
||||||
// Route 2 → (5,4): C(0,1↔0,2), D(0,2↔2,2), E(2,2↔3,2),
|
|
||||||
// Bishop(3,2↔4,3), G(4,3↔5,3)
|
|
||||||
// Total needed: 6 Rooks + 1 Bishop
|
|
||||||
var level = new BoardBuilder(6, 6)
|
var level = new BoardBuilder(6, 6)
|
||||||
.WithProduction(0, 0, "Scierie", CargoType.Wood)
|
.WithProduction(0, 0, "Scierie", CargoType.Wood)
|
||||||
.WithDemand(5, 0, "Depot Royal", CargoType.Wood, 2, 50)
|
.WithDemand(5, 0, "Depot Royal", CargoType.Wood, 2, 50)
|
||||||
|
|
@ -53,35 +42,21 @@ public class FullLevelTests
|
||||||
// Route 2: up then right → demand (5,4)
|
// Route 2: up then right → demand (5,4)
|
||||||
sim.Place(PieceKind.Rook, (0, 1), (0, 2));
|
sim.Place(PieceKind.Rook, (0, 1), (0, 2));
|
||||||
sim.Place(PieceKind.Rook, (0, 2), (2, 2));
|
sim.Place(PieceKind.Rook, (0, 2), (2, 2));
|
||||||
// 5th rook — stock exhausted at 4!
|
|
||||||
var events5 = sim.Place(PieceKind.Rook, (2, 2), (3, 2));
|
var events5 = sim.Place(PieceKind.Rook, (2, 2), (3, 2));
|
||||||
Assert.DoesNotContain(events5, e => e is PlacementRejectedEvent);
|
Assert.DoesNotContain(events5, e => e is PlacementRejectedEvent);
|
||||||
|
|
||||||
sim.Place(PieceKind.Bishop, (3, 2), (4, 3));
|
sim.Place(PieceKind.Bishop, (3, 2), (4, 3));
|
||||||
|
|
||||||
// 6th rook needed but only 4 in stock
|
|
||||||
var events6 = sim.Place(PieceKind.Rook, (4, 3), (5, 3));
|
var events6 = sim.Place(PieceKind.Rook, (4, 3), (5, 3));
|
||||||
Assert.DoesNotContain(events6, e => e is PlacementRejectedEvent);
|
Assert.DoesNotContain(events6, e => e is PlacementRejectedEvent);
|
||||||
|
|
||||||
sim.Start();
|
|
||||||
var allEvents = sim.StepN(60);
|
var allEvents = sim.StepN(60);
|
||||||
Assert.Contains(allEvents, e => e is VictoryEvent);
|
Assert.Contains(allEvents, e => e is MissionCompleteEvent);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Level3_LeCol_Victory()
|
public void Level3_LeCol_MissionComplete()
|
||||||
{
|
{
|
||||||
// GDD Level 3: 6x6, L-shaped wall, 2 cargo types, knights jump obstacle
|
|
||||||
// Stock: 8 Rooks + 2 Knights (fixed from GDD's 4R+1B+2K)
|
|
||||||
//
|
|
||||||
// CargoFilter (Phase 2) prevents cross-route contamination:
|
|
||||||
// pieces auto-inherit their production's cargo type via relay chain.
|
|
||||||
//
|
|
||||||
// Route Wood (0,0→5,5): R1(0,1↔1,1), K1(1,1↔3,2),
|
|
||||||
// R2(3,2↔4,2), R3(4,2↔5,2), R4(5,2↔5,3), R5(5,3↔5,4)
|
|
||||||
// Route Stone (5,0→0,5): S1(4,0↔3,0), S2(3,0↔2,0),
|
|
||||||
// K2(2,0↔1,2), S3(1,2↔1,3), S4(1,3↔1,4), S5(1,4↔0,4)
|
|
||||||
// Total: 10 Rooks + 2 Knights
|
|
||||||
var level = new BoardBuilder(6, 6)
|
var level = new BoardBuilder(6, 6)
|
||||||
.WithProduction(0, 0, "Scierie", CargoType.Wood)
|
.WithProduction(0, 0, "Scierie", CargoType.Wood)
|
||||||
.WithProduction(5, 0, "Carriere", CargoType.Stone)
|
.WithProduction(5, 0, "Carriere", CargoType.Stone)
|
||||||
|
|
@ -109,13 +84,12 @@ public class FullLevelTests
|
||||||
sim.Place(PieceKind.Rook, (1, 3), (1, 4));
|
sim.Place(PieceKind.Rook, (1, 3), (1, 4));
|
||||||
sim.Place(PieceKind.Rook, (1, 4), (0, 4));
|
sim.Place(PieceKind.Rook, (1, 4), (0, 4));
|
||||||
|
|
||||||
sim.Start();
|
|
||||||
var allEvents = sim.StepN(80);
|
var allEvents = sim.StepN(80);
|
||||||
Assert.Contains(allEvents, e => e is VictoryEvent);
|
Assert.Contains(allEvents, e => e is MissionCompleteEvent);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Level1_InsufficientPieces_NoVictory()
|
public void Level1_InsufficientPieces_NoMissionComplete()
|
||||||
{
|
{
|
||||||
var level = new BoardBuilder(4, 4)
|
var level = new BoardBuilder(4, 4)
|
||||||
.WithProduction(0, 0, "Scierie", CargoType.Wood)
|
.WithProduction(0, 0, "Scierie", CargoType.Wood)
|
||||||
|
|
@ -125,11 +99,10 @@ public class FullLevelTests
|
||||||
var sim = SimHelper.FromLevel(level);
|
var sim = SimHelper.FromLevel(level);
|
||||||
|
|
||||||
sim.Place(PieceKind.Rook, (1, 1), (2, 1));
|
sim.Place(PieceKind.Rook, (1, 1), (2, 1));
|
||||||
sim.Start();
|
|
||||||
|
|
||||||
var allEvents = sim.StepN(8);
|
var allEvents = sim.StepN(8);
|
||||||
|
|
||||||
Assert.DoesNotContain(allEvents, e => e is VictoryEvent);
|
// No deadline concept anymore — just no mission complete
|
||||||
Assert.Contains(allEvents, e => e is DeadlineExpiredEvent);
|
Assert.DoesNotContain(allEvents, e => e is MissionCompleteEvent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,41 +44,27 @@ public class GameSimTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void PlaceDuringRunning_Rejected()
|
public void PlaceDuringRunning_Succeeds()
|
||||||
{
|
{
|
||||||
|
// In the new system, placement works in any phase
|
||||||
var sim = CreateLevel1Sim();
|
var sim = CreateLevel1Sim();
|
||||||
sim.Place(PieceKind.Rook, (0, 0), (1, 0));
|
sim.Place(PieceKind.Rook, (0, 0), (1, 0));
|
||||||
sim.Start();
|
sim.Resume(); // Paused → Running
|
||||||
|
|
||||||
var events = sim.Place(PieceKind.Rook, (0, 1), (1, 1));
|
var events = sim.Place(PieceKind.Rook, (0, 1), (1, 1));
|
||||||
Assert.IsType<CommandRejectedEvent>(events[0]);
|
Assert.IsType<PiecePlacedEvent>(events[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void StartWithNoPieces_Rejected()
|
public void RemoveDuringRunning_Succeeds()
|
||||||
{
|
|
||||||
var sim = CreateLevel1Sim();
|
|
||||||
var events = sim.Start();
|
|
||||||
Assert.IsType<CommandRejectedEvent>(events[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void RemoveDuringRunning_Rejected()
|
|
||||||
{
|
{
|
||||||
|
// In the new system, removal works in any phase
|
||||||
var sim = CreateLevel1Sim();
|
var sim = CreateLevel1Sim();
|
||||||
sim.Place(PieceKind.Rook, (0, 0), (1, 0));
|
sim.Place(PieceKind.Rook, (0, 0), (1, 0));
|
||||||
sim.Start();
|
sim.Resume(); // Paused → Running
|
||||||
|
|
||||||
var events = sim.Remove(1);
|
var events = sim.Remove(1);
|
||||||
Assert.IsType<CommandRejectedEvent>(events[0]);
|
Assert.IsType<PieceRemovedEvent>(events[0]);
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void StopDuringEdit_Rejected()
|
|
||||||
{
|
|
||||||
var sim = CreateLevel1Sim();
|
|
||||||
var events = sim.Stop();
|
|
||||||
Assert.IsType<CommandRejectedEvent>(events[0]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -86,7 +72,6 @@ public class GameSimTests
|
||||||
{
|
{
|
||||||
var sim = CreateLevel1Sim();
|
var sim = CreateLevel1Sim();
|
||||||
sim.Place(PieceKind.Rook, (0, 0), (2, 0));
|
sim.Place(PieceKind.Rook, (0, 0), (2, 0));
|
||||||
sim.Start();
|
|
||||||
|
|
||||||
// Step 1: piece moves from (0,0) to (2,0)
|
// Step 1: piece moves from (0,0) to (2,0)
|
||||||
var events1 = sim.Step();
|
var events1 = sim.Step();
|
||||||
|
|
@ -105,16 +90,11 @@ public class GameSimTests
|
||||||
public void ChainedPieces_TransferCargo()
|
public void ChainedPieces_TransferCargo()
|
||||||
{
|
{
|
||||||
var sim = CreateLevel1Sim();
|
var sim = CreateLevel1Sim();
|
||||||
// Piece A: (0,0) → (1,0), Piece B: (2,0) → (3,0)
|
|
||||||
// Adjacent at (1,0)↔(2,0) when A is at end and B is at start
|
|
||||||
sim.Place(PieceKind.Rook, (0, 0), (1, 0));
|
sim.Place(PieceKind.Rook, (0, 0), (1, 0));
|
||||||
sim.Place(PieceKind.Rook, (2, 0), (3, 0));
|
sim.Place(PieceKind.Rook, (2, 0), (3, 0));
|
||||||
sim.Start();
|
|
||||||
|
|
||||||
// Run until we see a cargo transfer between pieces
|
|
||||||
var allEvents = sim.StepN(20);
|
var allEvents = sim.StepN(20);
|
||||||
|
|
||||||
// Should have production events and cargo transfers
|
|
||||||
Assert.Contains(allEvents, e => e is CargoProducedEvent);
|
Assert.Contains(allEvents, e => e is CargoProducedEvent);
|
||||||
Assert.Contains(allEvents, e => e is CargoTransferredEvent);
|
Assert.Contains(allEvents, e => e is CargoTransferredEvent);
|
||||||
}
|
}
|
||||||
|
|
@ -125,18 +105,15 @@ public class GameSimTests
|
||||||
var sim = CreateLevel1Sim();
|
var sim = CreateLevel1Sim();
|
||||||
sim.Place(PieceKind.Rook, (0, 0), (1, 0));
|
sim.Place(PieceKind.Rook, (0, 0), (1, 0));
|
||||||
|
|
||||||
sim.Start();
|
|
||||||
var allEvents = sim.StepN(6);
|
var allEvents = sim.StepN(6);
|
||||||
|
|
||||||
var prodEvents = allEvents.OfType<CargoProducedEvent>().ToList();
|
var prodEvents = allEvents.OfType<CargoProducedEvent>().ToList();
|
||||||
// Production fires every turn
|
|
||||||
Assert.Equal(6, prodEvents.Count);
|
Assert.Equal(6, prodEvents.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Victory_WhenAllDemandsMet()
|
public void MissionComplete_WhenAllDemandsMet()
|
||||||
{
|
{
|
||||||
// Tiny level: prod adjacent to demand, just need one piece to relay
|
|
||||||
var level = new BoardBuilder(3, 1)
|
var level = new BoardBuilder(3, 1)
|
||||||
.WithProduction(0, 0, "P", CargoType.Wood)
|
.WithProduction(0, 0, "P", CargoType.Wood)
|
||||||
.WithDemand(2, 0, "D", CargoType.Wood, 1, 30)
|
.WithDemand(2, 0, "D", CargoType.Wood, 1, 30)
|
||||||
|
|
@ -145,59 +122,28 @@ public class GameSimTests
|
||||||
var sim = SimHelper.FromLevel(level);
|
var sim = SimHelper.FromLevel(level);
|
||||||
|
|
||||||
sim.Place(PieceKind.Rook, (1, 0), (2, 0));
|
sim.Place(PieceKind.Rook, (1, 0), (2, 0));
|
||||||
sim.Start();
|
|
||||||
|
|
||||||
// Run enough turns for production → piece → demand
|
|
||||||
var allEvents = sim.StepN(10);
|
var allEvents = sim.StepN(10);
|
||||||
|
|
||||||
Assert.Contains(allEvents, e => e is VictoryEvent);
|
Assert.Contains(allEvents, e => e is MissionCompleteEvent);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Defeat_WhenDeadlineExpires()
|
public void InitialPhase_IsPaused()
|
||||||
{
|
{
|
||||||
// Demand with very tight deadline, piece placed far from demand
|
var sim = CreateLevel1Sim();
|
||||||
var level = new BoardBuilder(4, 4)
|
var snap = sim.Snapshot;
|
||||||
.WithProduction(0, 0, "P", CargoType.Wood)
|
Assert.Equal(SimPhase.Paused, snap.Phase);
|
||||||
.WithDemand(3, 3, "D", CargoType.Wood, 10, 3) // need 10 in 3 turns — impossible
|
}
|
||||||
.WithStock(PieceKind.Rook, 3)
|
|
||||||
.Build();
|
|
||||||
var sim = SimHelper.FromLevel(level);
|
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StepFromPaused_Works()
|
||||||
|
{
|
||||||
|
var sim = CreateLevel1Sim();
|
||||||
sim.Place(PieceKind.Rook, (1, 0), (2, 0));
|
sim.Place(PieceKind.Rook, (1, 0), (2, 0));
|
||||||
sim.Start();
|
|
||||||
|
|
||||||
var allEvents = sim.StepN(5);
|
// Step directly from Paused
|
||||||
|
var events = sim.Step();
|
||||||
Assert.Contains(allEvents, e => e is DeadlineExpiredEvent);
|
Assert.Contains(events, e => e is TurnStartedEvent);
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void StopResetsState()
|
|
||||||
{
|
|
||||||
var sim = CreateLevel1Sim();
|
|
||||||
sim.Place(PieceKind.Rook, (0, 0), (1, 0));
|
|
||||||
sim.Start();
|
|
||||||
sim.StepN(5);
|
|
||||||
sim.Stop();
|
|
||||||
|
|
||||||
var snap = sim.Snapshot;
|
|
||||||
Assert.Equal(SimPhase.Edit, snap.Phase);
|
|
||||||
Assert.Equal(0, snap.TurnNumber);
|
|
||||||
// Pieces should be back at start cells
|
|
||||||
Assert.All(snap.Pieces, p => Assert.Equal(p.StartCell, p.CurrentCell));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ResetClearsEverything()
|
|
||||||
{
|
|
||||||
var sim = CreateLevel1Sim();
|
|
||||||
sim.Place(PieceKind.Rook, (0, 0), (1, 0));
|
|
||||||
sim.Reset();
|
|
||||||
|
|
||||||
var snap = sim.Snapshot;
|
|
||||||
Assert.Equal(SimPhase.Edit, snap.Phase);
|
|
||||||
Assert.Empty(snap.Pieces);
|
|
||||||
Assert.Equal(3, snap.RemainingStock[PieceKind.Rook]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
247
chessistics-tests/Simulation/PhaseTests.cs
Normal file
247
chessistics-tests/Simulation/PhaseTests.cs
Normal file
|
|
@ -0,0 +1,247 @@
|
||||||
|
using Chessistics.Engine.Commands;
|
||||||
|
using Chessistics.Engine.Events;
|
||||||
|
using Chessistics.Engine.Model;
|
||||||
|
using Chessistics.Tests.Helpers;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Chessistics.Tests.Simulation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Regression tests for SimPhase transitions.
|
||||||
|
/// Bug: StepSimulationCommand always set phase to Paused, even during auto-play (Running).
|
||||||
|
/// </summary>
|
||||||
|
public class PhaseTests
|
||||||
|
{
|
||||||
|
private static SimHelper CreateSimpleRunnable()
|
||||||
|
{
|
||||||
|
var campaign = new CampaignDef
|
||||||
|
{
|
||||||
|
Name = "Phase Test", InitialWidth = 4, InitialHeight = 1,
|
||||||
|
Missions =
|
||||||
|
[
|
||||||
|
new MissionDef
|
||||||
|
{
|
||||||
|
Id = 1, Name = "M1",
|
||||||
|
TerrainPatch = new TerrainPatch
|
||||||
|
{
|
||||||
|
NewWidth = 4, NewHeight = 1,
|
||||||
|
Cells =
|
||||||
|
[
|
||||||
|
new PatchCell { Col = 0, Row = 0, Type = CellType.Production,
|
||||||
|
Production = new ProductionDef(new Coords(0, 0), "P", CargoType.Wood, 4) },
|
||||||
|
new PatchCell { Col = 3, Row = 0, Type = CellType.Demand,
|
||||||
|
Demand = new DemandDef(new Coords(3, 0), "D", CargoType.Wood, 99) }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
UnlockedPieces = [PieceKind.Rook],
|
||||||
|
UnlockedLevels = [new PieceUpgrade(PieceKind.Rook, 1)],
|
||||||
|
Stock = [new PieceStock(PieceKind.Rook, 2)]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
var sim = SimHelper.FromCampaign(campaign);
|
||||||
|
sim.Sim.ProcessCommand(new LoadCampaignCommand());
|
||||||
|
sim.Place(PieceKind.Rook, (1, 0), (2, 0));
|
||||||
|
return sim;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ManualStep_FromPaused_RemainsInPaused()
|
||||||
|
{
|
||||||
|
var sim = CreateSimpleRunnable();
|
||||||
|
Assert.Equal(SimPhase.Paused, sim.Snapshot.Phase);
|
||||||
|
|
||||||
|
sim.Step();
|
||||||
|
|
||||||
|
Assert.Equal(SimPhase.Paused, sim.Snapshot.Phase);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AutoPlayStep_FromRunning_StaysRunning()
|
||||||
|
{
|
||||||
|
var sim = CreateSimpleRunnable();
|
||||||
|
|
||||||
|
// Resume → Running, then step (simulates auto-play timer)
|
||||||
|
sim.Resume();
|
||||||
|
Assert.Equal(SimPhase.Running, sim.Snapshot.Phase);
|
||||||
|
|
||||||
|
sim.Step();
|
||||||
|
|
||||||
|
// Phase should stay Running — this was the bug (used to revert to Paused)
|
||||||
|
Assert.Equal(SimPhase.Running, sim.Snapshot.Phase);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AutoPlayStep_MultipleSteps_StaysRunning()
|
||||||
|
{
|
||||||
|
var sim = CreateSimpleRunnable();
|
||||||
|
sim.Resume();
|
||||||
|
|
||||||
|
// Multiple consecutive steps from Running should all stay Running
|
||||||
|
for (int i = 0; i < 5; i++)
|
||||||
|
{
|
||||||
|
sim.Step();
|
||||||
|
Assert.Equal(SimPhase.Running, sim.Snapshot.Phase);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AutoPlayStep_CollisionCausesPause()
|
||||||
|
{
|
||||||
|
var campaign = new CampaignDef
|
||||||
|
{
|
||||||
|
Name = "Collision Phase Test", InitialWidth = 4, InitialHeight = 2,
|
||||||
|
Missions =
|
||||||
|
[
|
||||||
|
new MissionDef
|
||||||
|
{
|
||||||
|
Id = 1, Name = "M1",
|
||||||
|
TerrainPatch = new TerrainPatch
|
||||||
|
{
|
||||||
|
NewWidth = 4, NewHeight = 2,
|
||||||
|
Cells =
|
||||||
|
[
|
||||||
|
new PatchCell { Col = 0, Row = 0, Type = CellType.Production,
|
||||||
|
Production = new ProductionDef(new Coords(0, 0), "P", CargoType.Wood, 4) },
|
||||||
|
new PatchCell { Col = 3, Row = 0, Type = CellType.Demand,
|
||||||
|
Demand = new DemandDef(new Coords(3, 0), "D", CargoType.Wood, 99) }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
UnlockedPieces = [PieceKind.Rook],
|
||||||
|
UnlockedLevels = [new PieceUpgrade(PieceKind.Rook, 1)],
|
||||||
|
Stock = [new PieceStock(PieceKind.Rook, 3)]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
var sim = SimHelper.FromCampaign(campaign);
|
||||||
|
sim.Sim.ProcessCommand(new LoadCampaignCommand());
|
||||||
|
|
||||||
|
// Two rooks with same end cell → collision on first step
|
||||||
|
sim.Place(PieceKind.Rook, (0, 1), (1, 1));
|
||||||
|
sim.Place(PieceKind.Rook, (2, 1), (1, 1));
|
||||||
|
|
||||||
|
sim.Resume();
|
||||||
|
Assert.Equal(SimPhase.Running, sim.Snapshot.Phase);
|
||||||
|
|
||||||
|
var events = sim.Step();
|
||||||
|
|
||||||
|
// Collision should auto-pause even from Running
|
||||||
|
Assert.Contains(events, e => e is PieceReturnedToStockEvent);
|
||||||
|
Assert.Equal(SimPhase.Paused, sim.Snapshot.Phase);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AutoPlayStep_LastMissionComplete_SetsMissionComplete()
|
||||||
|
{
|
||||||
|
var campaign = new CampaignDef
|
||||||
|
{
|
||||||
|
Name = "Last Mission Phase Test", InitialWidth = 3, InitialHeight = 1,
|
||||||
|
Missions =
|
||||||
|
[
|
||||||
|
new MissionDef
|
||||||
|
{
|
||||||
|
Id = 1, Name = "M1",
|
||||||
|
TerrainPatch = new TerrainPatch
|
||||||
|
{
|
||||||
|
NewWidth = 3, NewHeight = 1,
|
||||||
|
Cells =
|
||||||
|
[
|
||||||
|
new PatchCell { Col = 0, Row = 0, Type = CellType.Production,
|
||||||
|
Production = new ProductionDef(new Coords(0, 0), "P", CargoType.Wood, 4) },
|
||||||
|
new PatchCell { Col = 2, Row = 0, Type = CellType.Demand,
|
||||||
|
Demand = new DemandDef(new Coords(2, 0), "D", CargoType.Wood, 1) }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
UnlockedPieces = [PieceKind.Rook],
|
||||||
|
UnlockedLevels = [new PieceUpgrade(PieceKind.Rook, 1)],
|
||||||
|
Stock = [new PieceStock(PieceKind.Rook, 1)]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
var sim = SimHelper.FromCampaign(campaign);
|
||||||
|
sim.Sim.ProcessCommand(new LoadCampaignCommand());
|
||||||
|
sim.Place(PieceKind.Rook, (1, 0), (2, 0));
|
||||||
|
|
||||||
|
// Running auto-play → last mission completes → MissionComplete phase
|
||||||
|
sim.Resume();
|
||||||
|
Assert.Equal(SimPhase.Running, sim.Snapshot.Phase);
|
||||||
|
|
||||||
|
// Step until mission complete
|
||||||
|
for (int i = 0; i < 20; i++)
|
||||||
|
{
|
||||||
|
sim.Step();
|
||||||
|
if (sim.Snapshot.Phase == SimPhase.MissionComplete)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.Equal(SimPhase.MissionComplete, sim.Snapshot.Phase);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AutoPlayStep_NonLastMissionComplete_AutoAdvances_StaysRunning()
|
||||||
|
{
|
||||||
|
var campaign = new CampaignDef
|
||||||
|
{
|
||||||
|
Name = "Auto-Advance Phase Test", InitialWidth = 3, InitialHeight = 1,
|
||||||
|
Missions =
|
||||||
|
[
|
||||||
|
new MissionDef
|
||||||
|
{
|
||||||
|
Id = 1, Name = "M1",
|
||||||
|
TerrainPatch = new TerrainPatch
|
||||||
|
{
|
||||||
|
NewWidth = 3, NewHeight = 1,
|
||||||
|
Cells =
|
||||||
|
[
|
||||||
|
new PatchCell { Col = 0, Row = 0, Type = CellType.Production,
|
||||||
|
Production = new ProductionDef(new Coords(0, 0), "P", CargoType.Wood, 4) },
|
||||||
|
new PatchCell { Col = 2, Row = 0, Type = CellType.Demand,
|
||||||
|
Demand = new DemandDef(new Coords(2, 0), "D", CargoType.Wood, 1) }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
UnlockedPieces = [PieceKind.Rook],
|
||||||
|
UnlockedLevels = [new PieceUpgrade(PieceKind.Rook, 1)],
|
||||||
|
Stock = [new PieceStock(PieceKind.Rook, 2)]
|
||||||
|
},
|
||||||
|
new MissionDef
|
||||||
|
{
|
||||||
|
Id = 2, Name = "M2",
|
||||||
|
TerrainPatch = new TerrainPatch
|
||||||
|
{
|
||||||
|
NewWidth = 5, NewHeight = 1,
|
||||||
|
Cells =
|
||||||
|
[
|
||||||
|
new PatchCell { Col = 4, Row = 0, Type = CellType.Demand,
|
||||||
|
Demand = new DemandDef(new Coords(4, 0), "D2", CargoType.Wood, 99) }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
UnlockedPieces = [],
|
||||||
|
UnlockedLevels = [],
|
||||||
|
Stock = [new PieceStock(PieceKind.Rook, 1)]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
var sim = SimHelper.FromCampaign(campaign);
|
||||||
|
sim.Sim.ProcessCommand(new LoadCampaignCommand());
|
||||||
|
sim.Place(PieceKind.Rook, (1, 0), (2, 0));
|
||||||
|
|
||||||
|
sim.Resume();
|
||||||
|
|
||||||
|
// Step until mission 1 completes and auto-advances
|
||||||
|
bool advanced = false;
|
||||||
|
for (int i = 0; i < 20; i++)
|
||||||
|
{
|
||||||
|
var events = sim.Step();
|
||||||
|
if (events.Any(e => e is MissionStartedEvent ms && ms.MissionIndex == 1))
|
||||||
|
{
|
||||||
|
advanced = true;
|
||||||
|
// Phase should still be Running after auto-advance
|
||||||
|
Assert.Equal(SimPhase.Running, sim.Snapshot.Phase);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.True(advanced, "Mission 1 should have auto-advanced to Mission 2");
|
||||||
|
Assert.Equal(1, sim.Snapshot.Campaign!.CurrentMissionIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
1
chessistics-tests/Simulation/PhaseTests.cs.uid
Normal file
1
chessistics-tests/Simulation/PhaseTests.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://dxv44w3l5rw66
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using Chessistics.Engine.Commands;
|
||||||
using Chessistics.Engine.Events;
|
using Chessistics.Engine.Events;
|
||||||
using Chessistics.Engine.Model;
|
using Chessistics.Engine.Model;
|
||||||
using Chessistics.Tests.Helpers;
|
using Chessistics.Tests.Helpers;
|
||||||
|
|
@ -7,17 +8,13 @@ namespace Chessistics.Tests.Simulation;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// End-to-end solvability tests: each test places pieces, runs the simulation,
|
/// End-to-end solvability tests: each test places pieces, runs the simulation,
|
||||||
/// and asserts VictoryEvent is produced — proving the level is winnable.
|
/// and asserts MissionCompleteEvent is produced — proving the level is winnable.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class SolvabilityTests
|
public class SolvabilityTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[Fact]
|
||||||
public void SingleRook_ShortRelay_Victory()
|
public void SingleRook_ShortRelay_MissionComplete()
|
||||||
{
|
{
|
||||||
// 3x1: Prod(0,0) — Rook(1,0↔2,0) — Demand(2,0)
|
|
||||||
// Rook at (1,0) picks up from prod, at (2,0) is ON demand (not adjacent).
|
|
||||||
// Delivery happens when rook returns to (1,0), adjacent to demand at (2,0).
|
|
||||||
// Wait — (1,0) is adjacent to (2,0) ✓ so delivery from (1,0).
|
|
||||||
var level = new BoardBuilder(3, 1)
|
var level = new BoardBuilder(3, 1)
|
||||||
.WithProduction(0, 0, "Scierie", CargoType.Wood)
|
.WithProduction(0, 0, "Scierie", CargoType.Wood)
|
||||||
.WithDemand(2, 0, "Depot", CargoType.Wood, 2, 30)
|
.WithDemand(2, 0, "Depot", CargoType.Wood, 2, 30)
|
||||||
|
|
@ -26,21 +23,15 @@ public class SolvabilityTests
|
||||||
var sim = SimHelper.FromLevel(level);
|
var sim = SimHelper.FromLevel(level);
|
||||||
|
|
||||||
sim.Place(PieceKind.Rook, (1, 0), (2, 0));
|
sim.Place(PieceKind.Rook, (1, 0), (2, 0));
|
||||||
sim.Start();
|
|
||||||
|
|
||||||
var allEvents = sim.StepN(20);
|
var allEvents = sim.StepN(20);
|
||||||
|
|
||||||
Assert.Contains(allEvents, e => e is VictoryEvent);
|
Assert.Contains(allEvents, e => e is MissionCompleteEvent);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ThreePieceChain_SharedRelayPoints_Victory()
|
public void ThreePieceChain_SharedRelayPoints_MissionComplete()
|
||||||
{
|
{
|
||||||
// 5x2: three rooks form a chain with shared relay points.
|
|
||||||
// Prod(0,0) — A(1,0↔2,0) — B(2,0↔3,0) — C(3,0↔4,0) — Demand(4,0)
|
|
||||||
// Pieces share cells (2,0) and (3,0) but never collide:
|
|
||||||
// Odd turns: A@(2,0) B@(3,0) C@(4,0)
|
|
||||||
// Even turns: A@(1,0) B@(2,0) C@(3,0)
|
|
||||||
var level = new BoardBuilder(5, 2)
|
var level = new BoardBuilder(5, 2)
|
||||||
.WithProduction(0, 0, "Scierie", CargoType.Wood)
|
.WithProduction(0, 0, "Scierie", CargoType.Wood)
|
||||||
.WithDemand(4, 0, "Depot", CargoType.Wood, 2, 40)
|
.WithDemand(4, 0, "Depot", CargoType.Wood, 2, 40)
|
||||||
|
|
@ -51,12 +42,10 @@ public class SolvabilityTests
|
||||||
sim.Place(PieceKind.Rook, (1, 0), (2, 0));
|
sim.Place(PieceKind.Rook, (1, 0), (2, 0));
|
||||||
sim.Place(PieceKind.Rook, (2, 0), (3, 0));
|
sim.Place(PieceKind.Rook, (2, 0), (3, 0));
|
||||||
sim.Place(PieceKind.Rook, (3, 0), (4, 0));
|
sim.Place(PieceKind.Rook, (3, 0), (4, 0));
|
||||||
sim.Start();
|
|
||||||
|
|
||||||
var allEvents = sim.StepN(30);
|
var allEvents = sim.StepN(30);
|
||||||
|
|
||||||
Assert.Contains(allEvents, e => e is VictoryEvent);
|
Assert.Contains(allEvents, e => e is MissionCompleteEvent);
|
||||||
// Verify cargo actually traversed the chain (not just a shortcut)
|
|
||||||
Assert.True(
|
Assert.True(
|
||||||
allEvents.OfType<CargoTransferredEvent>().Count() >= 4,
|
allEvents.OfType<CargoTransferredEvent>().Count() >= 4,
|
||||||
"Expected at least 4 cargo transfers across the 3-piece chain");
|
"Expected at least 4 cargo transfers across the 3-piece chain");
|
||||||
|
|
@ -65,12 +54,6 @@ public class SolvabilityTests
|
||||||
[Fact]
|
[Fact]
|
||||||
public void TwoDemands_SingleSource_BothSatisfied()
|
public void TwoDemands_SingleSource_BothSatisfied()
|
||||||
{
|
{
|
||||||
// 4x3: one production feeds two demands via two rooks.
|
|
||||||
// Prod(0,0) at origin.
|
|
||||||
// D1(2,0) along row 0, D2(0,2) along col 0.
|
|
||||||
// Rook A(1,0↔2,0): picks up at (1,0), delivers to D1 from (1,0).
|
|
||||||
// Rook B(0,1↔0,2): picks up at (0,1), delivers to D2 from (0,1).
|
|
||||||
// Both rooks compete for the same buffer; A gets priority (placed first).
|
|
||||||
var level = new BoardBuilder(4, 3)
|
var level = new BoardBuilder(4, 3)
|
||||||
.WithProduction(0, 0, "Scierie", CargoType.Wood)
|
.WithProduction(0, 0, "Scierie", CargoType.Wood)
|
||||||
.WithDemand(2, 0, "Depot Royal", CargoType.Wood, 2, 30)
|
.WithDemand(2, 0, "Depot Royal", CargoType.Wood, 2, 30)
|
||||||
|
|
@ -81,24 +64,18 @@ public class SolvabilityTests
|
||||||
|
|
||||||
sim.Place(PieceKind.Rook, (1, 0), (2, 0));
|
sim.Place(PieceKind.Rook, (1, 0), (2, 0));
|
||||||
sim.Place(PieceKind.Rook, (0, 1), (0, 2));
|
sim.Place(PieceKind.Rook, (0, 1), (0, 2));
|
||||||
sim.Start();
|
|
||||||
|
|
||||||
var allEvents = sim.StepN(20);
|
var allEvents = sim.StepN(20);
|
||||||
|
|
||||||
Assert.Contains(allEvents, e => e is VictoryEvent);
|
Assert.Contains(allEvents, e => e is MissionCompleteEvent);
|
||||||
// Both demands must have received progress events
|
|
||||||
var demandProgress = allEvents.OfType<DemandProgressEvent>().ToList();
|
var demandProgress = allEvents.OfType<DemandProgressEvent>().ToList();
|
||||||
Assert.Contains(demandProgress, dp => dp.DemandCell == new Coords(2, 0) && dp.Current == dp.Required);
|
Assert.Contains(demandProgress, dp => dp.DemandCell == new Coords(2, 0) && dp.Current == dp.Required);
|
||||||
Assert.Contains(demandProgress, dp => dp.DemandCell == new Coords(0, 2) && dp.Current == dp.Required);
|
Assert.Contains(demandProgress, dp => dp.DemandCell == new Coords(0, 2) && dp.Current == dp.Required);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void TwoCargoTypes_ParallelRoutes_Victory()
|
public void TwoCargoTypes_ParallelRoutes_MissionComplete()
|
||||||
{
|
{
|
||||||
// 4x2: two independent production→demand chains, one Wood, one Stone.
|
|
||||||
// Row 0: Prod_Wood(0,0) — Rook A(1,0↔2,0) — Demand_Wood(3,0)
|
|
||||||
// Row 1: Prod_Stone(0,1) — Rook B(1,1↔2,1) — Demand_Stone(3,1)
|
|
||||||
// Proves two cargo types flow independently to their matching demands.
|
|
||||||
var level = new BoardBuilder(4, 2)
|
var level = new BoardBuilder(4, 2)
|
||||||
.WithProduction(0, 0, "Scierie", CargoType.Wood)
|
.WithProduction(0, 0, "Scierie", CargoType.Wood)
|
||||||
.WithProduction(0, 1, "Carriere", CargoType.Stone)
|
.WithProduction(0, 1, "Carriere", CargoType.Stone)
|
||||||
|
|
@ -110,12 +87,10 @@ public class SolvabilityTests
|
||||||
|
|
||||||
sim.Place(PieceKind.Rook, (1, 0), (2, 0));
|
sim.Place(PieceKind.Rook, (1, 0), (2, 0));
|
||||||
sim.Place(PieceKind.Rook, (1, 1), (2, 1));
|
sim.Place(PieceKind.Rook, (1, 1), (2, 1));
|
||||||
sim.Start();
|
|
||||||
|
|
||||||
var allEvents = sim.StepN(20);
|
var allEvents = sim.StepN(20);
|
||||||
|
|
||||||
Assert.Contains(allEvents, e => e is VictoryEvent);
|
Assert.Contains(allEvents, e => e is MissionCompleteEvent);
|
||||||
// Verify no wrong-type delivery (Wood to Stone demand or vice-versa)
|
|
||||||
var transfers = allEvents.OfType<CargoTransferredEvent>().ToList();
|
var transfers = allEvents.OfType<CargoTransferredEvent>().ToList();
|
||||||
foreach (var t in transfers.Where(t => t.To == new Coords(3, 0)))
|
foreach (var t in transfers.Where(t => t.To == new Coords(3, 0)))
|
||||||
Assert.Equal(CargoType.Wood, t.Type);
|
Assert.Equal(CargoType.Wood, t.Type);
|
||||||
|
|
@ -124,14 +99,8 @@ public class SolvabilityTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Bishop_DiagonalRelay_Victory()
|
public void Bishop_DiagonalRelay_MissionComplete()
|
||||||
{
|
{
|
||||||
// 4x3: bishop provides the diagonal link in a two-piece chain.
|
|
||||||
// Prod(0,0), Demand(2,1).
|
|
||||||
// Rook(0,1↔0,0): at (0,1) picks up from prod.
|
|
||||||
// Bishop(1,1↔2,2): at (1,1) receives from rook, at (2,2) delivers to demand (2,1).
|
|
||||||
// Even turns: Rook@(0,1), Bishop@(1,1) — adjacent, transfer.
|
|
||||||
// Odd turns: Rook@(0,0), Bishop@(2,2) — bishop delivers.
|
|
||||||
var level = new BoardBuilder(4, 3)
|
var level = new BoardBuilder(4, 3)
|
||||||
.WithProduction(0, 0, "Scierie", CargoType.Wood)
|
.WithProduction(0, 0, "Scierie", CargoType.Wood)
|
||||||
.WithDemand(2, 1, "Depot", CargoType.Wood, 2, 30)
|
.WithDemand(2, 1, "Depot", CargoType.Wood, 2, 30)
|
||||||
|
|
@ -142,23 +111,15 @@ public class SolvabilityTests
|
||||||
|
|
||||||
sim.Place(PieceKind.Rook, (0, 1), (0, 0));
|
sim.Place(PieceKind.Rook, (0, 1), (0, 0));
|
||||||
sim.Place(PieceKind.Bishop, (1, 1), (2, 2));
|
sim.Place(PieceKind.Bishop, (1, 1), (2, 2));
|
||||||
sim.Start();
|
|
||||||
|
|
||||||
var allEvents = sim.StepN(20);
|
var allEvents = sim.StepN(20);
|
||||||
|
|
||||||
Assert.Contains(allEvents, e => e is VictoryEvent);
|
Assert.Contains(allEvents, e => e is MissionCompleteEvent);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Knight_JumpsWall_Victory()
|
public void Knight_JumpsWall_MissionComplete()
|
||||||
{
|
{
|
||||||
// 5x3: a wall blocks the direct path, knight jumps over it.
|
|
||||||
// Prod(0,0), Demand(4,0).
|
|
||||||
// Walls: full column 2 — (2,0), (2,1), (2,2).
|
|
||||||
// Rook(1,0↔1,1): at (1,0) picks up from prod.
|
|
||||||
// Knight(1,1↔3,0): L-shape (+2,-1) jumps over wall, at (3,0) delivers to demand (4,0).
|
|
||||||
// Even turns: Rook@(1,0), Knight@(1,1) — adjacent, transfer.
|
|
||||||
// Odd turns: Knight@(3,0), adjacent to demand — delivers.
|
|
||||||
var level = new BoardBuilder(5, 3)
|
var level = new BoardBuilder(5, 3)
|
||||||
.WithProduction(0, 0, "Scierie", CargoType.Wood)
|
.WithProduction(0, 0, "Scierie", CargoType.Wood)
|
||||||
.WithDemand(4, 0, "Depot", CargoType.Wood, 2, 30)
|
.WithDemand(4, 0, "Depot", CargoType.Wood, 2, 30)
|
||||||
|
|
@ -170,43 +131,16 @@ public class SolvabilityTests
|
||||||
|
|
||||||
sim.Place(PieceKind.Rook, (1, 0), (1, 1));
|
sim.Place(PieceKind.Rook, (1, 0), (1, 1));
|
||||||
sim.Place(PieceKind.Knight, (1, 1), (3, 0));
|
sim.Place(PieceKind.Knight, (1, 1), (3, 0));
|
||||||
sim.Start();
|
|
||||||
|
|
||||||
var allEvents = sim.StepN(20);
|
var allEvents = sim.StepN(20);
|
||||||
|
|
||||||
Assert.Contains(allEvents, e => e is VictoryEvent);
|
Assert.Contains(allEvents, e => e is MissionCompleteEvent);
|
||||||
// Verify the knight actually moved across the wall
|
|
||||||
Assert.Contains(allEvents, e => e is PieceMovedEvent m && m.To == new Coords(3, 0));
|
Assert.Contains(allEvents, e => e is PieceMovedEvent m && m.To == new Coords(3, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Victory_ReportsCorrectMetrics()
|
|
||||||
{
|
|
||||||
// 3x1: single rook relay, verify PiecesUsed, TurnsTaken, CellsOccupied.
|
|
||||||
var level = new BoardBuilder(3, 1)
|
|
||||||
.WithProduction(0, 0, "P", CargoType.Wood)
|
|
||||||
.WithDemand(2, 0, "D", CargoType.Wood, 2, 30)
|
|
||||||
.WithStock(PieceKind.Rook, 1)
|
|
||||||
.Build();
|
|
||||||
var sim = SimHelper.FromLevel(level);
|
|
||||||
|
|
||||||
sim.Place(PieceKind.Rook, (1, 0), (2, 0));
|
|
||||||
sim.Start();
|
|
||||||
|
|
||||||
var allEvents = sim.StepN(20);
|
|
||||||
|
|
||||||
var victory = allEvents.OfType<VictoryEvent>().FirstOrDefault();
|
|
||||||
Assert.NotNull(victory);
|
|
||||||
Assert.Equal(1, victory.Metrics.PiecesUsed);
|
|
||||||
Assert.True(victory.Metrics.TurnsTaken > 0);
|
|
||||||
Assert.Equal(2, victory.Metrics.CellsOccupied); // cells (1,0) and (2,0)
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void NoCollision_WithSharedRelayPoints()
|
public void NoCollision_WithSharedRelayPoints()
|
||||||
{
|
{
|
||||||
// Two rooks sharing a relay point never collide.
|
|
||||||
// A(1,0↔2,0), B(2,0↔3,0) — share cell (2,0) but occupy it on alternate turns.
|
|
||||||
var level = new BoardBuilder(5, 2)
|
var level = new BoardBuilder(5, 2)
|
||||||
.WithProduction(0, 0, "P", CargoType.Wood)
|
.WithProduction(0, 0, "P", CargoType.Wood)
|
||||||
.WithDemand(4, 0, "D", CargoType.Wood, 1, 40)
|
.WithDemand(4, 0, "D", CargoType.Wood, 1, 40)
|
||||||
|
|
@ -216,22 +150,15 @@ public class SolvabilityTests
|
||||||
|
|
||||||
sim.Place(PieceKind.Rook, (1, 0), (2, 0));
|
sim.Place(PieceKind.Rook, (1, 0), (2, 0));
|
||||||
sim.Place(PieceKind.Rook, (2, 0), (3, 0));
|
sim.Place(PieceKind.Rook, (2, 0), (3, 0));
|
||||||
sim.Start();
|
|
||||||
|
|
||||||
var allEvents = sim.StepN(20);
|
var allEvents = sim.StepN(20);
|
||||||
|
|
||||||
Assert.DoesNotContain(allEvents, e => e is PieceDestroyedEvent);
|
Assert.DoesNotContain(allEvents, e => e is PieceReturnedToStockEvent);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void CargoFilter_AutoAssigned_PreventsContamination()
|
public void CargoFilter_AutoAssigned_PreventsContamination()
|
||||||
{
|
{
|
||||||
// 4x1: two productions side by side, two routes with adjacent pieces.
|
|
||||||
// Prod_Wood(0,0), Prod_Stone(3,0)
|
|
||||||
// Rook A(1,0↔2,0) — adjacent to both prods on alternating turns.
|
|
||||||
// Without CargoFilter, A would pick up both types randomly.
|
|
||||||
// With CargoFilter, A's start (1,0) is adjacent to prod_Wood(0,0),
|
|
||||||
// so A is filtered to Wood and ignores Stone.
|
|
||||||
var level = new BoardBuilder(4, 1)
|
var level = new BoardBuilder(4, 1)
|
||||||
.WithProduction(0, 0, "Scierie", CargoType.Wood)
|
.WithProduction(0, 0, "Scierie", CargoType.Wood)
|
||||||
.WithProduction(3, 0, "Carriere", CargoType.Stone)
|
.WithProduction(3, 0, "Carriere", CargoType.Stone)
|
||||||
|
|
@ -242,24 +169,19 @@ public class SolvabilityTests
|
||||||
|
|
||||||
sim.Place(PieceKind.Rook, (1, 0), (2, 0));
|
sim.Place(PieceKind.Rook, (1, 0), (2, 0));
|
||||||
|
|
||||||
// Verify CargoFilter was auto-assigned
|
|
||||||
var snapshot = sim.Snapshot;
|
var snapshot = sim.Snapshot;
|
||||||
Assert.Equal(CargoType.Wood, snapshot.Pieces[0].CargoFilter);
|
Assert.Equal(CargoType.Wood, snapshot.Pieces[0].CargoFilter);
|
||||||
|
|
||||||
sim.Start();
|
|
||||||
var allEvents = sim.StepN(20);
|
var allEvents = sim.StepN(20);
|
||||||
|
|
||||||
// Piece should only carry Wood — never Stone
|
|
||||||
var transfers = allEvents.OfType<CargoTransferredEvent>().ToList();
|
var transfers = allEvents.OfType<CargoTransferredEvent>().ToList();
|
||||||
Assert.All(transfers, t => Assert.Equal(CargoType.Wood, t.Type));
|
Assert.All(transfers, t => Assert.Equal(CargoType.Wood, t.Type));
|
||||||
Assert.Contains(allEvents, e => e is VictoryEvent);
|
Assert.Contains(allEvents, e => e is MissionCompleteEvent);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void CargoFilter_PropagatesThroughChain()
|
public void CargoFilter_PropagatesThroughChain()
|
||||||
{
|
{
|
||||||
// 5x2: chain of 3 rooks, first adjacent to Wood production.
|
|
||||||
// All should inherit Wood filter via relay chain propagation.
|
|
||||||
var level = new BoardBuilder(5, 2)
|
var level = new BoardBuilder(5, 2)
|
||||||
.WithProduction(0, 0, "Scierie", CargoType.Wood)
|
.WithProduction(0, 0, "Scierie", CargoType.Wood)
|
||||||
.WithDemand(4, 0, "Depot", CargoType.Wood, 2, 40)
|
.WithDemand(4, 0, "Depot", CargoType.Wood, 2, 40)
|
||||||
|
|
@ -267,9 +189,9 @@ public class SolvabilityTests
|
||||||
.Build();
|
.Build();
|
||||||
var sim = SimHelper.FromLevel(level);
|
var sim = SimHelper.FromLevel(level);
|
||||||
|
|
||||||
sim.Place(PieceKind.Rook, (1, 0), (2, 0)); // adj to prod → Wood
|
sim.Place(PieceKind.Rook, (1, 0), (2, 0));
|
||||||
sim.Place(PieceKind.Rook, (2, 0), (3, 0)); // shares (2,0) → inherits Wood
|
sim.Place(PieceKind.Rook, (2, 0), (3, 0));
|
||||||
sim.Place(PieceKind.Rook, (3, 0), (4, 0)); // shares (3,0) → inherits Wood
|
sim.Place(PieceKind.Rook, (3, 0), (4, 0));
|
||||||
|
|
||||||
var snapshot = sim.Snapshot;
|
var snapshot = sim.Snapshot;
|
||||||
Assert.Equal(CargoType.Wood, snapshot.Pieces[0].CargoFilter);
|
Assert.Equal(CargoType.Wood, snapshot.Pieces[0].CargoFilter);
|
||||||
|
|
@ -278,9 +200,8 @@ public class SolvabilityTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void StepFromEdit_AutoStartsSimulation()
|
public void StepFromPaused_Works()
|
||||||
{
|
{
|
||||||
// Stepping from Edit phase should auto-start without needing Start command.
|
|
||||||
var level = new BoardBuilder(3, 1)
|
var level = new BoardBuilder(3, 1)
|
||||||
.WithProduction(0, 0, "P", CargoType.Wood)
|
.WithProduction(0, 0, "P", CargoType.Wood)
|
||||||
.WithDemand(2, 0, "D", CargoType.Wood, 1, 30)
|
.WithDemand(2, 0, "D", CargoType.Wood, 1, 30)
|
||||||
|
|
@ -289,10 +210,109 @@ public class SolvabilityTests
|
||||||
var sim = SimHelper.FromLevel(level);
|
var sim = SimHelper.FromLevel(level);
|
||||||
|
|
||||||
sim.Place(PieceKind.Rook, (1, 0), (2, 0));
|
sim.Place(PieceKind.Rook, (1, 0), (2, 0));
|
||||||
// No Start() — step directly from Edit
|
// Step directly from Paused
|
||||||
var allEvents = sim.StepN(20);
|
var allEvents = sim.StepN(20);
|
||||||
|
|
||||||
Assert.Contains(allEvents, e => e is TurnStartedEvent);
|
Assert.Contains(allEvents, e => e is TurnStartedEvent);
|
||||||
Assert.Contains(allEvents, e => e is VictoryEvent);
|
Assert.Contains(allEvents, e => e is MissionCompleteEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Full transformer chain: Production(Wood) → Piece → Forge(Wood→Tools) → Piece → Demand(Tools)
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void TransformerChain_WoodToTools_MissionComplete()
|
||||||
|
{
|
||||||
|
var campaign = new CampaignDef
|
||||||
|
{
|
||||||
|
Name = "Solvability: Transformer", InitialWidth = 5, InitialHeight = 1,
|
||||||
|
Missions =
|
||||||
|
[
|
||||||
|
new MissionDef
|
||||||
|
{
|
||||||
|
Id = 1, Name = "Forge",
|
||||||
|
TerrainPatch = new TerrainPatch
|
||||||
|
{
|
||||||
|
NewWidth = 5, NewHeight = 1,
|
||||||
|
Cells =
|
||||||
|
[
|
||||||
|
new PatchCell { Col = 0, Row = 0, Type = CellType.Production,
|
||||||
|
Production = new ProductionDef(new Coords(0, 0), "Scierie", CargoType.Wood, 4) },
|
||||||
|
new PatchCell { Col = 2, Row = 0, Type = CellType.Transformer,
|
||||||
|
Transformer = new TransformerDef(new Coords(2, 0), "Forge", CargoType.Wood, 2, CargoType.Tools, 1) },
|
||||||
|
new PatchCell { Col = 4, Row = 0, Type = CellType.Demand,
|
||||||
|
Demand = new DemandDef(new Coords(4, 0), "Atelier", CargoType.Tools, 2) }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
UnlockedPieces = [PieceKind.Rook],
|
||||||
|
UnlockedLevels = [new PieceUpgrade(PieceKind.Rook, 1)],
|
||||||
|
Stock = [new PieceStock(PieceKind.Rook, 3)]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
var sim = SimHelper.FromCampaign(campaign);
|
||||||
|
sim.Sim.ProcessCommand(new LoadCampaignCommand());
|
||||||
|
|
||||||
|
// Rook 1: delivers wood to forge input
|
||||||
|
sim.Place(PieceKind.Rook, (0, 0), (1, 0));
|
||||||
|
// Rook 2: picks up tools from forge output, delivers to demand
|
||||||
|
sim.Place(PieceKind.Rook, (3, 0), (4, 0));
|
||||||
|
|
||||||
|
var allEvents = sim.StepN(50);
|
||||||
|
|
||||||
|
Assert.Contains(allEvents, e => e is CargoConvertedEvent);
|
||||||
|
Assert.Contains(allEvents, e => e is MissionCompleteEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Two-stage transformation: Wood → Forge → Tools → Comptoir → Gold
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void DoubleTransformerChain_WoodToToolsToGold_MissionComplete()
|
||||||
|
{
|
||||||
|
var campaign = new CampaignDef
|
||||||
|
{
|
||||||
|
Name = "Solvability: Double Transformer", InitialWidth = 7, InitialHeight = 1,
|
||||||
|
Missions =
|
||||||
|
[
|
||||||
|
new MissionDef
|
||||||
|
{
|
||||||
|
Id = 1, Name = "Double Chain",
|
||||||
|
TerrainPatch = new TerrainPatch
|
||||||
|
{
|
||||||
|
NewWidth = 7, NewHeight = 1,
|
||||||
|
Cells =
|
||||||
|
[
|
||||||
|
new PatchCell { Col = 0, Row = 0, Type = CellType.Production,
|
||||||
|
Production = new ProductionDef(new Coords(0, 0), "Scierie", CargoType.Wood, 4) },
|
||||||
|
new PatchCell { Col = 2, Row = 0, Type = CellType.Transformer,
|
||||||
|
Transformer = new TransformerDef(new Coords(2, 0), "Forge", CargoType.Wood, 2, CargoType.Tools, 1) },
|
||||||
|
new PatchCell { Col = 4, Row = 0, Type = CellType.Transformer,
|
||||||
|
Transformer = new TransformerDef(new Coords(4, 0), "Comptoir", CargoType.Tools, 2, CargoType.Gold, 1) },
|
||||||
|
new PatchCell { Col = 6, Row = 0, Type = CellType.Demand,
|
||||||
|
Demand = new DemandDef(new Coords(6, 0), "Tresor", CargoType.Gold, 1) }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
UnlockedPieces = [PieceKind.Rook],
|
||||||
|
UnlockedLevels = [new PieceUpgrade(PieceKind.Rook, 1)],
|
||||||
|
Stock = [new PieceStock(PieceKind.Rook, 4)]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
var sim = SimHelper.FromCampaign(campaign);
|
||||||
|
sim.Sim.ProcessCommand(new LoadCampaignCommand());
|
||||||
|
|
||||||
|
// Chain: Scierie → Rook1 → Forge → Rook2 → Comptoir → Rook3 → Tresor
|
||||||
|
sim.Place(PieceKind.Rook, (0, 0), (1, 0)); // wood delivery
|
||||||
|
sim.Place(PieceKind.Rook, (3, 0), (4, 0)); // tools delivery (picks from forge, delivers to comptoir)
|
||||||
|
sim.Place(PieceKind.Rook, (5, 0), (6, 0)); // gold delivery
|
||||||
|
|
||||||
|
var allEvents = sim.StepN(80);
|
||||||
|
|
||||||
|
// Should see both transformations
|
||||||
|
var conversions = allEvents.OfType<CargoConvertedEvent>().ToList();
|
||||||
|
Assert.Contains(conversions, c => c.OutputCargo == CargoType.Tools);
|
||||||
|
Assert.Contains(conversions, c => c.OutputCargo == CargoType.Gold);
|
||||||
|
Assert.Contains(allEvents, e => e is MissionCompleteEvent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
219
chessistics-tests/Simulation/TransformerTests.cs
Normal file
219
chessistics-tests/Simulation/TransformerTests.cs
Normal file
|
|
@ -0,0 +1,219 @@
|
||||||
|
using Chessistics.Engine.Commands;
|
||||||
|
using Chessistics.Engine.Events;
|
||||||
|
using Chessistics.Engine.Model;
|
||||||
|
using Chessistics.Tests.Helpers;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Chessistics.Tests.Simulation;
|
||||||
|
|
||||||
|
public class TransformerTests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Build a campaign with a production, a transformer, and a demand:
|
||||||
|
/// Production(Wood) → [Transformer: Wood→Tools] → Demand(Tools)
|
||||||
|
/// Layout (5x1):
|
||||||
|
/// (0,0) Production(Wood,4)
|
||||||
|
/// (2,0) Transformer(Wood→Tools, input=2, output=1)
|
||||||
|
/// (4,0) Demand(Tools, 2)
|
||||||
|
/// </summary>
|
||||||
|
private static CampaignDef CreateTransformerCampaign()
|
||||||
|
{
|
||||||
|
return new CampaignDef
|
||||||
|
{
|
||||||
|
Name = "Transformer Test",
|
||||||
|
InitialWidth = 5,
|
||||||
|
InitialHeight = 1,
|
||||||
|
Missions =
|
||||||
|
[
|
||||||
|
new MissionDef
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
Name = "Forge",
|
||||||
|
TerrainPatch = new TerrainPatch
|
||||||
|
{
|
||||||
|
NewWidth = 5,
|
||||||
|
NewHeight = 1,
|
||||||
|
Cells =
|
||||||
|
[
|
||||||
|
new PatchCell { Col = 0, Row = 0, Type = CellType.Production,
|
||||||
|
Production = new ProductionDef(new Coords(0, 0), "Scierie", CargoType.Wood, 4) },
|
||||||
|
new PatchCell { Col = 2, Row = 0, Type = CellType.Transformer,
|
||||||
|
Transformer = new TransformerDef(new Coords(2, 0), "Forge", CargoType.Wood, 2, CargoType.Tools, 1) },
|
||||||
|
new PatchCell { Col = 4, Row = 0, Type = CellType.Demand,
|
||||||
|
Demand = new DemandDef(new Coords(4, 0), "Armurerie", CargoType.Tools, 2) }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
UnlockedPieces = [PieceKind.Rook],
|
||||||
|
UnlockedLevels = [new PieceUpgrade(PieceKind.Rook, 1)],
|
||||||
|
Stock = [new PieceStock(PieceKind.Rook, 3)]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Transformer_ReceivesInputCargo()
|
||||||
|
{
|
||||||
|
var campaign = CreateTransformerCampaign();
|
||||||
|
var sim = SimHelper.FromCampaign(campaign);
|
||||||
|
sim.Sim.ProcessCommand(new LoadCampaignCommand());
|
||||||
|
|
||||||
|
// Place rook between production and transformer: (1,0)↔(2,0)
|
||||||
|
// Rook oscillates between (1,0) and (2,0), picking up wood and delivering to transformer
|
||||||
|
sim.Place(PieceKind.Rook, (0, 0), (1, 0));
|
||||||
|
|
||||||
|
var events = sim.StepN(5);
|
||||||
|
|
||||||
|
// Should see cargo transferred to the transformer position
|
||||||
|
Assert.Contains(events, e => e is CargoTransferredEvent ct
|
||||||
|
&& ct.To == new Coords(2, 0) && ct.Type == CargoType.Wood);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Transformer_ConvertsAfterInputThreshold()
|
||||||
|
{
|
||||||
|
var campaign = CreateTransformerCampaign();
|
||||||
|
var sim = SimHelper.FromCampaign(campaign);
|
||||||
|
sim.Sim.ProcessCommand(new LoadCampaignCommand());
|
||||||
|
|
||||||
|
// Rook delivers wood to transformer
|
||||||
|
sim.Place(PieceKind.Rook, (0, 0), (1, 0));
|
||||||
|
|
||||||
|
var events = sim.StepN(10);
|
||||||
|
|
||||||
|
// After enough deliveries, transformation should occur
|
||||||
|
Assert.Contains(events, e => e is CargoConvertedEvent cc
|
||||||
|
&& cc.TransformerCell == new Coords(2, 0)
|
||||||
|
&& cc.InputCargo == CargoType.Wood
|
||||||
|
&& cc.OutputCargo == CargoType.Tools);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Transformer_OutputPickedUpByPiece()
|
||||||
|
{
|
||||||
|
var campaign = CreateTransformerCampaign();
|
||||||
|
var sim = SimHelper.FromCampaign(campaign);
|
||||||
|
sim.Sim.ProcessCommand(new LoadCampaignCommand());
|
||||||
|
|
||||||
|
// Rook 1: delivers wood to transformer
|
||||||
|
sim.Place(PieceKind.Rook, (0, 0), (1, 0));
|
||||||
|
// Rook 2: picks up tools from transformer and delivers to demand
|
||||||
|
sim.Place(PieceKind.Rook, (3, 0), (4, 0));
|
||||||
|
|
||||||
|
var events = sim.StepN(20);
|
||||||
|
|
||||||
|
// Should see tools being transferred from transformer to rook 2
|
||||||
|
Assert.Contains(events, e => e is CargoTransferredEvent ct
|
||||||
|
&& ct.From == new Coords(2, 0) && ct.Type == CargoType.Tools);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Transformer_FullChain_CompleteMission()
|
||||||
|
{
|
||||||
|
var campaign = CreateTransformerCampaign();
|
||||||
|
var sim = SimHelper.FromCampaign(campaign);
|
||||||
|
sim.Sim.ProcessCommand(new LoadCampaignCommand());
|
||||||
|
|
||||||
|
// Full chain: Production → Rook1 → Transformer → Rook2 → Demand
|
||||||
|
sim.Place(PieceKind.Rook, (0, 0), (1, 0)); // delivers wood to transformer
|
||||||
|
sim.Place(PieceKind.Rook, (3, 0), (4, 0)); // picks up tools, delivers to demand
|
||||||
|
|
||||||
|
var events = sim.StepN(50);
|
||||||
|
|
||||||
|
// Mission should complete (2 tools delivered)
|
||||||
|
Assert.Contains(events, e => e is MissionCompleteEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Transformer_DoesNotConvertWrongCargo()
|
||||||
|
{
|
||||||
|
// Transformer expects Wood but receives Stone — should not convert
|
||||||
|
var campaign = new CampaignDef
|
||||||
|
{
|
||||||
|
Name = "Wrong Cargo",
|
||||||
|
InitialWidth = 4,
|
||||||
|
InitialHeight = 1,
|
||||||
|
Missions =
|
||||||
|
[
|
||||||
|
new MissionDef
|
||||||
|
{
|
||||||
|
Id = 1, Name = "M1",
|
||||||
|
TerrainPatch = new TerrainPatch
|
||||||
|
{
|
||||||
|
NewWidth = 4, NewHeight = 1,
|
||||||
|
Cells =
|
||||||
|
[
|
||||||
|
new PatchCell { Col = 0, Row = 0, Type = CellType.Production,
|
||||||
|
Production = new ProductionDef(new Coords(0, 0), "Carriere", CargoType.Stone, 4) },
|
||||||
|
new PatchCell { Col = 2, Row = 0, Type = CellType.Transformer,
|
||||||
|
Transformer = new TransformerDef(new Coords(2, 0), "Forge", CargoType.Wood, 1, CargoType.Tools, 1) },
|
||||||
|
new PatchCell { Col = 3, Row = 0, Type = CellType.Demand,
|
||||||
|
Demand = new DemandDef(new Coords(3, 0), "D", CargoType.Tools, 1) }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
UnlockedPieces = [PieceKind.Rook],
|
||||||
|
UnlockedLevels = [new PieceUpgrade(PieceKind.Rook, 1)],
|
||||||
|
Stock = [new PieceStock(PieceKind.Rook, 2)]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
var sim = SimHelper.FromCampaign(campaign);
|
||||||
|
sim.Sim.ProcessCommand(new LoadCampaignCommand());
|
||||||
|
|
||||||
|
sim.Place(PieceKind.Rook, (0, 0), (1, 0));
|
||||||
|
|
||||||
|
var events = sim.StepN(10);
|
||||||
|
|
||||||
|
// No conversion should happen (stone != wood)
|
||||||
|
Assert.DoesNotContain(events, e => e is CargoConvertedEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Transformer_AccumulatesInput()
|
||||||
|
{
|
||||||
|
// Transformer with inputRequired=3 should accumulate over multiple deliveries
|
||||||
|
var campaign = new CampaignDef
|
||||||
|
{
|
||||||
|
Name = "Accumulate",
|
||||||
|
InitialWidth = 4,
|
||||||
|
InitialHeight = 1,
|
||||||
|
Missions =
|
||||||
|
[
|
||||||
|
new MissionDef
|
||||||
|
{
|
||||||
|
Id = 1, Name = "M1",
|
||||||
|
TerrainPatch = new TerrainPatch
|
||||||
|
{
|
||||||
|
NewWidth = 4, NewHeight = 1,
|
||||||
|
Cells =
|
||||||
|
[
|
||||||
|
new PatchCell { Col = 0, Row = 0, Type = CellType.Production,
|
||||||
|
Production = new ProductionDef(new Coords(0, 0), "Scierie", CargoType.Wood, 4) },
|
||||||
|
new PatchCell { Col = 2, Row = 0, Type = CellType.Transformer,
|
||||||
|
Transformer = new TransformerDef(new Coords(2, 0), "Forge", CargoType.Wood, 3, CargoType.Tools, 2) },
|
||||||
|
new PatchCell { Col = 3, Row = 0, Type = CellType.Demand,
|
||||||
|
Demand = new DemandDef(new Coords(3, 0), "D", CargoType.Tools, 2) }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
UnlockedPieces = [PieceKind.Rook],
|
||||||
|
UnlockedLevels = [new PieceUpgrade(PieceKind.Rook, 1)],
|
||||||
|
Stock = [new PieceStock(PieceKind.Rook, 2)]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
var sim = SimHelper.FromCampaign(campaign);
|
||||||
|
sim.Sim.ProcessCommand(new LoadCampaignCommand());
|
||||||
|
|
||||||
|
sim.Place(PieceKind.Rook, (0, 0), (1, 0));
|
||||||
|
sim.Place(PieceKind.Rook, (2, 0), (3, 0));
|
||||||
|
|
||||||
|
var events = sim.StepN(30);
|
||||||
|
|
||||||
|
// After 3 wood deliveries, conversion should produce 2 tools
|
||||||
|
var conversions = events.Where(e => e is CargoConvertedEvent cc && cc.OutputAmount == 2).ToList();
|
||||||
|
Assert.NotEmpty(conversions);
|
||||||
|
|
||||||
|
// Mission should complete (2 tools delivered)
|
||||||
|
Assert.Contains(events, e => e is MissionCompleteEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
1
chessistics-tests/Simulation/TransformerTests.cs.uid
Normal file
1
chessistics-tests/Simulation/TransformerTests.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://c7ifw7o8xahpv
|
||||||
|
|
@ -16,17 +16,21 @@ Chaque piece est un **maillon de convoyeur**. La strategie est dans la compositi
|
||||||
**Core loop** :
|
**Core loop** :
|
||||||
|
|
||||||
```
|
```
|
||||||
OBSERVER la situation (productions, demandes, terrain, pieces disponibles)
|
OBSERVER le reseau en fonctionnement
|
||||||
|
|
|
|
||||||
PLACER des pieces sur le plateau (point de depart + point d'arrivee)
|
IDENTIFIER un goulet, une mission non remplie, ou une collision
|
||||||
|
|
|
|
||||||
LANCER la simulation — les pieces font leurs allers-retours,
|
PLACER ou RETIRER des pieces (la simulation se met en pause automatiquement
|
||||||
les colis se transmettent automatiquement entre pieces adjacentes
|
pendant le placement, puis reprend)
|
||||||
|
|
|
|
||||||
+---> Le debit est insuffisant ? Observer les goulets, reorganiser
|
OBSERVER le resultat — le reseau s'adapte immediatement
|
||||||
+---> Le debit est atteint ? Optimiser ou niveau suivant
|
|
|
||||||
|
+---> Le debit est insuffisant ? Reorganiser les chaines
|
||||||
|
+---> La mission est remplie ? Avancer vers la mission suivante
|
||||||
```
|
```
|
||||||
|
|
||||||
|
La simulation tourne en continu. Le joueur ne "lance" jamais — il intervient sur un systeme vivant.
|
||||||
|
|
||||||
**Ce qui distingue Chessistics** :
|
**Ce qui distingue Chessistics** :
|
||||||
- La logistique (macro) : le joueur compose des chaines, choisit sa flotte, gere l'espace
|
- La logistique (macro) : le joueur compose des chaines, choisit sa flotte, gere l'espace
|
||||||
- Le puzzle chess (micro) : les contraintes de mouvement creent des enigmes de couverture et d'espacement emergentes
|
- Le puzzle chess (micro) : les contraintes de mouvement creent des enigmes de couverture et d'espacement emergentes
|
||||||
|
|
@ -51,7 +55,7 @@ Le plateau est un damier avec des cases claires et sombres alternees. Chaque cas
|
||||||
| **Case claire** | Carre clair du damier | Traversable normalement |
|
| **Case claire** | Carre clair du damier | Traversable normalement |
|
||||||
| **Case sombre** | Carre sombre du damier | Traversable (les Fous sont limites a une couleur) |
|
| **Case sombre** | Carre sombre du damier | Traversable (les Fous sont limites a une couleur) |
|
||||||
| **Mur** | Case barree, gris fonce | Bloque toutes les pieces sauf le Cavalier (qui saute) |
|
| **Mur** | Case barree, gris fonce | Bloque toutes les pieces sauf le Cavalier (qui saute) |
|
||||||
| **Production** | Icone ressource + nom (ex: "Scierie") | Produit M cargaisons tous les N coups (M=1 dans le proto, futur: 2-4). Le buffer est ecrase a chaque production — les cargaisons non recuperees sont perdues. Donne automatiquement aux pieces adjacentes disponibles. |
|
| **Production** | Icone ressource + nom (ex: "Scierie") | Produit M cargaisons par tour (M=1 a 4 selon le batiment). Le buffer max = M — les cargaisons non recuperees sont ecrasees au tour suivant. Donne automatiquement aux pieces adjacentes disponibles. |
|
||||||
| **Demande** | Icone cible + nom (ex: "Depot") + jauge | Recoit la cargaison d'une piece adjacente qui arrive avec un colis compatible. Accepte toujours un colis du bon type, meme si l'objectif est deja atteint. |
|
| **Demande** | Icone cible + nom (ex: "Depot") + jauge | Recoit la cargaison d'une piece adjacente qui arrive avec un colis compatible. Accepte toujours un colis du bon type, meme si l'objectif est deja atteint. |
|
||||||
|
|
||||||
### 2.3 Cargaison
|
### 2.3 Cargaison
|
||||||
|
|
@ -171,7 +175,8 @@ C'est tout. Pas de programmation, pas de route multi-etapes. La piece fait l'all
|
||||||
- Une piece bloque le passage des autres pieces (sauf le Cavalier qui saute)
|
- Une piece bloque le passage des autres pieces (sauf le Cavalier qui saute)
|
||||||
- Si deux pieces arrivent sur la meme case apres le mouvement, la piece de **statut le plus eleve** detruit l'autre. A statut egal, le **niveau** departage. A egalite parfaite (meme statut ET meme niveau), **destruction mutuelle**.
|
- Si deux pieces arrivent sur la meme case apres le mouvement, la piece de **statut le plus eleve** detruit l'autre. A statut egal, le **niveau** departage. A egalite parfaite (meme statut ET meme niveau), **destruction mutuelle**.
|
||||||
- La piece survivante reste sur la case avec sa cargaison intacte. La cargaison des pieces detruites est perdue.
|
- La piece survivante reste sur la case avec sa cargaison intacte. La cargaison des pieces detruites est perdue.
|
||||||
- Les pieces detruites sont restaurees quand le joueur arrete la simulation (retour en mode edition).
|
- Les pieces detruites **retournent immediatement dans le stock** du joueur — il peut les replacer a tout moment.
|
||||||
|
- En cas de collision, la simulation se met en **pause automatique**. La camera effectue un pan et zoom vers la zone de collision pour montrer ce qui s'est passe. Une notification apparait dans un coin de l'ecran pour expliciter l'evenement (ex: "Tour II detruite par Dame — retournee au stock").
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -188,11 +193,13 @@ Les transferts se produisent entre :
|
||||||
|
|
||||||
### 4.2 Quand le transfert a lieu
|
### 4.2 Quand le transfert a lieu
|
||||||
|
|
||||||
Un transfert se produit quand, a la **fin d'un coup** (une fois que toutes les pieces ont bouge) :
|
Les transferts se resolvent **avant le mouvement**, dans la meme sequence de coup (voir §5.1). Une piece adjacente a une production prend le colis puis se deplace avec dans le meme tour.
|
||||||
|
|
||||||
|
Condition pour qu'un transfert ait lieu :
|
||||||
- Une entite avec colis et une entite sans colis (ou une demande) sont sur des **cases adjacentes** (4-connecte)
|
- Une entite avec colis et une entite sans colis (ou une demande) sont sur des **cases adjacentes** (4-connecte)
|
||||||
- Le colis est compatible (la demande accepte ce type de cargaison)
|
- Le colis est compatible (la demande accepte ce type de cargaison)
|
||||||
|
|
||||||
Le transfert est **instantane** : le colis passe d'une entite a l'autre entre deux coups.
|
Le transfert est **instantane** au sein du tour.
|
||||||
|
|
||||||
### 4.3 Priorite et departage
|
### 4.3 Priorite et departage
|
||||||
|
|
||||||
|
|
@ -286,36 +293,33 @@ Quand deux pieces ou plus occupent la meme case apres le mouvement :
|
||||||
- A statut egal, le **niveau** departage (niveau superieur survit)
|
- A statut egal, le **niveau** departage (niveau superieur survit)
|
||||||
- A egalite parfaite (meme statut ET meme niveau), **destruction mutuelle** (aucun survivant)
|
- A egalite parfaite (meme statut ET meme niveau), **destruction mutuelle** (aucun survivant)
|
||||||
- La piece survivante conserve sa cargaison. Les cargaisons detruites sont perdues.
|
- La piece survivante conserve sa cargaison. Les cargaisons detruites sont perdues.
|
||||||
- La simulation **continue** (pas de pause automatique)
|
- Les pieces detruites **retournent immediatement dans le stock** du joueur.
|
||||||
|
- La simulation se met en **pause automatique**. La camera pan et zoom vers la zone de collision. Une notification explicite l'evenement.
|
||||||
|
|
||||||
Les collisions deviennent un element strategique — le joueur doit anticiper les croisements de trajectoires et espacer ses chaines pour les eviter.
|
Les collisions deviennent un element strategique — le joueur doit anticiper les croisements de trajectoires et espacer ses chaines pour les eviter.
|
||||||
|
|
||||||
### 5.3 Condition de victoire
|
### 5.3 Condition de completion de mission
|
||||||
|
|
||||||
Le niveau est reussi quand **toutes les demandes** ont ete satisfaites selon leur objectif.
|
La mission courante est completee quand **toutes ses demandes** ont ete satisfaites.
|
||||||
|
|
||||||
Chaque demande specifie : "recevoir N colis de type X en Y coups ou moins".
|
Chaque demande specifie : "recevoir N colis de type X".
|
||||||
|
|
||||||
Exemple : "Le Depot Royal demande 3 livraisons de Bois en 30 coups."
|
Exemple : "Le Depot Royal demande 3 livraisons de Bois."
|
||||||
|
|
||||||
Le compteur de coups tourne en temps reel. Le joueur voit sa progression.
|
Il n'y a pas de deadline. Le compteur de tours est affiche comme **metrique d'optimisation** (le joueur voit combien de tours il a mis), mais ne constitue pas une contrainte. Le joueur prend le temps qu'il veut.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. Les metriques
|
## 6. Les metriques
|
||||||
|
|
||||||
A la completion d'un niveau, 3 metriques sont affichees :
|
A la completion d'une mission, 3 metriques sont affichees :
|
||||||
|
|
||||||
| Metrique | Description | Ce que ca mesure |
|
| Metrique | Description | Ce que ca mesure |
|
||||||
|----------|-------------|------------------|
|
|----------|-------------|------------------|
|
||||||
| **Pieces** | Nombre de pieces deployees | Economie de flotte |
|
| **Pieces** | Nombre de pieces deployees pour cette mission | Economie de flotte |
|
||||||
| **Coups** | Nombre de coups pour atteindre l'objectif | Efficacite du reseau |
|
| **Coups** | Nombre de coups pour atteindre l'objectif | Efficacite du reseau |
|
||||||
| **Espace** | Nombre de cases du plateau utilisees (occupees par une piece au moins 1 coup) | Compacite du reseau |
|
| **Espace** | Nombre de cases du plateau utilisees (occupees par une piece au moins 1 coup) | Compacite du reseau |
|
||||||
|
|
||||||
Chaque metrique a un **histogramme** montrant la distribution des solutions de tous les joueurs.
|
|
||||||
|
|
||||||
> **Proto** : histogrammes avec donnees fictives pour tester l'UI.
|
|
||||||
|
|
||||||
**Triangle d'optimisation** :
|
**Triangle d'optimisation** :
|
||||||
- Moins de pieces = chaines courtes = couverture limitee → plus de coups
|
- Moins de pieces = chaines courtes = couverture limitee → plus de coups
|
||||||
- Moins de coups = pieces puissantes ou nombreuses → plus de pieces, plus d'espace
|
- Moins de coups = pieces puissantes ou nombreuses → plus de pieces, plus d'espace
|
||||||
|
|
@ -323,7 +327,7 @@ Chaque metrique a un **histogramme** montrant la distribution des solutions de t
|
||||||
|
|
||||||
**Affichage en jeu** (pendant la simulation) :
|
**Affichage en jeu** (pendant la simulation) :
|
||||||
```
|
```
|
||||||
Coup: 12/30 Depot Royal: 2/3 Bois ✓ Forge: 0/2 Pierre ✗
|
Tour: 12 Depot Royal: 2/3 Bois ✓ Forge: 0/2 Pierre ✗
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -336,34 +340,36 @@ Le plateau est le centre. L'interface est minimale.
|
||||||
|
|
||||||
```
|
```
|
||||||
+---------------------------------------------------------------+
|
+---------------------------------------------------------------+
|
||||||
| CHESSISTICS La Scierie Royale [≡] [?] [←] |
|
| CHESSISTICS La Quete du Roi [≡] [?] [←] |
|
||||||
+---------------------------------------------------------------+
|
+---------------------------------------------------------------+
|
||||||
| | |
|
| | |
|
||||||
| | OBJECTIF |
|
| | MISSION 3/8 |
|
||||||
| | Depot Royal |
|
| | Forger les Tours |
|
||||||
| | 3x Bois / 30c |
|
| | Depot: 0/3 Bois |
|
||||||
| P L A T E A U | |
|
| P L A T E A U | ✓ Mission 1 |
|
||||||
| (damier interactif) | ───────── |
|
| (damier interactif) | ✓ Mission 2 |
|
||||||
| | |
|
| | ───────── |
|
||||||
| Les pieces et leurs trajets | PIECES |
|
| Les pieces et leurs trajets | |
|
||||||
| sont visibles sur le plateau | [Tour II] x3 |
|
| sont visibles sur le plateau | PIECES |
|
||||||
| | [Fou II] x1 |
|
| | [Pion I] x4 |
|
||||||
| | [Cavalier] x1 |
|
| | [Tour I] x3 |
|
||||||
| | |
|
| | |
|
||||||
+---------------------------------------------------------------+
|
+---------------------------------------------------------------+
|
||||||
| [▶ PLAY] [⏩ x2] [⏸] [⏹ STOP] Coup: -- |
|
| [⏸ / ▶] [x1] [x2] [x4] Tour: 42 Mission: 3/8 |
|
||||||
+---------------------------------------------------------------+
|
+---------------------------------------------------------------+
|
||||||
```
|
```
|
||||||
|
|
||||||
### 7.2 Placement d'une piece
|
### 7.2 Placement d'une piece
|
||||||
|
|
||||||
Le flux de placement est en 2 clics :
|
Le flux de placement est en 2 clics. **La simulation se met en pause automatiquement** des que le joueur selectionne un type de piece, et reprend une fois le placement confirme ou annule.
|
||||||
|
|
||||||
1. Le joueur **selectionne un type de piece** dans le panneau de droite
|
1. Le joueur **selectionne un type de piece** dans le panneau de droite → **pause automatique**
|
||||||
2. Il **clique une case du plateau** → c'est le point de depart. Les cases d'arrivee possibles s'affichent en surbrillance.
|
2. Il **clique une case du plateau** → c'est le point de depart. Les cases d'arrivee possibles s'affichent en surbrillance.
|
||||||
3. Il **clique une case en surbrillance** → c'est le point d'arrivee. La piece est placee.
|
3. Il **clique une case en surbrillance** → c'est le point d'arrivee. La piece est placee. → **la simulation reprend**
|
||||||
4. Un trait apparait entre depart et arrivee, montrant le trajet.
|
4. Un trait apparait entre depart et arrivee, montrant le trajet.
|
||||||
|
|
||||||
|
Si le joueur annule (Echap), la simulation reprend sans placement.
|
||||||
|
|
||||||
```
|
```
|
||||||
Placement d'une Tour II :
|
Placement d'une Tour II :
|
||||||
|
|
||||||
|
|
@ -379,9 +385,9 @@ Le flux de placement est en 2 clics :
|
||||||
```
|
```
|
||||||
|
|
||||||
**Interactions** :
|
**Interactions** :
|
||||||
- **Clic sur une piece placee** → la selectionne, affiche son trajet, panneau de detail
|
- **Clic gauche sur une piece placee** → la selectionne, affiche son trajet, panneau de detail
|
||||||
- **Clic droit sur une piece** → la retire du plateau (retourne dans le stock)
|
- **Touche Suppr** (piece selectionnee) → la retire du plateau (retourne dans le stock)
|
||||||
- **Glisser une piece** → deplace son point de depart (recalcule les arrivees possibles)
|
- **Bouton [Retirer]** dans le panneau de detail → meme effet
|
||||||
|
|
||||||
### 7.3 Visualisation des trajets
|
### 7.3 Visualisation des trajets
|
||||||
|
|
||||||
|
|
@ -411,21 +417,20 @@ Quand une piece est selectionnee :
|
||||||
+---------------------------+
|
+---------------------------+
|
||||||
```
|
```
|
||||||
|
|
||||||
### 7.5 Phases de jeu
|
### 7.5 Simulation continue
|
||||||
|
|
||||||
**Phase EDIT** (temps arrete)
|
La simulation tourne en continu — il n'y a pas de phases Edit/Exec separees. Le joueur modifie son reseau a tout moment, la simulation integre les changements au tour suivant.
|
||||||
- Placer, deplacer, retirer des pieces
|
|
||||||
- Pas de limite de temps
|
|
||||||
- Les trajets sont visibles comme des traits sur le plateau
|
|
||||||
|
|
||||||
**Phase EXEC** (simulation)
|
**Controles** :
|
||||||
- Les pieces font leurs allers-retours simultanement
|
- **Espace** : pause / reprendre (le tour en cours se termine avant la pause)
|
||||||
- Les colis se transmettent automatiquement aux points de contact
|
- **Vitesse** : x1, x2, x4
|
||||||
- Compteur de coups et progression des objectifs en temps reel
|
- **Placement d'une piece** : met la simulation en pause automatiquement le temps du placement (voir §7.2)
|
||||||
- Controles : pause, step-by-step, vitesse x1/x2/x4, stop (retour a EDIT)
|
|
||||||
- En cas de collision → pause auto, pieces en erreur surlignees
|
|
||||||
|
|
||||||
Le joueur peut arreter la simulation a tout moment, reorganiser, et relancer.
|
**Pauses automatiques** :
|
||||||
|
- Quand le joueur selectionne une piece a placer → pause jusqu'a confirmation ou annulation
|
||||||
|
- Quand une collision se produit → pause + pan/zoom camera vers la zone + notification (voir §5.2)
|
||||||
|
|
||||||
|
Le joueur peut placer, retirer et reorganiser ses pieces a tout moment, en pause ou pendant que la simulation tourne.
|
||||||
|
|
||||||
### 7.6 Feedback visuel
|
### 7.6 Feedback visuel
|
||||||
|
|
||||||
|
|
@ -444,13 +449,23 @@ Le joueur peut arreter la simulation a tout moment, reorganiser, et relancer.
|
||||||
- Les demandes ont une **jauge** de progression (ex: "2/3")
|
- Les demandes ont une **jauge** de progression (ex: "2/3")
|
||||||
- Jauge verte = objectif atteint. Jauge rouge = pas encore.
|
- Jauge verte = objectif atteint. Jauge rouge = pas encore.
|
||||||
|
|
||||||
**Erreurs** :
|
**Collisions** :
|
||||||
- Collision : flash rouge + shake des deux pieces
|
- Flash rouge + shake des deux pieces
|
||||||
- Simulation en pause automatiquement
|
- Simulation en pause automatiquement
|
||||||
|
- La camera pan et zoom vers la zone de collision
|
||||||
|
- Notification dans un coin de l'ecran : "Tour II detruite par Dame — retournee au stock"
|
||||||
|
- La piece detruite retourne dans le stock, le joueur peut la replacer
|
||||||
|
|
||||||
**Victoire** :
|
**Completion de mission** :
|
||||||
- Toutes les jauges au vert → animation sobre (les trajets scintillent en dore)
|
- Toutes les jauges au vert → animation sobre (les trajets scintillent en dore)
|
||||||
- Overlay des metriques + histogrammes
|
- Overlay de felicitations avec metriques de la mission
|
||||||
|
- Bouton "Mission suivante" pour avancer
|
||||||
|
|
||||||
|
**Transition de mission** :
|
||||||
|
- Titre "Nouvelle mission" apparait en plein ecran en fade-in
|
||||||
|
- La camera se lock (pan et zoom desactives) pour montrer la zone de la prochaine mission
|
||||||
|
- Les nouvelles cases apparaissent sur le plateau avec une animation d'expansion
|
||||||
|
- Le titre de mission se deplace vers la zone d'objectif dans le panneau lateral avant de disparaitre, emmenant l'oeil du joueur vers les nouveaux objectifs
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -470,7 +485,7 @@ Le joueur peut arreter la simulation a tout moment, reorganiser, et relancer.
|
||||||
```
|
```
|
||||||
|
|
||||||
- Plateau : **4x4**
|
- Plateau : **4x4**
|
||||||
- S = Scierie (a1, produit du Bois tous les 2 coups)
|
- S = Scierie (a1, produit du Bois 1 par tour)
|
||||||
- D = Depot Royal (d1, objectif : recevoir 3 Bois en 30 coups)
|
- D = Depot Royal (d1, objectif : recevoir 3 Bois en 30 coups)
|
||||||
- Pieces disponibles : **3x Tour II**
|
- Pieces disponibles : **3x Tour II**
|
||||||
|
|
||||||
|
|
@ -514,17 +529,15 @@ Ou : Tour A couvre a1↔c1 (2 cases), Tour B couvre c1↔d1 (1 case). Ils ne son
|
||||||
|
|
||||||
```
|
```
|
||||||
6 . . . . . .
|
6 . . . . . .
|
||||||
5 . . . . . [D2] Caserne — 2 Bois en 30 coups
|
5 . . . . . [D2] Caserne — 2 Bois 4 . . . . . .
|
||||||
4 . . . . . .
|
|
||||||
3 . . . . . .
|
3 . . . . . .
|
||||||
2 . . . . . .
|
2 . . . . . .
|
||||||
1 [S] . . . . [D1] Depot Royal — 2 Bois en 30 coups
|
1 [S] . . . . [D1] Depot Royal — 2 Bois
|
||||||
|
|
||||||
a b c d e f
|
a b c d e f
|
||||||
```
|
```
|
||||||
|
|
||||||
- Plateau : **6x6**
|
- Plateau : **6x6**
|
||||||
- S = Scierie (a1, produit du Bois tous les 2 coups)
|
- S = Scierie (a1, produit du Bois 1 par tour)
|
||||||
- D1 = Depot Royal (f1, objectif : 2 Bois en 30 coups)
|
- D1 = Depot Royal (f1, objectif : 2 Bois en 30 coups)
|
||||||
- D2 = Caserne (f5, objectif : 2 Bois en 30 coups)
|
- D2 = Caserne (f5, objectif : 2 Bois en 30 coups)
|
||||||
- Pieces disponibles : **4x Tour II, 1x Fou II**
|
- Pieces disponibles : **4x Tour II, 1x Fou II**
|
||||||
|
|
@ -532,7 +545,7 @@ Ou : Tour A couvre a1↔c1 (2 cases), Tour B couvre c1↔d1 (1 case). Ils ne son
|
||||||
**L'enjeu** :
|
**L'enjeu** :
|
||||||
- S→D1 : 5 cases en ligne droite. Faisable avec une chaine de Tours.
|
- S→D1 : 5 cases en ligne droite. Faisable avec une chaine de Tours.
|
||||||
- S→D2 : trajet en angle (droite + haut). Plusieurs routes possibles.
|
- S→D2 : trajet en angle (droite + haut). Plusieurs routes possibles.
|
||||||
- La Scierie ne produit qu'un colis tous les 2 coups. Les deux chaines partagent la meme source.
|
- La Scierie ne produit qu'un colis 1 par tour. Les deux chaines partagent la meme source.
|
||||||
- Le joueur doit decider : comment repartir les colis entre les deux destinations ?
|
- Le joueur doit decider : comment repartir les colis entre les deux destinations ?
|
||||||
|
|
||||||
**Le statut social entre en jeu** :
|
**Le statut social entre en jeu** :
|
||||||
|
|
@ -559,9 +572,7 @@ Le Fou peut couvrir des trajets diagonaux que les Tours ne peuvent pas. Pour att
|
||||||
**Intention** : un vrai reseau avec terrain, 2 types de cargaison, et le Cavalier comme solution aux obstacles.
|
**Intention** : un vrai reseau avec terrain, 2 types de cargaison, et le Cavalier comme solution aux obstacles.
|
||||||
|
|
||||||
```
|
```
|
||||||
6 [D2] . . . . [D1] Depot Royal — 2 Bois en 40 coups
|
6 [D2] . . . . [D1] Depot Royal — 2 Bois 5 . . # # # . Forge — 2 Pierre 4 . . # . . .
|
||||||
5 . . # # # . Forge — 2 Pierre en 40 coups
|
|
||||||
4 . . # . . .
|
|
||||||
3 . . # . . .
|
3 . . # . . .
|
||||||
2 . . . . . .
|
2 . . . . . .
|
||||||
1 [S1] . . . . [S2] Scierie (Bois)
|
1 [S1] . . . . [S2] Scierie (Bois)
|
||||||
|
|
@ -570,8 +581,8 @@ Le Fou peut couvrir des trajets diagonaux que les Tours ne peuvent pas. Pour att
|
||||||
```
|
```
|
||||||
|
|
||||||
- Plateau : **6x6**
|
- Plateau : **6x6**
|
||||||
- S1 = Scierie (a1, Bois, tous les 2 coups)
|
- S1 = Scierie (a1, Bois, 1 par tour)
|
||||||
- S2 = Carriere (f1, Pierre, tous les 2 coups)
|
- S2 = Carriere (f1, Pierre, 1 par tour)
|
||||||
- D1 = Depot Royal (f6, objectif : 2 Bois en 40 coups)
|
- D1 = Depot Royal (f6, objectif : 2 Bois en 40 coups)
|
||||||
- D2 = Forge (a6, objectif : 2 Pierre en 40 coups)
|
- D2 = Forge (a6, objectif : 2 Pierre en 40 coups)
|
||||||
- Murs : c3, c4, c5, d5, e5 (barriere en L)
|
- Murs : c3, c4, c5, d5, e5 (barriere en L)
|
||||||
|
|
@ -613,21 +624,19 @@ Le Cavalier saute le mur en L. Il peut connecter les deux cotes du plateau la ou
|
||||||
|
|
||||||
```
|
```
|
||||||
8 . . . . . . . .
|
8 . . . . . . . .
|
||||||
7 [D2] . . . . . . . Forge — 3 Pierre en 40 coups
|
7 [D2] . . . . . . . Forge — 3 Pierre 6 . . . . . . . .
|
||||||
6 . . . . . . . .
|
|
||||||
5 . . . ## . . . .
|
5 . . . ## . . . .
|
||||||
4 . . . ## . . . .
|
4 . . . ## . . . .
|
||||||
3 . . . . . . . .
|
3 . . . . . . . .
|
||||||
2 . . . . . . . .
|
2 . . . . . . . .
|
||||||
1 [S1] . . . . . . [D1] Depot Royal — 3 Bois en 40 coups
|
1 [S1] . . . . . . [D1] Depot Royal — 3 Bois
|
||||||
|
|
||||||
a b c d e f g h
|
a b c d e f g h
|
||||||
[S2] Carriere (h8)
|
[S2] Carriere (h8)
|
||||||
```
|
```
|
||||||
|
|
||||||
- Plateau : **8x8**
|
- Plateau : **8x8**
|
||||||
- S1 = Scierie (a1, Bois), S2 = Carriere (h8, Pierre)
|
- S1 = Scierie (a1, Bois), S2 = Carriere (h8, Pierre)
|
||||||
- D1 = Depot Royal (h1, 3 Bois/40c), D2 = Forge (a8, 3 Pierre/40c)
|
- D1 = Depot Royal (h1, 3 Bois), D2 = Forge (a8, 3 Pierre)
|
||||||
- Murs : bloc 2x2 au centre (d4, e5, d5, e4)
|
- Murs : bloc 2x2 au centre (d4, e5, d5, e4)
|
||||||
- Stock : 8 Pions, 4 Tours, 2 Fous, 2 Cavaliers
|
- Stock : 8 Pions, 4 Tours, 2 Fous, 2 Cavaliers
|
||||||
|
|
||||||
|
|
@ -642,17 +651,15 @@ Le Cavalier saute le mur en L. Il peut connecter les deux cotes du plateau la ou
|
||||||
```
|
```
|
||||||
6 [S2] . # . # . # . Carriere (a6)
|
6 [S2] . # . # . # . Carriere (a6)
|
||||||
5 . . # . # . # .
|
5 . . # . # . # .
|
||||||
4 . . # . # . . [D1] Depot Royal — 3 Bois en 50 coups
|
4 . . # . # . . [D1] Depot Royal — 3 Bois 3 . . # . . . # .
|
||||||
3 . . # . . . # .
|
|
||||||
2 . . . . # . # .
|
2 . . . . # . # .
|
||||||
1 [S1] . . . # . . [D2] Forge — 3 Pierre en 50 coups
|
1 [S1] . . . # . . [D2] Forge — 3 Pierre
|
||||||
|
|
||||||
a b c d e f g h
|
a b c d e f g h
|
||||||
```
|
```
|
||||||
|
|
||||||
- Plateau : **8x6**
|
- Plateau : **8x6**
|
||||||
- S1 = Scierie (a1, Bois), S2 = Carriere (a6, Pierre)
|
- S1 = Scierie (a1, Bois), S2 = Carriere (a6, Pierre)
|
||||||
- D1 = Depot Royal (h6, 3 Bois/50c), D2 = Forge (h1, 3 Pierre/50c)
|
- D1 = Depot Royal (h6, 3 Bois), D2 = Forge (h1, 3 Pierre)
|
||||||
- Murs : 3 colonnes partielles formant un labyrinthe
|
- Murs : 3 colonnes partielles formant un labyrinthe
|
||||||
- Stock : 10 Pions, 4 Tours, 2 Fous, 3 Cavaliers
|
- Stock : 10 Pions, 4 Tours, 2 Fous, 3 Cavaliers
|
||||||
|
|
||||||
|
|
@ -665,21 +672,19 @@ Le Cavalier saute le mur en L. Il peut connecter les deux cotes du plateau la ou
|
||||||
**Intention** : reseau a 3 productions et 3 demandes, plateau 10x8. Le joueur gere un vrai reseau logistique.
|
**Intention** : reseau a 3 productions et 3 demandes, plateau 10x8. Le joueur gere un vrai reseau logistique.
|
||||||
|
|
||||||
```
|
```
|
||||||
8 [S2] . . # . . # . . [D2] Forge — 3 Pierre en 50 coups
|
8 [S2] . . # . . # . . [D2] Forge — 3 Pierre 7 . . . # . . # . . .
|
||||||
7 . . . # . . # . . .
|
|
||||||
6 . . . # ## . # . . .
|
6 . . . # ## . # . . .
|
||||||
5 . . . . . . . . . .
|
5 . . . . . . . . . .
|
||||||
4 . . . # ## . # . . [S3] Scierie Est (j4, Bois)
|
4 . . . # ## . # . . [S3] Scierie Est (j4, Bois)
|
||||||
3 . . . # . . # . . .
|
3 . . . # . . # . . .
|
||||||
2 . . . . . . . . . .
|
2 . . . . . . . . . .
|
||||||
1 [S1] . . . [D3] . . . . [D1] Depot Royal — 3 Bois en 50 coups
|
1 [S1] . . . [D3] . . . . [D1] Depot Royal — 3 Bois
|
||||||
|
|
||||||
a b c d e f g h i j
|
a b c d e f g h i j
|
||||||
```
|
```
|
||||||
|
|
||||||
- Plateau : **10x8**
|
- Plateau : **10x8**
|
||||||
- S1 = Scierie (a1, Bois), S2 = Carriere (a8, Pierre), S3 = Scierie Est (j4, Bois)
|
- S1 = Scierie (a1, Bois), S2 = Carriere (a8, Pierre), S3 = Scierie Est (j4, Bois)
|
||||||
- D1 = Depot Royal (j1, 3 Bois/50c), D2 = Forge (j8, 3 Pierre/50c), D3 = Chantier (e8, 3 Bois/50c)
|
- D1 = Depot Royal (j1, 3 Bois), D2 = Forge (j8, 3 Pierre), D3 = Chantier (e8, 3 Bois)
|
||||||
- Murs : deux colonnes avec pont horizontal
|
- Murs : deux colonnes avec pont horizontal
|
||||||
- Stock : 14 Pions, 6 Tours, 3 Fous, 4 Cavaliers
|
- Stock : 14 Pions, 6 Tours, 3 Fous, 4 Cavaliers
|
||||||
|
|
||||||
|
|
@ -746,7 +751,7 @@ Chessistics/
|
||||||
UI/
|
UI/
|
||||||
ObjectivePanel.tscn — Objectifs + stock de pieces
|
ObjectivePanel.tscn — Objectifs + stock de pieces
|
||||||
DetailPanel.tscn — Detail piece selectionnee
|
DetailPanel.tscn — Detail piece selectionnee
|
||||||
ControlBar.tscn — Play / pause / stop / vitesse
|
ControlBar.tscn — Pause / vitesse
|
||||||
MetricsOverlay.tscn — Resultats post-victoire
|
MetricsOverlay.tscn — Resultats post-victoire
|
||||||
LevelSelect.tscn — Selection de niveau
|
LevelSelect.tscn — Selection de niveau
|
||||||
scripts/
|
scripts/
|
||||||
|
|
@ -762,7 +767,7 @@ Chessistics/
|
||||||
LevelLoader.cs — Chargement JSON
|
LevelLoader.cs — Chargement JSON
|
||||||
UI/
|
UI/
|
||||||
PiecePlacer.cs — Logique du placement 2 clics
|
PiecePlacer.cs — Logique du placement 2 clics
|
||||||
ControlBar.cs — Play/pause/stop/vitesse
|
ControlBar.cs — Pause/vitesse
|
||||||
ProgressDisplay.cs — Compteur de coups + progression objectifs
|
ProgressDisplay.cs — Compteur de coups + progression objectifs
|
||||||
data/
|
data/
|
||||||
levels/
|
levels/
|
||||||
|
|
@ -781,10 +786,10 @@ Chessistics/
|
||||||
"width": 4,
|
"width": 4,
|
||||||
"height": 4,
|
"height": 4,
|
||||||
"productions": [
|
"productions": [
|
||||||
{ "x": 0, "y": 0, "name": "Scierie", "cargo": "wood", "interval": 2 }
|
{ "x": 0, "y": 0, "name": "Scierie", "cargo": "wood", "amount": 1 }
|
||||||
],
|
],
|
||||||
"demands": [
|
"demands": [
|
||||||
{ "x": 3, "y": 0, "name": "Depot Royal", "cargo": "wood", "amount": 3, "deadline": 30 }
|
{ "x": 3, "y": 0, "name": "Depot Royal", "cargo": "wood", "amount": 3 }
|
||||||
],
|
],
|
||||||
"walls": [],
|
"walls": [],
|
||||||
"pieces": [
|
"pieces": [
|
||||||
|
|
@ -801,7 +806,7 @@ Chessistics/
|
||||||
|----------|---------|----------------|
|
|----------|---------|----------------|
|
||||||
| Adjacence pour transfert ? | 4-connecte (bords) vs 8-connecte (bords + coins) | **4-connecte** — plus de contrainte = plus de puzzle |
|
| Adjacence pour transfert ? | 4-connecte (bords) vs 8-connecte (bords + coins) | **4-connecte** — plus de contrainte = plus de puzzle |
|
||||||
| La piece tourne a vide ? | Oui (aller-retour permanent) vs attend un colis | **Oui, tourne en permanence** — plus visuel, plus simple |
|
| La piece tourne a vide ? | Oui (aller-retour permanent) vs attend un colis | **Oui, tourne en permanence** — plus visuel, plus simple |
|
||||||
| Collision = destruction ? | Destruction (fort mange faible) vs erreur stricte (pause) | **Destruction** — la piece de plus haut statut/niveau survit, les autres sont detruites. Destruction mutuelle si egalite parfaite. |
|
| Collision = destruction ? | Destruction (fort mange faible) vs erreur stricte (pause) | **Destruction + retour au stock** — la piece de plus haut statut/niveau survit, les autres retournent au stock. Pause auto + camera pan vers la collision + notification. Destruction mutuelle si egalite parfaite. |
|
||||||
| Sources infinies ? | Oui (production periodique sans stock) vs stock limite | **Production periodique infinie** — le proto teste le reseau, pas la gestion de stock |
|
| Sources infinies ? | Oui (production periodique sans stock) vs stock limite | **Production periodique infinie** — le proto teste le reseau, pas la gestion de stock |
|
||||||
| Pieces fixes par niveau ? | Fixes (catalogue impose) vs achat libre | **Fixes** — plus facile a designer. L'achat/fabrication est post-proto. |
|
| Pieces fixes par niveau ? | Fixes (catalogue impose) vs achat libre | **Fixes** — plus facile a designer. L'achat/fabrication est post-proto. |
|
||||||
| Egalite de statut social ? | Niveau > direction horaire | **Niveau puis direction horaire alternee** (pairs: depuis droite, impairs: depuis gauche) — deterministe, pas de biais permanent |
|
| Egalite de statut social ? | Niveau > direction horaire | **Niveau puis direction horaire alternee** (pairs: depuis droite, impairs: depuis gauche) — deterministe, pas de biais permanent |
|
||||||
|
|
|
||||||
6
global.json
Normal file
6
global.json
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"sdk": {
|
||||||
|
"version": "9.0.312",
|
||||||
|
"rollForward": "latestMinor"
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue