@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/engine.js DELETED
@@ -1,535 +0,0 @@
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
- const variants = ['trax', 'traxloop', 'trax8'] // known variants
23
-
24
- // fun trax helper constants
25
-
26
- const slash = { a: '\\', b: '+', c: '/', d: '/', e: '+', f: '\\' }
27
-
28
- const symmetry = { // convert tile to different views
29
- flip: { a: 'd', b: 'b', c: 'f', d: 'a', e: 'e', f: 'c' },
30
- mirror: { a: 'c', b: 'b', c: 'a', d: 'f', e: 'e', f: 'd' },
31
- swap: { a: 'f', b: 'e', c: 'd', d: 'c', e: 'b', f: 'a' },
32
- counterclock: { a: 'c', b: 'e', c: 'f', d: 'a', e: 'b', f: 'd' },
33
- }
34
-
35
- const notation_regex = /^ *([@a-z]+) *(\d+) *([/\\+]) *$/i
36
-
37
- const color_of = { 1: 'w', 2: 'b' }
38
- const player_num = { b: 2, w: 1 }
39
- const other = { b: 'w', w: 'b' }
40
-
41
- // fun trax helper functions
42
-
43
- // unexpected note: str.includes(t) is true if t is '' but false if t is undefined
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)
54
- }
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
-
74
- // this is where the magic happens
75
- const Trax = class {
76
- constructor (rules='trax', moves=[], id='trax') {
77
- if (!variants.includes(rules)) rules = 'trax'
78
- this.id = String(id)
79
- this.rules = rules
80
- this.move = 0
81
- this.turn = 1
82
- this.over = false
83
- this.left = 1
84
- this.right = 0
85
- this.top = 1
86
- this.bottom = 0
87
- this.notation = ''
88
- this.tiles = {}
89
- this.path = []
90
- this.invalid = false
91
- // moves might be a notation string
92
- if (moves) {
93
- if (typeof moves === 'string') moves = moves.split(' ')
94
- moves.forEach(move => {
95
- if (typeof move === 'string') {
96
- move = { move: this.move, notation: move }
97
- }
98
- this.play_move(move.move, move.notation)
99
- })
100
- }
101
- }
102
-
103
- save () { // in order to evaluate potential moves, we have to modify the state
104
- return {
105
- move: this.move,
106
- turn: this.turn,
107
- over: this.over,
108
- left: this.left,
109
- right: this.right,
110
- top: this.top,
111
- bottom: this.bottom,
112
- notation: this.notation,
113
- tiles: JSON.stringify(this.tiles),
114
- path: JSON.stringify(this.path),
115
- invalid: this.invalid,
116
- }
117
- }
118
-
119
- restore (saved) { // so we need to be able to restore it when we are done
120
- this.move = saved.move
121
- this.turn = saved.turn
122
- this.over = saved.over
123
- this.left = saved.left
124
- this.right = saved.right
125
- this.top = saved.top
126
- this.bottom = saved.bottom
127
- this.notation = saved.notation
128
- this.tiles = JSON.parse(saved.tiles)
129
- this.path = JSON.parse(saved.path)
130
- this.invalid = saved.invalid
131
- }
132
-
133
- get count () { // get the number of tiles currently in play
134
- return Object.keys(this.tiles).length
135
- }
136
-
137
- get color () { // get the color of the current player (w or b)
138
- return color_of[this.turn]
139
- }
140
-
141
- get game_over () { // returns true if the game is over
142
- return this.over
143
- }
144
-
145
- get winner () { // returns false if the game is still in progress
146
- return this.over ? this.turn : false // or 1 or 2 for a win, or 0 for a tie
147
- }
148
-
149
- tile_id (x, y) {
150
- return this.id + '-' + String(x) + 'x' + String(y)
151
- }
152
-
153
- add_tile (type, x, y) { // note: no validity checking is done here
154
- const id = this.tile_id(x, y)
155
- const tile = { type, x, y, id, move: this.move, seq: this.count }
156
- if (type === 'x') {
157
- this.invalid = true // invalid tile is being played
158
- } else {
159
- this.tiles[id] = tile
160
- this.left = Math.min(this.left, x)
161
- this.right = Math.max(this.right, x)
162
- this.top = Math.min(this.top, y)
163
- this.bottom = Math.max(this.bottom, y)
164
- }
165
- return tile
166
- }
167
-
168
- tile_at (x, y) {
169
- return (this.tiles[this.tile_id(x, y)] || {}).type
170
- }
171
-
172
- valid_tile (type, x, y) {
173
- if (this.count === 0 && x === 0 && y === 0) return type === 'd' || type === 'e'
174
- let up = down_color(this.tile_at(x, y - 1))
175
- let down = up_color(this.tile_at(x, y + 1))
176
- let left = right_color(this.tile_at(x - 1, y))
177
- let right = left_color(this.tile_at(x + 1, y))
178
- return (!up || up === up_color(type)) && (!down || down === down_color(type)) &&
179
- (!left || left === left_color(type)) && (!right || right === right_color(type))
180
- }
181
-
182
- possible_tiles (x, y, s=false) { // s: slash (/ or \ or +)
183
- let possibles = []
184
- ;['a', 'b', 'c', 'd', 'e', 'f'].forEach(t => {
185
- if ((!s || s === slash[t]) && this.valid_tile(t, x, y)) possibles.push(t)
186
- })
187
- return possibles
188
- }
189
-
190
- get height () {
191
- return this.bottom - this.top + 1
192
- }
193
-
194
- get width () {
195
- return this.right - this.left + 1
196
- }
197
-
198
- valid_location (x, y) {
199
- if (this.over) return false
200
- if (this.tile_at(x, y)) return false
201
- if (this.count === 0 && x === 0 && y === 0) return true
202
- if (this.rules === 'trax8') {
203
- if (this.width > 7 && (x < this.left || x > this.right)) return false
204
- if (this.height > 7 && (y < this.top || y > this.bottom)) return false
205
- }
206
- return Boolean(
207
- this.tile_at(x-1, y) || this.tile_at(x+1, y) ||
208
- this.tile_at(x, y-1) || this.tile_at(x, y+1)
209
- )
210
- }
211
-
212
- possible_locations () {
213
- let locs = { '0x0': { x:0, y:0 } } // if no tiles are placed, we can play a starting tile
214
- const maybe = (x, y) => { // add a loc if a tile isn't already there
215
- let loc = x + 'x' + y
216
- locs[loc] = locs[loc] || { x, y }
217
- }
218
- for (let tile of Object.values(this.tiles)) {
219
- locs[tile.x + 'x' + tile.y] = 'tile' // add an existing tile
220
- maybe(tile.x - 1, tile.y) // and four maybe tiles around it
221
- maybe(tile.x + 1, tile.y) // which will either be replaced by real tiles
222
- maybe(tile.x, tile.y - 1) // or will be potentially valid locations
223
- maybe(tile.x, tile.y + 1)
224
- }
225
- // get rid of all the real tiles and return all the maybes
226
- return Object.values(locs).filter(l => l !== 'tile')
227
- }
228
-
229
- possible_moves () { // returns a list of every currently possible move
230
- // as notations, ie ['@1+', '@1/', '@1\\', ...]
231
- let possibles = []
232
- if (this.over) return possibles // shortcut if the game is over
233
- this.possible_locations().forEach(loc => {
234
- this.possible_tiles(loc.x, loc.y).forEach(tile => {
235
- let drop = this.drop_tile(tile, loc.x, loc.y, 'tentative')
236
- if (drop.valid) possibles.push(drop.notation)
237
- })
238
- })
239
- return possibles
240
- }
241
-
242
- forced_moves (x, y) { // we just played at x, y, so return all forced moves
243
- let forced = []
244
- let invalids = {}
245
- let check = squares_around(x, y)
246
- while (check.length) {
247
- let pos = check.shift()
248
- if (this.valid_location(pos.x, pos.y)) {
249
- let possibles = this.possible_tiles(pos.x, pos.y)
250
- if (possibles.length === 1) { // only 1 possible move
251
- forced.push(this.add_tile(possibles[0], pos.x, pos.y))
252
- check = check.concat(squares_around(pos.x, pos.y))
253
- } else if (possibles.length === 0) { // no possible moves at all
254
- let invalid = this.add_tile('x', pos.x, pos.y)
255
- if (!invalids[invalid.id]) forced.push(invalid)
256
- invalids[invalid.id] = true
257
- }
258
- }
259
- }
260
- return forced
261
- }
262
-
263
- notate (type, x, y) { // notation depends on the position of the board BEFORE the move
264
- return encode_col(x - this.left + 1) + String(y - this.top + 1) + slash[type]
265
- }
266
-
267
- decode_notation (notation) { // return {type, x, y} from a notation
268
- let r = { type: 'x', x: 0, y: 0 } // default to an invalid move
269
- let match = notation.match(notation_regex)
270
- if (match === null) return r
271
- r.x = this.left - 1 + decode_col(match[1])
272
- r.y = this.top - 1 + Number(match[2])
273
- let possible = this.possible_tiles(r.x, r.y, match[3])
274
- if (possible) r.type = possible[0]
275
- return r
276
- }
277
-
278
- follow (color, x, y, from) {
279
- let path = []
280
- let id = this.tile_id(x, y)
281
- let tile = this.tile_at(x, y)
282
- while (tile && path[0] !== id) {
283
- if (from !== 'top' && up_color(tile) === color) {
284
- from = 'bottom'
285
- y--
286
- } else if (from !== 'bottom' && down_color(tile) === color) {
287
- from = 'top'
288
- y++
289
- } else if (from !== 'left' && left_color(tile) === color) {
290
- from = 'right'
291
- x--
292
- } else if (from !== 'right' && right_color(tile) === color) {
293
- from = 'left'
294
- x++
295
- }
296
- path.push(id)
297
- id = this.tile_id(x, y)
298
- tile = this.tile_at(x, y)
299
- }
300
- return { x, y, path }
301
- }
302
-
303
- find_ends (color, x, y) {
304
- let ends = []
305
- let tile = this.tile_at(x, y)
306
- if (up_color(tile) === color) ends.push(this.follow(color, x, y, 'top'))
307
- if (down_color(tile) === color) ends.push(this.follow(color, x, y, 'bottom'))
308
- if (left_color(tile) === color) ends.push(this.follow(color, x, y, 'left'))
309
- if (right_color(tile) === color) ends.push(this.follow(color, x, y, 'right'))
310
- return ends
311
- }
312
-
313
- check_win (tiles) {
314
- let winner = false
315
- let colors = [ this.color, other[this.color] ] // check win for current player first
316
- colors.forEach(color => {
317
- if (winner) return // skip the second player if the first has won
318
- tiles.forEach(tile => {
319
- if (winner) return // skip the remaining tiles once we find a win
320
- let ends = this.find_ends(color, tile.x, tile.y)
321
- if (ends[0].x === ends[1].x && ends[0].y === ends[1].y) {
322
- winner = color // loop win
323
- this.path = ends[0].path // both paths are essentially the same
324
- } else if (this.rules !== 'traxloop') { // check for line win
325
- if (this.width > 7 && Math.abs(ends[0].x - ends[1].x) > this.width ||
326
- this.height > 7 && Math.abs(ends[0].y - ends[1].y) > this.height) {
327
- winner = color // line win, paths need to be combined
328
- this.path = ends[0].path.reverse().concat(ends[1].path.slice(1))
329
- }
330
- }
331
- })
332
- })
333
- if (winner) {
334
- this.over = true
335
- this.turn = player_num[winner]
336
- } else {
337
- // 8x8 Trax can end in a tie once there are no valid moves left
338
- if (this.rules === 'trax8' && this.width > 7 && this.height > 7) {
339
- if (this.possible_moves().length < 1) {
340
- this.over = true
341
- this.turn = 0 // tie
342
- }
343
- }
344
- }
345
- }
346
-
347
- play (notation) { // fast and easy way to play a move
348
- return this.drop_tile(notation)
349
- }
350
-
351
- play_move (move_num, notation) { // generic API call for drop_tile
352
- if (move_num === this.move) return this.drop_tile(notation)
353
- return { valid: false }
354
- }
355
-
356
- update_notation (notation) {
357
- this.move++
358
- let note = this.move +'. ' + notation
359
- let line = this.notation.split('\n').pop()
360
- if (line) {
361
- note = (line.length + note.length > 79 ? '\n' : ' ') + note
362
- }
363
- this.notation += note
364
- }
365
-
366
- get moves () {
367
- return this.notation.replace(/\n/g, ' ').split(' ').filter(n => !n.endsWith('.'))
368
- }
369
-
370
- drop_tile (type, x, y, tentative=false) { // pass in tentative to not save the move
371
- let dropped = []
372
- let notation = ''
373
- let valid = false
374
- if (['timeout', 'resign', 'draw', 'puzzled'].includes(type)) { // handle special events
375
- this.over = true
376
- if (type !== 'puzzled') { // puzzled: puzzle was not completed; this player wins
377
- this.turn = this.turn === 1 ? 2 : 1 // timeout, resign means the other player wins
378
- if (type === 'draw') this.turn = 0 // draw means we both win
379
- }
380
- this.update_notation(type)
381
- notation = type
382
- valid = true
383
- } else {
384
- if (type.search(notation_regex) === 0) { // we were passed a notation
385
- ({ type, x, y } = this.decode_notation(type))
386
- }
387
- if (this.valid_location(x, y) && this.valid_tile(type, x, y)) {
388
- valid = true
389
- let saved = this.save() // just in case we need to roll back
390
- notation = this.notate(type, x, y) // determine notation BEFORE we play the move
391
- this.update_notation(notation)
392
- dropped.push(this.add_tile(type, x, y)) // play the tile
393
- dropped = dropped.concat(this.forced_moves(x, y)) // play all forced moves
394
- if (this.invalid || tentative) {
395
- if (this.invalid) valid = false
396
- this.restore(saved) // abort! abort!
397
- } else {
398
- this.check_win(dropped)
399
- if (!this.over) this.turn = this.turn === 1 ? 2 : 1
400
- }
401
- }
402
- }
403
- return { dropped, notation, valid }
404
- }
405
-
406
- move_rotations (move) { // symmetry helper, rotate a move around the board
407
- let match = move.match(notation_regex)
408
- if (match === null) return []
409
- let x = decode_col(match[1])
410
- let y = Number(match[2])
411
- let s = match[3]
412
- let slashes = s === '+' ? [s] : ['/', '\\']
413
- let moves = new Set()
414
- // this isn't super precise, but it limits the search space ... maybe #TODO?
415
- for (let slash of slashes) {
416
- moves.add(encode_col(x) + String(y) + slash)
417
- moves.add(encode_col(y) + String(x) + slash)
418
- moves.add(encode_col(this.height - x + 1) + String(this.width - y + 1) + slash)
419
- moves.add(encode_col(this.width - y + 1) + String(this.height -x + 1) + slash)
420
- }
421
- return [...moves]
422
- }
423
-
424
- provisional_move (from, to, via) { // test a provisional move against the current state
425
- let rows = from.slice(1).split(':')
426
- let cols = rows.reduce((acc, cur) => Math.max(acc, code_row_len(cur)), 0)
427
- rows = rows.length
428
- if (this.width > rows && this.height > cols || this.width > cols && this.height > rows) {
429
- return 'delete-provisional' // current board is bigger than the provisional move
430
- }
431
- if (from === this.normalized) {
432
- let saved = this.save() // save this position
433
- for (let move of this.move_rotations(via)) { // try all symmetrical moves
434
- let drop = this.drop_tile(move)
435
- if (drop.valid) {
436
- if (to === this.normalized) return move // this move matched!
437
- this.restore(saved) // put the board back how it was
438
- }
439
- }
440
- }
441
- return false
442
- }
443
-
444
- get icon () {
445
- return this.position_code()
446
- }
447
-
448
- tentative_icon (drops) {
449
- return this.position_code(undefined, undefined, undefined, undefined, drops)
450
- }
451
-
452
- // symmetry helper
453
- position_code (right_to_left, bottom_to_top, rotate, swap_colors, drops) {
454
- const start_x = right_to_left ? this.right : this.left
455
- const start_y = bottom_to_top ? this.bottom : this.top
456
- const inc_x = right_to_left ? -1 : 1
457
- const inc_y = bottom_to_top ? -1 : 1
458
- let code = []
459
- let line = ''
460
- let x = start_x
461
- let y = start_y
462
- do {
463
- let tile = this.tile_at(x, y) // normal tile for normal orientation
464
- if (tile) {
465
- if (right_to_left) tile = symmetry.mirror[tile]
466
- if (bottom_to_top) tile = symmetry.flip[tile]
467
- if (rotate) tile = symmetry.counterclock[tile]
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()
474
- }
475
- line += tile
476
- } else {
477
- line = add_blank(line)
478
- }
479
- if (rotate) { // increment y more often than x
480
- y += inc_y
481
- if (y < this.top || y > this.bottom) {
482
- y = start_y
483
- x += inc_x
484
- // remove final number and add to code
485
- code.push(line.replace(/[0-9]+$/, ''))
486
- line = ''
487
- }
488
- } else { // this is the standard order
489
- x += inc_x
490
- if (x < this.left || x > this.right) {
491
- x = start_x
492
- y += inc_y
493
- // remove final number and add to code
494
- code.push(line.replace(/[0-9]+$/, ''))
495
- line = ''
496
- }
497
- }
498
- } while (x >= this.left && x <= this.right && y >= this.top && y <= this.bottom)
499
- if (rotate) code.reverse()
500
- return code.join(':')
501
- }
502
-
503
- normalize (swap_colors=false) {
504
- // trax has the potential for symmetry, so this gives us the ability to
505
- // examine horizontal, vertical, and rotational symmetry for a color
506
- // --- pass in a truthy value to get a position for the opposite color
507
- let norm = 'z'
508
- let bools = [true, false]
509
- bools.forEach(r_to_l => {
510
- bools.forEach(b_to_t => {
511
- bools.forEach(rot => {
512
- let code = this.position_code(r_to_l, b_to_t, rot, swap_colors)
513
- if (code < norm) norm = code
514
- })
515
- })
516
- })
517
- return (this.color || 't').toUpperCase() + norm
518
- }
519
-
520
- get normalized () {
521
- // the default call does not check for color symmetry, because you rarely need that
522
- return this.normalize()
523
- }
524
- }
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
package/jsconfig.json DELETED
@@ -1,8 +0,0 @@
1
- {
2
- "compilerOptions": {
3
- "module": "es2020",
4
- "target": "es2020"
5
- },
6
- "checkJs": true,
7
- "exclude": ["node_modules"]
8
- }