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(
|
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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue