@slugbugblue/trax 0.2.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 +51 -0
- package/README.md +29 -13
- package/docs/engine.md +64 -0
- package/engine.js +390 -267
- package/package.json +17 -6
- package/tty.js +130 -73
package/engine.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/* Copyright
|
|
1
|
+
/* Copyright 2019-2022 Chad Transtrum
|
|
2
2
|
*
|
|
3
3
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
4
|
* you may not use this file except in compliance with the License.
|
|
@@ -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
|
|
36
37
|
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
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
|
+
}
|
|
40
44
|
|
|
41
|
-
//
|
|
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
|
+
}
|
|
42
68
|
|
|
43
|
-
//
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
+
}
|
|
90
|
+
|
|
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
|
|
@@ -87,19 +118,22 @@ const Trax = class {
|
|
|
87
118
|
this.notation = ''
|
|
88
119
|
this.tiles = {}
|
|
89
120
|
this.path = []
|
|
90
|
-
this.count = 0
|
|
91
121
|
this.invalid = false
|
|
92
|
-
//
|
|
93
|
-
if (
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
122
|
+
// Moves might be a notation string
|
|
123
|
+
if (moves) {
|
|
124
|
+
if (typeof moves === 'string') moves = moves.split(' ')
|
|
125
|
+
for (let move of moves) {
|
|
126
|
+
if (typeof move === 'string') {
|
|
127
|
+
move = { move: this.move, notation: move }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
this.playMove(move.move, move.notation)
|
|
97
131
|
}
|
|
98
|
-
|
|
99
|
-
})
|
|
132
|
+
}
|
|
100
133
|
}
|
|
101
134
|
|
|
102
|
-
save
|
|
135
|
+
save() {
|
|
136
|
+
// In order to evaluate potential moves, we have to modify the state
|
|
103
137
|
return {
|
|
104
138
|
move: this.move,
|
|
105
139
|
turn: this.turn,
|
|
@@ -111,12 +145,12 @@ const Trax = class {
|
|
|
111
145
|
notation: this.notation,
|
|
112
146
|
tiles: JSON.stringify(this.tiles),
|
|
113
147
|
path: JSON.stringify(this.path),
|
|
114
|
-
count: this.count,
|
|
115
148
|
invalid: this.invalid,
|
|
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
|
|
@@ -127,386 +161,475 @@ const Trax = class {
|
|
|
127
161
|
this.notation = saved.notation
|
|
128
162
|
this.tiles = JSON.parse(saved.tiles)
|
|
129
163
|
this.path = JSON.parse(saved.path)
|
|
130
|
-
this.count = saved.count
|
|
131
164
|
this.invalid = saved.invalid
|
|
132
165
|
}
|
|
133
166
|
|
|
134
|
-
get
|
|
135
|
-
|
|
167
|
+
get count() {
|
|
168
|
+
// Get the number of tiles currently in play
|
|
169
|
+
return Object.keys(this.tiles).length
|
|
136
170
|
}
|
|
137
171
|
|
|
138
|
-
get
|
|
172
|
+
get color() {
|
|
173
|
+
// Get the color of the current player (w or b)
|
|
174
|
+
return Trax.colorOf(this.turn)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
get gameOver() {
|
|
178
|
+
// Returns true if the game is over
|
|
139
179
|
return this.over
|
|
140
180
|
}
|
|
141
181
|
|
|
142
|
-
get winner
|
|
143
|
-
|
|
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
|
|
144
185
|
}
|
|
145
186
|
|
|
146
|
-
|
|
187
|
+
tileId(x, y) {
|
|
147
188
|
return this.id + '-' + String(x) + 'x' + String(y)
|
|
148
189
|
}
|
|
149
190
|
|
|
150
|
-
|
|
151
|
-
|
|
191
|
+
addTile(type, x, y) {
|
|
192
|
+
// Note: no validity checking is done here
|
|
193
|
+
const id = this.tileId(x, y)
|
|
152
194
|
const tile = { type, x, y, id, move: this.move, seq: this.count }
|
|
153
195
|
if (type === 'x') {
|
|
154
|
-
this.invalid = true //
|
|
196
|
+
this.invalid = true // Invalid tile is being played
|
|
155
197
|
} else {
|
|
156
198
|
this.tiles[id] = tile
|
|
157
|
-
this.count++
|
|
158
199
|
this.left = Math.min(this.left, x)
|
|
159
200
|
this.right = Math.max(this.right, x)
|
|
160
201
|
this.top = Math.min(this.top, y)
|
|
161
202
|
this.bottom = Math.max(this.bottom, y)
|
|
162
203
|
}
|
|
204
|
+
|
|
163
205
|
return tile
|
|
164
206
|
}
|
|
165
207
|
|
|
166
|
-
|
|
167
|
-
return (this.tiles[this.
|
|
208
|
+
tileAt(x, y) {
|
|
209
|
+
return (this.tiles[this.tileId(x, y)] || {}).type
|
|
168
210
|
}
|
|
169
211
|
|
|
170
|
-
|
|
171
|
-
if (this.count === 0 && x === 0 && y === 0)
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
+
)
|
|
178
225
|
}
|
|
179
226
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
+
|
|
185
234
|
return possibles
|
|
186
235
|
}
|
|
187
236
|
|
|
188
|
-
get height
|
|
237
|
+
get height() {
|
|
189
238
|
return this.bottom - this.top + 1
|
|
190
239
|
}
|
|
191
240
|
|
|
192
|
-
get width
|
|
241
|
+
get width() {
|
|
193
242
|
return this.right - this.left + 1
|
|
194
243
|
}
|
|
195
244
|
|
|
196
|
-
|
|
245
|
+
validLocation(x, y) {
|
|
197
246
|
if (this.over) return false
|
|
198
|
-
if (this.
|
|
247
|
+
if (this.tileAt(x, y)) return false
|
|
199
248
|
if (this.count === 0 && x === 0 && y === 0) return true
|
|
200
249
|
if (this.rules === 'trax8') {
|
|
201
250
|
if (this.width > 7 && (x < this.left || x > this.right)) return false
|
|
202
251
|
if (this.height > 7 && (y < this.top || y > this.bottom)) return false
|
|
203
252
|
}
|
|
253
|
+
|
|
204
254
|
return Boolean(
|
|
205
|
-
this.
|
|
206
|
-
|
|
255
|
+
this.tileAt(x - 1, y) ||
|
|
256
|
+
this.tileAt(x + 1, y) ||
|
|
257
|
+
this.tileAt(x, y - 1) ||
|
|
258
|
+
this.tileAt(x, y + 1),
|
|
207
259
|
)
|
|
208
260
|
}
|
|
209
261
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
|
267
|
+
locs[loc] = locs[loc] || { x, y }
|
|
216
268
|
}
|
|
217
|
-
|
|
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
|
|
275
|
+
maybe(tile.x, tile.y + 1)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Get rid of all the real tiles and return all the maybes
|
|
279
|
+
return Object.values(locs).filter((l) => l !== 'tile')
|
|
218
280
|
}
|
|
219
281
|
|
|
220
|
-
|
|
282
|
+
possibleMoves() {
|
|
283
|
+
// Returns a list of every currently possible move
|
|
221
284
|
// as notations, ie ['@1+', '@1/', '@1\\', ...]
|
|
222
|
-
|
|
223
|
-
if (this.over) return possibles
|
|
224
|
-
this.
|
|
225
|
-
this.
|
|
226
|
-
|
|
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')
|
|
227
290
|
if (drop.valid) possibles.push(drop.notation)
|
|
228
|
-
}
|
|
229
|
-
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
230
294
|
return possibles
|
|
231
295
|
}
|
|
232
296
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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)
|
|
246
313
|
if (!invalids[invalid.id]) forced.push(invalid)
|
|
247
314
|
invalids[invalid.id] = true
|
|
248
315
|
}
|
|
249
316
|
}
|
|
250
317
|
}
|
|
318
|
+
|
|
251
319
|
return forced
|
|
252
320
|
}
|
|
253
321
|
|
|
254
|
-
notate
|
|
255
|
-
|
|
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
|
+
)
|
|
256
327
|
}
|
|
257
328
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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)
|
|
261
333
|
if (match === null) return r
|
|
262
|
-
r.x = this.left - 1 +
|
|
334
|
+
r.x = this.left - 1 + Trax.decodeCol(match[1])
|
|
263
335
|
r.y = this.top - 1 + Number(match[2])
|
|
264
|
-
|
|
336
|
+
const possible = this.possibleTiles(r.x, r.y, match[3])
|
|
265
337
|
if (possible) r.type = possible[0]
|
|
266
338
|
return r
|
|
267
339
|
}
|
|
268
340
|
|
|
269
|
-
follow
|
|
270
|
-
|
|
271
|
-
let id = this.
|
|
272
|
-
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)
|
|
273
345
|
while (tile && path[0] !== id) {
|
|
274
|
-
if (from !== 'top' &&
|
|
346
|
+
if (from !== 'top' && upColor(tile) === color) {
|
|
275
347
|
from = 'bottom'
|
|
276
348
|
y--
|
|
277
|
-
} else if (from !== 'bottom' &&
|
|
349
|
+
} else if (from !== 'bottom' && downColor(tile) === color) {
|
|
278
350
|
from = 'top'
|
|
279
351
|
y++
|
|
280
|
-
} else if (from !== 'left' &&
|
|
352
|
+
} else if (from !== 'left' && leftColor(tile) === color) {
|
|
281
353
|
from = 'right'
|
|
282
354
|
x--
|
|
283
|
-
} else if (from !== 'right' &&
|
|
355
|
+
} else if (from !== 'right' && rightColor(tile) === color) {
|
|
284
356
|
from = 'left'
|
|
285
357
|
x++
|
|
286
358
|
}
|
|
359
|
+
|
|
287
360
|
path.push(id)
|
|
288
|
-
id = this.
|
|
289
|
-
tile = this.
|
|
361
|
+
id = this.tileId(x, y)
|
|
362
|
+
tile = this.tileAt(x, y)
|
|
290
363
|
}
|
|
364
|
+
|
|
291
365
|
return { x, y, path }
|
|
292
366
|
}
|
|
293
367
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
if (
|
|
298
|
-
if (
|
|
299
|
-
if (
|
|
300
|
-
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'))
|
|
301
375
|
return ends
|
|
302
376
|
}
|
|
303
377
|
|
|
304
|
-
|
|
378
|
+
checkWin(tiles) {
|
|
305
379
|
let winner = false
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
if (winner)
|
|
309
|
-
|
|
310
|
-
if (winner)
|
|
311
|
-
|
|
312
|
-
if (ends.length !== 2) return // this should never happen, but if it does, don't die
|
|
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)
|
|
313
386
|
if (ends[0].x === ends[1].x && ends[0].y === ends[1].y) {
|
|
314
|
-
winner = color //
|
|
315
|
-
this.path = ends[0].path //
|
|
316
|
-
} else if (
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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)]
|
|
322
396
|
}
|
|
323
|
-
}
|
|
324
|
-
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
325
400
|
if (winner) {
|
|
326
401
|
this.over = true
|
|
327
|
-
this.turn =
|
|
328
|
-
} else
|
|
402
|
+
this.turn = Trax.playerNumber(winner)
|
|
403
|
+
} else if (
|
|
329
404
|
// 8x8 Trax can end in a tie once there are no valid moves left
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
|
336
412
|
}
|
|
337
413
|
}
|
|
338
414
|
|
|
339
|
-
play
|
|
340
|
-
|
|
415
|
+
play(notation) {
|
|
416
|
+
// Fast and easy way to play a move
|
|
417
|
+
return this.dropTile(notation)
|
|
341
418
|
}
|
|
342
419
|
|
|
343
|
-
|
|
344
|
-
|
|
420
|
+
playMove(moveNumber, notation) {
|
|
421
|
+
// Generic API call for dropTile
|
|
422
|
+
if (moveNumber === this.move) return this.dropTile(notation)
|
|
345
423
|
return { valid: false }
|
|
346
424
|
}
|
|
347
425
|
|
|
348
|
-
|
|
426
|
+
updateNotation(notation) {
|
|
349
427
|
this.move++
|
|
350
|
-
let note = this.move +'. ' + notation
|
|
351
|
-
|
|
428
|
+
let note = this.move + '. ' + notation
|
|
429
|
+
const line = this.notation.split('\n').pop()
|
|
352
430
|
if (line) {
|
|
353
431
|
note = (line.length + note.length > 79 ? '\n' : ' ') + note
|
|
354
432
|
}
|
|
433
|
+
|
|
355
434
|
this.notation += note
|
|
356
435
|
}
|
|
357
436
|
|
|
358
|
-
get moves
|
|
359
|
-
return this.notation
|
|
437
|
+
get moves() {
|
|
438
|
+
return this.notation
|
|
439
|
+
.replace(/\n/g, ' ')
|
|
440
|
+
.split(' ')
|
|
441
|
+
.filter((n) => !n.endsWith('.'))
|
|
360
442
|
}
|
|
361
443
|
|
|
362
|
-
|
|
444
|
+
dropTile(type, x, y, tentative = false) {
|
|
445
|
+
// Pass in tentative to not save the move
|
|
363
446
|
let dropped = []
|
|
364
447
|
let notation = ''
|
|
365
448
|
let valid = false
|
|
366
|
-
if (['timeout', 'resign', 'draw', 'puzzled'].includes(type)) {
|
|
449
|
+
if (['timeout', 'resign', 'draw', 'puzzled'].includes(type)) {
|
|
450
|
+
// Handle special events
|
|
367
451
|
this.over = true
|
|
368
|
-
if (type !== 'puzzled') {
|
|
369
|
-
|
|
370
|
-
|
|
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
|
|
371
456
|
}
|
|
372
|
-
|
|
457
|
+
|
|
458
|
+
this.updateNotation(type)
|
|
373
459
|
notation = type
|
|
374
460
|
valid = true
|
|
375
461
|
} else {
|
|
376
|
-
if (type.search(
|
|
377
|
-
|
|
462
|
+
if (type.search(notationRegex) === 0) {
|
|
463
|
+
// We were passed a notation
|
|
464
|
+
;({ type, x, y } = this.decodeNotation(type))
|
|
378
465
|
}
|
|
379
|
-
|
|
466
|
+
|
|
467
|
+
if (this.validLocation(x, y) && this.validTile(type, x, y)) {
|
|
380
468
|
valid = true
|
|
381
|
-
|
|
382
|
-
notation = this.notate(type, x, y) //
|
|
383
|
-
this.
|
|
384
|
-
dropped.push(this.
|
|
385
|
-
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
|
|
386
474
|
if (this.invalid || tentative) {
|
|
387
|
-
valid = false
|
|
388
|
-
this.restore(saved) //
|
|
475
|
+
if (this.invalid) valid = false
|
|
476
|
+
this.restore(saved) // Abort! abort!
|
|
389
477
|
} else {
|
|
390
|
-
this.
|
|
478
|
+
this.checkWin(dropped)
|
|
391
479
|
if (!this.over) this.turn = this.turn === 1 ? 2 : 1
|
|
392
480
|
}
|
|
393
481
|
}
|
|
394
482
|
}
|
|
483
|
+
|
|
395
484
|
return { dropped, notation, valid }
|
|
396
485
|
}
|
|
397
486
|
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
487
|
+
moveRotations(move) {
|
|
488
|
+
// Symmetry helper, rotate a move around the board
|
|
489
|
+
const match = move.match(notationRegex)
|
|
490
|
+
if (match === null) return []
|
|
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
|
+
)
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
return [...moves]
|
|
405
513
|
}
|
|
406
514
|
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
let
|
|
411
|
-
|
|
412
|
-
|
|
515
|
+
provisionalMove(from, to, via) {
|
|
516
|
+
// Test a provisional move against the current state
|
|
517
|
+
let rows = from.slice(1).split(':')
|
|
518
|
+
let cols = 0
|
|
519
|
+
for (const row of rows) {
|
|
520
|
+
cols = Math.max(cols, codeRowLength(row))
|
|
521
|
+
}
|
|
522
|
+
|
|
413
523
|
rows = rows.length
|
|
414
|
-
if (
|
|
415
|
-
|
|
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
|
|
416
529
|
}
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
for (
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
530
|
+
|
|
531
|
+
if (from === this.normalized) {
|
|
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)
|
|
536
|
+
if (drop.valid) {
|
|
537
|
+
if (to === this.normalized) return move // This move matched!
|
|
538
|
+
this.restore(saved) // Put the board back how it was
|
|
539
|
+
}
|
|
425
540
|
}
|
|
426
541
|
}
|
|
427
|
-
|
|
542
|
+
|
|
543
|
+
return false
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
get icon() {
|
|
547
|
+
return this.positionCode()
|
|
428
548
|
}
|
|
429
549
|
|
|
430
|
-
|
|
431
|
-
return this.
|
|
550
|
+
tentativeIcon(drops) {
|
|
551
|
+
return this.positionCode(undefined, undefined, undefined, drops)
|
|
432
552
|
}
|
|
433
553
|
|
|
434
|
-
//
|
|
435
|
-
|
|
436
|
-
const
|
|
437
|
-
const
|
|
438
|
-
const
|
|
439
|
-
const
|
|
440
|
-
|
|
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 = []
|
|
441
561
|
let line = ''
|
|
442
|
-
let x =
|
|
443
|
-
let y =
|
|
562
|
+
let x = startX
|
|
563
|
+
let y = startY
|
|
444
564
|
do {
|
|
445
|
-
let tile = this.
|
|
565
|
+
let tile = this.tileAt(x, y) // Normal tile for normal orientation
|
|
446
566
|
if (tile) {
|
|
447
|
-
|
|
448
|
-
if (
|
|
449
|
-
|
|
450
|
-
if (swap_colors) tile = symmetry.swap[tile]
|
|
451
|
-
if (this.path.includes(this.tile_id(x, y))) {
|
|
452
|
-
tile = String.fromCharCode(tile.charCodeAt(0) + this.turn * 6)
|
|
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)
|
|
453
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()
|
|
454
578
|
line += tile
|
|
455
579
|
} else {
|
|
456
|
-
line =
|
|
580
|
+
line = addBlank(line)
|
|
457
581
|
}
|
|
458
|
-
|
|
459
|
-
|
|
582
|
+
|
|
583
|
+
if (rotate) {
|
|
584
|
+
// Increment y more often than x
|
|
585
|
+
y += incY
|
|
460
586
|
if (y < this.top || y > this.bottom) {
|
|
461
|
-
y =
|
|
462
|
-
x +=
|
|
463
|
-
//
|
|
464
|
-
code.push(line.replace(
|
|
587
|
+
y = startY
|
|
588
|
+
x += incX
|
|
589
|
+
// Remove final number and add to code
|
|
590
|
+
code.push(line.replace(/\d+$/, ''))
|
|
465
591
|
line = ''
|
|
466
592
|
}
|
|
467
|
-
} else {
|
|
468
|
-
|
|
593
|
+
} else {
|
|
594
|
+
// This is the standard order
|
|
595
|
+
x += incX
|
|
469
596
|
if (x < this.left || x > this.right) {
|
|
470
|
-
x =
|
|
471
|
-
y +=
|
|
472
|
-
//
|
|
473
|
-
code.push(line.replace(
|
|
597
|
+
x = startX
|
|
598
|
+
y += incY
|
|
599
|
+
// Remove final number and add to code
|
|
600
|
+
code.push(line.replace(/\d+$/, ''))
|
|
474
601
|
line = ''
|
|
475
602
|
}
|
|
476
603
|
}
|
|
477
|
-
} while (
|
|
604
|
+
} while (
|
|
605
|
+
x >= this.left &&
|
|
606
|
+
x <= this.right &&
|
|
607
|
+
y >= this.top &&
|
|
608
|
+
y <= this.bottom
|
|
609
|
+
)
|
|
610
|
+
|
|
478
611
|
if (rotate) code.reverse()
|
|
479
612
|
return code.join(':')
|
|
480
613
|
}
|
|
481
614
|
|
|
482
|
-
normalize
|
|
483
|
-
//
|
|
615
|
+
normalize() {
|
|
616
|
+
// Trax has the potential for symmetry, so this gives us the ability to
|
|
484
617
|
// examine horizontal, vertical, and rotational symmetry for a color
|
|
485
|
-
// --- pass in a truthy value to get a position for the opposite color
|
|
486
618
|
let norm = 'z'
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
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)
|
|
492
624
|
if (code < norm) norm = code
|
|
493
|
-
}
|
|
494
|
-
}
|
|
495
|
-
}
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
496
629
|
return (this.color || 't').toUpperCase() + norm
|
|
497
630
|
}
|
|
498
631
|
|
|
499
|
-
get normalized
|
|
500
|
-
// the default call does not check for color symmetry, because you rarely need that
|
|
632
|
+
get normalized() {
|
|
501
633
|
return this.normalize()
|
|
502
634
|
}
|
|
503
635
|
}
|
|
504
|
-
|
|
505
|
-
// add class methods
|
|
506
|
-
Trax.column = encode_col
|
|
507
|
-
Trax.variants = variants
|
|
508
|
-
Trax.color_of = color_of
|
|
509
|
-
Trax.player_num = player_num
|
|
510
|
-
Trax.other = other
|
|
511
|
-
|
|
512
|
-
export default Trax
|