@slugbugblue/trax 0.4.0 → 0.8.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/CHANGELOG.md +68 -17
- package/README.md +123 -69
- package/docs/engine.md +414 -0
- package/docs/point.md +134 -0
- package/package.json +33 -6
- package/src/cli.js +994 -0
- package/src/engine.js +657 -0
- package/src/point.js +116 -0
- package/src/tty.js +295 -0
- package/src/version.js +2 -0
- package/.eslintrc.yaml +0 -68
- package/engine.js +0 -535
- package/jsconfig.json +0 -8
- package/tty.js +0 -202
package/src/engine.js
ADDED
|
@@ -0,0 +1,657 @@
|
|
|
1
|
+
/* Copyright 2019-2022 Chad Transtrum
|
|
2
|
+
*
|
|
3
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
* you may not use this file except in compliance with the License.
|
|
5
|
+
* You may obtain a copy of the License at
|
|
6
|
+
*
|
|
7
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
*
|
|
9
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
* See the License for the specific language governing permissions and
|
|
13
|
+
* limitations under the License.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
// Trax internals. Woot.
|
|
17
|
+
|
|
18
|
+
// Tile type names are determined by listing the color of line at each edge, clockwise
|
|
19
|
+
// and then sorted alphabetically and given a single letter name, so 'bbww' becomes 'a'
|
|
20
|
+
// which gives us six different tile names: a-f
|
|
21
|
+
|
|
22
|
+
import { Point } from './point.js'
|
|
23
|
+
|
|
24
|
+
// Fun trax helper constants
|
|
25
|
+
|
|
26
|
+
const zero = new Point(0, 0)
|
|
27
|
+
const notationRegex = /^([@a-z]+)(\d+)([/\\+])$/i
|
|
28
|
+
|
|
29
|
+
const slash = { a: '\\', b: '+', c: '/', d: '/', e: '+', f: '\\' }
|
|
30
|
+
|
|
31
|
+
const symmetry = {
|
|
32
|
+
// Convert tile to different views
|
|
33
|
+
flip: { a: 'd', b: 'b', c: 'f', d: 'a', e: 'e', f: 'c' },
|
|
34
|
+
mirror: { a: 'c', b: 'b', c: 'a', d: 'f', e: 'e', f: 'd' },
|
|
35
|
+
swap: { a: 'f', b: 'e', c: 'd', d: 'c', e: 'b', f: 'a' },
|
|
36
|
+
counterclock: { a: 'c', b: 'e', c: 'f', d: 'a', e: 'b', f: 'd' },
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Fun trax helper functions
|
|
40
|
+
|
|
41
|
+
const applySymmetry = (tile, rightToLeft, bottomToTop, rotate) => {
|
|
42
|
+
if (rightToLeft) tile = symmetry.mirror[tile]
|
|
43
|
+
if (bottomToTop) tile = symmetry.flip[tile]
|
|
44
|
+
if (rotate) tile = symmetry.counterclock[tile]
|
|
45
|
+
return tile
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Unexpected note: str.includes(t) is true if t is '' but false if t is undefined
|
|
49
|
+
const upColor = (t) =>
|
|
50
|
+
'abc'.includes(t) ? 'b' : 'def'.includes(t) ? 'w' : false
|
|
51
|
+
const downColor = (t) =>
|
|
52
|
+
'bdf'.includes(t) ? 'b' : 'ace'.includes(t) ? 'w' : false
|
|
53
|
+
const rightColor = (t) =>
|
|
54
|
+
'ade'.includes(t) ? 'b' : 'bcf'.includes(t) ? 'w' : false
|
|
55
|
+
const leftColor = (t) =>
|
|
56
|
+
'cef'.includes(t) ? 'b' : 'abd'.includes(t) ? 'w' : false
|
|
57
|
+
|
|
58
|
+
const addBlank = (code) => {
|
|
59
|
+
// Encode missing tiles as digits 0-9, for 1-10 missing
|
|
60
|
+
const lastNumber = code.codePointAt(code.length - 1) // This is NaN if code is empty, which is ok
|
|
61
|
+
if (lastNumber > 47 && lastNumber < 57) {
|
|
62
|
+
return code.slice(0, -1) + String.fromCodePoint(lastNumber + 1)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return code + '0'
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Ugh, this is a dirty little function to get the length of an encoded position row
|
|
69
|
+
const codeRowLength = (row) =>
|
|
70
|
+
row.replace(/\d/g, (n) => '.'.repeat(Number(n) + 1)).length
|
|
71
|
+
|
|
72
|
+
// This is where the magic happens
|
|
73
|
+
export const Trax = class {
|
|
74
|
+
// Static class properties
|
|
75
|
+
static names = {
|
|
76
|
+
trax: 'Trax',
|
|
77
|
+
traxloop: 'Loop Trax',
|
|
78
|
+
trax8: '8x8 Trax',
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
static variants = new Set(Object.keys(this.names)) // Known variants
|
|
82
|
+
|
|
83
|
+
// Static class methods
|
|
84
|
+
static point = (x, y) => new Point(x, y)
|
|
85
|
+
static colorOf = (playerNumber) => ({ 1: 'w', 2: 'b' }[playerNumber])
|
|
86
|
+
static playerNumber = (color) => ({ b: 2, w: 1 }[color])
|
|
87
|
+
static other = (color) => ({ b: 'w', w: 'b' }[color])
|
|
88
|
+
|
|
89
|
+
static encodeCol = (col) => {
|
|
90
|
+
// For notation purposes: 0 -> @, 6 -> F, 28 -> AB
|
|
91
|
+
let n = ''
|
|
92
|
+
while (col > 0) {
|
|
93
|
+
n = String.fromCodePoint(65 + ((col - 1) % 26)) + n
|
|
94
|
+
col = Math.floor((col - 1) / 26)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return n || '@'
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
static decodeCol = (col) => {
|
|
101
|
+
// Turn letters back into numbers
|
|
102
|
+
col = col.toUpperCase()
|
|
103
|
+
let n = 0
|
|
104
|
+
while (col.length > 0) {
|
|
105
|
+
n += (col.codePointAt(0) - 64) * 26 ** (col.length - 1)
|
|
106
|
+
col = col.slice(1)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return n
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Trax class constructor
|
|
113
|
+
constructor(rules = 'trax', moves = [], id = 'trax') {
|
|
114
|
+
if (!Trax.variants.has(rules)) rules = 'trax'
|
|
115
|
+
this.id = String(id)
|
|
116
|
+
this.rules = rules
|
|
117
|
+
this.move = 0
|
|
118
|
+
this.turn = 1
|
|
119
|
+
this.over = false
|
|
120
|
+
this.left = 1
|
|
121
|
+
this.right = 0
|
|
122
|
+
this.top = 1
|
|
123
|
+
this.bottom = 0
|
|
124
|
+
this.notation = ''
|
|
125
|
+
this.tiles = {}
|
|
126
|
+
this.path = []
|
|
127
|
+
this.invalid = false
|
|
128
|
+
// Moves might be a notation string
|
|
129
|
+
if (moves) {
|
|
130
|
+
if (typeof moves === 'string') moves = moves.split(/\s+/)
|
|
131
|
+
for (const move of moves) {
|
|
132
|
+
if (notationRegex.test(move)) {
|
|
133
|
+
this.dropTile(move)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
save() {
|
|
140
|
+
// In order to evaluate potential moves, we have to modify the state
|
|
141
|
+
return {
|
|
142
|
+
move: this.move,
|
|
143
|
+
turn: this.turn,
|
|
144
|
+
over: this.over,
|
|
145
|
+
left: this.left,
|
|
146
|
+
right: this.right,
|
|
147
|
+
top: this.top,
|
|
148
|
+
bottom: this.bottom,
|
|
149
|
+
notation: this.notation,
|
|
150
|
+
tiles: JSON.stringify(this.tiles),
|
|
151
|
+
path: JSON.stringify(this.path),
|
|
152
|
+
invalid: this.invalid,
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
restore(saved) {
|
|
157
|
+
// So we need to be able to restore it when we are done
|
|
158
|
+
this.move = saved.move
|
|
159
|
+
this.turn = saved.turn
|
|
160
|
+
this.over = saved.over
|
|
161
|
+
this.left = saved.left
|
|
162
|
+
this.right = saved.right
|
|
163
|
+
this.top = saved.top
|
|
164
|
+
this.bottom = saved.bottom
|
|
165
|
+
this.notation = saved.notation
|
|
166
|
+
this.tiles = JSON.parse(saved.tiles)
|
|
167
|
+
this.path = JSON.parse(saved.path)
|
|
168
|
+
this.invalid = saved.invalid
|
|
169
|
+
// Reconstitute the locations
|
|
170
|
+
for (const tile of Object.values(this.tiles)) {
|
|
171
|
+
tile.loc = new Point(tile.loc)
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
get name() {
|
|
176
|
+
// Get the name of this variant
|
|
177
|
+
return Trax.names[this.rules]
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
get count() {
|
|
181
|
+
// Get the number of tiles currently in play
|
|
182
|
+
return Object.keys(this.tiles).length
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
get color() {
|
|
186
|
+
// Get the color of the current player (w or b)
|
|
187
|
+
return Trax.colorOf(this.turn)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
get gameOver() {
|
|
191
|
+
// Returns true if the game is over
|
|
192
|
+
return this.over
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
get winner() {
|
|
196
|
+
// Returns false if the game is still in progress
|
|
197
|
+
return this.over ? this.turn : false // Or 1 or 2 for a win, or 0 for a tie
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
tileId(loc) {
|
|
201
|
+
return this.id + '-' + String(loc.x) + 'x' + String(loc.y)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
addTile(type, loc) {
|
|
205
|
+
// Note: no validity checking is done here
|
|
206
|
+
const id = this.tileId(loc)
|
|
207
|
+
const tile = { type, loc, id, move: this.move, seq: this.count }
|
|
208
|
+
if (type === 'x') {
|
|
209
|
+
this.invalid = true // Invalid tile is being played
|
|
210
|
+
} else {
|
|
211
|
+
this.tiles[id] = tile
|
|
212
|
+
this.left = Math.min(this.left, loc.x)
|
|
213
|
+
this.right = Math.max(this.right, loc.x)
|
|
214
|
+
this.top = Math.min(this.top, loc.y)
|
|
215
|
+
this.bottom = Math.max(this.bottom, loc.y)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return tile
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
tileAt(loc) {
|
|
222
|
+
return (this.tiles[this.tileId(loc)] || {}).type
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
validTile(type, loc) {
|
|
226
|
+
if (this.count === 0 && loc.x === 0 && loc.y === 0) {
|
|
227
|
+
return type === 'd' || type === 'e'
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const up = downColor(this.tileAt(loc.up))
|
|
231
|
+
const down = upColor(this.tileAt(loc.down))
|
|
232
|
+
const left = rightColor(this.tileAt(loc.left))
|
|
233
|
+
const right = leftColor(this.tileAt(loc.right))
|
|
234
|
+
return (
|
|
235
|
+
(up || down || left || right) &&
|
|
236
|
+
(!up || up === upColor(type)) &&
|
|
237
|
+
(!down || down === downColor(type)) &&
|
|
238
|
+
(!left || left === leftColor(type)) &&
|
|
239
|
+
(!right || right === rightColor(type))
|
|
240
|
+
)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
possibleTiles(loc, s = false) {
|
|
244
|
+
// S: slash (/ or \ or +)
|
|
245
|
+
const possibles = []
|
|
246
|
+
for (const t of ['a', 'b', 'c', 'd', 'e', 'f']) {
|
|
247
|
+
if ((!s || s === slash[t]) && this.validTile(t, loc)) possibles.push(t)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return possibles
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
get height() {
|
|
254
|
+
return this.bottom - this.top + 1
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
get width() {
|
|
258
|
+
return this.right - this.left + 1
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
validLocation(loc) {
|
|
262
|
+
if (this.over) return false
|
|
263
|
+
if (this.tileAt(loc)) return false
|
|
264
|
+
if (this.count === 0 && loc.eq(zero)) return true
|
|
265
|
+
if (this.rules === 'trax8') {
|
|
266
|
+
if (this.width > 7 && (loc.x < this.left || loc.x > this.right)) {
|
|
267
|
+
return false
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (this.height > 7 && (loc.y < this.top || loc.y > this.bottom)) {
|
|
271
|
+
return false
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
for (const location of loc.around) {
|
|
276
|
+
if (this.tileAt(location)) return true
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return false
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
possibleLocations() {
|
|
283
|
+
const locs = { [this.tileId(zero)]: zero } // If no tiles are placed, we can play a starting tile
|
|
284
|
+
const maybe = (loc) => {
|
|
285
|
+
// Add a loc if a tile isn't already there
|
|
286
|
+
const id = this.tileId(loc)
|
|
287
|
+
locs[id] = locs[id] || loc
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
for (const tile of Object.values(this.tiles)) {
|
|
291
|
+
locs[tile.id] = 'tile' // Add an existing tile
|
|
292
|
+
tile.loc.around.map((loc) => maybe(loc))
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Get rid of all the real tiles and return all the maybes
|
|
296
|
+
return Object.values(locs).filter((loc) => loc !== 'tile')
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
possibleMoves() {
|
|
300
|
+
// Returns a list of every currently possible move
|
|
301
|
+
// as notations, ie ['@1+', '@1/', '@1\\', ...]
|
|
302
|
+
const possibles = []
|
|
303
|
+
if (this.over) return possibles // Shortcut if the game is over
|
|
304
|
+
for (const loc of this.possibleLocations()) {
|
|
305
|
+
for (const tile of this.possibleTiles(loc)) {
|
|
306
|
+
const drop = this.dropTile(tile, loc, 'tentative')
|
|
307
|
+
if (drop.valid) possibles.push(drop.notation)
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return possibles
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
forcedMoves(loc) {
|
|
315
|
+
// We just played at loc, so return all forced moves
|
|
316
|
+
const forced = []
|
|
317
|
+
const invalids = {}
|
|
318
|
+
let check = loc.around
|
|
319
|
+
while (check.length > 0) {
|
|
320
|
+
const pos = check.shift()
|
|
321
|
+
if (this.validLocation(pos)) {
|
|
322
|
+
const possibles = this.possibleTiles(pos)
|
|
323
|
+
if (possibles.length === 1) {
|
|
324
|
+
// Only 1 possible move
|
|
325
|
+
forced.push(this.addTile(possibles[0], pos))
|
|
326
|
+
check = [...check, ...pos.around]
|
|
327
|
+
} else if (possibles.length === 0) {
|
|
328
|
+
// No possible moves at all
|
|
329
|
+
const invalid = this.addTile('x', pos)
|
|
330
|
+
if (!invalids[invalid.id]) forced.push(invalid)
|
|
331
|
+
invalids[invalid.id] = true
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return forced
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
notate(type, loc) {
|
|
340
|
+
// Notation depends on the position of the board BEFORE the move
|
|
341
|
+
let notation = Trax.encodeCol(loc.x - this.left + 1)
|
|
342
|
+
notation += String(loc.y - this.top + 1)
|
|
343
|
+
notation += slash[type]
|
|
344
|
+
return notation
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
decodeNotation(notation) {
|
|
348
|
+
// Return {type, loc} from a notation
|
|
349
|
+
const bad = { type: 'x', loc: zero }
|
|
350
|
+
const match = notation.match(notationRegex)
|
|
351
|
+
if (match === null) return bad
|
|
352
|
+
const loc = new Point(
|
|
353
|
+
this.left - 1 + Trax.decodeCol(match[1]),
|
|
354
|
+
this.top - 1 + Number(match[2]),
|
|
355
|
+
)
|
|
356
|
+
const possible = this.possibleTiles(loc, match[3])
|
|
357
|
+
if (possible.length > 0) return { type: possible[0], loc }
|
|
358
|
+
return bad
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
follow(color, loc, from) {
|
|
362
|
+
const path = []
|
|
363
|
+
let id = this.tileId(loc)
|
|
364
|
+
let tile = this.tileAt(loc)
|
|
365
|
+
while (tile && path[0] !== id) {
|
|
366
|
+
if (from !== 'top' && upColor(tile) === color) {
|
|
367
|
+
from = 'bottom'
|
|
368
|
+
loc = loc.up
|
|
369
|
+
} else if (from !== 'bottom' && downColor(tile) === color) {
|
|
370
|
+
from = 'top'
|
|
371
|
+
loc = loc.down
|
|
372
|
+
} else if (from !== 'left' && leftColor(tile) === color) {
|
|
373
|
+
from = 'right'
|
|
374
|
+
loc = loc.left
|
|
375
|
+
} else if (from !== 'right' && rightColor(tile) === color) {
|
|
376
|
+
from = 'left'
|
|
377
|
+
loc = loc.right
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
path.push(id)
|
|
381
|
+
id = this.tileId(loc)
|
|
382
|
+
tile = this.tileAt(loc)
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
return { loc, path }
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
findEnds(color, loc) {
|
|
389
|
+
const ends = []
|
|
390
|
+
const tile = this.tileAt(loc)
|
|
391
|
+
if (upColor(tile) === color) ends.push(this.follow(color, loc, 'top'))
|
|
392
|
+
if (downColor(tile) === color) ends.push(this.follow(color, loc, 'bottom'))
|
|
393
|
+
if (leftColor(tile) === color) ends.push(this.follow(color, loc, 'left'))
|
|
394
|
+
if (rightColor(tile) === color) ends.push(this.follow(color, loc, 'right'))
|
|
395
|
+
return ends
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
lineWin(locA, locB) {
|
|
399
|
+
if (this.rules === 'traxloop') return false
|
|
400
|
+
if (this.width > 7 && locA.distX(locB) > this.width) return true
|
|
401
|
+
if (this.height > 7 && locA.distY(locB) > this.height) return true
|
|
402
|
+
return false
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
checkWin(tiles) {
|
|
406
|
+
let winner = false
|
|
407
|
+
const colors = [this.color, Trax.other(this.color)] // Check win for current player first
|
|
408
|
+
for (const color of colors) {
|
|
409
|
+
if (winner) continue // Skip the second player if the first has won
|
|
410
|
+
for (const tile of tiles) {
|
|
411
|
+
if (winner) continue // Skip the remaining tiles once we find a win
|
|
412
|
+
const ends = this.findEnds(color, tile.loc)
|
|
413
|
+
if (ends[0].loc.eq(ends[1].loc)) {
|
|
414
|
+
winner = color // Loop win
|
|
415
|
+
this.path = ends[0].path // Both paths are essentially the same
|
|
416
|
+
} else if (this.lineWin(ends[0].loc, ends[1].loc)) {
|
|
417
|
+
winner = color // Line win, paths need to be combined
|
|
418
|
+
this.path = [...ends[0].path.reverse(), ...ends[1].path.slice(1)]
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if (winner) {
|
|
424
|
+
this.over = true
|
|
425
|
+
this.turn = Trax.playerNumber(winner)
|
|
426
|
+
} else if (
|
|
427
|
+
// 8x8 Trax can end in a tie once there are no valid moves left
|
|
428
|
+
this.rules === 'trax8' &&
|
|
429
|
+
this.width > 7 &&
|
|
430
|
+
this.height > 7 &&
|
|
431
|
+
this.possibleMoves().length === 0
|
|
432
|
+
) {
|
|
433
|
+
this.over = true
|
|
434
|
+
this.turn = 0 // Tie
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
play(notation) {
|
|
439
|
+
// Fast and easy way to play a move
|
|
440
|
+
return this.dropTile(notation)
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
playMove(moveNumber, notation) {
|
|
444
|
+
// Generic API call for playing a move with move number safety
|
|
445
|
+
if (moveNumber === this.move + 1) return this.dropTile(notation)
|
|
446
|
+
return { valid: false }
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
updateNotation(notation) {
|
|
450
|
+
this.move++
|
|
451
|
+
let note = this.move + '. ' + notation
|
|
452
|
+
const line = this.notation.split('\n').pop()
|
|
453
|
+
if (line) {
|
|
454
|
+
note = (line.length + note.length > 79 ? '\n' : ' ') + note
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
this.notation += note
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
get moves() {
|
|
461
|
+
return this.notation
|
|
462
|
+
.replace(/\n/g, ' ')
|
|
463
|
+
.split(' ')
|
|
464
|
+
.filter((n) => !n.endsWith('.'))
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
dropTile(type, loc, tentative = false) {
|
|
468
|
+
// Pass in tentative to not save the move
|
|
469
|
+
let dropped = []
|
|
470
|
+
let notation = ''
|
|
471
|
+
let valid = false
|
|
472
|
+
if (['timeout', 'resign', 'draw', 'puzzled'].includes(type)) {
|
|
473
|
+
// Handle special events
|
|
474
|
+
this.over = true
|
|
475
|
+
if (type !== 'puzzled') {
|
|
476
|
+
// Puzzled: puzzle was not completed; this player wins
|
|
477
|
+
this.turn = this.turn === 1 ? 2 : 1 // Timeout, resign means the other player wins
|
|
478
|
+
if (type === 'draw') this.turn = 0 // Draw means we both win
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
this.updateNotation(type)
|
|
482
|
+
notation = type
|
|
483
|
+
valid = true
|
|
484
|
+
} else {
|
|
485
|
+
if (type.search(notationRegex) === 0) {
|
|
486
|
+
// We were passed a notation
|
|
487
|
+
const decoded = this.decodeNotation(type)
|
|
488
|
+
type = decoded.type
|
|
489
|
+
loc = decoded.loc
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
if (loc && this.validLocation(loc) && this.validTile(type, loc)) {
|
|
493
|
+
valid = true
|
|
494
|
+
const saved = this.save() // Just in case we need to roll back
|
|
495
|
+
notation = this.notate(type, loc) // Determine notation BEFORE we play the move
|
|
496
|
+
this.updateNotation(notation)
|
|
497
|
+
dropped.push(this.addTile(type, loc)) // Play the tile
|
|
498
|
+
dropped = [...dropped, ...this.forcedMoves(loc)] // Play all forced moves
|
|
499
|
+
if (this.invalid || tentative) {
|
|
500
|
+
if (this.invalid) valid = false
|
|
501
|
+
this.restore(saved) // Abort! abort!
|
|
502
|
+
} else {
|
|
503
|
+
this.checkWin(dropped)
|
|
504
|
+
if (!this.over) this.turn = this.turn === 1 ? 2 : 1
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
return { dropped, notation, valid }
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
moveRotations(move) {
|
|
513
|
+
// Symmetry helper, rotate a move around the board
|
|
514
|
+
const match = move.match(notationRegex)
|
|
515
|
+
if (match === null) return []
|
|
516
|
+
const x = Trax.decodeCol(match[1])
|
|
517
|
+
const y = Number(match[2])
|
|
518
|
+
const X = this.height - x + 1
|
|
519
|
+
const Y = this.width - y + 1
|
|
520
|
+
const s = match[3]
|
|
521
|
+
const slashes = s === '+' ? [s] : ['/', '\\']
|
|
522
|
+
const moves = new Set()
|
|
523
|
+
// This isn't super precise, but it limits the search space ... maybe #TODO?
|
|
524
|
+
for (const slash of slashes) {
|
|
525
|
+
moves.add(Trax.encodeCol(x) + String(y) + slash)
|
|
526
|
+
moves.add(Trax.encodeCol(y) + String(x) + slash)
|
|
527
|
+
moves.add(Trax.encodeCol(X) + String(Y) + slash)
|
|
528
|
+
moves.add(Trax.encodeCol(Y) + String(X) + slash)
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
return [...moves]
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
provisionalMove(from, to, via) {
|
|
535
|
+
// Test a provisional move against the current state
|
|
536
|
+
let rows = from.slice(1).split(':')
|
|
537
|
+
let cols = 0
|
|
538
|
+
for (const row of rows) {
|
|
539
|
+
cols = Math.max(cols, codeRowLength(row))
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
rows = rows.length
|
|
543
|
+
if (
|
|
544
|
+
(this.width > rows && this.height > cols) ||
|
|
545
|
+
(this.width > cols && this.height > rows)
|
|
546
|
+
) {
|
|
547
|
+
return 'delete-provisional' // Current board is bigger than the provisional move
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
if (from === this.normalized) {
|
|
551
|
+
const saved = this.save() // Save this position
|
|
552
|
+
for (const move of this.moveRotations(via)) {
|
|
553
|
+
// Try all symmetrical moves
|
|
554
|
+
const drop = this.dropTile(move)
|
|
555
|
+
if (drop.valid) {
|
|
556
|
+
if (to === this.normalized) return move // This move matched!
|
|
557
|
+
this.restore(saved) // Put the board back how it was
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
return false
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
get icon() {
|
|
566
|
+
return this.positionCode()
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
dropsIcon(drops) {
|
|
570
|
+
return this.positionCode(undefined, undefined, undefined, drops)
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// Symmetry helper, draw the board from different angles
|
|
574
|
+
positionCode(rightToLeft, bottomToTop, rotate, drops) {
|
|
575
|
+
const startX = rightToLeft ? this.right : this.left
|
|
576
|
+
const startY = bottomToTop ? this.bottom : this.top
|
|
577
|
+
const incX = rightToLeft ? -1 : 1
|
|
578
|
+
const incY = bottomToTop ? -1 : 1
|
|
579
|
+
const code = []
|
|
580
|
+
let line = ''
|
|
581
|
+
let x = startX
|
|
582
|
+
let y = startY
|
|
583
|
+
do {
|
|
584
|
+
const loc = new Point(x, y)
|
|
585
|
+
let tile = this.tileAt(loc) // Normal tile for normal orientation
|
|
586
|
+
if (tile) {
|
|
587
|
+
tile = applySymmetry(tile, rightToLeft, bottomToTop, rotate)
|
|
588
|
+
if (this.path.includes(this.tileId(loc))) {
|
|
589
|
+
tile = String.fromCodePoint(tile.codePointAt(0) + this.turn * 6)
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
if (
|
|
593
|
+
drops &&
|
|
594
|
+
drops.dropped &&
|
|
595
|
+
drops.dropped.some((t) => loc.eq(t.loc))
|
|
596
|
+
) {
|
|
597
|
+
tile = tile.toUpperCase()
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
line += tile
|
|
601
|
+
} else {
|
|
602
|
+
line = addBlank(line)
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
if (rotate) {
|
|
606
|
+
// Increment y more often than x
|
|
607
|
+
y += incY
|
|
608
|
+
if (y < this.top || y > this.bottom) {
|
|
609
|
+
y = startY
|
|
610
|
+
x += incX
|
|
611
|
+
// Remove final number and add to code
|
|
612
|
+
code.push(line.replace(/\d+$/, ''))
|
|
613
|
+
line = ''
|
|
614
|
+
}
|
|
615
|
+
} else {
|
|
616
|
+
// This is the standard order
|
|
617
|
+
x += incX
|
|
618
|
+
if (x < this.left || x > this.right) {
|
|
619
|
+
x = startX
|
|
620
|
+
y += incY
|
|
621
|
+
// Remove final number and add to code
|
|
622
|
+
code.push(line.replace(/\d+$/, ''))
|
|
623
|
+
line = ''
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
} while (
|
|
627
|
+
x >= this.left &&
|
|
628
|
+
x <= this.right &&
|
|
629
|
+
y >= this.top &&
|
|
630
|
+
y <= this.bottom
|
|
631
|
+
)
|
|
632
|
+
|
|
633
|
+
if (rotate) code.reverse()
|
|
634
|
+
return code.join(':')
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
normalize() {
|
|
638
|
+
// Trax has the potential for symmetry, so this gives us the ability to
|
|
639
|
+
// examine horizontal, vertical, and rotational symmetry for a color
|
|
640
|
+
let norm = 'z'
|
|
641
|
+
const bools = [true, false]
|
|
642
|
+
for (const rightToLeft of bools) {
|
|
643
|
+
for (const bottomToTop of bools) {
|
|
644
|
+
for (const rotate of bools) {
|
|
645
|
+
const code = this.positionCode(rightToLeft, bottomToTop, rotate)
|
|
646
|
+
if (code < norm) norm = code
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
return (this.color || 't').toUpperCase() + norm
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
get normalized() {
|
|
655
|
+
return this.normalize()
|
|
656
|
+
}
|
|
657
|
+
}
|