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.
Files changed (2) hide show
  1. package/index.js +481 -0
  2. 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
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "knightstour",
3
+ "version": "1.0.0",
4
+ "description": "",
5
+ "main": "index.js",
6
+ "type": "module",
7
+ "scripts": {
8
+ "test": "echo \"Error: no test specified\" && exit 1"
9
+ },
10
+ "keywords": [],
11
+ "author": "",
12
+ "license": "ISC"
13
+ }