Repository navigation
Expand file tree
/
Copy pathengine.test.ts
More file actions
217 lines (192 loc) · 9.12 KB
/
Copy pathengine.test.ts
File metadata and controls
217 lines (192 loc) · 9.12 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
// engine.test.ts — MANDATORY. Do not edit this file.
//
// These four suites cover the edge-case rules that are easiest to get
// subtly wrong. All four must pass for a submission to be considered
// for the "Correctness" score. Passing these does NOT guarantee a good
// score elsewhere (architecture, extensibility, clarity are graded
// separately) — this file only removes "did the rules actually work"
// as a source of grader subjectivity.
import { getLegalMoves, applyMove, advanceTurn } from "./engine";
import { Board, Piece, Position, Element, Owner, squareKey, isBurning } from "./types";
function makeEmptyBoard(turn = 1): Board {
return {
size: 6,
pieces: [],
squareStatus: new Map(),
turn,
};
}
let idCounter = 0;
function placePiece(
board: Board,
element: Element,
owner: Owner,
position: Position,
extra: Partial<Piece> = {}
): Piece {
const piece: Piece = {
id: `p${idCounter++}`,
element,
owner,
position,
...extra,
};
board.pieces.push(piece);
return piece;
}
function findPiece(board: Board, id: string): Piece {
const p = board.pieces.find((p) => p.id === id);
if (!p) throw new Error(`piece ${id} not found after move`);
return p;
}
function containsPosition(positions: Position[], target: Position): boolean {
return positions.some((p) => p.row === target.row && p.col === target.col);
}
// ---------------------------------------------------------------------
// 1. Tide alternation across turns
// ---------------------------------------------------------------------
describe("Tide: must alternate movement axis across turns", () => {
it("forbids a second consecutive horizontal move", () => {
let board = makeEmptyBoard();
const tide = placePiece(board, "Tide", "A", { row: 3, col: 0 });
board = applyMove(board, tide.id, { row: 3, col: 4 }); // horizontal
const moved = findPiece(board, tide.id);
expect(moved.lastMoveAxis).toBe("horizontal");
const legal = getLegalMoves(board, moved);
// Any further horizontal destination (same row, different col) must be excluded
const illegalHorizontal = { row: 3, col: 1 };
expect(containsPosition(legal, illegalHorizontal)).toBe(false);
});
it("allows a vertical move immediately after a horizontal move", () => {
let board = makeEmptyBoard();
const tide = placePiece(board, "Tide", "A", { row: 3, col: 0 });
board = applyMove(board, tide.id, { row: 3, col: 4 }); // horizontal
const moved = findPiece(board, tide.id);
const legal = getLegalMoves(board, moved);
const legalVertical = { row: 0, col: 4 };
expect(containsPosition(legal, legalVertical)).toBe(true);
});
it("allows horizontal again after an intervening vertical move (three-move sequence)", () => {
let board = makeEmptyBoard();
const tide = placePiece(board, "Tide", "A", { row: 3, col: 0 });
board = applyMove(board, tide.id, { row: 3, col: 4 }); // horizontal
board = applyMove(board, tide.id, { row: 0, col: 4 }); // vertical
const moved = findPiece(board, tide.id);
const legal = getLegalMoves(board, moved);
const legalHorizontalAgain = { row: 0, col: 2 };
expect(containsPosition(legal, legalHorizontalAgain)).toBe(true);
});
it("does not restrict a Tide piece's very first move", () => {
const board = makeEmptyBoard();
const tide = placePiece(board, "Tide", "A", { row: 3, col: 3 });
const legal = getLegalMoves(board, tide);
// Both a horizontal and vertical destination should be available on move 1
expect(containsPosition(legal, { row: 3, col: 0 })).toBe(true);
expect(containsPosition(legal, { row: 0, col: 3 })).toBe(true);
});
});
// ---------------------------------------------------------------------
// 2. Gale: cannot end its move on the same row or column it started on
//
// ERRATA: the original spec described Gale as a single straight diagonal
// slide with this constraint — but a single diagonal slide can NEVER
// return to its starting row or column (row and column always change by
// the same nonzero amount), making the rule impossible to violate and
// therefore untestable. Gale is corrected here to allow ONE diagonal
// pivot per move (a change of diagonal direction partway through),
// which is what makes ending back on the starting row/column possible.
// ---------------------------------------------------------------------
describe("Gale: cannot end move on starting row or column (pivot variant)", () => {
it("excludes a pivoted destination that lands back on the starting row", () => {
const board = makeEmptyBoard();
// From (3,3): 2 squares up-right to (1,5), then pivot 2 squares
// down-right to (3,7) -- out of bounds, so use a smaller board-safe
// example: up-right 1 to (2,4), pivot down-right 1 to (3,5).
// Net: row 3 -> 2 -> 3 (back on starting row 3).
const gale = placePiece(board, "Gale", "A", { row: 3, col: 3 });
const legal = getLegalMoves(board, gale);
const backOnStartingRow = { row: 3, col: 5 };
expect(containsPosition(legal, backOnStartingRow)).toBe(false);
});
it("excludes a pivoted destination that lands back on the starting column", () => {
const board = makeEmptyBoard();
// From (3,3): down-right 1 to (4,4), pivot down-left 1 to (5,3).
// Net: col 3 -> 4 -> 3 (back on starting column 3).
const gale = placePiece(board, "Gale", "A", { row: 3, col: 3 });
const legal = getLegalMoves(board, gale);
const backOnStartingCol = { row: 5, col: 3 };
expect(containsPosition(legal, backOnStartingCol)).toBe(false);
});
it("allows a destination that shares neither the starting row nor column", () => {
const board = makeEmptyBoard();
const gale = placePiece(board, "Gale", "A", { row: 3, col: 3 });
const legal = getLegalMoves(board, gale);
// Plain single-direction diagonal move, no pivot needed: (3,3) -> (1,1)
expect(containsPosition(legal, { row: 1, col: 1 })).toBe(true);
});
});
// ---------------------------------------------------------------------
// 3. Ember: midpoint must be empty; Steam blocks path/landing
// ---------------------------------------------------------------------
describe("Ember: midpoint-empty rule and Steam blocking", () => {
it("forbids a jump when the midpoint square is occupied", () => {
const board = makeEmptyBoard();
const ember = placePiece(board, "Ember", "A", { row: 2, col: 2 });
placePiece(board, "Root", "B", { row: 3, col: 2 }); // midpoint of a 2-square vertical jump
const legal = getLegalMoves(board, ember);
expect(containsPosition(legal, { row: 4, col: 2 })).toBe(false);
});
it("allows the jump when the midpoint square is empty", () => {
const board = makeEmptyBoard();
const ember = placePiece(board, "Ember", "A", { row: 2, col: 2 });
const legal = getLegalMoves(board, ember);
expect(containsPosition(legal, { row: 4, col: 2 })).toBe(true);
});
it("forbids a jump whose midpoint is a Steam square, even if unoccupied", () => {
const board = makeEmptyBoard();
board.squareStatus.set(squareKey({ row: 3, col: 2 }), {
steamUntilTurn: board.turn + 1,
});
const ember = placePiece(board, "Ember", "A", { row: 2, col: 2 });
const legal = getLegalMoves(board, ember);
expect(containsPosition(legal, { row: 4, col: 2 })).toBe(false);
});
it("forbids landing directly on a Steam square", () => {
const board = makeEmptyBoard();
board.squareStatus.set(squareKey({ row: 4, col: 2 }), {
steamUntilTurn: board.turn + 1,
});
const ember = placePiece(board, "Ember", "A", { row: 2, col: 2 });
const legal = getLegalMoves(board, ember);
expect(containsPosition(legal, { row: 4, col: 2 })).toBe(false);
});
});
// ---------------------------------------------------------------------
// 4. Burning: expiry timing (the classic off-by-one)
//
// Contract: if a piece becomes Burning as a result of a move made
// during turn T, it is Burning during turns T, T+1, and T+2, and is
// NO LONGER burning from turn T+3 onward. advanceTurn() is what moves
// the turn counter forward and performs expiry.
// ---------------------------------------------------------------------
describe("Burning: expiry timing", () => {
it("keeps a piece burning through turn T+2 and clears it by T+3", () => {
let board = makeEmptyBoard(1); // start on turn 1
const ember = placePiece(board, "Ember", "A", { row: 0, col: 0 });
// Ember jumps 2 squares to (0,2); place the enemy at (0,3) so it
// ends up adjacent to Ember's landing square.
const enemy = placePiece(board, "Root", "B", { row: 0, col: 3 });
board = applyMove(board, ember.id, { row: 0, col: 2 }); // turn 1 move
let target = findPiece(board, enemy.id);
expect(isBurning(target, board)).toBe(true); // burning during turn 1 (T)
board = advanceTurn(board); // now turn 2 (T+1)
target = findPiece(board, enemy.id);
expect(isBurning(target, board)).toBe(true);
board = advanceTurn(board); // now turn 3 (T+2)
target = findPiece(board, enemy.id);
expect(isBurning(target, board)).toBe(true);
board = advanceTurn(board); // now turn 4 (T+3) -- must be cleared
target = findPiece(board, enemy.id);
expect(isBurning(target, board)).toBe(false);
});
});