@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/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
+ }