From 3120d9835e0d0d8c2207740298e89e1bfa6063d9 Mon Sep 17 00:00:00 2001 From: Samuel Bouchet Date: Fri, 10 Apr 2026 15:35:37 +0200 Subject: [PATCH] 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. --- chessistics-engine/Commands/WorldCommands.cs | 32 +++++++++++ chessistics-engine/Model/BoardSnapshot.cs | 4 +- chessistics-engine/Model/PieceState.cs | 1 + chessistics-engine/Rules/TransferResolver.cs | 29 ++++++---- .../Simulation/FullLevelTests.cs | 37 ++++++++----- .../Simulation/SolvabilityTests.cs | 54 +++++++++++++++++++ 6 files changed, 131 insertions(+), 26 deletions(-) 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() {