@slugbugblue/trax 0.11.0 → 0.13.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 CHANGED
@@ -20,16 +20,29 @@
20
20
  // and given a single letter name, so 'bbww' becomes 'a', which gives us six
21
21
  // different tile names: a-f
22
22
 
23
- import { Point } from './point.js'
23
+ import { Point } from '@slugbugblue/trax/point'
24
24
 
25
+ // Type definitions
25
26
  // Fun trax helper constants
26
27
 
27
28
  const zero = new Point(0, 0)
28
29
  const moveNumberRegex = /^(\d+)[.):]?$/
29
30
  const notationRegex = /^([@a-z]+)(\d+)([/\\+])$/i
30
31
 
32
+ /** @type ValidTiles[] */
33
+ const tileTypes = ['a', 'b', 'c', 'd', 'e', 'f']
34
+
35
+ /** Tiles are represented by the letters a-f.
36
+ * Slashes are represented by / \ or +.
37
+ * @readonly
38
+ * @type {Record<ValidTiles, Slash>}
39
+ */
31
40
  const slash = { a: '\\', b: '+', c: '/', d: '/', e: '+', f: '\\' }
32
41
 
42
+ /** Symmetry helper to convert from one tile to another.
43
+ * @readonly
44
+ * @type {Record<string, Record<ValidTiles, ValidTiles>>}
45
+ */
33
46
  const symmetry = {
34
47
  // Convert tile to different views
35
48
  flip: { a: 'd', b: 'b', c: 'f', d: 'a', e: 'e', f: 'c' },
@@ -40,6 +53,13 @@ const symmetry = {
40
53
 
41
54
  // Fun trax helper functions
42
55
 
56
+ /** Apply certain combinations of symmetry.
57
+ * @arg {TileType} tile - the tile to apply symmetry to
58
+ * @arg {boolean} [rightToLeft] - mirror the tile
59
+ * @arg {boolean} [bottomToTop] - flip the tile
60
+ * @arg {boolean} [rotate] - rotate the tile counterclockwise
61
+ * @returns {TileType}
62
+ */
43
63
  const applySymmetry = (tile, rightToLeft, bottomToTop, rotate) => {
44
64
  if (rightToLeft) tile = symmetry.mirror[tile]
45
65
  if (bottomToTop) tile = symmetry.flip[tile]
@@ -48,15 +68,38 @@ const applySymmetry = (tile, rightToLeft, bottomToTop, rotate) => {
48
68
  }
49
69
 
50
70
  // Unexpected note: str.includes(t) is true if t is '' but false if t is undefined
71
+ /** The color at the top of the tile.
72
+ * @arg {string} t - the tile to check
73
+ * @returns {Color|false} the color or false if t is not a valid tile
74
+ */
51
75
  const upColor = (t) =>
52
76
  'abc'.includes(t) ? 'b' : 'def'.includes(t) ? 'w' : false
77
+
78
+ /** The color at the bottom of the tile.
79
+ * @arg {string} t - the tile to check
80
+ * @returns {Color|false} the color or false if t is not a valid tile
81
+ */
53
82
  const downColor = (t) =>
54
83
  'bdf'.includes(t) ? 'b' : 'ace'.includes(t) ? 'w' : false
84
+
85
+ /** The color at the right edge of the tile.
86
+ * @arg {string} t - the tile to check
87
+ * @returns {Color|false} the color or false if t is not a valid tile
88
+ */
55
89
  const rightColor = (t) =>
56
90
  'ade'.includes(t) ? 'b' : 'bcf'.includes(t) ? 'w' : false
91
+
92
+ /** The color at the left edge of the tile.
93
+ * @arg {string} t - the tile to check
94
+ * @returns {Color|false} the color or false if t is not a valid tile
95
+ */
57
96
  const leftColor = (t) =>
58
97
  'cef'.includes(t) ? 'b' : 'abd'.includes(t) ? 'w' : false
59
98
 
99
+ /** Add an empty space to the position encoding string.
100
+ * @arg {string} code - The position encoding string
101
+ * @returns {string}
102
+ */
60
103
  const addBlank = (code) => {
61
104
  // Encode missing tiles as digits 0-9, for 1-10 missing
62
105
  const lastNumber = code.codePointAt(code.length - 1) // This is NaN if code is empty, which is ok
@@ -67,27 +110,60 @@ const addBlank = (code) => {
67
110
  return code + '0'
68
111
  }
69
112
 
70
- // Ugh, this is a dirty little function to get the length of an encoded position row
113
+ // Ugh, this is a dirty little function to get
114
+ /** The length of an encoded position row.
115
+ * @arg {string} row - the encoded row
116
+ * @returns {number} - the actual length of the row
117
+ */
71
118
  const codeRowLength = (row) =>
72
119
  row.replace(/\d/g, (n) => '.'.repeat(Number(n) + 1)).length
73
120
 
74
121
  // This is where the magic happens
122
+ /** A digital representation of a Trax game. */
75
123
  export const Trax = class {
76
124
  // Static class properties
125
+
126
+ /** @readonly @type {Record<TraxVariant, string>} */
77
127
  static names = {
78
128
  trax: 'Trax',
79
129
  traxloop: 'Loop Trax',
80
130
  trax8: '8x8 Trax',
81
131
  }
82
132
 
133
+ /** @readonly */
83
134
  static variants = new Set(Object.keys(this.names)) // Known variants
84
135
 
85
136
  // Static class methods
137
+
138
+ /** Create an x,y Point object with special functions.
139
+ * @arg {number|PointLike} x - either the x value, or an object with x,y keys
140
+ * @arg {number} [y] - if x is a number, y must be provided as well
141
+ * @returns {Point} a new Point object
142
+ */
86
143
  static point = (x, y) => new Point(x, y)
144
+
145
+ /** Given a player number, get the color.
146
+ * @arg {number} playerNumber - the player number, 1 or 2
147
+ * @returns {Color} the color of that player, w or b
148
+ */
87
149
  static colorOf = (playerNumber) => ({ 1: 'w', 2: 'b' }[playerNumber])
150
+
151
+ /** Given a color, get the player number.
152
+ * @arg {string} color - the color, w or b
153
+ * @returns {number} the player number, 1 or 2
154
+ */
88
155
  static playerNumber = (color) => ({ b: 2, w: 1 }[color])
156
+
157
+ /** Given a color, get the other color.
158
+ * @arg {string} color - the color, w or b
159
+ * @returns {Color} the other color, b or w
160
+ */
89
161
  static other = (color) => ({ b: 'w', w: 'b' }[color])
90
162
 
163
+ /** Encode a numeric column number into the Trax notation column letter.
164
+ * @arg {number} col - the colum number, with 0 just to the left of the tiles
165
+ * @returns {string} the encoded column letter
166
+ */
91
167
  static encodeCol = (col) => {
92
168
  // For notation purposes: 0 -> @, 6 -> F, 28 -> AB
93
169
  let n = ''
@@ -99,6 +175,10 @@ export const Trax = class {
99
175
  return n || '@'
100
176
  }
101
177
 
178
+ /** Decode a Trax notation column letter back to a number.
179
+ * @arg {string} col - the Trax column letter
180
+ * @returns {number} the column number
181
+ */
102
182
  static decodeCol = (col) => {
103
183
  // Turn letters back into numbers
104
184
  col = col.toUpperCase()
@@ -112,6 +192,11 @@ export const Trax = class {
112
192
  }
113
193
 
114
194
  // Trax class constructor
195
+ /** Create a new Trax game
196
+ * @arg {TraxVariant} [rules='trax'] - the variant to play
197
+ * @arg {string|string[]} [moves=[]] - the initial moves to pre-play
198
+ * @arg {string} [id='trax'] - an id used to differentiate tiles from multiple games
199
+ */
115
200
  constructor(rules = 'trax', moves = [], id = 'trax') {
116
201
  if (!Trax.variants.has(rules)) rules = 'trax'
117
202
  this.id = String(id)
@@ -124,7 +209,9 @@ export const Trax = class {
124
209
  this.top = 1
125
210
  this.bottom = 0
126
211
  this.notation = ''
212
+ /** @type {Record<TileId, Tile>} */
127
213
  this.tiles = {}
214
+ /** @type TileId[] */
128
215
  this.path = []
129
216
  this.invalid = false
130
217
  // Moves might be a notation string
@@ -133,9 +220,14 @@ export const Trax = class {
133
220
  }
134
221
  }
135
222
 
223
+ /** Save the current game data to a variable.
224
+ * @returns {SaveState} an opaque save state object
225
+ * @see restore for restoring the state
226
+ */
136
227
  save() {
137
228
  // In order to evaluate potential moves, we have to modify the state
138
229
  return {
230
+ id: this.id,
139
231
  move: this.move,
140
232
  turn: this.turn,
141
233
  over: this.over,
@@ -150,8 +242,14 @@ export const Trax = class {
150
242
  }
151
243
  }
152
244
 
245
+ /** Restore a previously saved position.
246
+ * @arg {SaveState} saved - the previously saved state
247
+ * @see save for saving the state
248
+ * @returns {void}
249
+ */
153
250
  restore(saved) {
154
251
  // So we need to be able to restore it when we are done
252
+ this.id = saved.id
155
253
  this.move = saved.move
156
254
  this.turn = saved.turn
157
255
  this.over = saved.over
@@ -169,37 +267,47 @@ export const Trax = class {
169
267
  }
170
268
  }
171
269
 
270
+ /** The name of this variant. */
172
271
  get name() {
173
- // Get the name of this variant
174
272
  return Trax.names[this.rules]
175
273
  }
176
274
 
275
+ /** The number of tiles currently in play. */
177
276
  get count() {
178
- // Get the number of tiles currently in play
179
277
  return Object.keys(this.tiles).length
180
278
  }
181
279
 
280
+ /** The color of the current player, w or b. */
182
281
  get color() {
183
- // Get the color of the current player (w or b)
184
282
  return Trax.colorOf(this.turn)
185
283
  }
186
284
 
285
+ /** True if the game is over. */
187
286
  get gameOver() {
188
- // Returns true if the game is over
189
287
  return this.over
190
288
  }
191
289
 
290
+ /** 1 or 2 for a win, 0 for a tie, false if the game is still in progress. */
192
291
  get winner() {
193
- // Returns false if the game is still in progress
194
- return this.over ? this.turn : false // Or 1 or 2 for a win, or 0 for a tie
292
+ return this.over ? this.turn : false
195
293
  }
196
294
 
295
+ /** Provides the tile ID for the tile at the given location.
296
+ * @arg {PointLike} loc
297
+ * returns {TileId}
298
+ */
197
299
  tileId(loc) {
198
300
  return this.id + '-' + String(loc.x) + 'x' + String(loc.y)
199
301
  }
200
302
 
303
+ /** Add a tile to the board. Note that no validity checking is done here,
304
+ * except that invalid tiles are not actually placed on the board, so this
305
+ * should only be called externally.
306
+ * @arg {TileType} type
307
+ * @arg {Point} loc
308
+ * @returns {Tile} the tile placed on the board.
309
+ */
201
310
  addTile(type, loc) {
202
- // Note: no validity checking is done here
203
311
  const id = this.tileId(loc)
204
312
  const tile = { type, loc, id, move: this.move, seq: this.count }
205
313
  if (type === 'x') {
@@ -215,10 +323,17 @@ export const Trax = class {
215
323
  return tile
216
324
  }
217
325
 
326
+ /** The type of tile at the given location.
327
+ * @arg {PointLike} loc - the location the tile is in
328
+ * @returns {TileType|undefined} the type of tile, or undefined if no tile
329
+ */
218
330
  tileAt(loc) {
219
331
  return (this.tiles[this.tileId(loc)] || {}).type
220
332
  }
221
333
 
334
+ /** Is this tile valid?
335
+ * @type {(type: string, loc: Point) => type is TileType}
336
+ */
222
337
  validTile(type, loc) {
223
338
  if (this.count === 0 && loc.x === 0 && loc.y === 0) {
224
339
  return type === 'd' || type === 'e'
@@ -237,10 +352,15 @@ export const Trax = class {
237
352
  )
238
353
  }
239
354
 
355
+ /** Get a list of possible tiles that can be played in this location.
356
+ * @arg {Point} loc - the location to check
357
+ * @arg {string | boolean} s=false - if provided, a direction modifier, '/', '\\', or '+'
358
+ * @returns {TileType[]}
359
+ */
240
360
  possibleTiles(loc, s = false) {
241
- // S: slash (/ or \ or +)
361
+ /** @type TileType[] */
242
362
  const possibles = []
243
- for (const t of ['a', 'b', 'c', 'd', 'e', 'f']) {
363
+ for (const t of tileTypes) {
244
364
  if ((!s || s === slash[t]) && this.validTile(t, loc)) possibles.push(t)
245
365
  }
246
366
 
@@ -277,20 +397,20 @@ export const Trax = class {
277
397
  }
278
398
 
279
399
  possibleLocations() {
280
- const locs = { [this.tileId(zero)]: zero } // If no tiles are placed, we can play a starting tile
281
- const maybe = (loc) => {
282
- // Add a loc if a tile isn't already there
283
- const id = this.tileId(loc)
284
- locs[id] = locs[id] || loc
285
- }
400
+ if (this.count === 0) return [zero]
401
+ /** @type Record<TileId, Point> */
402
+ const possibles = {}
286
403
 
287
404
  for (const tile of Object.values(this.tiles)) {
288
- locs[tile.id] = 'tile' // Add an existing tile
289
- tile.loc.around.map((loc) => maybe(loc))
405
+ tile.loc.around.map((loc) => {
406
+ const id = this.tileId(loc)
407
+ if (!(id in this.tiles)) possibles[id] = loc
408
+ return loc
409
+ })
290
410
  }
291
411
 
292
412
  // Get rid of all the real tiles and return all the maybes
293
- return Object.values(locs).filter((loc) => loc !== 'tile')
413
+ return Object.values(possibles)
294
414
  }
295
415
 
296
416
  possibleMoves() {
@@ -400,6 +520,7 @@ export const Trax = class {
400
520
  }
401
521
 
402
522
  checkWin(tiles) {
523
+ /** @type {Color|false} */
403
524
  let winner = false
404
525
  const colors = [this.color, Trax.other(this.color)] // Check win for current player first
405
526
  for (const color of colors) {
@@ -432,13 +553,22 @@ export const Trax = class {
432
553
  }
433
554
  }
434
555
 
556
+ /** Play a move. Can be called either as:
557
+ * - play(moveNumber, notation) to ensure move safety, or
558
+ * - play(notation) for quicker access.
559
+ * @arg moveNumber {(number|string)} the move number or the notation
560
+ * @arg {string} [notation] - the notation, if a move number was provided
561
+ * @returns {{dropped: Tile[], notation: string, valid: boolean}}
562
+ */
435
563
  play(moveNumber, notation) {
436
564
  // Generic API call for playing a move with move number safety
437
565
  // or, alternately, pass only a single value, notation, for a quick play
438
566
  if (notation) {
439
- if (moveNumber !== this.move + 1) return { valid: false }
567
+ if (moveNumber !== this.move + 1) {
568
+ return { dropped: [], notation: '', valid: false }
569
+ }
440
570
  } else {
441
- notation = moveNumber
571
+ notation = String(moveNumber)
442
572
  }
443
573
 
444
574
  return this.dropTile(notation)
@@ -482,6 +612,7 @@ export const Trax = class {
482
612
  this.notation += note
483
613
  }
484
614
 
615
+ /** An array of the moves made in the game. */
485
616
  get moves() {
486
617
  return this.notation
487
618
  .replace(/\n/g, ' ')
@@ -489,8 +620,14 @@ export const Trax = class {
489
620
  .filter((n) => !n.endsWith('.'))
490
621
  }
491
622
 
492
- dropTile(type, loc, tentative = false) {
493
- // Pass in tentative to not save the move
623
+ /** Drop a tile onto the board. This is a lower level call. Use play() if possible instead.
624
+ * @arg {string|TileType} type - a special move, a tile type, or a notation
625
+ * @arg {Point} [loc] - a location if type is a tile type
626
+ * @arg {string|boolean} [tentative] - if truthy, the move will not be saved
627
+ * @returns {TileDrop} an object representing the results of the drop
628
+ */
629
+ dropTile(type, loc, tentative) {
630
+ /** @type Tile[] */
494
631
  let dropped = []
495
632
  let notation = ''
496
633
  let valid = false
@@ -607,11 +744,12 @@ export const Trax = class {
607
744
  let y = startY
608
745
  do {
609
746
  const loc = new Point(x, y)
610
- let tile = this.tileAt(loc) // Normal tile for normal orientation
747
+ const tile = this.tileAt(loc) // Normal tile for normal orientation
611
748
  if (tile) {
612
- tile = applySymmetry(tile, rightToLeft, bottomToTop, rotate)
749
+ /** @type string */
750
+ let encoded = applySymmetry(tile, rightToLeft, bottomToTop, rotate)
613
751
  if (this.path.includes(this.tileId(loc))) {
614
- tile = String.fromCodePoint(tile.codePointAt(0) + this.turn * 6)
752
+ encoded = String.fromCodePoint(encoded.codePointAt(0) + this.turn * 6)
615
753
  }
616
754
 
617
755
  if (
@@ -619,10 +757,10 @@ export const Trax = class {
619
757
  drops.dropped &&
620
758
  drops.dropped.some((t) => loc.eq(t.loc))
621
759
  ) {
622
- tile = tile.toUpperCase()
760
+ encoded = encoded.toUpperCase()
623
761
  }
624
762
 
625
- line += tile
763
+ line += encoded
626
764
  } else {
627
765
  line = addBlank(line)
628
766
  }
package/src/point.js CHANGED
@@ -13,71 +13,109 @@
13
13
  * limitations under the License.
14
14
  */
15
15
 
16
- const is = (v, s) => (typeof v).slice(0, 3) === s // Sigh
17
-
18
- // Are you tired of doing x,y movements in multiple statements? I am.
16
+ /** Because javascript doesn't have a "0 < 3 < 7" syntax.
17
+ * @arg {number} end1 - A number on one end or the other
18
+ * @arg {number} mid - The middle number
19
+ * @arg {number} end2 - The other end
20
+ * @returns {boolean} - If the number is in the middle
21
+ */
22
+ const between = (end1, mid, end2) => {
23
+ const low = Math.min(end1, end2)
24
+ const high = Math.max(end1, end2)
25
+ return low <= mid && mid <= high
26
+ }
19
27
 
20
- // Point: an immutable two-dimensional location (x,y)
21
- // with functions for moving around quickly to neighboring points
28
+ /** Are you tired of doing x,y movements in multiple statements?
29
+ *
30
+ * I know I am.
31
+ *
32
+ * Point: an immutable two-dimensional location (x,y) with
33
+ * functions for moving around quickly to neighboring points.
34
+ */
22
35
  export const Point = class {
23
36
  #x = 0
24
37
  #y = 0
25
38
 
26
39
  static dirs = ['up', 'down', 'left', 'right']
27
40
 
41
+ /** You can call this with new Point(point) or new Point({x, y}) or new Point(x, y)
42
+ * @arg {number | PointLike} x - the x value or a point
43
+ * @arg {number} [y] - the y value
44
+ */
28
45
  constructor(x, y) {
29
- if (is(x, 'obj') && is(x.x, 'num') && is(x.y, 'num')) {
30
- // You can call this with new Point(point) or new Point({x,y})
46
+ if (
47
+ typeof x === 'object' &&
48
+ typeof x.x === 'number' &&
49
+ typeof x.y === 'number'
50
+ ) {
31
51
  this.#x = x.x
32
52
  this.#y = x.y
33
53
  } else {
34
- // Or with new Point(x, y)
35
- if (!is(x, 'num')) throw new SyntaxError('x must be a number')
36
- if (!is(y, 'num')) throw new SyntaxError('y must be a number')
54
+ if (typeof x !== 'number' || Number.isNaN(x)) {
55
+ throw new SyntaxError('x must be a number or a point-like object')
56
+ }
57
+
58
+ if (typeof y !== 'number' || Number.isNaN(x)) {
59
+ throw new SyntaxError('y must be a number')
60
+ }
61
+
37
62
  this.#x = x
38
63
  this.#y = y
39
64
  }
40
65
  }
41
66
 
67
+ /** The horizontal value of the point. */
42
68
  get x() {
43
69
  return this.#x
44
70
  }
45
71
 
72
+ /** Throws an error. Points are immutable. */
46
73
  set x(_) {
47
74
  throw new ReferenceError('Point is immutable')
48
75
  }
49
76
 
77
+ /** The vertical value of the point. */
50
78
  get y() {
51
79
  return this.#y
52
80
  }
53
81
 
82
+ /** Throws an error. Points are immutable. */
54
83
  set y(_) {
55
84
  throw new ReferenceError('Point is immutable')
56
85
  }
57
86
 
87
+ /** The point immediately above this point. */
58
88
  get up() {
59
89
  return new Point(this.#x, this.#y - 1)
60
90
  }
61
91
 
92
+ /** The point immediately below this point. */
62
93
  get down() {
63
94
  return new Point(this.#x, this.#y + 1)
64
95
  }
65
96
 
97
+ /** The point to the immediate left of this point. */
66
98
  get left() {
67
99
  return new Point(this.#x - 1, this.#y)
68
100
  }
69
101
 
102
+ /** The point to the immediate right of this point. */
70
103
  get right() {
71
104
  return new Point(this.#x + 1, this.#y)
72
105
  }
73
106
 
107
+ /** A list of the four points that surround this point: up, down, left, and right. */
74
108
  get around() {
75
109
  return [this.up, this.down, this.left, this.right]
76
110
  }
77
111
 
112
+ /** Move in a specific direction by passing in a direction string.
113
+ * @arg {string} direction - any string that begins with u, d, l, or r
114
+ * @returns {Point}
115
+ */
78
116
  dir(direction) {
79
117
  const error = 'direction must be one of: up, down, left, right, u, d, l, r'
80
- if (!is(direction, 'str')) throw new SyntaxError(error)
118
+ if (typeof direction !== 'string') throw new SyntaxError(error)
81
119
  direction = direction.toLowerCase()
82
120
  if (direction[0] === 'u') return this.up
83
121
  if (direction[0] === 'd') return this.down
@@ -86,29 +124,35 @@ export const Point = class {
86
124
  throw new SyntaxError(error)
87
125
  }
88
126
 
127
+ /** @arg {PointLike} from - the point to measure x against. */
89
128
  distX(from) {
90
129
  return Math.abs(this.#x - from.x)
91
130
  }
92
131
 
132
+ /** @arg {PointLike} from - the point to measure y against. */
93
133
  distY(from) {
94
134
  return Math.abs(this.#y - from.y)
95
135
  }
96
136
 
137
+ /** @arg {PointLike} from - the point to measure against. */
97
138
  distance(from) {
98
139
  return Math.hypot(this.distX(from), this.distY(from))
99
140
  }
100
141
 
142
+ /** @arg {PointLike} that - the point to test equality against. */
101
143
  eq(that) {
102
144
  return this.#x === that.x && this.#y === that.y
103
145
  }
104
146
 
105
- in(topLeft, bottomRight) {
106
- return (
107
- this.x >= topLeft.x &&
108
- this.x <= bottomRight.x &&
109
- this.y >= topLeft.y &&
110
- this.y <= bottomRight.y
111
- )
147
+ /** Bounding box corners can be passed in any order.
148
+ * @arg {PointLike} a - one corner of the bounding box
149
+ * @arg {PointLike} b - the opposite corner
150
+ * @returns {boolean} - true if the point is on or in the box
151
+ */
152
+ in(a, b) {
153
+ // Allow for the bounding points to be passed in any order
154
+ // as long as they are opposite corners:
155
+ return between(a.x, this.x, b.x) && between(a.y, this.y, b.y)
112
156
  }
113
157
 
114
158
  // Play nicely with the rest of the world
@@ -117,6 +161,7 @@ export const Point = class {
117
161
  return 'Point(' + this.#x + ',' + this.#y + ')'
118
162
  }
119
163
 
164
+ /** @returns {PointLike} */
120
165
  toJSON() {
121
166
  return { x: this.#x, y: this.#y }
122
167
  }