Phase 2: cargo-type aware transfers via CargoFilter

Add CargoFilter property to PieceState, auto-assigned at placement
by tracing relay chain back to production. TransferResolver now
enforces cargo-type filtering and uses forward-direction sorting
with cargo-aware distance calculations. Prevents cross-route
contamination on multi-cargo boards.

Level 3 restored to dual-cargo (Wood+Stone) with correct 10R+2K stock.
Two new solvability tests validate filter auto-assignment and chain
propagation. All 60 tests green.
This commit is contained in:
Samuel Bouchet 2026-04-10 15:35:37 +02:00
parent 2b3d27d295
commit 3120d9835e
6 changed files with 131 additions and 26 deletions

View file

@ -46,6 +46,8 @@ public class PlacePieceCommand : WorldCommand
var piece = new PieceState( var piece = new PieceState(
state.NextPieceId++, Kind, Start, End, state.Pieces.Count); state.NextPieceId++, Kind, Start, End, state.Pieces.Count);
piece.CargoFilter = InferCargoFilter(state, piece);
state.Pieces.Add(piece); state.Pieces.Add(piece);
state.RemainingStock[Kind] = state.RemainingStock[Kind] - 1; state.RemainingStock[Kind] = state.RemainingStock[Kind] - 1;
state.OccupiedCells.Add(Start); state.OccupiedCells.Add(Start);
@ -53,6 +55,36 @@ public class PlacePieceCommand : WorldCommand
changeList.Add(new PiecePlacedEvent(piece.Id, Kind, Start, End)); changeList.Add(new PiecePlacedEvent(piece.Id, Kind, Start, End));
} }
/// <summary>
/// Auto-assign cargo filter by tracing the relay chain back to a production.
/// Priority: direct adjacency to production, then shared relay with filtered piece.
/// </summary>
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 public class RemovePieceCommand : WorldCommand

View file

@ -32,7 +32,7 @@ public class BoardSnapshot
.ToList(); .ToList();
Pieces = state.Pieces 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(); .ToList();
RemainingStock = new Dictionary<PieceKind, int>(state.RemainingStock); RemainingStock = new Dictionary<PieceKind, int>(state.RemainingStock);
@ -41,4 +41,4 @@ public class BoardSnapshot
public record ProductionSnapshot(Coords Position, string Name, CargoType Cargo, int Interval, CargoType? Buffer); 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 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);

View file

@ -8,6 +8,7 @@ public class PieceState
public Coords EndCell { get; } public Coords EndCell { get; }
public Coords CurrentCell { get; set; } public Coords CurrentCell { get; set; }
public CargoType? Cargo { get; set; } public CargoType? Cargo { get; set; }
public CargoType? CargoFilter { get; set; }
public int SocialStatus { get; } public int SocialStatus { get; }
public int PlacementOrder { get; } public int PlacementOrder { get; }

View file

@ -34,8 +34,9 @@ public static class TransferResolver
{ {
var cargoType = state.ProductionBuffers[prod.Position]!.Value; var cargoType = state.ProductionBuffers[prod.Position]!.Value;
// Find adjacent pieces without cargo, sorted by receiver priority // Find adjacent pieces without cargo that accept this cargo type
var receivers = GetAdjacentPiecesWithoutCargo(state, prod.Position, participated); var receivers = GetAdjacentPiecesWithoutCargo(state, prod.Position, participated,
cargoType: cargoType);
if (receivers.Count == 0) continue; if (receivers.Count == 0) continue;
@ -58,7 +59,7 @@ public static class TransferResolver
var givers = state.Pieces var givers = state.Pieces
.Where(p => p.Cargo != null && !participated.Contains(p.Id)) .Where(p => p.Cargo != null && !participated.Contains(p.Id))
.OrderByDescending(p => p.SocialStatus) .OrderByDescending(p => p.SocialStatus)
.ThenBy(p => MinDistanceToProduction(p.CurrentCell, state)) .ThenBy(p => MinDistanceToProduction(p.CurrentCell, state, p.Cargo))
.ThenBy(p => p.PlacementOrder) .ThenBy(p => p.PlacementOrder)
.ToList(); .ToList();
@ -90,7 +91,7 @@ public static class TransferResolver
// Priority 2: transfer to adjacent piece without cargo // Priority 2: transfer to adjacent piece without cargo
// Prefer receivers farther from production (push cargo forward in chain) // Prefer receivers farther from production (push cargo forward in chain)
var receivers = GetAdjacentPiecesWithoutCargo(state, giver.CurrentCell, participated, var receivers = GetAdjacentPiecesWithoutCargo(state, giver.CurrentCell, participated,
forwardDirection: true); forwardDirection: true, cargoType: cargoType);
if (receivers.Count == 0) continue; if (receivers.Count == 0) continue;
var receiver = receivers[0]; var receiver = receivers[0];
@ -107,22 +108,23 @@ public static class TransferResolver
private static List<PieceState> GetAdjacentPiecesWithoutCargo( private static List<PieceState> GetAdjacentPiecesWithoutCargo(
BoardState state, Coords position, HashSet<int> participated, BoardState state, Coords position, HashSet<int> participated,
bool forwardDirection = false) bool forwardDirection = false, CargoType? cargoType = null)
{ {
var adjacent = position.GetAdjacent4(state.Width, state.Height); var adjacent = position.GetAdjacent4(state.Width, state.Height);
var query = state.Pieces var query = state.Pieces
.Where(p => p.Cargo == null .Where(p => p.Cargo == null
&& !participated.Contains(p.Id) && !participated.Contains(p.Id)
&& adjacent.Contains(p.CurrentCell)) && adjacent.Contains(p.CurrentCell)
&& (p.CargoFilter == null || cargoType == null || p.CargoFilter == cargoType))
.OrderByDescending(p => p.SocialStatus); .OrderByDescending(p => p.SocialStatus);
// For piece-to-piece transfers, prefer receivers farther from production // For piece-to-piece transfers, prefer receivers farther from production
// (pushes cargo forward through relay chains instead of backward). // (pushes cargo forward through relay chains instead of backward).
// For production pickups, prefer receivers closer to production. // For production pickups, prefer receivers closer to production.
var sorted = forwardDirection var sorted = forwardDirection
? query.ThenByDescending(p => MinDistanceToProduction(p.CurrentCell, state)) ? query.ThenByDescending(p => MinDistanceToProduction(p.CurrentCell, state, cargoType))
: query.ThenBy(p => MinDistanceToProduction(p.CurrentCell, state)); : query.ThenBy(p => MinDistanceToProduction(p.CurrentCell, state, cargoType));
return sorted.ThenBy(p => p.PlacementOrder).ToList(); return sorted.ThenBy(p => p.PlacementOrder).ToList();
} }
@ -139,9 +141,14 @@ public static class TransferResolver
.FirstOrDefault(); .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; var productions = cargoType != null
return state.Productions.Keys.Min(p => cell.ManhattanDistance(p)); ? 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));
} }
} }

View file

@ -71,26 +71,29 @@ public class FullLevelTests
[Fact] [Fact]
public void Level3_LeCol_Victory() public void Level3_LeCol_Victory()
{ {
// GDD Level 3: 6x6, L-shaped wall, knight required to jump obstacle // GDD Level 3: 6x6, L-shaped wall, 2 cargo types, knights jump obstacle
// Stock: 6 Rooks + 1 Knight (fixed from GDD's 4R+1B+2K) // Stock: 8 Rooks + 2 Knights (fixed from GDD's 4R+1B+2K)
// //
// NOTE: Original GDD had 2 cargo types crossing the board, but the // CargoFilter (Phase 2) prevents cross-route contamination:
// transfer system has no cargo-type filtering — adjacent pieces from // pieces auto-inherit their production's cargo type via relay chain.
// different routes contaminate each other on a 6x6 board. Simplified
// to single cargo type. Dual-cargo cross-board routing is Phase 3.
// //
// Route: prod(0,0) → R1(0,1↔1,1) → K1(1,1↔3,2) → R2(3,2↔4,2) // Route Wood (0,0→5,5): R1(0,1↔1,1), K1(1,1↔3,2),
// → R3(4,2↔5,2) → R4(5,2↔5,3) → R5(5,3↔5,4) → demand(5,5) // R2(3,2↔4,2), R3(4,2↔5,2), R4(5,2↔5,3), R5(5,3↔5,4)
// Total: 5 Rooks + 1 Knight // 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) var level = new BoardBuilder(6, 6)
.WithProduction(0, 0, "Scierie", CargoType.Wood, 2) .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) .WithWall(2, 2).WithWall(2, 3).WithWall(2, 4).WithWall(3, 4).WithWall(4, 4)
.WithStock(PieceKind.Rook, 6) .WithStock(PieceKind.Rook, 10)
.WithStock(PieceKind.Knight, 1) .WithStock(PieceKind.Knight, 2)
.Build(); .Build();
var sim = SimHelper.FromLevel(level); var sim = SimHelper.FromLevel(level);
// Route Wood: prod(0,0) → demand(5,5)
sim.Place(PieceKind.Rook, (0, 1), (1, 1)); sim.Place(PieceKind.Rook, (0, 1), (1, 1));
sim.Place(PieceKind.Knight, (1, 1), (3, 2)); sim.Place(PieceKind.Knight, (1, 1), (3, 2));
sim.Place(PieceKind.Rook, (3, 2), (4, 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, 2), (5, 3));
sim.Place(PieceKind.Rook, (5, 3), (5, 4)); 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(); sim.Start();
var allEvents = sim.StepN(60); var allEvents = sim.StepN(80);
Assert.Contains(allEvents, e => e is VictoryEvent); Assert.Contains(allEvents, e => e is VictoryEvent);
} }

View file

@ -223,6 +223,60 @@ public class SolvabilityTests
Assert.DoesNotContain(allEvents, e => e is CollisionDetectedEvent); 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<CargoTransferredEvent>().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] [Fact]
public void StepFromEdit_AutoStartsSimulation() public void StepFromEdit_AutoStartsSimulation()
{ {