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