Add recurring-demand mode with shortage tracking

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.
This commit is contained in:
Samuel Bouchet 2026-04-17 22:39:28 +02:00
parent e3eb10570b
commit 8a377c2e41
9 changed files with 204 additions and 18 deletions

View file

@ -37,3 +37,7 @@ public record PieceMovedByPlayerEvent(int PieceId, Coords OldStart, Coords OldEn
// QuickSave/QuickLoad — presentation must rebuild all visuals from Snapshot on Restored. // QuickSave/QuickLoad — presentation must rebuild all visuals from Snapshot on Restored.
public record StateSavedEvent(int TurnNumber, int? SlotId) : IWorldEvent; public record StateSavedEvent(int TurnNumber, int? SlotId) : IWorldEvent;
public record StateRestoredEvent(BoardSnapshot Snapshot, 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;

View file

@ -77,7 +77,10 @@ public static class CampaignLoader
DemandDef? demand = null; DemandDef? demand = null;
if (cellType == CellType.Demand && c.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; TransformerDef? transformer = null;
@ -190,6 +193,8 @@ public static class CampaignLoader
public string Name { get; set; } = ""; public string Name { get; set; } = "";
public string Cargo { get; set; } = ""; public string Cargo { get; set; } = "";
public int Amount { get; set; } public int Amount { get; set; }
public int ConsumptionPerTurn { get; set; } = 0;
public int SustainTurns { get; set; } = 0;
} }
private class TransformerDto private class TransformerDto

View file

@ -32,7 +32,10 @@ public class BoardSnapshot
.ToList(); .ToList();
Demands = state.Demands.Values Demands = state.Demands.Values
.Select(d => new DemandSnapshot(d.Position, d.Name, d.Cargo, d.Required, d.Deadline, d.ReceivedCount, d.IsSatisfied, 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(); .ToList();
Pieces = state.Pieces 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 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 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 TransformerSnapshot(Coords Position, string Name, CargoType InputCargo, int InputRequired, CargoType OutputCargo, int OutputAmount, int InputBufferCount, int OutputBufferCount);
public record CampaignSnapshot(string Name, int CurrentMissionIndex, IReadOnlyList<int> CompletedMissions, IReadOnlySet<PieceKind> AvailablePieceKinds, IReadOnlySet<PieceUpgrade> AvailableLevels); public record CampaignSnapshot(string Name, int CurrentMissionIndex, IReadOnlyList<int> CompletedMissions, IReadOnlySet<PieceKind> AvailablePieceKinds, IReadOnlySet<PieceUpgrade> AvailableLevels);

View file

@ -1,3 +1,22 @@
namespace Chessistics.Engine.Model; namespace Chessistics.Engine.Model;
public record DemandDef(Coords Position, string Name, CargoType Cargo, int Amount, int Deadline = 0); /// <summary>
/// A demand building.
///
/// Classic mode (default): counts deliveries up to <see cref="Amount"/>,
/// then <see cref="DemandState.IsSatisfied"/> stays true forever.
///
/// Recurring mode: set <see cref="ConsumptionPerTurn"/> &gt; 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 <see cref="SustainTurns"/>
/// consecutive turns without shortage.
/// </summary>
public record DemandDef(
Coords Position,
string Name,
CargoType Cargo,
int Amount,
int Deadline = 0,
int ConsumptionPerTurn = 0,
int SustainTurns = 0);

View file

@ -6,6 +6,11 @@ public class DemandState
public int ReceivedCount { get; set; } public int ReceivedCount { get; set; }
public int MissionIndex { get; } 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) public DemandState(DemandDef definition, int missionIndex = 0)
{ {
Definition = definition; Definition = definition;
@ -13,7 +18,12 @@ public class DemandState
ReceivedCount = 0; 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 Coords Position => Definition.Position;
public string Name => Definition.Name; public string Name => Definition.Name;
public CargoType Cargo => Definition.Cargo; public CargoType Cargo => Definition.Cargo;
@ -22,6 +32,12 @@ public class DemandState
public DemandState Clone() public DemandState Clone()
{ {
return new DemandState(Definition, MissionIndex) { ReceivedCount = ReceivedCount }; return new DemandState(Definition, MissionIndex)
{
ReceivedCount = ReceivedCount,
Buffer = Buffer,
SustainedTurns = SustainedTurns,
InShortage = InShortage
};
} }
} }

View file

@ -110,6 +110,8 @@ public static class TransferResolver
{ {
giver.Cargo = null; giver.Cargo = null;
adjacentDemand.ReceivedCount++; adjacentDemand.ReceivedCount++;
if (adjacentDemand.IsRecurring)
adjacentDemand.Buffer++;
participated.Add(giver.Id); participated.Add(giver.Id);
events.Add(new CargoTransferredEvent( events.Add(new CargoTransferredEvent(

View file

@ -24,6 +24,9 @@ public static class TurnExecutor
// Sub-phase 4: MOVEMENT // Sub-phase 4: MOVEMENT
ExecuteMovement(state, changeList); ExecuteMovement(state, changeList);
// Sub-phase 4b: RECURRING DEMAND CONSUMPTION
ExecuteRecurringConsumption(state, changeList);
// Sub-phase 5: COLLISION RESOLUTION // Sub-phase 5: COLLISION RESOLUTION
var collisions = CollisionResolver.ResolveCollisions(state.Pieces); var collisions = CollisionResolver.ResolveCollisions(state.Pieces);
foreach (var (survivor, destroyed, cell) in collisions) foreach (var (survivor, destroyed, cell) in collisions)
@ -126,6 +129,40 @@ public static class TurnExecutor
} }
} }
private static void ExecuteRecurringConsumption(BoardState state, List<IWorldEvent> 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<IWorldEvent> changeList) private static void ExecuteProduction(BoardState state, List<IWorldEvent> changeList)
{ {
foreach (var (pos, prod) in state.Productions) foreach (var (pos, prod) in state.Productions)

View file

@ -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<ProductionDef>
{
new(new Coords(0, 0), "Scierie", CargoType.Wood, 1)
},
Demands = new List<DemandDef>
{
new(new Coords(2, 0), "Ville", CargoType.Wood, Amount: 1,
Deadline: 0, ConsumptionPerTurn: 1, SustainTurns: 3)
},
Walls = new List<Coords>(),
Stock = new List<PieceStock>
{
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);
}
}

View file

@ -20,18 +20,6 @@ les surfaces d'interaction et d'animation.
Fou → Dame + 2 transformateurs). La vision GDD/plan prevoit une campagne Fou → Dame + 2 transformateurs). La vision GDD/plan prevoit une campagne
plus longue et un final orchestrant toutes les chaines. 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) ### 2.2 Demandes recurrentes (post-campagne 1)
Pour forcer la preservation des automatisations entre missions : Pour forcer la preservation des automatisations entre missions :
- Une demande consomme N unites par tour. - Une demande consomme N unites par tour.