knightstour 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.js +481 -0
- package/package.json +13 -0
package/index.js
ADDED
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
class KnightsTour {
|
|
2
|
+
static KNIGHT_OFFSETS = [
|
|
3
|
+
[-2, -1],
|
|
4
|
+
[-2, 1],
|
|
5
|
+
[-1, -2],
|
|
6
|
+
[-1, 2],
|
|
7
|
+
[1, -2],
|
|
8
|
+
[1, 2],
|
|
9
|
+
[2, -1],
|
|
10
|
+
[2, 1]
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
constructor({
|
|
14
|
+
rows = 6,
|
|
15
|
+
cols = 5,
|
|
16
|
+
players = [{
|
|
17
|
+
name: "Player 1",
|
|
18
|
+
color: "blue",
|
|
19
|
+
moveIndexes: []
|
|
20
|
+
}],
|
|
21
|
+
gameMode = "",
|
|
22
|
+
disabledIndexes = [],
|
|
23
|
+
} = {}) {
|
|
24
|
+
|
|
25
|
+
this.initialConfig = {
|
|
26
|
+
rows,
|
|
27
|
+
cols,
|
|
28
|
+
players: JSON.parse(JSON.stringify(players)),
|
|
29
|
+
gameMode,
|
|
30
|
+
disabledIndexes,
|
|
31
|
+
};
|
|
32
|
+
this.init(this.initialConfig);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
init({
|
|
36
|
+
rows,
|
|
37
|
+
cols,
|
|
38
|
+
players,
|
|
39
|
+
gameMode,
|
|
40
|
+
disabledIndexes,
|
|
41
|
+
}) {
|
|
42
|
+
this.rows = rows;
|
|
43
|
+
this.cols = cols;
|
|
44
|
+
this.disabledIndexes = [...disabledIndexes];
|
|
45
|
+
|
|
46
|
+
this.players = players.map(p => ({
|
|
47
|
+
...p,
|
|
48
|
+
// Ensures each player gets a fresh copy of their original moves
|
|
49
|
+
moveIndexes: [...p.moveIndexes]
|
|
50
|
+
}));
|
|
51
|
+
|
|
52
|
+
//need to fix allMoveIndexes
|
|
53
|
+
this.allMoveIndexes = [];
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
const maxMoves = Math.max(...this.players.map(p => p.moveIndexes.length));
|
|
57
|
+
for (let i = 0; i < maxMoves; i++) {
|
|
58
|
+
for(let j = 0; j < this.players.length; j++)
|
|
59
|
+
{
|
|
60
|
+
if (this.players[j].moveIndexes[i] !== undefined) {
|
|
61
|
+
this.allMoveIndexes.push(this.players[j].moveIndexes[i]);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
this.turnIndex = this.allMoveIndexes.length % this.players.length;
|
|
67
|
+
|
|
68
|
+
this.state = "";
|
|
69
|
+
|
|
70
|
+
this.solved = false;
|
|
71
|
+
this.generated = false;
|
|
72
|
+
|
|
73
|
+
this.cells = [];
|
|
74
|
+
|
|
75
|
+
// Set Game Mode logic
|
|
76
|
+
if (this.players.length === 1) {
|
|
77
|
+
this.gameMode = (gameMode === "Open" || gameMode === "Closed") ? gameMode : "Open";
|
|
78
|
+
} else {
|
|
79
|
+
this.gameMode = (gameMode === "Versus" || gameMode === "Co-op") ? gameMode : "Co-op";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
this.createBoard();
|
|
83
|
+
this.updateBoard();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
reset() {
|
|
87
|
+
this.init(this.initialConfig);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
createBoard() {
|
|
91
|
+
this.matrix = Array.from({
|
|
92
|
+
length: this.rows
|
|
93
|
+
}, (_, r) =>
|
|
94
|
+
Array.from({
|
|
95
|
+
length: this.cols
|
|
96
|
+
}, (_, c) => {
|
|
97
|
+
const index = r * this.cols + c;
|
|
98
|
+
return {
|
|
99
|
+
index,
|
|
100
|
+
isDisabled: this.disabledIndexes.includes(index),
|
|
101
|
+
isHighlighted: false,
|
|
102
|
+
isClosed: false,
|
|
103
|
+
value: null // Stores the move number or exit count
|
|
104
|
+
};
|
|
105
|
+
})
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Helper to get cell by index without repetitive math
|
|
110
|
+
getCell(index) {
|
|
111
|
+
const r = Math.floor(index / this.cols);
|
|
112
|
+
const c = index % this.cols;
|
|
113
|
+
return this.matrix[r] ? this.matrix[r][c] : null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Core logic: Identifies where the current player can move.
|
|
118
|
+
*/
|
|
119
|
+
getAvailableMovesFor(playerIndex) {
|
|
120
|
+
const player = this.players[playerIndex];
|
|
121
|
+
const lastMove = player.moveIndexes[player.moveIndexes.length - 1];
|
|
122
|
+
|
|
123
|
+
if (lastMove === undefined) {
|
|
124
|
+
// First move: any non-disabled, non-visited cell
|
|
125
|
+
return this.matrix.flat()
|
|
126
|
+
.filter(cell => this.isAvailable(cell.index))
|
|
127
|
+
.map(cell => cell.index);
|
|
128
|
+
}
|
|
129
|
+
return this.getKnightMovesAt(lastMove);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
move(index) {
|
|
133
|
+
if (!this.isValid(index)) return false;
|
|
134
|
+
|
|
135
|
+
const currentPlayer = this.players[this.turnIndex];
|
|
136
|
+
|
|
137
|
+
// Record move
|
|
138
|
+
currentPlayer.moveIndexes.push(index);
|
|
139
|
+
this.allMoveIndexes.push(this.turnIndex);
|
|
140
|
+
|
|
141
|
+
// Visually mark the cell with the move number
|
|
142
|
+
const cell = this.getCell(index);
|
|
143
|
+
cell.value = currentPlayer.moveIndexes.length;
|
|
144
|
+
|
|
145
|
+
// Switch turn and refresh board
|
|
146
|
+
this.turnIndex = (this.turnIndex + 1) % this.players.length;
|
|
147
|
+
this.updateBoard();
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
undo() {
|
|
153
|
+
|
|
154
|
+
if (this.allMoveIndexes.length === 0) {
|
|
155
|
+
this.updateBoard();
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const lastPlayerIndex = this.allMoveIndexes.pop();
|
|
160
|
+
const lastMoveIndex = this.players[lastPlayerIndex].moveIndexes.pop();
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
this.generated = false;
|
|
164
|
+
this.solved = false;
|
|
165
|
+
// Reset the cell value completely
|
|
166
|
+
const cell = this.getCell(lastMoveIndex);
|
|
167
|
+
if (cell) cell.value = null;
|
|
168
|
+
|
|
169
|
+
// Return turn to the player who was undone
|
|
170
|
+
this.turnIndex = lastPlayerIndex;
|
|
171
|
+
this.updateBoard(false);
|
|
172
|
+
|
|
173
|
+
if (this.players[this.turnIndex].CPU) {
|
|
174
|
+
this.undo();
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
getNextMoves(startIndex, increment = 0) {
|
|
179
|
+
if (increment === this.players.length) {
|
|
180
|
+
return [];
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
let nextMoves = this.getAvailableMovesFor(startIndex);
|
|
184
|
+
|
|
185
|
+
// 3. If current player is stuck, try skipping to next player (Co-op/Versus)
|
|
186
|
+
if (nextMoves.length === 0 && this.players.length > 1) {
|
|
187
|
+
const nextIdx = (startIndex + 1) % this.players.length;
|
|
188
|
+
const nextMoves = this.getAvailableMovesFor(nextIdx);
|
|
189
|
+
|
|
190
|
+
if (nextMoves.length > 0) {
|
|
191
|
+
this.turnIndex = nextIdx;
|
|
192
|
+
return nextMoves;
|
|
193
|
+
} else {
|
|
194
|
+
return this.getNextMoves(nextIdx, increment + 1);
|
|
195
|
+
}
|
|
196
|
+
} else {
|
|
197
|
+
return nextMoves;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
}
|
|
201
|
+
updateBoard(autoMove = true) {
|
|
202
|
+
|
|
203
|
+
if(this.solved || this.generated)
|
|
204
|
+
{
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
this.clear();
|
|
208
|
+
|
|
209
|
+
// 2. Calculate next moves for the current player
|
|
210
|
+
let nextMoves = this.getNextMoves(this.turnIndex);
|
|
211
|
+
|
|
212
|
+
// 4. Update Board State
|
|
213
|
+
if (nextMoves.length === 0) {
|
|
214
|
+
this.state = "Game Over";
|
|
215
|
+
if (this.gameMode === "Open") {
|
|
216
|
+
if (this.isComplete()) {
|
|
217
|
+
this.state = "Open tour complete!";
|
|
218
|
+
this.solved = true;
|
|
219
|
+
} else {
|
|
220
|
+
this.state = "Incomplete open tour. Please try again";
|
|
221
|
+
if(this.solving)
|
|
222
|
+
{
|
|
223
|
+
this.state = "No open tour solution found";
|
|
224
|
+
this.solved = false;
|
|
225
|
+
}
|
|
226
|
+
else if(this.generating)
|
|
227
|
+
{
|
|
228
|
+
this.state = "Unable to generate open tour";
|
|
229
|
+
this.generated = false;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
}
|
|
233
|
+
} else if (this.gameMode === "Closed") {
|
|
234
|
+
if (this.isComplete() && this.isClosed()) {
|
|
235
|
+
this.state = "Closed tour complete!";
|
|
236
|
+
this.solved = true;
|
|
237
|
+
} else {
|
|
238
|
+
this.state = "Incomplete closed tour. Please try again";
|
|
239
|
+
if(this.solving)
|
|
240
|
+
{
|
|
241
|
+
this.state = "No closed tour solution found";
|
|
242
|
+
this.solved = false;
|
|
243
|
+
}
|
|
244
|
+
else if(this.generating)
|
|
245
|
+
{
|
|
246
|
+
this.state = "Unable to generate closed tour";
|
|
247
|
+
this.generated = false;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
} else if (this.gameMode === "Versus") {
|
|
251
|
+
this.state = this.determineWinner();
|
|
252
|
+
} else if (this.gameMode === "Co-op") {
|
|
253
|
+
if (this.isComplete()) {
|
|
254
|
+
this.solved = true;
|
|
255
|
+
this.state = "Co-op tour complete!"
|
|
256
|
+
} else {
|
|
257
|
+
this.state = "Incomplete co-op tour. Please try again"
|
|
258
|
+
if(this.solving)
|
|
259
|
+
{
|
|
260
|
+
this.state = "No co-op solution found";
|
|
261
|
+
this.solved = false;
|
|
262
|
+
}
|
|
263
|
+
else if(this.generating)
|
|
264
|
+
{
|
|
265
|
+
this.state = "Unable to generate co-op tour";
|
|
266
|
+
this.generated = false;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
} else {
|
|
271
|
+
|
|
272
|
+
this.cells = [];
|
|
273
|
+
nextMoves.forEach(idx => {
|
|
274
|
+
const cell = this.getCell(idx);
|
|
275
|
+
cell.isHighlighted = true;
|
|
276
|
+
cell.isClosed = this.isClosed(idx);
|
|
277
|
+
cell.value = this.getKnightMovesAt(idx).length;
|
|
278
|
+
this.cells.push(cell);
|
|
279
|
+
});
|
|
280
|
+
this.state = `${this.players[this.turnIndex].name}'s Turn`;
|
|
281
|
+
|
|
282
|
+
if ((this.players[this.turnIndex].CPU && autoMove) || this.generating || this.solving) {
|
|
283
|
+
if (this.players[this.turnIndex].moveIndexes.length === 0) {
|
|
284
|
+
const nextCell = this.cells[Math.floor(Math.random() * this.cells.length) ];
|
|
285
|
+
this.move(nextCell.index);
|
|
286
|
+
} else {
|
|
287
|
+
const total = (this.rows * this.cols) - this.disabledIndexes.length;
|
|
288
|
+
|
|
289
|
+
if (this.gameMode === "Closed") {
|
|
290
|
+
const closedCells = this.cells.filter(cell => cell.isClosed);
|
|
291
|
+
if (this.generating && this.players[this.turnIndex].moveIndexes.length >= Math.floor(total * 0.75) && closedCells.length > 0) {
|
|
292
|
+
this.generated = true;
|
|
293
|
+
this.state = "Closed tour generated!";
|
|
294
|
+
}
|
|
295
|
+
} else {
|
|
296
|
+
if (this.generating && this.allMoveIndexes.length >= Math.floor(total * 0.75)) {
|
|
297
|
+
this.generated = true;
|
|
298
|
+
if(this.gameMode === "Open")
|
|
299
|
+
{
|
|
300
|
+
this.state = "Open tour generated!";
|
|
301
|
+
}
|
|
302
|
+
else if(this.gameMode === "Co-op")
|
|
303
|
+
{
|
|
304
|
+
this.state = "Co-op tour generated!";
|
|
305
|
+
}
|
|
306
|
+
else if(this.gameMode === "Versus")
|
|
307
|
+
{
|
|
308
|
+
this.state = "Versus tour generated!";
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
this.hint();
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
clear()
|
|
321
|
+
{
|
|
322
|
+
this.matrix.flat().forEach(cell => {
|
|
323
|
+
cell.isHighlighted = false;
|
|
324
|
+
cell.isClosed = false;
|
|
325
|
+
if (!this.isVisited(cell.index)) cell.value = null;
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
determineWinner() {
|
|
330
|
+
const sortedPlayers = [...this.players].sort(
|
|
331
|
+
(a, b) => (b.moveIndexes.length - a.moveIndexes.length) || a.name.localeCompare(b.name)
|
|
332
|
+
);
|
|
333
|
+
|
|
334
|
+
const winningScore = sortedPlayers[0].moveIndexes.length;
|
|
335
|
+
const winners = sortedPlayers.filter(p => p.moveIndexes.length === winningScore);
|
|
336
|
+
|
|
337
|
+
if (winners.length === 1) {
|
|
338
|
+
return `${winners[0].name} wins!`;
|
|
339
|
+
} else {
|
|
340
|
+
const names = winners.map(p => p.name);
|
|
341
|
+
const formatter = new Intl.ListFormat('en', {
|
|
342
|
+
style: 'long',
|
|
343
|
+
type: 'conjunction'
|
|
344
|
+
});
|
|
345
|
+
return `Tie between ${formatter.format(names)}`;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
isComplete() {
|
|
350
|
+
return this.allMoveIndexes.length + this.disabledIndexes.length === this.rows * this.cols;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
isClosed(lastMove = this.players[0].moveIndexes[this.players[0].moveIndexes.length - 1]) {
|
|
354
|
+
const firstMove = this.players[0].moveIndexes[0];
|
|
355
|
+
const knightMoves = this.getKnightMovesAt(firstMove, false);
|
|
356
|
+
//const lastMove = this.players[0].moveIndexes[this.players[0].moveIndexes.length - 1];
|
|
357
|
+
return knightMoves.includes(lastMove);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
getKnightMovesAt(index, context = true) {
|
|
361
|
+
const r = Math.floor(index / this.cols);
|
|
362
|
+
const c = index % this.cols;
|
|
363
|
+
const moves = [];
|
|
364
|
+
|
|
365
|
+
for (const [dr, dc] of Game.KNIGHT_OFFSETS) {
|
|
366
|
+
const nR = r + dr,
|
|
367
|
+
nC = c + dc;
|
|
368
|
+
const nIdx = nR * this.cols + nC;
|
|
369
|
+
if (context) {
|
|
370
|
+
if (nR >= 0 && nR < this.rows && nC >= 0 && nC < this.cols && this.isAvailable(nIdx)) {
|
|
371
|
+
moves.push(nIdx);
|
|
372
|
+
}
|
|
373
|
+
} else {
|
|
374
|
+
if (nR >= 0 && nR < this.rows && nC >= 0 && nC < this.cols && !this.disabledIndexes.includes(nIdx)) {
|
|
375
|
+
moves.push(nIdx);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
return moves;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
isAvailable(index) {
|
|
383
|
+
return !this.disabledIndexes.includes(index) && !this.isVisited(index);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
isVisited(index) {
|
|
387
|
+
return this.players.some(p => p.moveIndexes.includes(index));
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
isValid(index) {
|
|
391
|
+
const moves = this.getAvailableMovesFor(this.turnIndex);
|
|
392
|
+
return moves.includes(index);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
hint()
|
|
396
|
+
{
|
|
397
|
+
|
|
398
|
+
if(this.cells.length === 0)
|
|
399
|
+
{
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if(this.generated)
|
|
404
|
+
{
|
|
405
|
+
const closedCells = this.cells.filter(c => c.isClosed);
|
|
406
|
+
const nextCell = closedCells[Math.floor(Math.random() * closedCells.length)];
|
|
407
|
+
if(nextCell)
|
|
408
|
+
{
|
|
409
|
+
this.move(nextCell.index);
|
|
410
|
+
}
|
|
411
|
+
this.clear();
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
const openCells = this.cells.filter(c => !c.isClosed);
|
|
415
|
+
|
|
416
|
+
const candidates = openCells.length > 0 ? openCells : this.cells;
|
|
417
|
+
const minValue = Math.min(...candidates.map(c => c.value));
|
|
418
|
+
const bestCells = candidates.filter(c => c.value === minValue);
|
|
419
|
+
let nextCell = bestCells[Math.floor(Math.random() * bestCells.length)];
|
|
420
|
+
if((this.gameMode === "Open" || this.gameMode === "Versus") && (this.generating))
|
|
421
|
+
{
|
|
422
|
+
nextCell = this.cells[Math.floor(Math.random() * this.cells.length)];
|
|
423
|
+
}
|
|
424
|
+
this.move(nextCell.index);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
solve(increment = 0)
|
|
428
|
+
{
|
|
429
|
+
if(this.gameMode === "Versus")
|
|
430
|
+
{
|
|
431
|
+
//console.log("Cannot solve in Versus mode");
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
this.solving = true;
|
|
435
|
+
if(increment === 1000)
|
|
436
|
+
{
|
|
437
|
+
//console.log("No solution");
|
|
438
|
+
this.solving = false;
|
|
439
|
+
}
|
|
440
|
+
else
|
|
441
|
+
{
|
|
442
|
+
if(!this.solved)
|
|
443
|
+
{
|
|
444
|
+
this.reset();
|
|
445
|
+
this.solve(increment + 1);
|
|
446
|
+
}
|
|
447
|
+
else
|
|
448
|
+
{
|
|
449
|
+
//console.log("Solved in " + (increment + 1) + " attempt(s)");
|
|
450
|
+
this.solving = false;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
generate(increment = 0)
|
|
456
|
+
{
|
|
457
|
+
this.generating = true;
|
|
458
|
+
if(increment === 1000)
|
|
459
|
+
{
|
|
460
|
+
//console.log("No generation");
|
|
461
|
+
this.generating = false;
|
|
462
|
+
|
|
463
|
+
}
|
|
464
|
+
else
|
|
465
|
+
{
|
|
466
|
+
if(!this.generated)
|
|
467
|
+
{
|
|
468
|
+
this.reset();
|
|
469
|
+
this.generate(increment + 1);
|
|
470
|
+
|
|
471
|
+
}
|
|
472
|
+
else
|
|
473
|
+
{
|
|
474
|
+
//console.log("Generated in " + (increment + 1) + " attempt(s)");
|
|
475
|
+
this.generating = false;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
export default KnightsTour;
|
package/package.json
ADDED