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:
parent
2b3d27d295
commit
3120d9835e
6 changed files with 131 additions and 26 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue