diff --git a/Data/campaigns/campaign_01.json b/Data/campaigns/campaign_01.json new file mode 100644 index 0000000..d661c9b --- /dev/null +++ b/Data/campaigns/campaign_01.json @@ -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 } + ] + } + ] +} diff --git a/PLAN.md b/PLAN.md deleted file mode 100644 index d247415..0000000 --- a/PLAN.md +++ /dev/null @@ -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 diff --git a/PLAN_leveldesign.md b/PLAN_leveldesign.md new file mode 100644 index 0000000..1f30dbb --- /dev/null +++ b/PLAN_leveldesign.md @@ -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 diff --git a/PLAN_missions.md b/PLAN_missions.md new file mode 100644 index 0000000..1e4f759 --- /dev/null +++ b/PLAN_missions.md @@ -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 | diff --git a/PLAN_playtest.md b/PLAN_playtest.md new file mode 100644 index 0000000..af17f95 --- /dev/null +++ b/PLAN_playtest.md @@ -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 diff --git a/Scripts/Board/BoardView.cs b/Scripts/Board/BoardView.cs index 63d81c2..a3e82a2 100644 --- a/Scripts/Board/BoardView.cs +++ b/Scripts/Board/BoardView.cs @@ -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); diff --git a/Scripts/Board/CellView.cs b/Scripts/Board/CellView.cs index 4127a91..faa5805 100644 --- a/Scripts/Board/CellView.cs +++ b/Scripts/Board/CellView.cs @@ -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); diff --git a/Scripts/Input/InputMapper.cs b/Scripts/Input/InputMapper.cs index 0624d68..d4ec8b3 100644 --- a/Scripts/Input/InputMapper.cs +++ b/Scripts/Input/InputMapper.cs @@ -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(); diff --git a/Scripts/Main.cs b/Scripts/Main.cs index d0bcd2a..6e1650c 100644 --- a/Scripts/Main.cs +++ b/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(); + 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); }); } diff --git a/Scripts/Presentation/EventAnimator.cs b/Scripts/Presentation/EventAnimator.cs index 9625b1a..750f3b5 100644 --- a/Scripts/Presentation/EventAnimator.cs +++ b/Scripts/Presentation/EventAnimator.cs @@ -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(); var transferEvents = new List(); var moveEvents = new List(); - var collisionEvents = new List(); + var collisionEvents = new List(); + + // Pre-scan: if MissionStartedEvent follows MissionCompleteEvent, it's an auto-advance (not last mission) + bool hasAutoAdvance = events.Any(e => e is MissionStartedEvent); foreach (var evt in events) { @@ -90,6 +98,11 @@ public partial class EventAnimator : Node produceEvents.Add(produced); break; + case CargoConvertedEvent converted: + // Visual flash on transformer cell (treat like a produce event for animation) + produceEvents.Add(new CargoProducedEvent(converted.TurnNumber, converted.TransformerCell, converted.OutputCargo)); + break; + case CargoTransferredEvent: case DemandProgressEvent: transferEvents.Add(evt); @@ -99,21 +112,33 @@ public partial class EventAnimator : Node moveEvents.Add(moved); break; - case PieceDestroyedEvent destroyed: - collisionEvents.Add(destroyed); + case PieceReturnedToStockEvent returned: + collisionEvents.Add(returned); break; - case VictoryEvent victory: + case MissionCompleteEvent: FlushPhases(tween, produceEvents, transferEvents, moveEvents, collisionEvents); tween.TweenCallback(Callable.From(() => { SfxManager.Instance?.PlayVictory(); SpawnConfetti(); - _metricsOverlay.ShowMetrics(victory.Metrics); - EmitSignal(SignalName.VictoryReached); + if (!hasAutoAdvance) + EmitSignal(SignalName.VictoryReached); })); break; + case MissionStartedEvent: + FlushPhases(tween, produceEvents, transferEvents, moveEvents, collisionEvents); + tween.TweenCallback(Callable.From(() => + { + EmitSignal(SignalName.MissionAdvanced); + })); + break; + + case SimulationPausedEvent: + // Auto-pause from collision — handled by FlushPhases + break; + case TurnEndedEvent: FlushPhases(tween, produceEvents, transferEvents, moveEvents, collisionEvents); break; @@ -137,7 +162,7 @@ public partial class EventAnimator : Node List produceEvents, List transferEvents, List moveEvents, - List collisionEvents) + List 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 }; diff --git a/Scripts/UI/ControlBar.cs b/Scripts/UI/ControlBar.cs index 784b29c..20a9573 100644 --- a/Scripts/UI/ControlBar.cs +++ b/Scripts/UI/ControlBar.cs @@ -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) diff --git a/Scripts/UI/FlavorBanner.cs b/Scripts/UI/FlavorBanner.cs new file mode 100644 index 0000000..63daa68 --- /dev/null +++ b/Scripts/UI/FlavorBanner.cs @@ -0,0 +1,73 @@ +using Godot; + +namespace Chessistics.Scripts.UI; + +/// +/// Displays a one-line narrative blurb at the top of the screen when a mission starts. +/// Auto-fades out after a few seconds. +/// +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)); + } +} diff --git a/Scripts/UI/FlavorBanner.cs.uid b/Scripts/UI/FlavorBanner.cs.uid new file mode 100644 index 0000000..ca1ccd5 --- /dev/null +++ b/Scripts/UI/FlavorBanner.cs.uid @@ -0,0 +1 @@ +uid://bcapogap6qff2 diff --git a/Scripts/UI/LevelSelectScreen.cs b/Scripts/UI/LevelSelectScreen.cs index 51ab53e..af4f8dc 100644 --- a/Scripts/UI/LevelSelectScreen.cs +++ b/Scripts/UI/LevelSelectScreen.cs @@ -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); } } diff --git a/Scripts/UI/MetricsOverlay.cs b/Scripts/UI/MetricsOverlay.cs index 44198e3..f7eb097 100644 --- a/Scripts/UI/MetricsOverlay.cs +++ b/Scripts/UI/MetricsOverlay.cs @@ -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; diff --git a/Scripts/UI/ObjectivePanel.cs b/Scripts/UI/ObjectivePanel.cs index ece5ea3..bbb8ee0 100644 --- a/Scripts/UI/ObjectivePanel.cs +++ b/Scripts/UI/ObjectivePanel.cs @@ -6,7 +6,7 @@ namespace Chessistics.Scripts.UI; public partial class ObjectivePanel : VBoxContainer { - private readonly Dictionary _entries = new(); + private readonly Dictionary _entries = new(); public void Setup(IReadOnlyList 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); } } } diff --git a/Scripts/UI/PieceStockPanel.cs b/Scripts/UI/PieceStockPanel.cs index e2ec0f6..72c024b 100644 --- a/Scripts/UI/PieceStockPanel.cs +++ b/Scripts/UI/PieceStockPanel.cs @@ -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); diff --git a/chessistics-engine/Commands/CampaignCommands.cs b/chessistics-engine/Commands/CampaignCommands.cs new file mode 100644 index 0000000..f1a3489 --- /dev/null +++ b/chessistics-engine/Commands/CampaignCommands.cs @@ -0,0 +1,156 @@ +using Chessistics.Engine.Events; +using Chessistics.Engine.Model; + +namespace Chessistics.Engine.Commands; + +/// +/// Load a campaign: initializes the board with mission 0's terrain, stock, and unlocked pieces. +/// +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 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)); + } +} + +/// +/// Advance to the next mission: applies the terrain patch, adds stock, unlocks pieces. +/// +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 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)); + } +} + +/// +/// Move a piece already on the board (drag & drop). Validates the new placement. +/// +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 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)); + } +} diff --git a/chessistics-engine/Commands/CampaignCommands.cs.uid b/chessistics-engine/Commands/CampaignCommands.cs.uid new file mode 100644 index 0000000..80f5d3c --- /dev/null +++ b/chessistics-engine/Commands/CampaignCommands.cs.uid @@ -0,0 +1 @@ +uid://b2103p4uf8f3t diff --git a/chessistics-engine/Commands/WorldCommands.cs b/chessistics-engine/Commands/WorldCommands.cs index 7b0c76d..f23b893 100644 --- a/chessistics-engine/Commands/WorldCommands.cs +++ b/chessistics-engine/Commands/WorldCommands.cs @@ -5,6 +5,10 @@ using Chessistics.Engine.Simulation; namespace Chessistics.Engine.Commands; +/// +/// Place a piece on the board. Works in any phase (Running or Paused). +/// The placement takes effect between turns. +/// 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 changeList) @@ -58,20 +68,21 @@ public class PlacePieceCommand : WorldCommand changeList.Add(new PiecePlacedEvent(piece.Id, Kind, Start, End)); } - /// - /// Auto-assign cargo filter by tracing the relay chain back to a production. - /// Priority: direct adjacency to production, then shared relay with filtered piece. - /// private static CargoType? InferCargoFilter(BoardState state, PieceState piece) { - // Check if start or end cell is adjacent to a production foreach (var (prodPos, prod) in state.Productions) { 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 } } +/// +/// Remove a piece from the board. Works in any phase. +/// 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 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 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 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 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 changeList) - { - state.ResetFromLevel(); - changeList.Add(new LevelResetEvent()); - } -} diff --git a/chessistics-engine/Events/WorldEvents.cs b/chessistics-engine/Events/WorldEvents.cs index 9e2c853..4ddb55f 100644 --- a/chessistics-engine/Events/WorldEvents.cs +++ b/chessistics-engine/Events/WorldEvents.cs @@ -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 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; diff --git a/chessistics-engine/Loading/CampaignLoader.cs b/chessistics-engine/Loading/CampaignLoader.cs new file mode 100644 index 0000000..d9b65b9 --- /dev/null +++ b/chessistics-engine/Loading/CampaignLoader.cs @@ -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(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 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? Stock { get; set; } + public List? UnlockedPieces { get; set; } + public List? UnlockedLevels { get; set; } + } + + private class TerrainPatchDto + { + public int NewWidth { get; set; } + public int NewHeight { get; set; } + public List 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; } + } +} diff --git a/chessistics-engine/Loading/CampaignLoader.cs.uid b/chessistics-engine/Loading/CampaignLoader.cs.uid new file mode 100644 index 0000000..98658e2 --- /dev/null +++ b/chessistics-engine/Loading/CampaignLoader.cs.uid @@ -0,0 +1 @@ +uid://dq4ycsj6oc1nh diff --git a/chessistics-engine/Model/BoardSnapshot.cs b/chessistics-engine/Model/BoardSnapshot.cs index 2585518..f5dadb4 100644 --- a/chessistics-engine/Model/BoardSnapshot.cs +++ b/chessistics-engine/Model/BoardSnapshot.cs @@ -8,10 +8,14 @@ public class BoardSnapshot public IReadOnlyList Productions { get; } public IReadOnlyList Demands { get; } public IReadOnlyList Pieces { get; } + public IReadOnlyList Transformers { get; } public SimPhase Phase { get; } public int TurnNumber { get; } public IReadOnlyDictionary 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(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 CompletedMissions, IReadOnlySet AvailablePieceKinds, IReadOnlySet AvailableLevels); diff --git a/chessistics-engine/Model/BoardState.cs b/chessistics-engine/Model/BoardState.cs index 5872723..95c8fc7 100644 --- a/chessistics-engine/Model/BoardState.cs +++ b/chessistics-engine/Model/BoardState.cs @@ -4,97 +4,85 @@ namespace Chessistics.Engine.Model; public class BoardState { - public int Width { get; } - public int Height { get; } - public CellType[,] Grid { get; } + public int Width { get; private set; } + public int Height { get; private set; } + public CellType[,] Grid { get; private set; } public Dictionary Productions { get; } public Dictionary Demands { get; } public List Pieces { get; } public List DestroyedPieces { get; } = new(); public Dictionary ProductionBuffers { get; } + public Dictionary Transformers { get; } + public Dictionary TransformerInputBuffers { get; } + public Dictionary TransformerOutputBuffers { get; } public SimPhase Phase { get; set; } public int TurnNumber { get; set; } public int NextPieceId { get; set; } public Dictionary 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 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(); Demands = new Dictionary(); Pieces = new List(); ProductionBuffers = new Dictionary(); + Transformers = new Dictionary(); + TransformerInputBuffers = new Dictionary(); + TransformerOutputBuffers = new Dictionary(); RemainingStock = new Dictionary(); OccupiedCells = new HashSet(); - Phase = SimPhase.Edit; + Phase = SimPhase.Paused; TurnNumber = 0; NextPieceId = 1; - // Initialize grid as empty for (int c = 0; c < Width; c++) for (int r = 0; r < Height; r++) Grid[c, r] = CellType.Empty; + } - // Place walls - foreach (var wall in level.Walls) - Grid[wall.Col, wall.Row] = CellType.Wall; - - // Place productions - foreach (var prod in level.Productions) - { - Grid[prod.Position.Col, prod.Position.Row] = CellType.Production; - Productions[prod.Position] = prod; - ProductionBuffers[prod.Position] = 0; - } - - // Place demands - foreach (var demand in level.Demands) - { - Grid[demand.Position.Col, demand.Position.Row] = CellType.Demand; - Demands[demand.Position] = new DemandState(demand); - } - - // Initialize stock - foreach (var stock in level.Stock) - RemainingStock[stock.Kind] = stock.Count; + private BoardState(LevelDef level) : this(level.Width, level.Height) + { + _levelDef = level; + ApplyLevelDef(level); } public static BoardState FromLevel(LevelDef level) => new(level); + public static BoardState FromCampaign(CampaignDef campaignDef) + { + var state = new BoardState(campaignDef.InitialWidth, campaignDef.InitialHeight); + state.Campaign = new CampaignState(campaignDef); + return state; + } + public CellType GetCell(Coords coords) => Grid[coords.Col, coords.Row]; public bool IsOnBoard(Coords coords) => coords.IsOnBoard(Width, Height); /// - /// 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. /// public HashSet GetOccupiedCells() { var occupied = new HashSet(); foreach (var piece in Pieces) { - if (Phase == SimPhase.Edit) - { - occupied.Add(piece.StartCell); - occupied.Add(piece.EndCell); - } - else - { - occupied.Add(piece.CurrentCell); - } + occupied.Add(piece.CurrentCell); } return occupied; } @@ -117,17 +105,96 @@ public class BoardState } } + /// + /// Expand the board to new dimensions, preserving existing state. + /// + 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; + } + + /// + /// Apply a terrain patch (new cells, productions, demands, walls). + /// + 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; + } + } + } + + /// + /// Add stock to the remaining stock (cumulative for campaigns). + /// + public void AddStock(IReadOnlyList 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); + } + + /// + /// 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. + /// + 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; } } diff --git a/chessistics-engine/Model/CampaignDef.cs b/chessistics-engine/Model/CampaignDef.cs new file mode 100644 index 0000000..42d8598 --- /dev/null +++ b/chessistics-engine/Model/CampaignDef.cs @@ -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 Missions { get; init; } = []; +} diff --git a/chessistics-engine/Model/CampaignDef.cs.uid b/chessistics-engine/Model/CampaignDef.cs.uid new file mode 100644 index 0000000..b8943b7 --- /dev/null +++ b/chessistics-engine/Model/CampaignDef.cs.uid @@ -0,0 +1 @@ +uid://cpyjhyp308ybb diff --git a/chessistics-engine/Model/CampaignState.cs b/chessistics-engine/Model/CampaignState.cs new file mode 100644 index 0000000..b9a4b54 --- /dev/null +++ b/chessistics-engine/Model/CampaignState.cs @@ -0,0 +1,22 @@ +namespace Chessistics.Engine.Model; + +public class CampaignState +{ + public CampaignDef CampaignDef { get; } + public int CurrentMissionIndex { get; set; } + public List CompletedMissions { get; } = new(); + public HashSet AvailablePieceKinds { get; } = new(); + public HashSet 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)); +} diff --git a/chessistics-engine/Model/CampaignState.cs.uid b/chessistics-engine/Model/CampaignState.cs.uid new file mode 100644 index 0000000..d19e5ea --- /dev/null +++ b/chessistics-engine/Model/CampaignState.cs.uid @@ -0,0 +1 @@ +uid://bxmuxyxroua54 diff --git a/chessistics-engine/Model/CargoType.cs b/chessistics-engine/Model/CargoType.cs index f6adddd..1db76fc 100644 --- a/chessistics-engine/Model/CargoType.cs +++ b/chessistics-engine/Model/CargoType.cs @@ -3,5 +3,8 @@ namespace Chessistics.Engine.Model; public enum CargoType { Wood, - Stone + Stone, + Tools, + Arms, + Gold } diff --git a/chessistics-engine/Model/CellType.cs b/chessistics-engine/Model/CellType.cs index a1023e1..5834ff6 100644 --- a/chessistics-engine/Model/CellType.cs +++ b/chessistics-engine/Model/CellType.cs @@ -5,5 +5,6 @@ public enum CellType Empty, Wall, Production, - Demand + Demand, + Transformer } diff --git a/chessistics-engine/Model/DemandDef.cs b/chessistics-engine/Model/DemandDef.cs index 3fb5ae2..c57a354 100644 --- a/chessistics-engine/Model/DemandDef.cs +++ b/chessistics-engine/Model/DemandDef.cs @@ -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); diff --git a/chessistics-engine/Model/DemandState.cs b/chessistics-engine/Model/DemandState.cs index 2996da8..b7f663f 100644 --- a/chessistics-engine/Model/DemandState.cs +++ b/chessistics-engine/Model/DemandState.cs @@ -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; } diff --git a/chessistics-engine/Model/MissionDef.cs b/chessistics-engine/Model/MissionDef.cs new file mode 100644 index 0000000..569c261 --- /dev/null +++ b/chessistics-engine/Model/MissionDef.cs @@ -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 Stock { get; init; } = []; + public IReadOnlyList Demands { get; init; } = []; + public IReadOnlyList UnlockedPieces { get; init; } = []; + public IReadOnlyList UnlockedLevels { get; init; } = []; +} diff --git a/chessistics-engine/Model/MissionDef.cs.uid b/chessistics-engine/Model/MissionDef.cs.uid new file mode 100644 index 0000000..e203fd8 --- /dev/null +++ b/chessistics-engine/Model/MissionDef.cs.uid @@ -0,0 +1 @@ +uid://bh4mvmkqeohqj diff --git a/chessistics-engine/Model/PieceState.cs b/chessistics-engine/Model/PieceState.cs index 7208276..d15381b 100644 --- a/chessistics-engine/Model/PieceState.cs +++ b/chessistics-engine/Model/PieceState.cs @@ -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. /// public Coords TargetCell => CurrentCell == StartCell ? EndCell : StartCell; + + /// + /// Relocate this piece (drag & drop). + /// + public void SetPosition(Coords newStart, Coords newEnd) + { + StartCell = newStart; + EndCell = newEnd; + CurrentCell = newStart; + } } diff --git a/chessistics-engine/Model/PieceUpgrade.cs b/chessistics-engine/Model/PieceUpgrade.cs new file mode 100644 index 0000000..4f70307 --- /dev/null +++ b/chessistics-engine/Model/PieceUpgrade.cs @@ -0,0 +1,3 @@ +namespace Chessistics.Engine.Model; + +public record PieceUpgrade(PieceKind Kind, int Level); diff --git a/chessistics-engine/Model/PieceUpgrade.cs.uid b/chessistics-engine/Model/PieceUpgrade.cs.uid new file mode 100644 index 0000000..be873f5 --- /dev/null +++ b/chessistics-engine/Model/PieceUpgrade.cs.uid @@ -0,0 +1 @@ +uid://broa1hmowlt7 diff --git a/chessistics-engine/Model/SimPhase.cs b/chessistics-engine/Model/SimPhase.cs index 241c625..4c021cf 100644 --- a/chessistics-engine/Model/SimPhase.cs +++ b/chessistics-engine/Model/SimPhase.cs @@ -2,9 +2,7 @@ namespace Chessistics.Engine.Model; public enum SimPhase { - Edit, Running, Paused, - Victory, - Defeat + MissionComplete } diff --git a/chessistics-engine/Model/TerrainPatch.cs b/chessistics-engine/Model/TerrainPatch.cs new file mode 100644 index 0000000..869a4ac --- /dev/null +++ b/chessistics-engine/Model/TerrainPatch.cs @@ -0,0 +1,18 @@ +namespace Chessistics.Engine.Model; + +public class TerrainPatch +{ + public int NewWidth { get; init; } + public int NewHeight { get; init; } + public IReadOnlyList 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; } +} diff --git a/chessistics-engine/Model/TerrainPatch.cs.uid b/chessistics-engine/Model/TerrainPatch.cs.uid new file mode 100644 index 0000000..50a7164 --- /dev/null +++ b/chessistics-engine/Model/TerrainPatch.cs.uid @@ -0,0 +1 @@ +uid://c51en0egfstje diff --git a/chessistics-engine/Model/TransformerDef.cs b/chessistics-engine/Model/TransformerDef.cs new file mode 100644 index 0000000..16c32e4 --- /dev/null +++ b/chessistics-engine/Model/TransformerDef.cs @@ -0,0 +1,10 @@ +namespace Chessistics.Engine.Model; + +public record TransformerDef( + Coords Position, + string Name, + CargoType InputCargo, + int InputRequired, + CargoType OutputCargo, + int OutputAmount +); diff --git a/chessistics-engine/Model/TransformerDef.cs.uid b/chessistics-engine/Model/TransformerDef.cs.uid new file mode 100644 index 0000000..c24d744 --- /dev/null +++ b/chessistics-engine/Model/TransformerDef.cs.uid @@ -0,0 +1 @@ +uid://cu7cpt1u5mtxd diff --git a/chessistics-engine/Rules/MissionChecker.cs b/chessistics-engine/Rules/MissionChecker.cs new file mode 100644 index 0000000..84c68d8 --- /dev/null +++ b/chessistics-engine/Rules/MissionChecker.cs @@ -0,0 +1,25 @@ +using Chessistics.Engine.Model; + +namespace Chessistics.Engine.Rules; + +public static class MissionChecker +{ + /// + /// 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. + /// + 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); + } + + /// + /// Check if all demands on the board are satisfied (all missions). + /// + public static bool AllDemandsMet(BoardState state) + => state.Demands.Values.All(d => d.IsSatisfied); +} diff --git a/chessistics-engine/Rules/MissionChecker.cs.uid b/chessistics-engine/Rules/MissionChecker.cs.uid new file mode 100644 index 0000000..bd00338 --- /dev/null +++ b/chessistics-engine/Rules/MissionChecker.cs.uid @@ -0,0 +1 @@ +uid://b3vg5keyv2aj6 diff --git a/chessistics-engine/Rules/TransferResolver.cs b/chessistics-engine/Rules/TransferResolver.cs index 7cfa291..d861e30 100644 --- a/chessistics-engine/Rules/TransferResolver.cs +++ b/chessistics-engine/Rules/TransferResolver.cs @@ -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 events, HashSet 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 events, HashSet 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(); + } + /// /// Returns a sort key (0-3) based on cardinal direction from center to piece. /// In y-up coordinates, clockwise from 0° (right): diff --git a/chessistics-engine/Rules/VictoryChecker.cs b/chessistics-engine/Rules/VictoryChecker.cs deleted file mode 100644 index 1d558d3..0000000 --- a/chessistics-engine/Rules/VictoryChecker.cs +++ /dev/null @@ -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 GetExpiredDemands(BoardState state) - => state.Demands.Values - .Where(d => !d.IsSatisfied && state.TurnNumber > d.Deadline) - .ToList(); -} diff --git a/chessistics-engine/Rules/VictoryChecker.cs.uid b/chessistics-engine/Rules/VictoryChecker.cs.uid deleted file mode 100644 index 29f7b0d..0000000 --- a/chessistics-engine/Rules/VictoryChecker.cs.uid +++ /dev/null @@ -1 +0,0 @@ -uid://uh7qhohnsxpa diff --git a/chessistics-engine/Simulation/GameSim.cs b/chessistics-engine/Simulation/GameSim.cs index 0082ffb..b0c8374 100644 --- a/chessistics-engine/Simulation/GameSim.cs +++ b/chessistics-engine/Simulation/GameSim.cs @@ -13,6 +13,11 @@ public class GameSim _state = BoardState.FromLevel(level); } + public GameSim(CampaignDef campaign) + { + _state = BoardState.FromCampaign(campaign); + } + public IReadOnlyList ProcessCommand(IWorldCommand command) { var changeList = new List(); diff --git a/chessistics-engine/Simulation/TurnExecutor.cs b/chessistics-engine/Simulation/TurnExecutor.cs index 549a54b..a202f36 100644 --- a/chessistics-engine/Simulation/TurnExecutor.cs +++ b/chessistics-engine/Simulation/TurnExecutor.cs @@ -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 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 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 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 changeList) { foreach (var (pos, prod) in state.Productions) diff --git a/chessistics-tests/Helpers/SimHelper.cs b/chessistics-tests/Helpers/SimHelper.cs index 47cfd3f..75f9b1d 100644 --- a/chessistics-tests/Helpers/SimHelper.cs +++ b/chessistics-tests/Helpers/SimHelper.cs @@ -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 Place(PieceKind kind, Coords start, Coords end) => Sim.ProcessCommand(new PlacePieceCommand(kind, start, end)); @@ -22,9 +24,6 @@ public class SimHelper public IReadOnlyList Remove(int pieceId) => Sim.ProcessCommand(new RemovePieceCommand(pieceId)); - public IReadOnlyList Start() - => Sim.ProcessCommand(new StartSimulationCommand()); - public IReadOnlyList Step() => Sim.ProcessCommand(new StepSimulationCommand()); @@ -34,17 +33,20 @@ public class SimHelper public IReadOnlyList Resume() => Sim.ProcessCommand(new ResumeSimulationCommand()); - public IReadOnlyList Stop() - => Sim.ProcessCommand(new StopSimulationCommand()); - - public IReadOnlyList Reset() - => Sim.ProcessCommand(new ResetLevelCommand()); + public IReadOnlyList AdvanceMission() + => Sim.ProcessCommand(new AdvanceMissionCommand()); public List StepN(int n) { var allEvents = new List(); 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; } diff --git a/chessistics-tests/Loading/CampaignFileTests.cs b/chessistics-tests/Loading/CampaignFileTests.cs new file mode 100644 index 0000000..814f969 --- /dev/null +++ b/chessistics-tests/Loading/CampaignFileTests.cs @@ -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); + } +} diff --git a/chessistics-tests/Loading/CampaignFileTests.cs.uid b/chessistics-tests/Loading/CampaignFileTests.cs.uid new file mode 100644 index 0000000..3813f4b --- /dev/null +++ b/chessistics-tests/Loading/CampaignFileTests.cs.uid @@ -0,0 +1 @@ +uid://c6sab8mq5a201 diff --git a/chessistics-tests/Loading/CampaignLoaderTests.cs b/chessistics-tests/Loading/CampaignLoaderTests.cs new file mode 100644 index 0000000..940f989 --- /dev/null +++ b/chessistics-tests/Loading/CampaignLoaderTests.cs @@ -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(() => 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(() => CampaignLoader.Load(badJson)); + } + + [Fact] + public void LoadCampaign_NoMissions_Throws() + { + var badJson = """ + { + "name": "Empty", + "initialWidth": 4, + "initialHeight": 4, + "missions": [] + } + """; + + Assert.Throws(() => 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); + } +} diff --git a/chessistics-tests/Loading/CampaignLoaderTests.cs.uid b/chessistics-tests/Loading/CampaignLoaderTests.cs.uid new file mode 100644 index 0000000..19219a2 --- /dev/null +++ b/chessistics-tests/Loading/CampaignLoaderTests.cs.uid @@ -0,0 +1 @@ +uid://boxlkyt1rnb6l diff --git a/chessistics-tests/Loading/CampaignValidationTests.cs b/chessistics-tests/Loading/CampaignValidationTests.cs new file mode 100644 index 0000000..d8fce43 --- /dev/null +++ b/chessistics-tests/Loading/CampaignValidationTests.cs @@ -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; + +/// +/// Validates campaign_01.json structural integrity: +/// no cell overlaps, unique building names, walls only on new cells. +/// +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(); + 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(); + 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"); + } + } +} diff --git a/chessistics-tests/Loading/CampaignValidationTests.cs.uid b/chessistics-tests/Loading/CampaignValidationTests.cs.uid new file mode 100644 index 0000000..7c7be28 --- /dev/null +++ b/chessistics-tests/Loading/CampaignValidationTests.cs.uid @@ -0,0 +1 @@ +uid://dle5bi0rtya8x diff --git a/chessistics-tests/Model/TerrainPatchTests.cs b/chessistics-tests/Model/TerrainPatchTests.cs new file mode 100644 index 0000000..4976759 --- /dev/null +++ b/chessistics-tests/Model/TerrainPatchTests.cs @@ -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))); + } +} diff --git a/chessistics-tests/Model/TerrainPatchTests.cs.uid b/chessistics-tests/Model/TerrainPatchTests.cs.uid new file mode 100644 index 0000000..ffc6d74 --- /dev/null +++ b/chessistics-tests/Model/TerrainPatchTests.cs.uid @@ -0,0 +1 @@ +uid://kujovcfoy6j2 diff --git a/chessistics-tests/Rules/TransferResolverTests.cs b/chessistics-tests/Rules/TransferResolverTests.cs index 1c62541..e5eaacd 100644 --- a/chessistics-tests/Rules/TransferResolverTests.cs +++ b/chessistics-tests/Rules/TransferResolverTests.cs @@ -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() { diff --git a/chessistics-tests/Simulation/CampaignTests.cs b/chessistics-tests/Simulation/CampaignTests.cs new file mode 100644 index 0000000..97949c8 --- /dev/null +++ b/chessistics-tests/Simulation/CampaignTests.cs @@ -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(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(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(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); + } +} diff --git a/chessistics-tests/Simulation/CampaignTests.cs.uid b/chessistics-tests/Simulation/CampaignTests.cs.uid new file mode 100644 index 0000000..442bd85 --- /dev/null +++ b/chessistics-tests/Simulation/CampaignTests.cs.uid @@ -0,0 +1 @@ +uid://bxt85xb77h4jn diff --git a/chessistics-tests/Simulation/FullLevelTests.cs b/chessistics-tests/Simulation/FullLevelTests.cs index 1cf9830..6f64836 100644 --- a/chessistics-tests/Simulation/FullLevelTests.cs +++ b/chessistics-tests/Simulation/FullLevelTests.cs @@ -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); } } diff --git a/chessistics-tests/Simulation/GameSimTests.cs b/chessistics-tests/Simulation/GameSimTests.cs index 55f7160..58d4be7 100644 --- a/chessistics-tests/Simulation/GameSimTests.cs +++ b/chessistics-tests/Simulation/GameSimTests.cs @@ -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(events[0]); + Assert.IsType(events[0]); } [Fact] - public void StartWithNoPieces_Rejected() - { - var sim = CreateLevel1Sim(); - var events = sim.Start(); - Assert.IsType(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(events[0]); - } - - [Fact] - public void StopDuringEdit_Rejected() - { - var sim = CreateLevel1Sim(); - var events = sim.Stop(); - Assert.IsType(events[0]); + Assert.IsType(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().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); } } diff --git a/chessistics-tests/Simulation/PhaseTests.cs b/chessistics-tests/Simulation/PhaseTests.cs new file mode 100644 index 0000000..6be6d15 --- /dev/null +++ b/chessistics-tests/Simulation/PhaseTests.cs @@ -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; + +/// +/// Regression tests for SimPhase transitions. +/// Bug: StepSimulationCommand always set phase to Paused, even during auto-play (Running). +/// +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); + } +} diff --git a/chessistics-tests/Simulation/PhaseTests.cs.uid b/chessistics-tests/Simulation/PhaseTests.cs.uid new file mode 100644 index 0000000..49914f1 --- /dev/null +++ b/chessistics-tests/Simulation/PhaseTests.cs.uid @@ -0,0 +1 @@ +uid://dxv44w3l5rw66 diff --git a/chessistics-tests/Simulation/SolvabilityTests.cs b/chessistics-tests/Simulation/SolvabilityTests.cs index c55cb37..cc5fd49 100644 --- a/chessistics-tests/Simulation/SolvabilityTests.cs +++ b/chessistics-tests/Simulation/SolvabilityTests.cs @@ -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; /// /// 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. /// 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().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().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().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().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().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); + } + + /// + /// Full transformer chain: Production(Wood) → Piece → Forge(Wood→Tools) → Piece → Demand(Tools) + /// + [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); + } + + /// + /// Two-stage transformation: Wood → Forge → Tools → Comptoir → Gold + /// + [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().ToList(); + Assert.Contains(conversions, c => c.OutputCargo == CargoType.Tools); + Assert.Contains(conversions, c => c.OutputCargo == CargoType.Gold); + Assert.Contains(allEvents, e => e is MissionCompleteEvent); } } diff --git a/chessistics-tests/Simulation/TransformerTests.cs b/chessistics-tests/Simulation/TransformerTests.cs new file mode 100644 index 0000000..42ba3e7 --- /dev/null +++ b/chessistics-tests/Simulation/TransformerTests.cs @@ -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 +{ + /// + /// 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) + /// + 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); + } +} diff --git a/chessistics-tests/Simulation/TransformerTests.cs.uid b/chessistics-tests/Simulation/TransformerTests.cs.uid new file mode 100644 index 0000000..404e784 --- /dev/null +++ b/chessistics-tests/Simulation/TransformerTests.cs.uid @@ -0,0 +1 @@ +uid://c7ifw7o8xahpv diff --git a/docs/GDD_prototype.md b/docs/GDD_prototype.md index fff722a..7608f08 100644 --- a/docs/GDD_prototype.md +++ b/docs/GDD_prototype.md @@ -16,17 +16,21 @@ Chaque piece est un **maillon de convoyeur**. La strategie est dans la compositi **Core loop** : ``` -OBSERVER la situation (productions, demandes, terrain, pieces disponibles) - | -PLACER des pieces sur le plateau (point de depart + point d'arrivee) +OBSERVER le reseau en fonctionnement | -LANCER la simulation — les pieces font leurs allers-retours, -les colis se transmettent automatiquement entre pieces adjacentes +IDENTIFIER un goulet, une mission non remplie, ou une collision | - +---> Le debit est insuffisant ? Observer les goulets, reorganiser - +---> Le debit est atteint ? Optimiser ou niveau suivant +PLACER ou RETIRER des pieces (la simulation se met en pause automatiquement +pendant le placement, puis reprend) + | +OBSERVER le resultat — le reseau s'adapte immediatement + | + +---> Le debit est insuffisant ? Reorganiser les chaines + +---> La mission est remplie ? Avancer vers la mission suivante ``` +La simulation tourne en continu. Le joueur ne "lance" jamais — il intervient sur un systeme vivant. + **Ce qui distingue Chessistics** : - La logistique (macro) : le joueur compose des chaines, choisit sa flotte, gere l'espace - Le puzzle chess (micro) : les contraintes de mouvement creent des enigmes de couverture et d'espacement emergentes @@ -51,7 +55,7 @@ Le plateau est un damier avec des cases claires et sombres alternees. Chaque cas | **Case claire** | Carre clair du damier | Traversable normalement | | **Case sombre** | Carre sombre du damier | Traversable (les Fous sont limites a une couleur) | | **Mur** | Case barree, gris fonce | Bloque toutes les pieces sauf le Cavalier (qui saute) | -| **Production** | Icone ressource + nom (ex: "Scierie") | Produit M cargaisons tous les N coups (M=1 dans le proto, futur: 2-4). Le buffer est ecrase a chaque production — les cargaisons non recuperees sont perdues. Donne automatiquement aux pieces adjacentes disponibles. | +| **Production** | Icone ressource + nom (ex: "Scierie") | Produit M cargaisons par tour (M=1 a 4 selon le batiment). Le buffer max = M — les cargaisons non recuperees sont ecrasees au tour suivant. Donne automatiquement aux pieces adjacentes disponibles. | | **Demande** | Icone cible + nom (ex: "Depot") + jauge | Recoit la cargaison d'une piece adjacente qui arrive avec un colis compatible. Accepte toujours un colis du bon type, meme si l'objectif est deja atteint. | ### 2.3 Cargaison @@ -171,7 +175,8 @@ C'est tout. Pas de programmation, pas de route multi-etapes. La piece fait l'all - Une piece bloque le passage des autres pieces (sauf le Cavalier qui saute) - Si deux pieces arrivent sur la meme case apres le mouvement, la piece de **statut le plus eleve** detruit l'autre. A statut egal, le **niveau** departage. A egalite parfaite (meme statut ET meme niveau), **destruction mutuelle**. - La piece survivante reste sur la case avec sa cargaison intacte. La cargaison des pieces detruites est perdue. -- Les pieces detruites sont restaurees quand le joueur arrete la simulation (retour en mode edition). +- Les pieces detruites **retournent immediatement dans le stock** du joueur — il peut les replacer a tout moment. +- En cas de collision, la simulation se met en **pause automatique**. La camera effectue un pan et zoom vers la zone de collision pour montrer ce qui s'est passe. Une notification apparait dans un coin de l'ecran pour expliciter l'evenement (ex: "Tour II detruite par Dame — retournee au stock"). --- @@ -188,11 +193,13 @@ Les transferts se produisent entre : ### 4.2 Quand le transfert a lieu -Un transfert se produit quand, a la **fin d'un coup** (une fois que toutes les pieces ont bouge) : +Les transferts se resolvent **avant le mouvement**, dans la meme sequence de coup (voir §5.1). Une piece adjacente a une production prend le colis puis se deplace avec dans le meme tour. + +Condition pour qu'un transfert ait lieu : - Une entite avec colis et une entite sans colis (ou une demande) sont sur des **cases adjacentes** (4-connecte) - Le colis est compatible (la demande accepte ce type de cargaison) -Le transfert est **instantane** : le colis passe d'une entite a l'autre entre deux coups. +Le transfert est **instantane** au sein du tour. ### 4.3 Priorite et departage @@ -286,36 +293,33 @@ Quand deux pieces ou plus occupent la meme case apres le mouvement : - A statut egal, le **niveau** departage (niveau superieur survit) - A egalite parfaite (meme statut ET meme niveau), **destruction mutuelle** (aucun survivant) - La piece survivante conserve sa cargaison. Les cargaisons detruites sont perdues. -- La simulation **continue** (pas de pause automatique) +- Les pieces detruites **retournent immediatement dans le stock** du joueur. +- La simulation se met en **pause automatique**. La camera pan et zoom vers la zone de collision. Une notification explicite l'evenement. Les collisions deviennent un element strategique — le joueur doit anticiper les croisements de trajectoires et espacer ses chaines pour les eviter. -### 5.3 Condition de victoire +### 5.3 Condition de completion de mission -Le niveau est reussi quand **toutes les demandes** ont ete satisfaites selon leur objectif. +La mission courante est completee quand **toutes ses demandes** ont ete satisfaites. -Chaque demande specifie : "recevoir N colis de type X en Y coups ou moins". +Chaque demande specifie : "recevoir N colis de type X". -Exemple : "Le Depot Royal demande 3 livraisons de Bois en 30 coups." +Exemple : "Le Depot Royal demande 3 livraisons de Bois." -Le compteur de coups tourne en temps reel. Le joueur voit sa progression. +Il n'y a pas de deadline. Le compteur de tours est affiche comme **metrique d'optimisation** (le joueur voit combien de tours il a mis), mais ne constitue pas une contrainte. Le joueur prend le temps qu'il veut. --- ## 6. Les metriques -A la completion d'un niveau, 3 metriques sont affichees : +A la completion d'une mission, 3 metriques sont affichees : | Metrique | Description | Ce que ca mesure | |----------|-------------|------------------| -| **Pieces** | Nombre de pieces deployees | Economie de flotte | +| **Pieces** | Nombre de pieces deployees pour cette mission | Economie de flotte | | **Coups** | Nombre de coups pour atteindre l'objectif | Efficacite du reseau | | **Espace** | Nombre de cases du plateau utilisees (occupees par une piece au moins 1 coup) | Compacite du reseau | -Chaque metrique a un **histogramme** montrant la distribution des solutions de tous les joueurs. - -> **Proto** : histogrammes avec donnees fictives pour tester l'UI. - **Triangle d'optimisation** : - Moins de pieces = chaines courtes = couverture limitee → plus de coups - Moins de coups = pieces puissantes ou nombreuses → plus de pieces, plus d'espace @@ -323,7 +327,7 @@ Chaque metrique a un **histogramme** montrant la distribution des solutions de t **Affichage en jeu** (pendant la simulation) : ``` - Coup: 12/30 Depot Royal: 2/3 Bois ✓ Forge: 0/2 Pierre ✗ + Tour: 12 Depot Royal: 2/3 Bois ✓ Forge: 0/2 Pierre ✗ ``` --- @@ -336,34 +340,36 @@ Le plateau est le centre. L'interface est minimale. ``` +---------------------------------------------------------------+ -| CHESSISTICS La Scierie Royale [≡] [?] [←] | +| CHESSISTICS La Quete du Roi [≡] [?] [←] | +---------------------------------------------------------------+ | | | -| | OBJECTIF | -| | Depot Royal | -| | 3x Bois / 30c | -| P L A T E A U | | -| (damier interactif) | ───────── | -| | | -| Les pieces et leurs trajets | PIECES | -| sont visibles sur le plateau | [Tour II] x3 | -| | [Fou II] x1 | -| | [Cavalier] x1 | +| | MISSION 3/8 | +| | Forger les Tours | +| | Depot: 0/3 Bois | +| P L A T E A U | ✓ Mission 1 | +| (damier interactif) | ✓ Mission 2 | +| | ───────── | +| Les pieces et leurs trajets | | +| sont visibles sur le plateau | PIECES | +| | [Pion I] x4 | +| | [Tour I] x3 | | | | +---------------------------------------------------------------+ -| [▶ PLAY] [⏩ x2] [⏸] [⏹ STOP] Coup: -- | +| [⏸ / ▶] [x1] [x2] [x4] Tour: 42 Mission: 3/8 | +---------------------------------------------------------------+ ``` ### 7.2 Placement d'une piece -Le flux de placement est en 2 clics : +Le flux de placement est en 2 clics. **La simulation se met en pause automatiquement** des que le joueur selectionne un type de piece, et reprend une fois le placement confirme ou annule. -1. Le joueur **selectionne un type de piece** dans le panneau de droite +1. Le joueur **selectionne un type de piece** dans le panneau de droite → **pause automatique** 2. Il **clique une case du plateau** → c'est le point de depart. Les cases d'arrivee possibles s'affichent en surbrillance. -3. Il **clique une case en surbrillance** → c'est le point d'arrivee. La piece est placee. +3. Il **clique une case en surbrillance** → c'est le point d'arrivee. La piece est placee. → **la simulation reprend** 4. Un trait apparait entre depart et arrivee, montrant le trajet. +Si le joueur annule (Echap), la simulation reprend sans placement. + ``` Placement d'une Tour II : @@ -379,9 +385,9 @@ Le flux de placement est en 2 clics : ``` **Interactions** : -- **Clic sur une piece placee** → la selectionne, affiche son trajet, panneau de detail -- **Clic droit sur une piece** → la retire du plateau (retourne dans le stock) -- **Glisser une piece** → deplace son point de depart (recalcule les arrivees possibles) +- **Clic gauche sur une piece placee** → la selectionne, affiche son trajet, panneau de detail +- **Touche Suppr** (piece selectionnee) → la retire du plateau (retourne dans le stock) +- **Bouton [Retirer]** dans le panneau de detail → meme effet ### 7.3 Visualisation des trajets @@ -411,21 +417,20 @@ Quand une piece est selectionnee : +---------------------------+ ``` -### 7.5 Phases de jeu +### 7.5 Simulation continue -**Phase EDIT** (temps arrete) -- Placer, deplacer, retirer des pieces -- Pas de limite de temps -- Les trajets sont visibles comme des traits sur le plateau +La simulation tourne en continu — il n'y a pas de phases Edit/Exec separees. Le joueur modifie son reseau a tout moment, la simulation integre les changements au tour suivant. -**Phase EXEC** (simulation) -- Les pieces font leurs allers-retours simultanement -- Les colis se transmettent automatiquement aux points de contact -- Compteur de coups et progression des objectifs en temps reel -- Controles : pause, step-by-step, vitesse x1/x2/x4, stop (retour a EDIT) -- En cas de collision → pause auto, pieces en erreur surlignees +**Controles** : +- **Espace** : pause / reprendre (le tour en cours se termine avant la pause) +- **Vitesse** : x1, x2, x4 +- **Placement d'une piece** : met la simulation en pause automatiquement le temps du placement (voir §7.2) -Le joueur peut arreter la simulation a tout moment, reorganiser, et relancer. +**Pauses automatiques** : +- Quand le joueur selectionne une piece a placer → pause jusqu'a confirmation ou annulation +- Quand une collision se produit → pause + pan/zoom camera vers la zone + notification (voir §5.2) + +Le joueur peut placer, retirer et reorganiser ses pieces a tout moment, en pause ou pendant que la simulation tourne. ### 7.6 Feedback visuel @@ -444,13 +449,23 @@ Le joueur peut arreter la simulation a tout moment, reorganiser, et relancer. - Les demandes ont une **jauge** de progression (ex: "2/3") - Jauge verte = objectif atteint. Jauge rouge = pas encore. -**Erreurs** : -- Collision : flash rouge + shake des deux pieces +**Collisions** : +- Flash rouge + shake des deux pieces - Simulation en pause automatiquement +- La camera pan et zoom vers la zone de collision +- Notification dans un coin de l'ecran : "Tour II detruite par Dame — retournee au stock" +- La piece detruite retourne dans le stock, le joueur peut la replacer -**Victoire** : +**Completion de mission** : - Toutes les jauges au vert → animation sobre (les trajets scintillent en dore) -- Overlay des metriques + histogrammes +- Overlay de felicitations avec metriques de la mission +- Bouton "Mission suivante" pour avancer + +**Transition de mission** : +- Titre "Nouvelle mission" apparait en plein ecran en fade-in +- La camera se lock (pan et zoom desactives) pour montrer la zone de la prochaine mission +- Les nouvelles cases apparaissent sur le plateau avec une animation d'expansion +- Le titre de mission se deplace vers la zone d'objectif dans le panneau lateral avant de disparaitre, emmenant l'oeil du joueur vers les nouveaux objectifs --- @@ -470,7 +485,7 @@ Le joueur peut arreter la simulation a tout moment, reorganiser, et relancer. ``` - Plateau : **4x4** -- S = Scierie (a1, produit du Bois tous les 2 coups) +- S = Scierie (a1, produit du Bois 1 par tour) - D = Depot Royal (d1, objectif : recevoir 3 Bois en 30 coups) - Pieces disponibles : **3x Tour II** @@ -514,17 +529,15 @@ Ou : Tour A couvre a1↔c1 (2 cases), Tour B couvre c1↔d1 (1 case). Ils ne son ``` 6 . . . . . . - 5 . . . . . [D2] Caserne — 2 Bois en 30 coups - 4 . . . . . . + 5 . . . . . [D2] Caserne — 2 Bois 4 . . . . . . 3 . . . . . . 2 . . . . . . - 1 [S] . . . . [D1] Depot Royal — 2 Bois en 30 coups - + 1 [S] . . . . [D1] Depot Royal — 2 Bois a b c d e f ``` - Plateau : **6x6** -- S = Scierie (a1, produit du Bois tous les 2 coups) +- S = Scierie (a1, produit du Bois 1 par tour) - D1 = Depot Royal (f1, objectif : 2 Bois en 30 coups) - D2 = Caserne (f5, objectif : 2 Bois en 30 coups) - Pieces disponibles : **4x Tour II, 1x Fou II** @@ -532,7 +545,7 @@ Ou : Tour A couvre a1↔c1 (2 cases), Tour B couvre c1↔d1 (1 case). Ils ne son **L'enjeu** : - S→D1 : 5 cases en ligne droite. Faisable avec une chaine de Tours. - S→D2 : trajet en angle (droite + haut). Plusieurs routes possibles. -- La Scierie ne produit qu'un colis tous les 2 coups. Les deux chaines partagent la meme source. +- La Scierie ne produit qu'un colis 1 par tour. Les deux chaines partagent la meme source. - Le joueur doit decider : comment repartir les colis entre les deux destinations ? **Le statut social entre en jeu** : @@ -559,9 +572,7 @@ Le Fou peut couvrir des trajets diagonaux que les Tours ne peuvent pas. Pour att **Intention** : un vrai reseau avec terrain, 2 types de cargaison, et le Cavalier comme solution aux obstacles. ``` - 6 [D2] . . . . [D1] Depot Royal — 2 Bois en 40 coups - 5 . . # # # . Forge — 2 Pierre en 40 coups - 4 . . # . . . + 6 [D2] . . . . [D1] Depot Royal — 2 Bois 5 . . # # # . Forge — 2 Pierre 4 . . # . . . 3 . . # . . . 2 . . . . . . 1 [S1] . . . . [S2] Scierie (Bois) @@ -570,8 +581,8 @@ Le Fou peut couvrir des trajets diagonaux que les Tours ne peuvent pas. Pour att ``` - Plateau : **6x6** -- S1 = Scierie (a1, Bois, tous les 2 coups) -- S2 = Carriere (f1, Pierre, tous les 2 coups) +- S1 = Scierie (a1, Bois, 1 par tour) +- S2 = Carriere (f1, Pierre, 1 par tour) - D1 = Depot Royal (f6, objectif : 2 Bois en 40 coups) - D2 = Forge (a6, objectif : 2 Pierre en 40 coups) - Murs : c3, c4, c5, d5, e5 (barriere en L) @@ -613,21 +624,19 @@ Le Cavalier saute le mur en L. Il peut connecter les deux cotes du plateau la ou ``` 8 . . . . . . . . - 7 [D2] . . . . . . . Forge — 3 Pierre en 40 coups - 6 . . . . . . . . + 7 [D2] . . . . . . . Forge — 3 Pierre 6 . . . . . . . . 5 . . . ## . . . . 4 . . . ## . . . . 3 . . . . . . . . 2 . . . . . . . . - 1 [S1] . . . . . . [D1] Depot Royal — 3 Bois en 40 coups - + 1 [S1] . . . . . . [D1] Depot Royal — 3 Bois a b c d e f g h [S2] Carriere (h8) ``` - Plateau : **8x8** - S1 = Scierie (a1, Bois), S2 = Carriere (h8, Pierre) -- D1 = Depot Royal (h1, 3 Bois/40c), D2 = Forge (a8, 3 Pierre/40c) +- D1 = Depot Royal (h1, 3 Bois), D2 = Forge (a8, 3 Pierre) - Murs : bloc 2x2 au centre (d4, e5, d5, e4) - Stock : 8 Pions, 4 Tours, 2 Fous, 2 Cavaliers @@ -642,17 +651,15 @@ Le Cavalier saute le mur en L. Il peut connecter les deux cotes du plateau la ou ``` 6 [S2] . # . # . # . Carriere (a6) 5 . . # . # . # . - 4 . . # . # . . [D1] Depot Royal — 3 Bois en 50 coups - 3 . . # . . . # . + 4 . . # . # . . [D1] Depot Royal — 3 Bois 3 . . # . . . # . 2 . . . . # . # . - 1 [S1] . . . # . . [D2] Forge — 3 Pierre en 50 coups - + 1 [S1] . . . # . . [D2] Forge — 3 Pierre a b c d e f g h ``` - Plateau : **8x6** - S1 = Scierie (a1, Bois), S2 = Carriere (a6, Pierre) -- D1 = Depot Royal (h6, 3 Bois/50c), D2 = Forge (h1, 3 Pierre/50c) +- D1 = Depot Royal (h6, 3 Bois), D2 = Forge (h1, 3 Pierre) - Murs : 3 colonnes partielles formant un labyrinthe - Stock : 10 Pions, 4 Tours, 2 Fous, 3 Cavaliers @@ -665,21 +672,19 @@ Le Cavalier saute le mur en L. Il peut connecter les deux cotes du plateau la ou **Intention** : reseau a 3 productions et 3 demandes, plateau 10x8. Le joueur gere un vrai reseau logistique. ``` - 8 [S2] . . # . . # . . [D2] Forge — 3 Pierre en 50 coups - 7 . . . # . . # . . . + 8 [S2] . . # . . # . . [D2] Forge — 3 Pierre 7 . . . # . . # . . . 6 . . . # ## . # . . . 5 . . . . . . . . . . 4 . . . # ## . # . . [S3] Scierie Est (j4, Bois) 3 . . . # . . # . . . 2 . . . . . . . . . . - 1 [S1] . . . [D3] . . . . [D1] Depot Royal — 3 Bois en 50 coups - + 1 [S1] . . . [D3] . . . . [D1] Depot Royal — 3 Bois a b c d e f g h i j ``` - Plateau : **10x8** - S1 = Scierie (a1, Bois), S2 = Carriere (a8, Pierre), S3 = Scierie Est (j4, Bois) -- D1 = Depot Royal (j1, 3 Bois/50c), D2 = Forge (j8, 3 Pierre/50c), D3 = Chantier (e8, 3 Bois/50c) +- D1 = Depot Royal (j1, 3 Bois), D2 = Forge (j8, 3 Pierre), D3 = Chantier (e8, 3 Bois) - Murs : deux colonnes avec pont horizontal - Stock : 14 Pions, 6 Tours, 3 Fous, 4 Cavaliers @@ -746,7 +751,7 @@ Chessistics/ UI/ ObjectivePanel.tscn — Objectifs + stock de pieces DetailPanel.tscn — Detail piece selectionnee - ControlBar.tscn — Play / pause / stop / vitesse + ControlBar.tscn — Pause / vitesse MetricsOverlay.tscn — Resultats post-victoire LevelSelect.tscn — Selection de niveau scripts/ @@ -762,7 +767,7 @@ Chessistics/ LevelLoader.cs — Chargement JSON UI/ PiecePlacer.cs — Logique du placement 2 clics - ControlBar.cs — Play/pause/stop/vitesse + ControlBar.cs — Pause/vitesse ProgressDisplay.cs — Compteur de coups + progression objectifs data/ levels/ @@ -781,10 +786,10 @@ Chessistics/ "width": 4, "height": 4, "productions": [ - { "x": 0, "y": 0, "name": "Scierie", "cargo": "wood", "interval": 2 } + { "x": 0, "y": 0, "name": "Scierie", "cargo": "wood", "amount": 1 } ], "demands": [ - { "x": 3, "y": 0, "name": "Depot Royal", "cargo": "wood", "amount": 3, "deadline": 30 } + { "x": 3, "y": 0, "name": "Depot Royal", "cargo": "wood", "amount": 3 } ], "walls": [], "pieces": [ @@ -801,7 +806,7 @@ Chessistics/ |----------|---------|----------------| | Adjacence pour transfert ? | 4-connecte (bords) vs 8-connecte (bords + coins) | **4-connecte** — plus de contrainte = plus de puzzle | | La piece tourne a vide ? | Oui (aller-retour permanent) vs attend un colis | **Oui, tourne en permanence** — plus visuel, plus simple | -| Collision = destruction ? | Destruction (fort mange faible) vs erreur stricte (pause) | **Destruction** — la piece de plus haut statut/niveau survit, les autres sont detruites. Destruction mutuelle si egalite parfaite. | +| Collision = destruction ? | Destruction (fort mange faible) vs erreur stricte (pause) | **Destruction + retour au stock** — la piece de plus haut statut/niveau survit, les autres retournent au stock. Pause auto + camera pan vers la collision + notification. Destruction mutuelle si egalite parfaite. | | Sources infinies ? | Oui (production periodique sans stock) vs stock limite | **Production periodique infinie** — le proto teste le reseau, pas la gestion de stock | | Pieces fixes par niveau ? | Fixes (catalogue impose) vs achat libre | **Fixes** — plus facile a designer. L'achat/fabrication est post-proto. | | Egalite de statut social ? | Niveau > direction horaire | **Niveau puis direction horaire alternee** (pairs: depuis droite, impairs: depuis gauche) — deterministe, pas de biais permanent | diff --git a/global.json b/global.json new file mode 100644 index 0000000..4d72e62 --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "9.0.312", + "rollForward": "latestMinor" + } +}