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