@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/RELEASE.v1.3.0.md +12 -0
- package/benchmark/benchmarks.json +138 -0
- package/benchmark/engine.bench.js +228 -0
- package/gl-sast-report.json +1 -1
- package/package.json +6 -5
- package/src/engine.d.ts +363 -179
- package/src/engine.js +635 -419
- package/src/version.js +1 -1
- package/RELEASE.v1.1.1.md +0 -5
package/src/engine.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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 =
|
|
88
|
-
const notationRegex = /^([@a-z]+)(
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
/**
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
/**
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
/**
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
/**
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
/**
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
/**
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
/**
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
/**
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
/**
|
|
185
|
-
|
|
186
|
-
|
|
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/
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
/**
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
/**
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
/**
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
/**
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
/**
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
/**
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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.
|
|
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
|
-
/**
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
322
|
-
|
|
323
|
-
|
|
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.
|
|
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
|
|
351
|
-
get
|
|
352
|
-
return
|
|
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
|
-
/**
|
|
371
|
-
|
|
372
|
-
|
|
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
|
-
/**
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
-
/**
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
/**
|
|
413
|
-
|
|
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
|
-
/**
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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 (
|
|
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
|
-
/**
|
|
457
|
-
|
|
458
|
-
|
|
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
|
-
/**
|
|
482
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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
|
-
/**
|
|
519
|
-
|
|
520
|
-
|
|
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
|
|
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
|
-
/**
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
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
|
-
/**
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
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
|
|
573
|
-
this.top - 1 + Number(match
|
|
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
|
|
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
|
-
/**
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
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
|
-
/**
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
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
|
-
/**
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
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
|
-
/**
|
|
645
|
-
|
|
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
|
-
|
|
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
|
|
659
|
-
|
|
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.
|
|
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
|
-
/**
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
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
|
-
/**
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
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/
|
|
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
|
-
/**
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
this.
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
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
|
|
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.
|
|
1024
|
+
this.moves.push(type)
|
|
777
1025
|
notation = type
|
|
778
|
-
|
|
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
|
-
|
|
789
|
-
const
|
|
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.
|
|
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)
|
|
796
|
-
this.
|
|
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
|
-
/**
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
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
|
|
817
|
-
const y = Number(match
|
|
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
|
|
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
|
|
825
|
-
moves.add(Trax.encodeCol(x) + String(y) +
|
|
826
|
-
moves.add(Trax.encodeCol(y) + String(x) +
|
|
827
|
-
moves.add(Trax.encodeCol(X) + String(Y) +
|
|
828
|
-
moves.add(Trax.encodeCol(Y) + String(X) +
|
|
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
|
-
/**
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
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 =
|
|
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
|
|
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.
|
|
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
|
-
/**
|
|
875
|
-
|
|
876
|
-
|
|
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
|
-
/**
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
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
|
-
/**
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
966
|
-
|
|
967
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
985
|
-
|
|
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
|
}
|