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