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:
parent
e3eb10570b
commit
8a377c2e41
9 changed files with 204 additions and 18 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<int> CompletedMissions, IReadOnlySet<PieceKind> AvailablePieceKinds, IReadOnlySet<PieceUpgrade> AvailableLevels);
|
||||
|
|
|
|||
|
|
@ -1,3 +1,22 @@
|
|||
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"/> > 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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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<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)
|
||||
{
|
||||
foreach (var (pos, prod) in state.Productions)
|
||||
|
|
|
|||
99
chessistics-tests/Simulation/RecurringDemandTests.cs
Normal file
99
chessistics-tests/Simulation/RecurringDemandTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
12
docs/PLAN.md
12
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.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue