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(
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));
}
/// <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

View file

@ -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<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 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 CurrentCell { get; set; }
public CargoType? Cargo { get; set; }
public CargoType? CargoFilter { get; set; }
public int SocialStatus { get; }
public int PlacementOrder { get; }

View file

@ -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<PieceState> GetAdjacentPiecesWithoutCargo(
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 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));
}
}

View file

@ -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);
}

View file

@ -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<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]
public void StepFromEdit_AutoStartsSimulation()
{