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