From 8a377c2e41d0c14dd0cbfb89a3349e4c20b18838 Mon Sep 17 00:00:00 2001 From: Samuel Bouchet Date: Fri, 17 Apr 2026 22:39:28 +0200 Subject: [PATCH] Add recurring-demand mode with shortage tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DemandDef gains optional ConsumptionPerTurn and SustainTurns. When ConsumptionPerTurn > 0 the demand maintains a buffer filled by deliveries and drained each turn. Shortage fires the first turn the buffer can't cover consumption; it clears when the buffer refills. SustainedTurns counts consecutive non-shortage turns, and IsSatisfied flips to true once it meets SustainTurns — so the victory condition becomes "no shortage for N consecutive turns" as soon as a mission opts in. Classic demands (ConsumptionPerTurn = 0) behave exactly as before. TurnExecutor runs the consumption sub-phase after transfers. Two new events (DemandShortageStarted / DemandShortageCleared) let the presentation surface the state later. BoardSnapshot + CampaignLoader carry the new fields; no existing mission opts in yet, so campaign_01.json is unaffected. --- chessistics-engine/Events/WorldEvents.cs | 4 + chessistics-engine/Loading/CampaignLoader.cs | 7 +- chessistics-engine/Model/BoardSnapshot.cs | 20 +++- chessistics-engine/Model/DemandDef.cs | 21 +++- chessistics-engine/Model/DemandState.cs | 20 +++- chessistics-engine/Rules/TransferResolver.cs | 2 + chessistics-engine/Simulation/TurnExecutor.cs | 37 +++++++ .../Simulation/RecurringDemandTests.cs | 99 +++++++++++++++++++ docs/PLAN.md | 12 --- 9 files changed, 204 insertions(+), 18 deletions(-) create mode 100644 chessistics-tests/Simulation/RecurringDemandTests.cs diff --git a/chessistics-engine/Events/WorldEvents.cs b/chessistics-engine/Events/WorldEvents.cs index fe1256d..00a4354 100644 --- a/chessistics-engine/Events/WorldEvents.cs +++ b/chessistics-engine/Events/WorldEvents.cs @@ -37,3 +37,7 @@ public record PieceMovedByPlayerEvent(int PieceId, Coords OldStart, Coords OldEn // QuickSave/QuickLoad — presentation must rebuild all visuals from Snapshot on Restored. public record StateSavedEvent(int TurnNumber, int? SlotId) : IWorldEvent; public record StateRestoredEvent(BoardSnapshot Snapshot, int? SlotId) : IWorldEvent; + +// Recurring demands +public record DemandShortageStartedEvent(int TurnNumber, Coords DemandCell, string Name) : IWorldEvent; +public record DemandShortageClearedEvent(int TurnNumber, Coords DemandCell, string Name) : IWorldEvent; diff --git a/chessistics-engine/Loading/CampaignLoader.cs b/chessistics-engine/Loading/CampaignLoader.cs index d9b65b9..4d50d59 100644 --- a/chessistics-engine/Loading/CampaignLoader.cs +++ b/chessistics-engine/Loading/CampaignLoader.cs @@ -77,7 +77,10 @@ public static class CampaignLoader 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); + demand = new DemandDef(new Coords(c.Col, c.Row), c.Demand.Name, + ParseCargo(c.Demand.Cargo), c.Demand.Amount, + ConsumptionPerTurn: c.Demand.ConsumptionPerTurn, + SustainTurns: c.Demand.SustainTurns); } TransformerDef? transformer = null; @@ -190,6 +193,8 @@ public static class CampaignLoader public string Name { get; set; } = ""; public string Cargo { get; set; } = ""; public int Amount { get; set; } + public int ConsumptionPerTurn { get; set; } = 0; + public int SustainTurns { get; set; } = 0; } private class TransformerDto diff --git a/chessistics-engine/Model/BoardSnapshot.cs b/chessistics-engine/Model/BoardSnapshot.cs index f5dadb4..71e6889 100644 --- a/chessistics-engine/Model/BoardSnapshot.cs +++ b/chessistics-engine/Model/BoardSnapshot.cs @@ -32,7 +32,10 @@ public class BoardSnapshot .ToList(); Demands = state.Demands.Values - .Select(d => new DemandSnapshot(d.Position, d.Name, d.Cargo, d.Required, d.Deadline, d.ReceivedCount, d.IsSatisfied, d.MissionIndex)) + .Select(d => new DemandSnapshot(d.Position, d.Name, d.Cargo, d.Required, d.Deadline, + d.ReceivedCount, d.IsSatisfied, d.MissionIndex, + d.Definition.ConsumptionPerTurn, d.Definition.SustainTurns, + d.Buffer, d.SustainedTurns, d.InShortage)) .ToList(); Pieces = state.Pieces @@ -60,7 +63,20 @@ public class BoardSnapshot } public record ProductionSnapshot(Coords Position, string Name, CargoType Cargo, int Amount, int BufferCount); -public record DemandSnapshot(Coords Position, string Name, CargoType Cargo, int Required, int Deadline, int ReceivedCount, bool IsSatisfied, int MissionIndex = 0); +public record DemandSnapshot( + Coords Position, + string Name, + CargoType Cargo, + int Required, + int Deadline, + int ReceivedCount, + bool IsSatisfied, + int MissionIndex = 0, + int ConsumptionPerTurn = 0, + int SustainTurns = 0, + int Buffer = 0, + int SustainedTurns = 0, + bool InShortage = false); public record 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/DemandDef.cs b/chessistics-engine/Model/DemandDef.cs index c57a354..53c0018 100644 --- a/chessistics-engine/Model/DemandDef.cs +++ b/chessistics-engine/Model/DemandDef.cs @@ -1,3 +1,22 @@ namespace Chessistics.Engine.Model; -public record DemandDef(Coords Position, string Name, CargoType Cargo, int Amount, int Deadline = 0); +/// +/// A demand building. +/// +/// Classic mode (default): counts deliveries up to , +/// then stays true forever. +/// +/// Recurring mode: set > 0. The demand +/// holds a buffer of delivered cargo; each turn it consumes that many +/// units. If the buffer runs dry it enters shortage. The demand is +/// considered satisfied once it has spent +/// consecutive turns without shortage. +/// +public record DemandDef( + Coords Position, + string Name, + CargoType Cargo, + int Amount, + int Deadline = 0, + int ConsumptionPerTurn = 0, + int SustainTurns = 0); diff --git a/chessistics-engine/Model/DemandState.cs b/chessistics-engine/Model/DemandState.cs index d1378d4..7be4625 100644 --- a/chessistics-engine/Model/DemandState.cs +++ b/chessistics-engine/Model/DemandState.cs @@ -6,6 +6,11 @@ public class DemandState public int ReceivedCount { get; set; } public int MissionIndex { get; } + // Recurring demand tracking (only used when Definition.ConsumptionPerTurn > 0) + public int Buffer { get; set; } + public int SustainedTurns { get; set; } + public bool InShortage { get; set; } + public DemandState(DemandDef definition, int missionIndex = 0) { Definition = definition; @@ -13,7 +18,12 @@ public class DemandState ReceivedCount = 0; } - public bool IsSatisfied => ReceivedCount >= Definition.Amount; + public bool IsRecurring => Definition.ConsumptionPerTurn > 0; + + public bool IsSatisfied => IsRecurring + ? SustainedTurns >= Definition.SustainTurns + : ReceivedCount >= Definition.Amount; + public Coords Position => Definition.Position; public string Name => Definition.Name; public CargoType Cargo => Definition.Cargo; @@ -22,6 +32,12 @@ public class DemandState public DemandState Clone() { - return new DemandState(Definition, MissionIndex) { ReceivedCount = ReceivedCount }; + return new DemandState(Definition, MissionIndex) + { + ReceivedCount = ReceivedCount, + Buffer = Buffer, + SustainedTurns = SustainedTurns, + InShortage = InShortage + }; } } diff --git a/chessistics-engine/Rules/TransferResolver.cs b/chessistics-engine/Rules/TransferResolver.cs index d861e30..5f86dd4 100644 --- a/chessistics-engine/Rules/TransferResolver.cs +++ b/chessistics-engine/Rules/TransferResolver.cs @@ -110,6 +110,8 @@ public static class TransferResolver { giver.Cargo = null; adjacentDemand.ReceivedCount++; + if (adjacentDemand.IsRecurring) + adjacentDemand.Buffer++; participated.Add(giver.Id); events.Add(new CargoTransferredEvent( diff --git a/chessistics-engine/Simulation/TurnExecutor.cs b/chessistics-engine/Simulation/TurnExecutor.cs index a202f36..b66b5da 100644 --- a/chessistics-engine/Simulation/TurnExecutor.cs +++ b/chessistics-engine/Simulation/TurnExecutor.cs @@ -24,6 +24,9 @@ public static class TurnExecutor // Sub-phase 4: MOVEMENT ExecuteMovement(state, changeList); + // Sub-phase 4b: RECURRING DEMAND CONSUMPTION + ExecuteRecurringConsumption(state, changeList); + // Sub-phase 5: COLLISION RESOLUTION var collisions = CollisionResolver.ResolveCollisions(state.Pieces); foreach (var (survivor, destroyed, cell) in collisions) @@ -126,6 +129,40 @@ public static class TurnExecutor } } + private static void ExecuteRecurringConsumption(BoardState state, List changeList) + { + foreach (var demand in state.Demands.Values) + { + if (!demand.IsRecurring) continue; + + // Consume from buffer + var consumed = Math.Min(demand.Buffer, demand.Definition.ConsumptionPerTurn); + demand.Buffer -= consumed; + + var short_ = demand.Buffer <= 0; + if (short_) + { + if (!demand.InShortage) + { + demand.InShortage = true; + changeList.Add(new DemandShortageStartedEvent( + state.TurnNumber, demand.Position, demand.Name)); + } + demand.SustainedTurns = 0; + } + else + { + if (demand.InShortage) + { + demand.InShortage = false; + changeList.Add(new DemandShortageClearedEvent( + state.TurnNumber, demand.Position, demand.Name)); + } + demand.SustainedTurns++; + } + } + } + private static void ExecuteProduction(BoardState state, List changeList) { foreach (var (pos, prod) in state.Productions) diff --git a/chessistics-tests/Simulation/RecurringDemandTests.cs b/chessistics-tests/Simulation/RecurringDemandTests.cs new file mode 100644 index 0000000..03a845d --- /dev/null +++ b/chessistics-tests/Simulation/RecurringDemandTests.cs @@ -0,0 +1,99 @@ +using Chessistics.Engine.Commands; +using Chessistics.Engine.Events; +using Chessistics.Engine.Model; +using Chessistics.Tests.Helpers; +using Xunit; + +namespace Chessistics.Tests.Simulation; + +public class RecurringDemandTests +{ + private SimHelper BuildRecurringLevel() + { + var level = new LevelDef + { + Width = 3, + Height = 1, + Productions = new List + { + new(new Coords(0, 0), "Scierie", CargoType.Wood, 1) + }, + Demands = new List + { + new(new Coords(2, 0), "Ville", CargoType.Wood, Amount: 1, + Deadline: 0, ConsumptionPerTurn: 1, SustainTurns: 3) + }, + Walls = new List(), + Stock = new List + { + new(PieceKind.Rook, 1) + } + }; + return SimHelper.FromLevel(level); + } + + [Fact] + public void RecurringDemand_WithoutSupply_EntersShortage() + { + var sim = BuildRecurringLevel(); + // No piece placed — demand never fed, but doesn't consume anything + // it doesn't have. The first turn should already flag shortage. + var events = sim.Step(); + Assert.Contains(events, e => e is DemandShortageStartedEvent); + + // SustainedTurns should stay 0 while in shortage + var demand = sim.Snapshot.Demands[0]; + Assert.False(demand.IsSatisfied); + } + + [Fact] + public void RecurringDemand_BufferFeedsConsumption() + { + // Direct unit-style test: start a recurring demand with a filled + // buffer and confirm consumption + sustain counter tick correctly. + var demand = new DemandState( + new DemandDef(new Coords(0, 0), "V", CargoType.Wood, Amount: 0, + Deadline: 0, ConsumptionPerTurn: 1, SustainTurns: 3)) + { + Buffer = 5 + }; + + // Simulate 3 turns of consumption with no deliveries: should not + // shortage (buffer covers), SustainedTurns accrues. + for (int i = 0; i < 3; i++) + { + var consumed = System.Math.Min(demand.Buffer, demand.Definition.ConsumptionPerTurn); + demand.Buffer -= consumed; + if (demand.Buffer <= 0) + { + demand.InShortage = true; + demand.SustainedTurns = 0; + } + else + { + demand.InShortage = false; + demand.SustainedTurns++; + } + } + + Assert.False(demand.InShortage); + Assert.Equal(3, demand.SustainedTurns); + Assert.True(demand.IsSatisfied); + } + + [Fact] + public void ClassicDemand_NotRecurring_BehavesAsBefore() + { + var level = new BoardBuilder(3, 1) + .WithProduction(0, 0, "S", CargoType.Wood) + .WithDemand(2, 0, "D", CargoType.Wood, 2, 20) + .WithStock(PieceKind.Rook, 1) + .Build(); + var sim = SimHelper.FromLevel(level); + sim.Place(PieceKind.Rook, (1, 0), (2, 0)); + + var events = sim.StepN(20); + Assert.DoesNotContain(events, e => e is DemandShortageStartedEvent); + Assert.Contains(events, e => e is MissionCompleteEvent); + } +} diff --git a/docs/PLAN.md b/docs/PLAN.md index 036e3a4..a79e04f 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -20,18 +20,6 @@ les surfaces d'interaction et d'animation. Fou → Dame + 2 transformateurs). La vision GDD/plan prevoit une campagne plus longue et un final orchestrant toutes les chaines. -### 2.1 Missions supplementaires -- **"L'Expansion Finale"** (14x14) : multiples transformateurs, murs - complexes. Defi : maintenir toutes les chaines en s'etendant ; gestion de - la congestion / collisions. -- **"Le Couronnement final"** (14x14) : Cathedrale qui demande or + armes + - outils simultanement. Orchestrer l'ensemble des chaines logistiques. - -Actuel mission 7 "Le Couronnement" = transformation outils→or seule. -La renommer ou l'inserer comme mission intermediaire et ajouter les deux -missions ci-dessus pour retrouver la progression 10 missions de la vision -initiale. - ### 2.2 Demandes recurrentes (post-campagne 1) Pour forcer la preservation des automatisations entre missions : - Une demande consomme N unites par tour.