@slugbugblue/trax 1.1.1 → 1.3.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/src/engine.js CHANGED
@@ -1,8 +1,9 @@
1
- /** Trax engine.
2
- * @copyright 2019-2026
3
- * @author Chad Transtrum <chad@transtrum.net>
4
- * @license Apache-2.0
5
- */
1
+ /**
2
+ Trax engine.
3
+ @copyright 2019-2026
4
+ @author Chad Transtrum <chad@transtrum.net>
5
+ @license Apache-2.0
6
+ */
6
7
 
7
8
  /** @import { PointLike } from '@slugbugblue/point' */
8
9
  import { Point } from '@slugbugblue/point'
@@ -14,63 +15,64 @@ import { version } from './version.js'
14
15
  /** @typedef {'w' | 'b'} Color */
15
16
 
16
17
  /**
17
- * One end of a line and the tiles taken to get there.
18
- * @typedef {Object} LineEnd
19
- * @property {Point} loc
20
- * @property {TileId[]} path
21
- */
18
+ One end of a line and the tiles taken to get there.
19
+ @typedef {object} LineEnd
20
+ @property {Point} loc - the ending location
21
+ @property {TileId[]} path - tile ids from start to end
22
+ */
22
23
 
23
24
  /** A notation of a move. */
24
25
  /** @typedef {string} Notation */
25
26
 
26
27
  /**
27
- * A tile type and a location determine a raw move.
28
- * @typedef {Object} RawMove
29
- * @property {TileType} type
30
- * @property {Point} loc
31
- */
28
+ A tile type and a location determine a raw move.
29
+ @typedef {object} RawMove
30
+ @property {TileType} type - the tile type
31
+ @property {Point} loc - the location
32
+ */
32
33
 
33
34
  /**
34
- * Treat the save state as an opaque object,
35
- * produced by save() and fed into restore().
36
- * @typedef {Object} SaveState
37
- * @property {string} id
38
- * @property {number} move
39
- * @property {number} turn
40
- * @property {boolean} over
41
- * @property {number} left
42
- * @property {number} right
43
- * @property {number} top
44
- * @property {number} bottom
45
- * @property {Notation} notation
46
- * @property {string} tiles
47
- * @property {string} path
48
- * @property {boolean} invalid
49
- */
35
+ Treat the save state as an opaque object,
36
+ produced by save() and fed into restore().
37
+ @typedef {object} SaveState
38
+ @property {string} id - game id
39
+ @property {number} move - move number
40
+ @property {number} turn - current player (1 or 2)
41
+ @property {boolean} over - whether the game is over
42
+ @property {number} left - leftmost tile column
43
+ @property {number} right - rightmost tile column
44
+ @property {number} top - topmost tile row
45
+ @property {number} bottom - bottommost tile row
46
+ @property {string[]} moves - move notations
47
+ @property {[TileId, Point][]} frontier - frontier locations
48
+ @property {string} tiles - serialized tiles
49
+ @property {string} path - serialized winning path
50
+ @property {boolean} invalid - whether an invalid tile was placed
51
+ */
50
52
 
51
53
  /** Representations of the different ways a tile can curve. */
52
54
  /** @typedef {'/' | '\\' | '+'} Slash */
53
55
 
54
56
  /**
55
- * A single tile on the board.
56
- * @typedef {Object} Tile
57
- * @property {TileId} id
58
- * @property {Point} loc
59
- * @property {TileType} type
60
- * @property {number} move
61
- * @property {number} seq
62
- */
57
+ A single tile on the board.
58
+ @typedef {object} Tile
59
+ @property {TileId} id - unique tile identifier
60
+ @property {Point} loc - board location
61
+ @property {TileType} type - tile type letter
62
+ @property {number} move - move number when placed
63
+ @property {number} seq - sequence number among all tiles
64
+ */
63
65
 
64
66
  /** A TileId is just a string. */
65
67
  /** @typedef {string} TileId */
66
68
 
67
69
  /**
68
- * When a tile is dropped, this object represents the results.
69
- * @typedef {Object} TileDrop
70
- * @property {Tile[]} dropped
71
- * @property {Notation} notation
72
- * @property {boolean} valid
73
- */
70
+ When a tile is dropped, this object represents the results.
71
+ @typedef {object} TileDrop
72
+ @property {Tile[]} dropped - all tiles placed, including forced moves
73
+ @property {Notation} notation - the notation of the played move
74
+ @property {boolean} valid - whether the move was legal
75
+ */
74
76
 
75
77
  /** Invalid tiles are represented by 'x'. */
76
78
  /** @typedef {ValidTiles | 'x'} TileType */
@@ -84,34 +86,37 @@ import { version } from './version.js'
84
86
  // Fun trax helper constants
85
87
 
86
88
  const zero = new Point(0, 0)
87
- const moveNumberRegex = /^(\d+)[.):]?$/
88
- const notationRegex = /^([@a-z]+)(\d+)([/\\+])$/i
89
-
90
- /** Tiles are represented by the letters a-f.
91
- * a b c d e f
92
- * +--#--+ +--#--+ +--#--+ +--o--+ +--o--+ +--o--+
93
- * | # | | # | | # | | o | | o | | o |
94
- * oo ## ooo#ooo ## oo oo ## ####### ## oo
95
- * | o | | # | | o | | # | | o | | # |
96
- * +--o--+ +--#--+ +--o--+ +--#--+ +--o--+ +--#--+
97
- *
98
- * @readonly
99
- * @type ValidTiles[]
100
- * */
89
+ const moveNumberRegex = /^\d+[\).:]?$/v
90
+ const notationRegex = /^(?<col>[@a-z]+)(?<row>\d+)(?<slash>[+\/\\])$/iv
91
+
92
+ /**
93
+ Tiles are represented by the letters a-f.
94
+ a b c d e f
95
+ +--#--+ +--#--+ +--#--+ +--o--+ +--o--+ +--o--+
96
+ | # | | # | | # | | o | | o | | o |
97
+ oo ## ooo#ooo ## oo oo ## ####### ## oo
98
+ | o | | # | | o | | # | | o | | # |
99
+ +--o--+ +--#--+ +--o--+ +--#--+ +--o--+ +--#--+
100
+
101
+ @readonly
102
+ @type {ValidTiles[]}
103
+ */
101
104
  const tileTypes = ['a', 'b', 'c', 'd', 'e', 'f']
102
105
 
103
- /** Slashes indicate the direction the tile is placed. A forward slash or a
104
- * backslash indicate the tile is a "curve" and the plus sign indicates the
105
- * tile is a "straight".
106
- * @readonly
107
- * @type {Record<ValidTiles, Slash>}
108
- */
106
+ /**
107
+ Slashes indicate the direction the tile is placed. A forward slash or a
108
+ backslash indicate the tile is a "curve" and the plus sign indicates the
109
+ tile is a "straight".
110
+ @readonly
111
+ @type {Record<ValidTiles, Slash>}
112
+ */
109
113
  const slash = { a: '\\', b: '+', c: '/', d: '/', e: '+', f: '\\' }
110
114
 
111
- /** Symmetry helper to convert from one tile to another.
112
- * @readonly
113
- * @type {Record<string, Record<ValidTiles, ValidTiles>>}
114
- */
115
+ /**
116
+ Symmetry helper to convert from one tile to another.
117
+ @readonly
118
+ @type {Record<string, Record<ValidTiles, ValidTiles>>}
119
+ */
115
120
  const symmetry = {
116
121
  // Convert tile to different views
117
122
  flip: { a: 'd', b: 'b', c: 'f', d: 'a', e: 'e', f: 'c' },
@@ -122,14 +127,24 @@ const symmetry = {
122
127
 
123
128
  // Fun trax helper functions
124
129
 
125
- /** Apply certain combinations of symmetry.
126
- * @arg {ValidTiles} tile - the tile to apply symmetry to
127
- * @arg {boolean} [rightToLeft] - mirror the tile
128
- * @arg {boolean} [bottomToTop] - flip the tile
129
- * @arg {boolean} [rotate] - rotate the tile counterclockwise
130
- * @returns {ValidTiles}
131
- */
132
- const applySymmetry = (tile, rightToLeft, bottomToTop, rotate) => {
130
+ /**
131
+ Symmetry options for viewing a position from different angles.
132
+ @typedef {object} SymmetryOptions
133
+ @property {boolean} [rightToLeft] - mirror the tile
134
+ @property {boolean} [bottomToTop] - flip the tile
135
+ @property {boolean} [rotate] - rotate the tile counterclockwise
136
+ @property {boolean} [colorSwap] - swap the tile's colors
137
+ */
138
+
139
+ /**
140
+ Apply certain combinations of symmetry.
141
+ @param {ValidTiles} tile - the tile to apply symmetry to
142
+ @param {SymmetryOptions} [options] - the symmetry to apply
143
+ @returns {ValidTiles} the transformed tile
144
+ */
145
+ const applySymmetry = (tile, options = {}) => {
146
+ const { rightToLeft, bottomToTop, rotate, colorSwap } = options
147
+ if (colorSwap) tile = symmetry.swap[tile]
133
148
  if (rightToLeft) tile = symmetry.mirror[tile]
134
149
  if (bottomToTop) tile = symmetry.flip[tile]
135
150
  if (rotate) tile = symmetry.counterclock[tile]
@@ -138,38 +153,43 @@ const applySymmetry = (tile, rightToLeft, bottomToTop, rotate) => {
138
153
 
139
154
  // Note: String(undefined) === 'undefined', which is never a valid tile letter,
140
155
  // so these functions correctly return false when t is undefined.
141
- /** The color at the top of the tile.
142
- * @arg {string | undefined} t - the tile to check
143
- * @returns {Color|false} the color or false if t is not a valid tile
144
- */
156
+ /**
157
+ The color at the top of the tile.
158
+ @param {string | undefined} t - the tile to check
159
+ @returns {Color|false} the color or false if t is not a valid tile
160
+ */
145
161
  const upColor = (t) =>
146
162
  'abc'.includes(String(t)) ? 'b' : 'def'.includes(String(t)) ? 'w' : false
147
163
 
148
- /** The color at the bottom of the tile.
149
- * @arg {string | undefined} t - the tile to check
150
- * @returns {Color|false} the color or false if t is not a valid tile
151
- */
164
+ /**
165
+ The color at the bottom of the tile.
166
+ @param {string | undefined} t - the tile to check
167
+ @returns {Color|false} the color or false if t is not a valid tile
168
+ */
152
169
  const downColor = (t) =>
153
170
  'bdf'.includes(String(t)) ? 'b' : 'ace'.includes(String(t)) ? 'w' : false
154
171
 
155
- /** The color at the right edge of the tile.
156
- * @arg {string | undefined} t - the tile to check
157
- * @returns {Color|false} the color or false if t is not a valid tile
158
- */
172
+ /**
173
+ The color at the right edge of the tile.
174
+ @param {string | undefined} t - the tile to check
175
+ @returns {Color|false} the color or false if t is not a valid tile
176
+ */
159
177
  const rightColor = (t) =>
160
178
  'ade'.includes(String(t)) ? 'b' : 'bcf'.includes(String(t)) ? 'w' : false
161
179
 
162
- /** The color at the left edge of the tile.
163
- * @arg {string | undefined} t - the tile to check
164
- * @returns {Color|false} the color or false if t is not a valid tile
165
- */
180
+ /**
181
+ The color at the left edge of the tile.
182
+ @param {string | undefined} t - the tile to check
183
+ @returns {Color|false} the color or false if t is not a valid tile
184
+ */
166
185
  const leftColor = (t) =>
167
186
  'cef'.includes(String(t)) ? 'b' : 'abd'.includes(String(t)) ? 'w' : false
168
187
 
169
- /** Add an empty space to the position encoding string.
170
- * @arg {string} code - The position encoding string
171
- * @returns {string}
172
- */
188
+ /**
189
+ Add an empty space to the position encoding string.
190
+ @param {string} code - the position encoding string
191
+ @returns {string} the updated encoding with a blank appended
192
+ */
173
193
  const addBlank = (code) => {
174
194
  // Encode missing tiles as digits 0-9, for 1-10 missing
175
195
  const lastNumber = /** @type {number} */ (code.codePointAt(code.length - 1)) // Undefined if code is empty, which is ok
@@ -181,12 +201,13 @@ const addBlank = (code) => {
181
201
  }
182
202
 
183
203
  // Ugh, this is a dirty little function to get
184
- /** The length of an encoded position row.
185
- * @arg {string} row - the encoded row
186
- * @returns {number} - the actual length of the row
187
- */
204
+ /**
205
+ The length of an encoded position row.
206
+ @param {string} row - the encoded row
207
+ @returns {number} the actual length of the row
208
+ */
188
209
  const codeRowLength = (row) =>
189
- row.replaceAll(/\d/g, (n) => '.'.repeat(Number(n) + 1)).length
210
+ row.replaceAll(/\d/gv, (n) => '.'.repeat(Number(n) + 1)).length
190
211
 
191
212
  // This is where the magic happens
192
213
  /** A digital representation of a Trax game. */
@@ -194,7 +215,10 @@ export class Trax {
194
215
  // Static class properties
195
216
  /** @readonly */
196
217
  static version = version
197
- /** @readonly @type {Record<TraxVariant, string>} */
218
+ /**
219
+ @readonly
220
+ @type {Record<TraxVariant, string>}
221
+ */
198
222
  static names = {
199
223
  trax: 'Trax',
200
224
  traxloop: 'Loop Trax',
@@ -203,43 +227,48 @@ export class Trax {
203
227
  /** @readonly */
204
228
  static variants = new Set(Object.keys(this.names)) // Known variants
205
229
  // Static class methods
206
- /** Create an x,y Point object with special functions.
207
- * @arg {number|PointLike} x - either the x value, or an object with x,y keys
208
- * @arg {number} [y] - if x is a number, y must be provided as well
209
- * @returns {Point} a new Point object
210
- */
230
+ /**
231
+ Create an x,y Point object with special functions.
232
+ @param {number|PointLike} x - either the x value, or an object with x,y keys
233
+ @param {number} [y] - if x is a number, y must be provided as well
234
+ @returns {Point} a new Point object
235
+ */
211
236
  static point = (x, y) => new Point(x, y)
212
- /** Given a player number, get the color.
213
- * @arg {number} playerNumber - the player number, 1 or 2
214
- * @returns {Color} the color of that player, w or b
215
- */
237
+ /**
238
+ Given a player number, get the color.
239
+ @param {number} playerNumber - the player number, 1 or 2
240
+ @returns {Color} the color of that player, w or b
241
+ */
216
242
  static colorOf = (playerNumber) => {
217
243
  /** @type {Record<number, Color>} */
218
244
  const map = { 1: 'w', 2: 'b' }
219
245
  return map[playerNumber]
220
246
  }
221
- /** Given a color, get the player number.
222
- * @arg {string} color - the color, w or b
223
- * @returns {number} the player number, 1 or 2
224
- */
247
+ /**
248
+ Given a color, get the player number.
249
+ @param {string} color - the color, w or b
250
+ @returns {number} the player number, 1 or 2
251
+ */
225
252
  static playerNumber = (color) => {
226
253
  /** @type {Record<string, number>} */
227
254
  const map = { b: 2, w: 1 }
228
255
  return map[color]
229
256
  }
230
- /** Given a color, get the other color.
231
- * @arg {string} color - the color, w or b
232
- * @returns {Color} the other color, b or w
233
- */
257
+ /**
258
+ Given a color, get the other color.
259
+ @param {string} color - the color, w or b
260
+ @returns {Color} the other color, b or w
261
+ */
234
262
  static other = (color) => {
235
263
  /** @type {Record<string, Color>} */
236
264
  const map = { b: 'w', w: 'b' }
237
265
  return map[color]
238
266
  }
239
- /** Encode a numeric column number into the Trax notation column letter.
240
- * @arg {number} col - the colum number, with 0 just to the left of the tiles
241
- * @returns {string} the encoded column letter
242
- */
267
+ /**
268
+ Encode a numeric column number into the Trax notation column letter.
269
+ @param {number} col - the column number, with 0 just to the left of the tiles
270
+ @returns {string} the encoded column letter
271
+ */
243
272
  static encodeCol = (col) => {
244
273
  // For notation purposes: 0 -> @, 6 -> F, 28 -> AB
245
274
  let n = ''
@@ -250,10 +279,11 @@ export class Trax {
250
279
 
251
280
  return n || '@'
252
281
  }
253
- /** Decode a Trax notation column letter back to a number.
254
- * @arg {string} col - the Trax column letter
255
- * @returns {number} the column number
256
- */
282
+ /**
283
+ Decode a Trax notation column letter back to a number.
284
+ @param {string} col - the Trax column letter
285
+ @returns {number} the column number
286
+ */
257
287
  static decodeCol = (col) => {
258
288
  // Turn letters back into numbers
259
289
  col = col.toUpperCase()
@@ -268,26 +298,30 @@ export class Trax {
268
298
  }
269
299
 
270
300
  // Trax class constructor
271
- /** Create a new Trax game
272
- * @arg {TraxVariant} [rules='trax'] - the variant to play
273
- * @arg {string|string[]} [moves=''] - the initial moves to pre-play
274
- * @arg {string} [id='trax'] - an id used to differentiate tiles from multiple games
275
- */
301
+ /**
302
+ Create a new Trax game
303
+ @param {TraxVariant} [rules='trax'] - the variant to play
304
+ @param {string|string[]} [moves=''] - the initial moves to pre-play
305
+ @param {string} [id='trax'] - an id used to differentiate tiles from multiple games
306
+ */
276
307
  constructor(rules = 'trax', moves = '', id = 'trax') {
277
308
  if (!Trax.variants.has(rules)) rules = 'trax'
278
309
  this.id = String(id)
279
310
  this.rules = rules
280
- this.move = 0
281
311
  this.turn = 1
282
312
  this.over = false
283
313
  this.left = 1
284
314
  this.right = 0
285
315
  this.top = 1
286
316
  this.bottom = 0
287
- this.notation = ''
317
+ this.count = 0
318
+ /** @type {string[]} */
319
+ this.moves = []
320
+ /** @type {Map<TileId, Point>} */
321
+ this.frontier = new Map()
288
322
  /** @type {Record<TileId, Tile>} */
289
323
  this.tiles = {}
290
- /** @type TileId[] */
324
+ /** @type {TileId[]} */
291
325
  this.path = []
292
326
  this.invalid = false
293
327
  // Moves might be a notation string
@@ -296,10 +330,130 @@ export class Trax {
296
330
  }
297
331
  }
298
332
 
299
- /** Save the current game data to a variable.
300
- * @returns {SaveState} an opaque save state object
301
- * @see restore for restoring the state
302
- */
333
+ /**
334
+ Find the position code with the lowest sort order across all 8
335
+ combinations of mirroring, flipping, and rotating.
336
+ @param {boolean} [colorSwap] - swap tile colors before comparing
337
+ @returns {string} the minimal encoded position code
338
+ */
339
+ #minimalCode(colorSwap) {
340
+ let min = 'z'
341
+ const bools = [true, false]
342
+ for (const rightToLeft of bools) {
343
+ for (const bottomToTop of bools) {
344
+ for (const rotate of bools) {
345
+ const code = this.#encodePosition({
346
+ rightToLeft,
347
+ bottomToTop,
348
+ rotate,
349
+ colorSwap,
350
+ })
351
+ if (code < min) min = code
352
+ }
353
+ }
354
+ }
355
+
356
+ return min
357
+ }
358
+
359
+ /**
360
+ Encode a single tile for a position code: apply symmetry, highlight it if
361
+ it's part of the winning path, and highlight it if it was just dropped.
362
+ @param {ValidTiles} tile - the tile to encode
363
+ @param {Point} loc - the tile's location
364
+ @param {SymmetryOptions & {drops?: TileDrop}} options - the symmetry and
365
+ drop-highlighting options
366
+ @returns {string} the encoded tile
367
+ */
368
+ #encodeTile(tile, loc, options) {
369
+ const { colorSwap, drops } = options
370
+ /** @type {string} */
371
+ let encoded = applySymmetry(tile, options)
372
+ if (this.path.includes(this.tileId(loc))) {
373
+ // `this.turn` is whoever's winning path is highlighted (or 0 for a
374
+ // tie); canonicalizing colors also canonicalizes that turn, since a
375
+ // swapped black winner is presented as white.
376
+ const pathTurn =
377
+ colorSwap && this.turn
378
+ ? Trax.playerNumber(Trax.other(Trax.colorOf(this.turn)))
379
+ : this.turn
380
+ encoded = String.fromCodePoint(
381
+ /** @type {number} */ (encoded.codePointAt(0)) + pathTurn * 6,
382
+ )
383
+ }
384
+
385
+ if (drops?.dropped?.some((t) => loc.eq(t.loc))) {
386
+ encoded = encoded.toUpperCase()
387
+ }
388
+
389
+ return encoded
390
+ }
391
+
392
+ /**
393
+ Encode a position, applying optional symmetrical operations on it. Backs
394
+ both the public, color-blind `positionCode()` and the color-swap-aware
395
+ `#minimalCode()` used by `canonical`.
396
+ @param {SymmetryOptions & {drops?: TileDrop}} options - reverse order
397
+ horizontally/vertically, rotate the tiles by 90 degrees, swap tile colors,
398
+ and/or highlight the drops of the most recent play
399
+ @returns {string} an encoding of the position
400
+ */
401
+ #encodePosition(options) {
402
+ const { rightToLeft, bottomToTop, rotate } = options
403
+ const startX = rightToLeft ? this.right : this.left
404
+ const startY = bottomToTop ? this.bottom : this.top
405
+ const incX = rightToLeft ? -1 : 1
406
+ const incY = bottomToTop ? -1 : 1
407
+ const code = []
408
+ let line = ''
409
+ let x = startX
410
+ let y = startY
411
+ do {
412
+ const loc = new Point(x, y)
413
+ const tile = this.tileAt(loc) // Normal tile for normal orientation
414
+ if (tile) {
415
+ line += this.#encodeTile(tile, loc, options)
416
+ } else {
417
+ line = addBlank(line)
418
+ }
419
+
420
+ if (rotate) {
421
+ // Increment y more often than x
422
+ y += incY
423
+ if (y < this.top || y > this.bottom) {
424
+ y = startY
425
+ x += incX
426
+ // Remove final number and add to code
427
+ code.push(line.replace(/\d$/v, ''))
428
+ line = ''
429
+ }
430
+ } else {
431
+ // This is the standard order
432
+ x += incX
433
+ if (x < this.left || x > this.right) {
434
+ x = startX
435
+ y += incY
436
+ // Remove final number and add to code
437
+ code.push(line.replace(/\d$/v, ''))
438
+ line = ''
439
+ }
440
+ }
441
+ } while (
442
+ x >= this.left &&
443
+ x <= this.right &&
444
+ y >= this.top &&
445
+ y <= this.bottom
446
+ )
447
+
448
+ if (rotate) code.reverse()
449
+ return code.join(':')
450
+ }
451
+
452
+ /**
453
+ Save the current game data to a variable.
454
+ @returns {SaveState} an opaque save state object
455
+ @see restore for restoring the state
456
+ */
303
457
  save() {
304
458
  // In order to evaluate potential moves, we have to modify the state
305
459
  return {
@@ -311,28 +465,30 @@ export class Trax {
311
465
  right: this.right,
312
466
  top: this.top,
313
467
  bottom: this.bottom,
314
- notation: this.notation,
468
+ moves: [...this.moves],
469
+ frontier: this.frontier.entries().toArray(),
315
470
  tiles: JSON.stringify(this.tiles),
316
471
  path: JSON.stringify(this.path),
317
472
  invalid: this.invalid,
318
473
  }
319
474
  }
320
475
 
321
- /** Restore a previously saved position.
322
- * @arg {SaveState} saved - the previously saved state
323
- * @see save for saving the state
324
- */
476
+ /**
477
+ Restore a previously saved position.
478
+ @param {SaveState} saved - the previously saved state
479
+ @see save for saving the state
480
+ */
325
481
  restore(saved) {
326
482
  // So we need to be able to restore it when we are done
327
483
  this.id = saved.id
328
- this.move = saved.move
329
484
  this.turn = saved.turn
330
485
  this.over = saved.over
331
486
  this.left = saved.left
332
487
  this.right = saved.right
333
488
  this.top = saved.top
334
489
  this.bottom = saved.bottom
335
- this.notation = saved.notation
490
+ this.moves = [...saved.moves]
491
+ this.frontier = new Map(saved.frontier)
336
492
  this.tiles = JSON.parse(saved.tiles)
337
493
  this.path = JSON.parse(saved.path)
338
494
  this.invalid = saved.invalid
@@ -340,6 +496,68 @@ export class Trax {
340
496
  for (const tile of Object.values(this.tiles)) {
341
497
  tile.loc = new Point(tile.loc)
342
498
  }
499
+
500
+ this.count = Object.keys(this.tiles).length
501
+ }
502
+
503
+ /**
504
+ Lightweight checkpoint for internal rollback. Valid only for ancestor restores.
505
+ Callers must be in a non-over, valid game state — rewind() restores those
506
+ fields to those fixed values rather than saving and restoring them.
507
+ @returns {Checkpoint} a lightweight snapshot of mutable board dimensions
508
+ @typedef {{ move: number, count: number, turn: number, left: number, right: number, top: number, bottom: number }} Checkpoint
509
+ */
510
+ checkpoint() {
511
+ return {
512
+ move: this.move,
513
+ count: this.count,
514
+ turn: this.turn,
515
+ left: this.left,
516
+ right: this.right,
517
+ top: this.top,
518
+ bottom: this.bottom,
519
+ }
520
+ }
521
+
522
+ /**
523
+ Rewind to a checkpoint. Only valid for ancestor restores.
524
+ @param {Checkpoint} cp - the checkpoint to rewind to
525
+ */
526
+ rewind(cp) {
527
+ this.count = cp.count
528
+ this.moves.length = cp.move
529
+ this.turn = cp.turn
530
+ this.left = cp.left
531
+ this.right = cp.right
532
+ this.top = cp.top
533
+ this.bottom = cp.bottom
534
+ this.over = false
535
+ this.invalid = false
536
+ this.path = []
537
+ /** @type {Tile[]} */
538
+ const deleted = []
539
+ for (const [key, tile] of Object.entries(this.tiles)) {
540
+ if (tile.move <= cp.move) continue
541
+ deleted.push(tile)
542
+ delete this.tiles[key]
543
+ }
544
+
545
+ for (const tile of deleted) {
546
+ if (tile.loc.around.some((adjacent) => this.tileAt(adjacent))) {
547
+ this.frontier.set(tile.id, tile.loc)
548
+ }
549
+
550
+ for (const neighbor of tile.loc.around) {
551
+ const neighborId = this.tileId(neighbor)
552
+ if (
553
+ !this.tileAt(neighbor) &&
554
+ this.frontier.has(neighborId) &&
555
+ neighbor.around.every((adjacent) => !this.tileAt(adjacent))
556
+ ) {
557
+ this.frontier.delete(neighborId)
558
+ }
559
+ }
560
+ }
343
561
  }
344
562
 
345
563
  /** The name of this variant. */
@@ -347,9 +565,9 @@ export class Trax {
347
565
  return Trax.names[this.rules]
348
566
  }
349
567
 
350
- /** The number of tiles currently in play. */
351
- get count() {
352
- return Object.keys(this.tiles).length
568
+ /** The move number of the last-played move. */
569
+ get move() {
570
+ return this.moves.length
353
571
  }
354
572
 
355
573
  /** The color of the current player, w or b. */
@@ -367,21 +585,23 @@ export class Trax {
367
585
  return this.over ? this.turn : false
368
586
  }
369
587
 
370
- /** Provides the tile ID for the tile at the given location.
371
- * @arg {PointLike} loc
372
- * returns {TileId}
373
- */
588
+ /**
589
+ Provides the tile ID for the tile at the given location.
590
+ @param {PointLike} loc - the location
591
+ @returns {TileId} a string key encoding this game's id and the location
592
+ */
374
593
  tileId(loc) {
375
594
  return this.id + '-' + String(loc.x) + 'x' + String(loc.y)
376
595
  }
377
596
 
378
- /** Add a tile to the board. Note that no validity checking is done here,
379
- * except that invalid tiles are not actually placed on the board, so this
380
- * should only be called externally.
381
- * @arg {TileType} type
382
- * @arg {Point} loc
383
- * @returns {Tile} the tile placed on the board.
384
- */
597
+ /**
598
+ Add a tile to the board. Note that no validity checking is done here,
599
+ except that invalid tiles are not actually placed on the board, so this
600
+ should only be called externally.
601
+ @param {TileType} type - letter 'a'-'f' for valid tiles, 'x' for invalid
602
+ @param {Point} loc - the location
603
+ @returns {Tile} the tile placed on the board
604
+ */
385
605
  addTile(type, loc) {
386
606
  const id = this.tileId(loc)
387
607
  /** @type {Tile} */
@@ -390,6 +610,15 @@ export class Trax {
390
610
  this.invalid = true // Invalid tile is being played
391
611
  } else {
392
612
  this.tiles[id] = tile
613
+ this.count++
614
+ this.frontier.delete(id)
615
+ for (const neighbor of loc.around) {
616
+ const neighborId = this.tileId(neighbor)
617
+ if (!Object.hasOwn(this.tiles, neighborId)) {
618
+ this.frontier.set(neighborId, neighbor)
619
+ }
620
+ }
621
+
393
622
  this.left = Math.min(this.left, loc.x)
394
623
  this.right = Math.max(this.right, loc.x)
395
624
  this.top = Math.min(this.top, loc.y)
@@ -399,19 +628,21 @@ export class Trax {
399
628
  return tile
400
629
  }
401
630
 
402
- /** The type of tile at the given location.
403
- * @arg {PointLike} loc - the location the tile is in
404
- * @returns {ValidTiles|undefined} the type of tile, or undefined if no tile
405
- */
631
+ /**
632
+ The type of tile at the given location.
633
+ @param {PointLike} loc - the location the tile is in
634
+ @returns {ValidTiles|undefined} the type of tile, or undefined if no tile
635
+ */
406
636
  tileAt(loc) {
407
637
  return /** @type {ValidTiles | undefined} */ (
408
638
  (this.tiles[this.tileId(loc)] || {}).type
409
639
  )
410
640
  }
411
641
 
412
- /** Is this tile valid?
413
- * @type {(type: string, loc: Point) => type is ValidTiles}
414
- */
642
+ /**
643
+ Is this tile valid?
644
+ @type {(type: string, loc: Point) => type is ValidTiles}
645
+ */
415
646
  validTile(type, loc) {
416
647
  if (this.count === 0 && loc.x === 0 && loc.y === 0) {
417
648
  return type === 'd' || type === 'e'
@@ -430,16 +661,22 @@ export class Trax {
430
661
  )
431
662
  }
432
663
 
433
- /** Get a list of possible tiles that can be played in this location.
434
- * @arg {Point} loc - the location to check
435
- * @arg {string | boolean} s=false - if provided, a direction modifier, '/', '\\', or '+'
436
- * @returns {TileType[]}
437
- */
438
- possibleTiles(loc, s = false) {
439
- /** @type TileType[] */
664
+ /**
665
+ Get a list of possible tiles that can be played in this location.
666
+ @param {Point} loc - the location to check
667
+ @param {string} [slashFilter] - if provided, a direction modifier, '/', '\\', or '+'
668
+ @returns {TileType[]} the list of valid tile types for this location
669
+ */
670
+ possibleTiles(loc, slashFilter) {
671
+ /** @type {TileType[]} */
440
672
  const possibles = []
441
673
  for (const t of tileTypes) {
442
- if ((!s || s === slash[t]) && this.validTile(t, loc)) possibles.push(t)
674
+ if (
675
+ (!slashFilter || slashFilter === slash[t]) &&
676
+ this.validTile(t, loc)
677
+ ) {
678
+ possibles.push(t)
679
+ }
443
680
  }
444
681
 
445
682
  return possibles
@@ -453,10 +690,11 @@ export class Trax {
453
690
  return this.right - this.left + 1
454
691
  }
455
692
 
456
- /** Determine if a location is valid to play in.
457
- * @arg {Point} loc - the location to check
458
- * @returns {boolean}
459
- */
693
+ /**
694
+ Determine if a location is valid to play in.
695
+ @param {Point} loc - the location to check
696
+ @returns {boolean} true if a tile can be placed here
697
+ */
460
698
  validLocation(loc) {
461
699
  if (this.over) return false
462
700
  if (this.tileAt(loc)) return false
@@ -478,29 +716,23 @@ export class Trax {
478
716
  return false
479
717
  }
480
718
 
481
- /** Find all possible locations to play in. Note that these are not
482
- * necessarily valid locations, just empty ones that border existing tiles.
483
- */
719
+ /**
720
+ Find all possible locations to play in. Note that these are not
721
+ necessarily valid locations, just empty ones that border existing tiles.
722
+ @returns {Point[]} all frontier locations
723
+ */
484
724
  possibleLocations() {
485
725
  if (this.count === 0) return [zero]
486
- /** @type Record<TileId, Point> */
487
- const possibles = {}
488
-
489
- for (const tile of Object.values(this.tiles)) {
490
- for (const loc of tile.loc.around) {
491
- const id = this.tileId(loc)
492
- if (!(id in this.tiles)) possibles[id] = loc
493
- }
494
- }
495
-
496
- return Object.values(possibles)
726
+ return this.frontier.values().toArray()
497
727
  }
498
728
 
499
- /** Find all possible moves as a list of notations,
500
- * ie: ['@1+', '@1/', '@1\\', ...]
501
- *
502
- * Note that moves are not guaranteed to be valid.
503
- */
729
+ /**
730
+ Find all possible moves as a list of notations,
731
+ ie: ['@1+', '@1/', '@1\\', ...]
732
+
733
+ Note that moves are not guaranteed to be valid.
734
+ @returns {string[]} all valid move notations from the current position
735
+ */
504
736
  possibleMoves() {
505
737
  /** @type {string[]} */
506
738
  const possibles = []
@@ -515,10 +747,11 @@ export class Trax {
515
747
  return possibles
516
748
  }
517
749
 
518
- /** Called after a move was just played, to determine the forced moves.
519
- * @arg {Point} loc - the location just played at
520
- * @returns {Tile[]} a list of tiles that should be added as part of the move
521
- */
750
+ /**
751
+ Called after a move was just played, to determine the forced moves.
752
+ @param {Point} loc - the location just played at
753
+ @returns {Tile[]} a list of tiles that should be added as part of the move
754
+ */
522
755
  forcedMoves(loc) {
523
756
  /** @type {Tile[]} */
524
757
  const forced = []
@@ -536,7 +769,7 @@ export class Trax {
536
769
  } else if (possibles.length === 0) {
537
770
  // No possible moves at all
538
771
  const invalid = this.addTile('x', pos)
539
- if (!invalids[invalid.id]) forced.push(invalid)
772
+ if (!Object.hasOwn(invalids, invalid.id)) forced.push(invalid)
540
773
  invalids[invalid.id] = true
541
774
  }
542
775
  }
@@ -545,12 +778,13 @@ export class Trax {
545
778
  return forced
546
779
  }
547
780
 
548
- /** Determine the notation for a move. Note that this must be determined
549
- * BEFORE the move is placed on the board.
550
- * @arg {ValidTiles} type - the tile placed on the board
551
- * @arg {Point} loc - the location the tile is placed
552
- * @returns {string} the notation of the move
553
- */
781
+ /**
782
+ Determine the notation for a move. Note that this must be determined
783
+ BEFORE the move is placed on the board.
784
+ @param {ValidTiles} type - the tile placed on the board
785
+ @param {Point} loc - the location the tile is placed
786
+ @returns {string} the notation of the move
787
+ */
554
788
  notate(type, loc) {
555
789
  let notation = Trax.encodeCol(loc.x - this.left + 1)
556
790
  notation += String(loc.y - this.top + 1)
@@ -558,33 +792,35 @@ export class Trax {
558
792
  return notation
559
793
  }
560
794
 
561
- /** Turn a move notation into a tile type and location.
562
- * @arg {string} notation - a notation for a single move to be played at
563
- * the current board position
564
- * @returns {RawMove} the tile type and location of the move
565
- */
795
+ /**
796
+ Turn a move notation into a tile type and location.
797
+ @param {string} notation - a notation for a single move to be played at
798
+ the current board position
799
+ @returns {RawMove} the tile type and location of the move
800
+ */
566
801
  decodeNotation(notation) {
567
802
  /** @type {RawMove} */
568
803
  const bad = { type: 'x', loc: zero }
569
804
  const match = notation.match(notationRegex)
570
805
  if (match === null) return bad
571
806
  const loc = new Point(
572
- this.left - 1 + Trax.decodeCol(match[1]),
573
- this.top - 1 + Number(match[2]),
807
+ this.left - 1 + Trax.decodeCol(match.groups.col),
808
+ this.top - 1 + Number(match.groups.row),
574
809
  )
575
- const possible = this.possibleTiles(loc, match[3])
810
+ const possible = this.possibleTiles(loc, match.groups.slash)
576
811
  if (possible.length > 0) return { type: possible[0], loc }
577
812
  return bad
578
813
  }
579
814
 
580
- /** Follow a color from one location through one or more tiles to the other
581
- * end of the color line.
582
- * @arg {Color} color - the color to follow
583
- * @arg {Point} loc - the location to start
584
- * @arg {string} from - the edge of the tile to start from
585
- * @returns {LineEnd} the ending location and a list of the tile ids the path
586
- * takes to get there.
587
- */
815
+ /**
816
+ Follow a color from one location through one or more tiles to the other
817
+ end of the color line.
818
+ @param {Color} color - the color to follow
819
+ @param {Point} loc - the location to start
820
+ @param {string} from - the edge of the tile to start from
821
+ @returns {LineEnd} the ending location and a list of the tile ids the path
822
+ takes to get there
823
+ */
588
824
  follow(color, loc, from) {
589
825
  /** @type {TileId[]} */
590
826
  const path = []
@@ -613,11 +849,12 @@ export class Trax {
613
849
  return { loc, path }
614
850
  }
615
851
 
616
- /** Given a tile and a color, follow the line for that color to each end.
617
- * @arg {Color} color - the color of ends of interest
618
- * @arg { Point} loc - the location of the tile of interest
619
- * @returns {LineEnd[]} a list of two items, each one end of the line
620
- */
852
+ /**
853
+ Given a tile and a color, follow the line for that color to each end.
854
+ @param {Color} color - the color of ends of interest
855
+ @param {Point} loc - the location of the tile of interest
856
+ @returns {LineEnd[]} a list of two items, each one end of the line
857
+ */
621
858
  findEnds(color, loc) {
622
859
  /** @type {LineEnd[]} */
623
860
  const ends = []
@@ -629,11 +866,12 @@ export class Trax {
629
866
  return ends
630
867
  }
631
868
 
632
- /** Determine if a given line ends the game.
633
- * @arg {Point} locA - one end of the line
634
- * @arg {Point} locB - the other end of the line
635
- * @returns {boolean} - true if this line wins the game
636
- */
869
+ /**
870
+ Determine if a given line ends the game.
871
+ @param {Point} locA - one end of the line
872
+ @param {Point} locB - the other end of the line
873
+ @returns {boolean} true if this line wins the game
874
+ */
637
875
  lineWin(locA, locB) {
638
876
  if (this.rules === 'traxloop') return false
639
877
  if (this.width > 7 && locA.distX(locB) > this.width) return true
@@ -641,26 +879,32 @@ export class Trax {
641
879
  return false
642
880
  }
643
881
 
644
- /** Determine if the game has ended.
645
- * @arg {Tile[]} tiles - a list of the tiles placed during the last move.
646
- */
882
+ /**
883
+ Determine if the game has ended.
884
+ @param {Tile[]} tiles - a list of the tiles placed during the last move
885
+ */
647
886
  checkWin(tiles) {
648
887
  /** @type {Color|false} */
649
888
  let winner = false
650
889
  const colors = [this.color, Trax.other(this.color)] // Check win for current player first
651
890
  for (const color of colors) {
652
891
  if (winner) continue // Skip the second player if the first has won
653
- for (const tile of tiles) {
654
- if (winner) continue // Skip the remaining tiles once we find a win
892
+ tiles.some((tile) => {
655
893
  const [end1, end2] = this.findEnds(color, tile.loc)
656
894
  if (end1.loc.eq(end2.loc)) {
657
895
  winner = color // Loop win
658
- this.path = end1.path // Both paths are essentially the same
659
- } else if (this.lineWin(end1.loc, end2.loc)) {
896
+ this.path = end1.path
897
+ return true
898
+ }
899
+
900
+ if (this.lineWin(end1.loc, end2.loc)) {
660
901
  winner = color // Line win, paths need to be combined
661
- this.path = [...end1.path.reverse(), ...end2.path.slice(1)]
902
+ this.path = [...end1.path.toReversed(), ...end2.path.slice(1)]
903
+ return true
662
904
  }
663
- }
905
+
906
+ return false
907
+ })
664
908
  }
665
909
 
666
910
  if (winner) {
@@ -678,13 +922,14 @@ export class Trax {
678
922
  }
679
923
  }
680
924
 
681
- /** Play a move. Can be called either as:
682
- * - play(moveNumber, notation) to ensure move safety, or
683
- * - play(notation) for quicker access.
684
- * @arg moveNumber {(number|string)} the move number or the notation
685
- * @arg {string} [notation] - the notation, if a move number was provided
686
- * @returns {{dropped: Tile[], notation: string, valid: boolean}}
687
- */
925
+ /**
926
+ Play a move. Can be called either as:
927
+ - play(moveNumber, notation) to ensure move safety, or
928
+ - play(notation) for quicker access.
929
+ @param {number|string} moveNumber - the move number or the notation
930
+ @param {string} [notation] - the notation, if a move number was provided
931
+ @returns {{dropped: Tile[], notation: string, valid: boolean}} the result of the move
932
+ */
688
933
  play(moveNumber, notation) {
689
934
  // Generic API call for playing a move with move number safety
690
935
  // or, alternately, pass only a single value, notation, for a quick play
@@ -699,11 +944,12 @@ export class Trax {
699
944
  return this.dropTile(notation)
700
945
  }
701
946
 
702
- /** Play one or more moves.
703
- * @arg {string|string[]} moves - the list of moves, provided either as a
704
- * space-separated string of notations, or as a list of notations. Move
705
- * numbers are optional, but if provided will be checked for accuracy.
706
- */
947
+ /**
948
+ Play one or more moves.
949
+ @param {string|string[]} moves - the list of moves, provided either as a
950
+ space-separated string of notations, or as a list of notations. Move
951
+ numbers are optional, but if provided will be checked for accuracy.
952
+ */
707
953
  playMoves(moves) {
708
954
  // Play one or more moves, from a string or a list of moves
709
955
  // If move numbers are included, ensure they are accurate
@@ -711,12 +957,12 @@ export class Trax {
711
957
  moves = moves.join(' ')
712
958
  }
713
959
 
714
- moves = moves.replaceAll('\n', ' ').split(/\s+/)
960
+ moves = moves.replaceAll('\n', ' ').split(/\s+/v)
715
961
 
716
962
  let moveNumber = 0
717
963
  for (const move of moves) {
718
964
  if (moveNumberRegex.test(move)) {
719
- moveNumber = Number(move.replaceAll(/\D/g, ''))
965
+ moveNumber = Number(move.replaceAll(/\D/gv, ''))
720
966
  }
721
967
 
722
968
  if (notationRegex.test(move)) {
@@ -731,39 +977,41 @@ export class Trax {
731
977
  }
732
978
  }
733
979
 
734
- /** Add the current move notation to the notation string
735
- * @arg {string} notation - the notation of the current move
736
- */
737
- updateNotation(notation) {
738
- this.move++
739
- let note = this.move + '. ' + notation
740
- const line = this.notation.split('\n').pop()
741
- if (line) {
742
- note = (line.length + note.length > 79 ? '\n' : ' ') + note
980
+ /** The formatted notation string for the current game, wrapped at 80 columns. */
981
+ get notation() {
982
+ let result = ''
983
+ let lineLength = 0
984
+ for (const [i, move] of this.moves.entries()) {
985
+ const note = `${i + 1}. ${move}`
986
+ if (result) {
987
+ if (lineLength + 1 + note.length > 79) {
988
+ result += `\n${note}`
989
+ lineLength = note.length
990
+ } else {
991
+ result += ` ${note}`
992
+ lineLength += 1 + note.length
993
+ }
994
+ } else {
995
+ result = note
996
+ lineLength = note.length
997
+ }
743
998
  }
744
999
 
745
- this.notation += note
746
- }
747
-
748
- /** An array of the moves made in the game. */
749
- get moves() {
750
- return this.notation
751
- .replaceAll('\n', ' ')
752
- .split(' ')
753
- .filter((n) => Boolean(n) && !n.endsWith('.'))
1000
+ return result
754
1001
  }
755
1002
 
756
- /** Drop a tile onto the board. This is a lower level call. Use play() if possible instead.
757
- * @arg {string|ValidTiles} type - a special move, a tile type, or a notation
758
- * @arg {Point} [loc] - a location if type is a tile type
759
- * @arg {string|boolean} [tentative] - if truthy, the move will not be saved
760
- * @returns {TileDrop} an object representing the results of the drop
761
- */
1003
+ /**
1004
+ Drop a tile onto the board. This is a lower level call. Use play() if possible instead.
1005
+ @param {string|ValidTiles} type - a special move, a tile type, or a notation
1006
+ @param {Point} [loc] - a location if type is a tile type
1007
+ @param {string|boolean} [tentative] - if truthy, the move will not be saved
1008
+ @returns {TileDrop} an object representing the results of the drop
1009
+ */
762
1010
  dropTile(type, loc, tentative) {
763
- /** @type Tile[] */
1011
+ /** @type {Tile[]} */
764
1012
  let dropped = []
765
1013
  let notation = ''
766
- let valid = false
1014
+ let isValid = false
767
1015
  if (['timeout', 'resign', 'draw', 'puzzled'].includes(type)) {
768
1016
  // Handle special events
769
1017
  this.over = true
@@ -773,9 +1021,9 @@ export class Trax {
773
1021
  if (type === 'draw') this.turn = 0 // Draw means we both win
774
1022
  }
775
1023
 
776
- this.updateNotation(type)
1024
+ this.moves.push(type)
777
1025
  notation = type
778
- valid = true
1026
+ isValid = true
779
1027
  } else {
780
1028
  if (type.search(notationRegex) === 0) {
781
1029
  // We were passed a notation
@@ -785,15 +1033,15 @@ export class Trax {
785
1033
  }
786
1034
 
787
1035
  if (loc && this.validLocation(loc) && this.validTile(type, loc)) {
788
- valid = true
789
- const saved = this.save() // Just in case we need to roll back
1036
+ isValid = true
1037
+ const cp = this.checkpoint() // Just in case we need to roll back
790
1038
  notation = this.notate(type, loc) // Determine notation BEFORE we play the move
791
- this.updateNotation(notation)
1039
+ this.moves.push(notation)
792
1040
  dropped.push(this.addTile(type, loc)) // Play the tile
793
1041
  dropped = [...dropped, ...this.forcedMoves(loc)] // Play all forced moves
794
1042
  if (this.invalid || tentative) {
795
- if (this.invalid) valid = false
796
- this.restore(saved) // Abort! abort!
1043
+ if (this.invalid) isValid = false
1044
+ this.rewind(cp) // Abort! abort!
797
1045
  } else {
798
1046
  this.checkWin(dropped)
799
1047
  if (!this.over) this.turn = this.turn === 1 ? 2 : 1
@@ -801,48 +1049,63 @@ export class Trax {
801
1049
  }
802
1050
  }
803
1051
 
804
- return { dropped, notation, valid }
1052
+ return { dropped, notation, valid: isValid }
805
1053
  }
806
1054
 
807
- /** Symmetry helper. Rotates a move around the board in case we are trying to
808
- * play a symmetrical rather than an exact move.
809
- * @arg {string} move - the notation of the move to be rotated
810
- * @returns {string[]} the four rotations of this move
811
- */
1055
+ /**
1056
+ Symmetry helper. Rotates a move around the board in case we are trying to
1057
+ play a symmetrical rather than an exact move.
1058
+ @param {string} move - the notation of the move to be rotated
1059
+ @returns {string[]} the four rotations of this move
1060
+ */
812
1061
  moveRotations(move) {
813
1062
  // Symmetry helper, rotate a move around the board
814
1063
  const match = move.match(notationRegex)
815
1064
  if (match === null) return []
816
- const x = Trax.decodeCol(match[1])
817
- const y = Number(match[2])
1065
+ const x = Trax.decodeCol(match.groups.col)
1066
+ const y = Number(match.groups.row)
818
1067
  const X = this.height - x + 1
819
1068
  const Y = this.width - y + 1
820
- const s = match[3]
1069
+ const s = match.groups.slash
821
1070
  const slashes = s === '+' ? [s] : ['/', '\\']
822
1071
  const moves = new Set()
823
1072
  // This isn't super precise, but it limits the search space ... maybe #TODO?
824
- for (const slash of slashes) {
825
- moves.add(Trax.encodeCol(x) + String(y) + slash)
826
- moves.add(Trax.encodeCol(y) + String(x) + slash)
827
- moves.add(Trax.encodeCol(X) + String(Y) + slash)
828
- moves.add(Trax.encodeCol(Y) + String(X) + slash)
1073
+ for (const slashChar of slashes) {
1074
+ moves.add(Trax.encodeCol(x) + String(y) + slashChar)
1075
+ moves.add(Trax.encodeCol(y) + String(x) + slashChar)
1076
+ moves.add(Trax.encodeCol(X) + String(Y) + slashChar)
1077
+ moves.add(Trax.encodeCol(Y) + String(X) + slashChar)
829
1078
  }
830
1079
 
831
1080
  return [...moves]
832
1081
  }
833
1082
 
834
- /** Play a provisional move if it is valid.
835
- * @arg {string} from - the normalized encoding of the starting position
836
- * @arg {string} to - the normalized encoding of the ending position
837
- * @arg {string} via - the move to be used to transition
838
- * @returns {false|string} if the provisional move is invalid: false; if the
839
- * provisional move will never be valid for any future moves:
840
- * 'delete-provisional'; if the provisional move is valid, the correct
841
- * notation, which may be symmetrically adjusted as needed
842
- */
1083
+ /**
1084
+ Play a provisional move if it is valid.
1085
+ @param {string} from - the canonical encoding of the starting position (or,
1086
+ during the `normalize()` deprecation period, a normalized encoding)
1087
+ @param {string} to - the canonical (or normalized) encoding of the ending
1088
+ position, in the same encoding as `from`
1089
+ @param {string} via - the move to be used to transition
1090
+ @returns {false|string} if the provisional move is invalid: false; if the
1091
+ provisional move will never be valid for any future moves:
1092
+ 'delete-provisional'; if the provisional move is valid, the correct
1093
+ notation, which may be symmetrically adjusted as needed
1094
+ */
843
1095
  provisionalMove(from, to, via) {
1096
+ // A normalized code always starts with W/B/T (normalize() forces it);
1097
+ // a canonical code never does, since it's built purely from tile
1098
+ // letters, digits, and ':'. That makes the two formats
1099
+ // self-distinguishing, so provisional moves saved under the deprecated
1100
+ // normalize() keep resolving correctly without callers having to say
1101
+ // which encoding they used. Once normalize() is removed, this check (and
1102
+ // the corresponding slice below) can be deleted - `code` and `target`
1103
+ // become just `from`/`to` and `this.canonical`.
1104
+ const isLegacy = ['W', 'B', 'T'].includes(from[0])
1105
+ const code = isLegacy ? from.slice(1) : from
1106
+
844
1107
  // Test a provisional move against the current state
845
- const lines = from.slice(1).split(':')
1108
+ const lines = code.split(':')
846
1109
  let cols = 0
847
1110
  for (const line of lines) {
848
1111
  cols = Math.max(cols, codeRowLength(line))
@@ -856,14 +1119,14 @@ export class Trax {
856
1119
  return 'delete-provisional' // Current board is bigger than the provisional move
857
1120
  }
858
1121
 
859
- if (from === this.normalized) {
860
- const saved = this.save() // Save this position
1122
+ if (from === (isLegacy ? this.normalized : this.canonical)) {
1123
+ const cp = this.checkpoint() // Save this position
861
1124
  for (const move of this.moveRotations(via)) {
862
1125
  // Try all symmetrical moves
863
1126
  const drop = this.dropTile(move)
864
1127
  if (drop.valid) {
865
- if (to === this.normalized) return move // This move matched!
866
- this.restore(saved) // Put the board back how it was
1128
+ if (to === (isLegacy ? this.normalized : this.canonical)) return move // This move matched!
1129
+ this.rewind(cp) // Put the board back how it was
867
1130
  }
868
1131
  }
869
1132
  }
@@ -871,120 +1134,73 @@ export class Trax {
871
1134
  return false
872
1135
  }
873
1136
 
874
- /** Get an encoded representation of the current position, useful for drawing
875
- * the board without having to do much analysis.
876
- * @returns {string} the current position code
877
- */
1137
+ /**
1138
+ Get an encoded representation of the current position, useful for drawing
1139
+ the board without having to do much analysis.
1140
+ @returns {string} the current position code
1141
+ */
878
1142
  get icon() {
879
1143
  return this.positionCode()
880
1144
  }
881
1145
 
882
- /** Get an encoded representation of the current position, with a set of
883
- * tiles highlighted differently, useful for showing the effects of a move.
884
- * @arg {TileDrop} drops - the drops of the most recent play
885
- * @returns {string} the current position code, with drops highlighted
886
- */
1146
+ /**
1147
+ Get an encoded representation of the current position, with a set of
1148
+ tiles highlighted differently, useful for showing the effects of a move.
1149
+ @param {TileDrop} drops - the drops of the most recent play
1150
+ @returns {string} the current position code, with drops highlighted
1151
+ */
887
1152
  dropsIcon(drops) {
888
1153
  return this.positionCode(undefined, undefined, undefined, drops)
889
1154
  }
890
1155
 
891
- /** Symmetry helper, draw the board from different angles.
892
- * @arg {boolean} [rightToLeft] - reverse order horizontally
893
- * @arg {boolean} [bottomToTop] - reverse order vertically
894
- * @arg {boolean} [rotate] - rotate the tiles by 90 degrees
895
- * @arg {TileDrop} [drops] - the drops of the most recent play, if you want
896
- * them highlighted
897
- * @returns {string} an encoding of the position
898
- */
1156
+ /**
1157
+ Symmetry helper, draw the board from different angles.
1158
+ @param {boolean} [rightToLeft] - reverse order horizontally
1159
+ @param {boolean} [bottomToTop] - reverse order vertically
1160
+ @param {boolean} [rotate] - rotate the tiles by 90 degrees
1161
+ @param {TileDrop} [drops] - the drops of the most recent play, if you want
1162
+ them highlighted
1163
+ @returns {string} an encoding of the position
1164
+ */
899
1165
  positionCode(rightToLeft, bottomToTop, rotate, drops) {
900
- const startX = rightToLeft ? this.right : this.left
901
- const startY = bottomToTop ? this.bottom : this.top
902
- const incX = rightToLeft ? -1 : 1
903
- const incY = bottomToTop ? -1 : 1
904
- const code = []
905
- let line = ''
906
- let x = startX
907
- let y = startY
908
- do {
909
- const loc = new Point(x, y)
910
- const tile = this.tileAt(loc) // Normal tile for normal orientation
911
- if (tile) {
912
- /** @type string */
913
- let encoded = applySymmetry(tile, rightToLeft, bottomToTop, rotate)
914
- if (this.path.includes(this.tileId(loc))) {
915
- encoded = String.fromCodePoint(
916
- /** @type {number} */ (encoded.codePointAt(0)) + this.turn * 6,
917
- )
918
- }
919
-
920
- if (
921
- drops &&
922
- drops.dropped &&
923
- drops.dropped.some((t) => loc.eq(t.loc))
924
- ) {
925
- encoded = encoded.toUpperCase()
926
- }
927
-
928
- line += encoded
929
- } else {
930
- line = addBlank(line)
931
- }
932
-
933
- if (rotate) {
934
- // Increment y more often than x
935
- y += incY
936
- if (y < this.top || y > this.bottom) {
937
- y = startY
938
- x += incX
939
- // Remove final number and add to code
940
- code.push(line.replace(/\d+$/, ''))
941
- line = ''
942
- }
943
- } else {
944
- // This is the standard order
945
- x += incX
946
- if (x < this.left || x > this.right) {
947
- x = startX
948
- y += incY
949
- // Remove final number and add to code
950
- code.push(line.replace(/\d+$/, ''))
951
- line = ''
952
- }
953
- }
954
- } while (
955
- x >= this.left &&
956
- x <= this.right &&
957
- y >= this.top &&
958
- y <= this.bottom
959
- )
960
-
961
- if (rotate) code.reverse()
962
- return code.join(':')
1166
+ return this.#encodePosition({ rightToLeft, bottomToTop, rotate, drops })
963
1167
  }
964
1168
 
965
- /** Trax has the potential for symmetry, so this gives us the ability to
966
- * examine horizontal, vertical, and rotational symmetry for a color.
967
- * @returns {string} a position code that matches all symmetrical positions
968
- */
1169
+ /**
1170
+ Trax has the potential for symmetry, so this gives us the ability to
1171
+ examine horizontal, vertical, and rotational symmetry for a color.
1172
+ @deprecated Prefer `canonical`, which also collapses color-swapped
1173
+ duplicates - the same puzzle played by the other side. `normalize()` is
1174
+ kept for callers (such as `provisionalMove()`) that need a code sensitive
1175
+ to which color is to move, and is likely to be removed in a future major
1176
+ version.
1177
+ @returns {string} a position code that matches all symmetrical positions
1178
+ */
969
1179
  normalize() {
970
- let norm = 'z'
971
- const bools = [true, false]
972
- for (const rightToLeft of bools) {
973
- for (const bottomToTop of bools) {
974
- for (const rotate of bools) {
975
- const code = this.positionCode(rightToLeft, bottomToTop, rotate)
976
- if (code < norm) norm = code
977
- }
978
- }
979
- }
980
-
981
- return (this.color || 't').toUpperCase() + norm
1180
+ return (this.color || 't').toUpperCase() + this.#minimalCode()
982
1181
  }
983
1182
 
984
- /** Get the normalized code for this position. All symmetrical positions will
985
- * result in the same normalized code.
986
- */
1183
+ /**
1184
+ Get the normalized code for this position. All symmetrical positions will
1185
+ result in the same normalized code.
1186
+ @deprecated Prefer `canonical`; see `normalize()`.
1187
+ @returns {string} the normalized position code
1188
+ */
987
1189
  get normalized() {
988
1190
  return this.normalize()
989
1191
  }
1192
+
1193
+ /**
1194
+ Get the canonical code for this position: colors are swapped, if needed,
1195
+ so the code always reads as though white were about to move (or, once the
1196
+ game is over, as though white won), then minimized across all 8
1197
+ rotations/mirrors. Two positions that are identical up to a color swap and
1198
+ board symmetry - the same puzzle, played by the other side - produce the
1199
+ same canonical code. A tied game has no winner to canonicalize around, so
1200
+ its colors are left as-is.
1201
+ @returns {string} the canonical position code
1202
+ */
1203
+ get canonical() {
1204
+ return this.#minimalCode(this.turn === 2)
1205
+ }
990
1206
  }