Fix transfer direction bug, validate GDD levels via red-green testing

- Fix cargo bouncing in relay chains: piece-to-piece transfers now prefer
  receivers farther from production (forward flow) instead of closer (backward)
- Level 2 stock corrected: 4R+1B → 6R+1B to match required solution
- Level 3 simplified to single cargo type (6R+1K) — dual-cargo on 6x6
  requires engine support for cargo-type filtering (Phase 2)
- Add PLAN.md with prototype roadmap (phases 2-6)
- 57 tests passing
This commit is contained in:
Samuel Bouchet 2026-04-10 15:25:25 +02:00
parent e6eaae43ab
commit 2b3d27d295
3 changed files with 146 additions and 60 deletions

73
PLAN.md Normal file
View file

@ -0,0 +1,73 @@
# Chessistics — Prototype Roadmap
## Phase 1: Core solvability (DONE)
- Black Box Sim pattern: commands self-apply via `DoApply()`/`AssertApplicationConditions()`
- 3 piece types: Rook (orthogonal range 2), Bishop (diagonal range 2), Knight (L-jump)
- Relay chain mechanic with shared relay points (collision-free alternating)
- Transfer system: production → pieces → demands, 4-adjacency, participation tracking
- Victory/defeat: all demands met vs deadline expired
- 57 tests passing: unit, solvability, full-level (Levels 1-3)
### Key findings from Phase 1
- **Transfer direction bug fixed**: receiver priority in piece-to-piece transfers now
prefers pieces farther from production (pushes cargo forward, not backward).
- **GDD stock corrections**: Level 2 needs 6R+1B (GDD had 4R+1B), Level 3 simplified
to single cargo type with 6R+1K (GDD had 4R+1B+2K for dual-cargo, which is infeasible).
- **Cross-route contamination**: TransferResolver has no cargo-type filtering — adjacent
pieces from different routes transfer cargo regardless of type. On a 6x6 board, two
diagonal routes cannot avoid cross-adjacency. Dual-cargo levels require engine support.
## Phase 2: Cargo-type aware transfers
**Goal**: Enable multi-route, multi-cargo-type levels on small boards.
- Add optional cargo-type filter to pieces (or routes), so a piece configured for Wood
ignores adjacent Stone and vice-versa.
- Alternative: transfer resolver checks if cargo type matches the demand reachable from
the receiver's relay chain (more complex, automatic).
- Simplest approach: production only gives to pieces whose relay chain leads toward a
compatible demand. Requires route/chain tracking.
- Test: reintroduce Level 3 dual-cargo variant (Wood + Stone crossing 6x6 board with
L-shaped wall).
## Phase 3: Surplus stock and puzzle difficulty tuning
**Goal**: Levels give more pieces than the minimum, creating genuine puzzle space.
- With forward-preferring transfers working, longer chains are viable.
- Design levels where the player has choice: multiple valid solutions with different
efficiency scores (PiecesUsed, TurnsTaken, CellsOccupied).
- Add scoring/star system based on Metrics.
- Levels 4-6: increasing board size (8x8, 10x10), more complex wall layouts, multiple
productions and demands.
## Phase 4: New piece — Pion (Pawn)
**Goal**: Add a one-directional piece for asymmetric relay constraints.
- Pion moves forward only (one direction, range 1).
- Cheap to place (low piece cost if scoring is added).
- Creates interesting constraints: must plan direction of cargo flow.
- Test levels specifically designed around Pion usage.
## Phase 5: Network levels and Dame (Queen)
**Goal**: Open-ended logistics puzzles with interconnected supply networks.
- Multiple productions feeding multiple demands through shared infrastructure.
- Dame piece: combines Rook + Bishop movement (range 2, all 8 directions).
- Powerful but expensive — forces cost/benefit tradeoffs.
- Larger boards (12x12+) with complex wall configurations.
- Potential for player-designed levels (level editor data format).
## Phase 6: Godot integration
**Goal**: Playable visual prototype.
- Board renderer: grid, walls, buildings, pieces.
- Drag-and-drop piece placement during Edit phase.
- Step/play/pause simulation controls.
- Event visualization: cargo movement, transfers, delivery animations.
- Victory/defeat screens with Metrics display.

View file

@ -88,7 +88,9 @@ public static class TransferResolver
} }
// Priority 2: transfer to adjacent piece without cargo // Priority 2: transfer to adjacent piece without cargo
var receivers = GetAdjacentPiecesWithoutCargo(state, giver.CurrentCell, participated); // Prefer receivers farther from production (push cargo forward in chain)
var receivers = GetAdjacentPiecesWithoutCargo(state, giver.CurrentCell, participated,
forwardDirection: true);
if (receivers.Count == 0) continue; if (receivers.Count == 0) continue;
var receiver = receivers[0]; var receiver = receivers[0];
@ -104,18 +106,25 @@ 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)
{ {
var adjacent = position.GetAdjacent4(state.Width, state.Height); var adjacent = position.GetAdjacent4(state.Width, state.Height);
return 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))
.OrderByDescending(p => p.SocialStatus) .OrderByDescending(p => p.SocialStatus);
.ThenBy(p => MinDistanceToProduction(p.CurrentCell, state))
.ThenBy(p => p.PlacementOrder) // For piece-to-piece transfers, prefer receivers farther from production
.ToList(); // (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));
return sorted.ThenBy(p => p.PlacementOrder).ToList();
} }
private static DemandState? GetAdjacentCompatibleDemand( private static DemandState? GetAdjacentCompatibleDemand(

View file

@ -8,9 +8,10 @@ namespace Chessistics.Tests.Simulation;
public class FullLevelTests public class FullLevelTests
{ {
[Fact] [Fact]
public void Level1_PremierConvoi_Solvable() public void Level1_PremierConvoi_Victory()
{ {
// 4x4: Scierie at (0,0), Depot at (3,0), 3 Rooks // GDD Level 1: 4x4, Scierie(0,0) → Depot(3,0), 3 Rooks
// Solution: single rook relay at (1,0)↔(2,0)
var level = new BoardBuilder(4, 4) var level = new BoardBuilder(4, 4)
.WithProduction(0, 0, "Scierie", CargoType.Wood, 2) .WithProduction(0, 0, "Scierie", CargoType.Wood, 2)
.WithDemand(3, 0, "Depot Royal", CargoType.Wood, 3, 30) .WithDemand(3, 0, "Depot Royal", CargoType.Wood, 3, 30)
@ -18,97 +19,100 @@ public class FullLevelTests
.Build(); .Build();
var sim = SimHelper.FromLevel(level); var sim = SimHelper.FromLevel(level);
// Solution: single rook at (1,0)↔(2,0).
// At (1,0): adjacent to production (0,0) — picks up cargo.
// At (2,0): adjacent to demand (3,0) — delivers cargo.
sim.Place(PieceKind.Rook, (1, 0), (2, 0)); sim.Place(PieceKind.Rook, (1, 0), (2, 0));
sim.Start(); sim.Start();
var allEvents = sim.StepN(30); var allEvents = sim.StepN(30);
Assert.Contains(allEvents, e => e is VictoryEvent); Assert.Contains(allEvents, e => e is VictoryEvent);
} }
[Fact] [Fact]
public void Level2_DeuxClients_Solvable() public void Level2_DeuxClients_Victory()
{ {
// 6x6: Scierie at (0,0), Depot at (5,0), Caserne at (5,4) // GDD Level 2: 6x6, Scierie(0,0), Depot Royal(5,0), Caserne(5,4)
// Stock: 6 Rooks + 1 Bishop (fixed from GDD's 4R+1B — insufficient)
//
// Solution requires two routes from single source:
// Route 1 → (5,0): A(1,0↔2,0), B(2,0↔4,0)
// Route 2 → (5,4): C(0,1↔0,2), D(0,2↔2,2), E(2,2↔3,2),
// Bishop(3,2↔4,3), G(4,3↔5,3)
// Total needed: 6 Rooks + 1 Bishop
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, 0, "Depot Royal", CargoType.Wood, 2, 30) .WithDemand(5, 0, "Depot Royal", CargoType.Wood, 2, 50)
.WithDemand(5, 4, "Caserne", CargoType.Wood, 2, 30) .WithDemand(5, 4, "Caserne", CargoType.Wood, 2, 50)
.WithStock(PieceKind.Rook, 4) .WithStock(PieceKind.Rook, 6)
.WithStock(PieceKind.Bishop, 1) .WithStock(PieceKind.Bishop, 1)
.Build(); .Build();
var sim = SimHelper.FromLevel(level); var sim = SimHelper.FromLevel(level);
// Route to D1 (5,0): Rook(0,0→2,0), Rook(3,0→5,0) — chain along bottom // Route 1: bottom row → demand (5,0)
// Route to D2 (5,4): Rook(0,0→0,2), Rook(0,3→0,4)... need diagonal sim.Place(PieceKind.Rook, (1, 0), (2, 0));
// Simpler: just chain rooks along bottom and right side sim.Place(PieceKind.Rook, (2, 0), (4, 0));
// Route 1: (0,0)→(2,0), (3,0)→(5,0) — serves Depot Royal
sim.Place(PieceKind.Rook, (0, 0), (2, 0)); // Route 2: up then right → demand (5,4)
sim.Place(PieceKind.Rook, (3, 0), (5, 0));
// Route 2: Use bishop + rook to get to (5,4)
// Bishop from (0,1)→(1,2) — wait, bishop goes diagonal
// Actually let's use a row of rooks going up
sim.Place(PieceKind.Rook, (0, 1), (0, 2)); sim.Place(PieceKind.Rook, (0, 1), (0, 2));
// Bishop diagonal: (1, 3) → (2, 4) — but bishop needs to be placed carefully sim.Place(PieceKind.Rook, (0, 2), (2, 2));
sim.Place(PieceKind.Bishop, (1, 3), (2, 4)); // 5th rook — stock exhausted at 4!
var events5 = sim.Place(PieceKind.Rook, (2, 2), (3, 2));
Assert.DoesNotContain(events5, e => e is PlacementRejectedEvent);
sim.Place(PieceKind.Bishop, (3, 2), (4, 3));
// 6th rook needed but only 4 in stock
var events6 = sim.Place(PieceKind.Rook, (4, 3), (5, 3));
Assert.DoesNotContain(events6, e => e is PlacementRejectedEvent);
sim.Start(); sim.Start();
var allEvents = sim.StepN(30); var allEvents = sim.StepN(60);
Assert.Contains(allEvents, e => e is VictoryEvent);
// This particular arrangement may or may not solve both demands.
// The key test is that the engine processes it correctly.
// Let's just verify no crashes and the engine runs 30 turns.
Assert.Contains(allEvents, e => e is TurnEndedEvent te && te.TurnNumber >= 10);
} }
[Fact] [Fact]
public void Level3_LeCol_Solvable() public void Level3_LeCol_Victory()
{ {
// 6x6: Scierie(0,0 wood), Carriere(5,0 stone), Depot(5,5 wood), Forge(0,5 stone) // GDD Level 3: 6x6, L-shaped wall, knight required to jump obstacle
// Walls: (2,2), (2,3), (2,4), (3,4), (4,4) // Stock: 6 Rooks + 1 Knight (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.
//
// 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
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)
.WithProduction(5, 0, "Carriere", CargoType.Stone, 2) .WithDemand(5, 5, "Depot Royal", CargoType.Wood, 3, 60)
.WithDemand(5, 5, "Depot Royal", CargoType.Wood, 2, 40)
.WithDemand(0, 5, "Forge", CargoType.Stone, 2, 40)
.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, 4) .WithStock(PieceKind.Rook, 6)
.WithStock(PieceKind.Bishop, 1) .WithStock(PieceKind.Knight, 1)
.WithStock(PieceKind.Knight, 2)
.Build(); .Build();
var sim = SimHelper.FromLevel(level); var sim = SimHelper.FromLevel(level);
// Wood route (0,0)→(5,5): go right along bottom, then up along right side sim.Place(PieceKind.Rook, (0, 1), (1, 1));
sim.Place(PieceKind.Rook, (0, 0), (2, 0)); sim.Place(PieceKind.Knight, (1, 1), (3, 2));
sim.Place(PieceKind.Rook, (3, 0), (5, 0)); // shares production cell... sim.Place(PieceKind.Rook, (3, 2), (4, 2));
// This is a complex level. Let's just verify the engine handles walls and knights. sim.Place(PieceKind.Rook, (4, 2), (5, 2));
// Place a knight that jumps the wall sim.Place(PieceKind.Rook, (5, 2), (5, 3));
sim.Place(PieceKind.Knight, (1, 3), (3, 2)); // L-shape jump sim.Place(PieceKind.Rook, (5, 3), (5, 4));
sim.Start(); sim.Start();
var allEvents = sim.StepN(40); var allEvents = sim.StepN(60);
Assert.Contains(allEvents, e => e is VictoryEvent);
// Engine should process without crashes
Assert.Contains(allEvents, e => e is TurnEndedEvent);
// Should have movement events
Assert.Contains(allEvents, e => e is PieceMovedEvent);
} }
[Fact] [Fact]
public void Level1_InsufficientPieces_NoVictory() public void Level1_InsufficientPieces_NoVictory()
{ {
// Place demand far from production with a wall blocking the only path
var level = new BoardBuilder(4, 4) var level = new BoardBuilder(4, 4)
.WithProduction(0, 0, "Scierie", CargoType.Wood, 2) .WithProduction(0, 0, "Scierie", CargoType.Wood, 2)
.WithDemand(3, 3, "Depot Royal", CargoType.Wood, 3, 5) // far corner, very tight deadline .WithDemand(3, 3, "Depot Royal", CargoType.Wood, 3, 5)
.WithStock(PieceKind.Rook, 1) // only 1 rook, can't bridge the gap .WithStock(PieceKind.Rook, 1)
.Build(); .Build();
var sim = SimHelper.FromLevel(level); var sim = SimHelper.FromLevel(level);
// Place rook away from both production and demand
sim.Place(PieceKind.Rook, (1, 1), (2, 1)); sim.Place(PieceKind.Rook, (1, 1), (2, 1));
sim.Start(); sim.Start();