diff --git a/chessistics-engine/Commands/WorldCommands.cs b/chessistics-engine/Commands/WorldCommands.cs
index b06e823..2f92123 100644
--- a/chessistics-engine/Commands/WorldCommands.cs
+++ b/chessistics-engine/Commands/WorldCommands.cs
@@ -46,6 +46,8 @@ public class PlacePieceCommand : WorldCommand
var piece = new PieceState(
state.NextPieceId++, Kind, Start, End, state.Pieces.Count);
+ piece.CargoFilter = InferCargoFilter(state, piece);
+
state.Pieces.Add(piece);
state.RemainingStock[Kind] = state.RemainingStock[Kind] - 1;
state.OccupiedCells.Add(Start);
@@ -53,6 +55,36 @@ 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
+ foreach (var existing in state.Pieces)
+ {
+ if (existing.CargoFilter == null) continue;
+
+ bool sharesRelay = piece.StartCell == existing.StartCell
+ || piece.StartCell == existing.EndCell
+ || piece.EndCell == existing.StartCell
+ || piece.EndCell == existing.EndCell;
+
+ if (sharesRelay)
+ return existing.CargoFilter;
+ }
+
+ return null;
+ }
}
public class RemovePieceCommand : WorldCommand
diff --git a/chessistics-engine/Model/BoardSnapshot.cs b/chessistics-engine/Model/BoardSnapshot.cs
index c8c989d..5038340 100644
--- a/chessistics-engine/Model/BoardSnapshot.cs
+++ b/chessistics-engine/Model/BoardSnapshot.cs
@@ -32,7 +32,7 @@ public class BoardSnapshot
.ToList();
Pieces = state.Pieces
- .Select(p => new PieceSnapshot(p.Id, p.Kind, p.StartCell, p.EndCell, p.CurrentCell, p.Cargo, p.SocialStatus))
+ .Select(p => new PieceSnapshot(p.Id, p.Kind, p.StartCell, p.EndCell, p.CurrentCell, p.Cargo, p.CargoFilter, p.SocialStatus))
.ToList();
RemainingStock = new Dictionary(state.RemainingStock);
@@ -41,4 +41,4 @@ public class BoardSnapshot
public record ProductionSnapshot(Coords Position, string Name, CargoType Cargo, int Interval, CargoType? Buffer);
public record DemandSnapshot(Coords Position, string Name, CargoType Cargo, int Required, int Deadline, int ReceivedCount, bool IsSatisfied);
-public record PieceSnapshot(int Id, PieceKind Kind, Coords StartCell, Coords EndCell, Coords CurrentCell, CargoType? Cargo, int SocialStatus);
+public record PieceSnapshot(int Id, PieceKind Kind, Coords StartCell, Coords EndCell, Coords CurrentCell, CargoType? Cargo, CargoType? CargoFilter, int SocialStatus);
diff --git a/chessistics-engine/Model/PieceState.cs b/chessistics-engine/Model/PieceState.cs
index a2f9750..a0f08ef 100644
--- a/chessistics-engine/Model/PieceState.cs
+++ b/chessistics-engine/Model/PieceState.cs
@@ -8,6 +8,7 @@ public class PieceState
public Coords EndCell { get; }
public Coords CurrentCell { get; set; }
public CargoType? Cargo { get; set; }
+ public CargoType? CargoFilter { get; set; }
public int SocialStatus { get; }
public int PlacementOrder { get; }
diff --git a/chessistics-engine/Rules/TransferResolver.cs b/chessistics-engine/Rules/TransferResolver.cs
index 50fc597..5631154 100644
--- a/chessistics-engine/Rules/TransferResolver.cs
+++ b/chessistics-engine/Rules/TransferResolver.cs
@@ -34,8 +34,9 @@ public static class TransferResolver
{
var cargoType = state.ProductionBuffers[prod.Position]!.Value;
- // Find adjacent pieces without cargo, sorted by receiver priority
- var receivers = GetAdjacentPiecesWithoutCargo(state, prod.Position, participated);
+ // Find adjacent pieces without cargo that accept this cargo type
+ var receivers = GetAdjacentPiecesWithoutCargo(state, prod.Position, participated,
+ cargoType: cargoType);
if (receivers.Count == 0) continue;
@@ -58,7 +59,7 @@ public static class TransferResolver
var givers = state.Pieces
.Where(p => p.Cargo != null && !participated.Contains(p.Id))
.OrderByDescending(p => p.SocialStatus)
- .ThenBy(p => MinDistanceToProduction(p.CurrentCell, state))
+ .ThenBy(p => MinDistanceToProduction(p.CurrentCell, state, p.Cargo))
.ThenBy(p => p.PlacementOrder)
.ToList();
@@ -90,7 +91,7 @@ public static class TransferResolver
// Priority 2: transfer to adjacent piece without cargo
// Prefer receivers farther from production (push cargo forward in chain)
var receivers = GetAdjacentPiecesWithoutCargo(state, giver.CurrentCell, participated,
- forwardDirection: true);
+ forwardDirection: true, cargoType: cargoType);
if (receivers.Count == 0) continue;
var receiver = receivers[0];
@@ -107,22 +108,23 @@ public static class TransferResolver
private static List GetAdjacentPiecesWithoutCargo(
BoardState state, Coords position, HashSet participated,
- bool forwardDirection = false)
+ bool forwardDirection = false, CargoType? cargoType = null)
{
var adjacent = position.GetAdjacent4(state.Width, state.Height);
var query = state.Pieces
.Where(p => p.Cargo == null
&& !participated.Contains(p.Id)
- && adjacent.Contains(p.CurrentCell))
+ && adjacent.Contains(p.CurrentCell)
+ && (p.CargoFilter == null || cargoType == null || p.CargoFilter == cargoType))
.OrderByDescending(p => p.SocialStatus);
// For piece-to-piece transfers, prefer receivers farther from production
// (pushes cargo forward through relay chains instead of backward).
// For production pickups, prefer receivers closer to production.
var sorted = forwardDirection
- ? query.ThenByDescending(p => MinDistanceToProduction(p.CurrentCell, state))
- : query.ThenBy(p => MinDistanceToProduction(p.CurrentCell, state));
+ ? query.ThenByDescending(p => MinDistanceToProduction(p.CurrentCell, state, cargoType))
+ : query.ThenBy(p => MinDistanceToProduction(p.CurrentCell, state, cargoType));
return sorted.ThenBy(p => p.PlacementOrder).ToList();
}
@@ -139,9 +141,14 @@ public static class TransferResolver
.FirstOrDefault();
}
- private static int MinDistanceToProduction(Coords cell, BoardState state)
+ private static int MinDistanceToProduction(Coords cell, BoardState state, CargoType? cargoType = null)
{
- if (state.Productions.Count == 0) return int.MaxValue;
- return state.Productions.Keys.Min(p => cell.ManhattanDistance(p));
+ var productions = cargoType != null
+ ? state.Productions.Where(kv => kv.Value.Cargo == cargoType).Select(kv => kv.Key)
+ : state.Productions.Keys;
+
+ var prodList = productions.ToList();
+ if (prodList.Count == 0) return int.MaxValue;
+ return prodList.Min(p => cell.ManhattanDistance(p));
}
}
diff --git a/chessistics-tests/Simulation/FullLevelTests.cs b/chessistics-tests/Simulation/FullLevelTests.cs
index ef862d3..3801cd4 100644
--- a/chessistics-tests/Simulation/FullLevelTests.cs
+++ b/chessistics-tests/Simulation/FullLevelTests.cs
@@ -71,26 +71,29 @@ public class FullLevelTests
[Fact]
public void Level3_LeCol_Victory()
{
- // GDD Level 3: 6x6, L-shaped wall, knight required to jump obstacle
- // Stock: 6 Rooks + 1 Knight (fixed from GDD's 4R+1B+2K)
+ // 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)
//
- // NOTE: Original GDD had 2 cargo types crossing the board, but the
- // transfer system has no cargo-type filtering — adjacent pieces from
- // different routes contaminate each other on a 6x6 board. Simplified
- // to single cargo type. Dual-cargo cross-board routing is Phase 3.
+ // CargoFilter (Phase 2) prevents cross-route contamination:
+ // pieces auto-inherit their production's cargo type via relay chain.
//
- // Route: prod(0,0) → 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) → demand(5,5)
- // Total: 5 Rooks + 1 Knight
+ // 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, 2)
- .WithDemand(5, 5, "Depot Royal", CargoType.Wood, 3, 60)
+ .WithProduction(5, 0, "Carriere", CargoType.Stone, 2)
+ .WithDemand(5, 5, "Depot Royal", CargoType.Wood, 2, 60)
+ .WithDemand(0, 5, "Forge", CargoType.Stone, 2, 60)
.WithWall(2, 2).WithWall(2, 3).WithWall(2, 4).WithWall(3, 4).WithWall(4, 4)
- .WithStock(PieceKind.Rook, 6)
- .WithStock(PieceKind.Knight, 1)
+ .WithStock(PieceKind.Rook, 10)
+ .WithStock(PieceKind.Knight, 2)
.Build();
var sim = SimHelper.FromLevel(level);
+ // Route Wood: prod(0,0) → demand(5,5)
sim.Place(PieceKind.Rook, (0, 1), (1, 1));
sim.Place(PieceKind.Knight, (1, 1), (3, 2));
sim.Place(PieceKind.Rook, (3, 2), (4, 2));
@@ -98,8 +101,16 @@ public class FullLevelTests
sim.Place(PieceKind.Rook, (5, 2), (5, 3));
sim.Place(PieceKind.Rook, (5, 3), (5, 4));
+ // Route Stone: prod(5,0) → demand(0,5)
+ sim.Place(PieceKind.Rook, (4, 0), (3, 0));
+ sim.Place(PieceKind.Rook, (3, 0), (2, 0));
+ sim.Place(PieceKind.Knight, (2, 0), (1, 2));
+ sim.Place(PieceKind.Rook, (1, 2), (1, 3));
+ sim.Place(PieceKind.Rook, (1, 3), (1, 4));
+ sim.Place(PieceKind.Rook, (1, 4), (0, 4));
+
sim.Start();
- var allEvents = sim.StepN(60);
+ var allEvents = sim.StepN(80);
Assert.Contains(allEvents, e => e is VictoryEvent);
}
diff --git a/chessistics-tests/Simulation/SolvabilityTests.cs b/chessistics-tests/Simulation/SolvabilityTests.cs
index 69c9de7..eed1ea0 100644
--- a/chessistics-tests/Simulation/SolvabilityTests.cs
+++ b/chessistics-tests/Simulation/SolvabilityTests.cs
@@ -223,6 +223,60 @@ public class SolvabilityTests
Assert.DoesNotContain(allEvents, e => e is CollisionDetectedEvent);
}
+ [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, 1)
+ .WithProduction(3, 0, "Carriere", CargoType.Stone, 1)
+ .WithDemand(2, 0, "D_Wood", CargoType.Wood, 2, 20)
+ .WithStock(PieceKind.Rook, 1)
+ .Build();
+ var sim = SimHelper.FromLevel(level);
+
+ 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);
+ }
+
+ [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, 1)
+ .WithDemand(4, 0, "Depot", CargoType.Wood, 2, 40)
+ .WithStock(PieceKind.Rook, 3)
+ .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
+
+ var snapshot = sim.Snapshot;
+ Assert.Equal(CargoType.Wood, snapshot.Pieces[0].CargoFilter);
+ Assert.Equal(CargoType.Wood, snapshot.Pieces[1].CargoFilter);
+ Assert.Equal(CargoType.Wood, snapshot.Pieces[2].CargoFilter);
+ }
+
[Fact]
public void StepFromEdit_AutoStartsSimulation()
{