@slugbugblue/trax 1.3.0 → 1.3.1

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.
@@ -0,0 +1,8 @@
1
+ ## 1.3.1 - 2026-10-03
2
+
3
+ - Fix `positionCode()`, `normalize()`/`normalized`, and `canonical` throwing
4
+ "Receiver must be an instance of class Trax" when called on an object held in
5
+ reactive state (such as vue's), by replacing the private class methods added
6
+ in 1.3.0 with module-level helpers. Private class members are now avoided on
7
+ purpose; a test guards this.
8
+
@@ -1 +1 @@
1
- {"version":"15.2.4","vulnerabilities":[],"scan":{"analyzer":{"id":"semgrep","name":"Semgrep","url":"https://gitlab.com/gitlab-org/security-products/analyzers/semgrep","vendor":{"name":"GitLab"},"version":"6.19.1"},"scanner":{"id":"semgrep","name":"Semgrep","url":"https://github.com/returntocorp/semgrep","vendor":{"name":"GitLab"},"version":"1.168.0"},"type":"sast","start_time":"2026-08-16T23:13:50","end_time":"2026-08-16T23:13:59","status":"success","observability":{"events":[{"event":"collect_sast_scan_metrics_from_pipeline","property":"b7450097-a7e5-4ab8-88e8-d42c900bb7ec","label":"semgrep","value":0,"version":"6.19.1","exit_code":0,"override_count":0,"passthrough_count":0,"custom_exclude_path_count":0,"time_s":8,"file_count":8}]}}}
1
+ {"version":"15.2.4","vulnerabilities":[],"scan":{"analyzer":{"id":"semgrep","name":"Semgrep","url":"https://gitlab.com/gitlab-org/security-products/analyzers/semgrep","vendor":{"name":"GitLab"},"version":"6.26.1"},"scanner":{"id":"semgrep","name":"Semgrep","url":"https://github.com/returntocorp/semgrep","vendor":{"name":"GitLab"},"version":"1.174.0"},"type":"sast","start_time":"2026-10-03T17:30:51","end_time":"2026-10-03T17:31:00","status":"success","observability":{"events":[{"event":"collect_sast_scan_metrics_from_pipeline","property":"b0a96e4b-7b13-4045-a6a0-5e00f358eec7","label":"semgrep","value":0,"version":"6.26.1","exit_code":0,"override_count":0,"passthrough_count":0,"custom_exclude_path_count":0,"time_s":9,"file_count":8}]}}}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@slugbugblue/trax",
3
- "version": "1.3.0",
3
+ "version": "1.3.1",
4
4
  "description": "Trax game engine",
5
5
  "keywords": [
6
6
  "trax",
@@ -32,7 +32,7 @@
32
32
  "ts-build": "tsc -p tsconfig.build.json && rm -f src/version.d.ts",
33
33
  "test": "xo && c8 ava",
34
34
  "prepare": "[ -n \"$CI\" ] || husky",
35
- "preversion": "npm audit --audit-level=high && npm test",
35
+ "preversion": "npm audit --omit=dev --audit-level=high && npm test",
36
36
  "version": "npm run genversion ; npm run ts-build ; npm run git-add",
37
37
  "postversion": "git push && git push --tags"
38
38
  },
@@ -41,6 +41,7 @@
41
41
  "@slugbugblue/point": "^1.0.0"
42
42
  },
43
43
  "devDependencies": {
44
+ "@types/node": "^26.6.4",
44
45
  "ava": "^8.0.1",
45
46
  "c8": "^12.0.0",
46
47
  "genversion": "^3.0.2",
package/src/engine.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /** A digital representation of a Trax game. */
2
2
  export class Trax {
3
3
  /** @readonly */
4
- static readonly version: '1.3.0'
4
+ static readonly version: '1.3.1'
5
5
  /**
6
6
  @readonly
7
7
  @type {Record<TraxVariant, string>}
@@ -169,7 +169,6 @@ export class Trax {
169
169
  Find all possible moves as a list of notations,
170
170
  ie: ['@1+', '@1/', '@1\\', ...]
171
171
 
172
- Note that moves are not guaranteed to be valid.
173
172
  @returns {string[]} all valid move notations from the current position
174
173
  */
175
174
  possibleMoves(): string[]
@@ -337,7 +336,6 @@ export class Trax {
337
336
  @returns {string} the canonical position code
338
337
  */
339
338
  get canonical(): string
340
- #private
341
339
  }
342
340
  export type Color = 'w' | 'b'
343
341
  /**
package/src/engine.js CHANGED
@@ -89,6 +89,15 @@ const zero = new Point(0, 0)
89
89
  const moveNumberRegex = /^\d+[\).:]?$/v
90
90
  const notationRegex = /^(?<col>[@a-z]+)(?<row>\d+)(?<slash>[+\/\\])$/iv
91
91
 
92
+ /**
93
+ Named groups of a successful `notationRegex` match. TypeScript types
94
+ `groups` as possibly undefined, but it is always set when the regex matches.
95
+ @param {RegExpMatchArray} match - a match of `notationRegex`
96
+ @returns {{col: string, row: string, slash: string}} the notation parts
97
+ */
98
+ const notationGroups = (match) =>
99
+ /** @type {{col: string, row: string, slash: string}} */ (match.groups)
100
+
92
101
  /**
93
102
  Tiles are represented by the letters a-f.
94
103
  a b c d e f
@@ -209,6 +218,128 @@ The length of an encoded position row.
209
218
  const codeRowLength = (row) =>
210
219
  row.replaceAll(/\d/gv, (n) => '.'.repeat(Number(n) + 1)).length
211
220
 
221
+ /**
222
+ Find the position code with the lowest sort order across all 8
223
+ combinations of mirroring, flipping, and rotating.
224
+ @param {Trax} trax - the game to encode
225
+ @param {boolean} [colorSwap] - swap tile colors before comparing
226
+ @returns {string} the minimal encoded position code
227
+ */
228
+ const minimalCode = (trax, colorSwap) => {
229
+ let min = 'z'
230
+ const bools = [true, false]
231
+ for (const rightToLeft of bools) {
232
+ for (const bottomToTop of bools) {
233
+ for (const rotate of bools) {
234
+ const code = encodePosition(trax, {
235
+ rightToLeft,
236
+ bottomToTop,
237
+ rotate,
238
+ colorSwap,
239
+ })
240
+ if (code < min) min = code
241
+ }
242
+ }
243
+ }
244
+
245
+ return min
246
+ }
247
+
248
+ /**
249
+ Encode a single tile for a position code: apply symmetry, highlight it if
250
+ it's part of the winning path, and highlight it if it was just dropped.
251
+ @param {Trax} trax - the game the tile is in
252
+ @param {ValidTiles} tile - the tile to encode
253
+ @param {Point} loc - the tile's location
254
+ @param {SymmetryOptions & {drops?: TileDrop}} options - the symmetry and
255
+ drop-highlighting options
256
+ @returns {string} the encoded tile
257
+ */
258
+ const encodeTile = (trax, tile, loc, options) => {
259
+ const { colorSwap, drops } = options
260
+ /** @type {string} */
261
+ let encoded = applySymmetry(tile, options)
262
+ if (trax.path.includes(trax.tileId(loc))) {
263
+ // `trax.turn` is whoever's winning path is highlighted (or 0 for a
264
+ // tie); canonicalizing colors also canonicalizes that turn, since a
265
+ // swapped black winner is presented as white.
266
+ const pathTurn =
267
+ colorSwap && trax.turn
268
+ ? Trax.playerNumber(Trax.other(Trax.colorOf(trax.turn)))
269
+ : trax.turn
270
+ encoded = String.fromCodePoint(
271
+ /** @type {number} */ (encoded.codePointAt(0)) + pathTurn * 6,
272
+ )
273
+ }
274
+
275
+ if (drops?.dropped?.some((t) => loc.eq(t.loc))) {
276
+ encoded = encoded.toUpperCase()
277
+ }
278
+
279
+ return encoded
280
+ }
281
+
282
+ /**
283
+ Encode a position, applying optional symmetrical operations on it. Backs
284
+ both the public, color-blind `positionCode()` and the color-swap-aware
285
+ `minimalCode()` used by `canonical`.
286
+ @param {Trax} trax - the game to encode
287
+ @param {SymmetryOptions & {drops?: TileDrop}} options - reverse order
288
+ horizontally/vertically, rotate the tiles by 90 degrees, swap tile colors,
289
+ and/or highlight the drops of the most recent play
290
+ @returns {string} an encoding of the position
291
+ */
292
+ const encodePosition = (trax, options) => {
293
+ const { rightToLeft, bottomToTop, rotate } = options
294
+ const startX = rightToLeft ? trax.right : trax.left
295
+ const startY = bottomToTop ? trax.bottom : trax.top
296
+ const incX = rightToLeft ? -1 : 1
297
+ const incY = bottomToTop ? -1 : 1
298
+ const code = []
299
+ let line = ''
300
+ let x = startX
301
+ let y = startY
302
+ do {
303
+ const loc = new Point(x, y)
304
+ const tile = trax.tileAt(loc) // Normal tile for normal orientation
305
+ if (tile) {
306
+ line += encodeTile(trax, tile, loc, options)
307
+ } else {
308
+ line = addBlank(line)
309
+ }
310
+
311
+ if (rotate) {
312
+ // Increment y more often than x
313
+ y += incY
314
+ if (y < trax.top || y > trax.bottom) {
315
+ y = startY
316
+ x += incX
317
+ // Remove final number and add to code
318
+ code.push(line.replace(/\d$/v, ''))
319
+ line = ''
320
+ }
321
+ } else {
322
+ // This is the standard order
323
+ x += incX
324
+ if (x < trax.left || x > trax.right) {
325
+ x = startX
326
+ y += incY
327
+ // Remove final number and add to code
328
+ code.push(line.replace(/\d$/v, ''))
329
+ line = ''
330
+ }
331
+ }
332
+ } while (
333
+ x >= trax.left &&
334
+ x <= trax.right &&
335
+ y >= trax.top &&
336
+ y <= trax.bottom
337
+ )
338
+
339
+ if (rotate) code.reverse()
340
+ return code.join(':')
341
+ }
342
+
212
343
  // This is where the magic happens
213
344
  /** A digital representation of a Trax game. */
214
345
  export class Trax {
@@ -330,125 +461,6 @@ export class Trax {
330
461
  }
331
462
  }
332
463
 
333
- /**
334
- Find the position code with the lowest sort order across all 8
335
- combinations of mirroring, flipping, and rotating.
336
- @param {boolean} [colorSwap] - swap tile colors before comparing
337
- @returns {string} the minimal encoded position code
338
- */
339
- #minimalCode(colorSwap) {
340
- let min = 'z'
341
- const bools = [true, false]
342
- for (const rightToLeft of bools) {
343
- for (const bottomToTop of bools) {
344
- for (const rotate of bools) {
345
- const code = this.#encodePosition({
346
- rightToLeft,
347
- bottomToTop,
348
- rotate,
349
- colorSwap,
350
- })
351
- if (code < min) min = code
352
- }
353
- }
354
- }
355
-
356
- return min
357
- }
358
-
359
- /**
360
- Encode a single tile for a position code: apply symmetry, highlight it if
361
- it's part of the winning path, and highlight it if it was just dropped.
362
- @param {ValidTiles} tile - the tile to encode
363
- @param {Point} loc - the tile's location
364
- @param {SymmetryOptions & {drops?: TileDrop}} options - the symmetry and
365
- drop-highlighting options
366
- @returns {string} the encoded tile
367
- */
368
- #encodeTile(tile, loc, options) {
369
- const { colorSwap, drops } = options
370
- /** @type {string} */
371
- let encoded = applySymmetry(tile, options)
372
- if (this.path.includes(this.tileId(loc))) {
373
- // `this.turn` is whoever's winning path is highlighted (or 0 for a
374
- // tie); canonicalizing colors also canonicalizes that turn, since a
375
- // swapped black winner is presented as white.
376
- const pathTurn =
377
- colorSwap && this.turn
378
- ? Trax.playerNumber(Trax.other(Trax.colorOf(this.turn)))
379
- : this.turn
380
- encoded = String.fromCodePoint(
381
- /** @type {number} */ (encoded.codePointAt(0)) + pathTurn * 6,
382
- )
383
- }
384
-
385
- if (drops?.dropped?.some((t) => loc.eq(t.loc))) {
386
- encoded = encoded.toUpperCase()
387
- }
388
-
389
- return encoded
390
- }
391
-
392
- /**
393
- Encode a position, applying optional symmetrical operations on it. Backs
394
- both the public, color-blind `positionCode()` and the color-swap-aware
395
- `#minimalCode()` used by `canonical`.
396
- @param {SymmetryOptions & {drops?: TileDrop}} options - reverse order
397
- horizontally/vertically, rotate the tiles by 90 degrees, swap tile colors,
398
- and/or highlight the drops of the most recent play
399
- @returns {string} an encoding of the position
400
- */
401
- #encodePosition(options) {
402
- const { rightToLeft, bottomToTop, rotate } = options
403
- const startX = rightToLeft ? this.right : this.left
404
- const startY = bottomToTop ? this.bottom : this.top
405
- const incX = rightToLeft ? -1 : 1
406
- const incY = bottomToTop ? -1 : 1
407
- const code = []
408
- let line = ''
409
- let x = startX
410
- let y = startY
411
- do {
412
- const loc = new Point(x, y)
413
- const tile = this.tileAt(loc) // Normal tile for normal orientation
414
- if (tile) {
415
- line += this.#encodeTile(tile, loc, options)
416
- } else {
417
- line = addBlank(line)
418
- }
419
-
420
- if (rotate) {
421
- // Increment y more often than x
422
- y += incY
423
- if (y < this.top || y > this.bottom) {
424
- y = startY
425
- x += incX
426
- // Remove final number and add to code
427
- code.push(line.replace(/\d$/v, ''))
428
- line = ''
429
- }
430
- } else {
431
- // This is the standard order
432
- x += incX
433
- if (x < this.left || x > this.right) {
434
- x = startX
435
- y += incY
436
- // Remove final number and add to code
437
- code.push(line.replace(/\d$/v, ''))
438
- line = ''
439
- }
440
- }
441
- } while (
442
- x >= this.left &&
443
- x <= this.right &&
444
- y >= this.top &&
445
- y <= this.bottom
446
- )
447
-
448
- if (rotate) code.reverse()
449
- return code.join(':')
450
- }
451
-
452
464
  /**
453
465
  Save the current game data to a variable.
454
466
  @returns {SaveState} an opaque save state object
@@ -730,7 +742,6 @@ export class Trax {
730
742
  Find all possible moves as a list of notations,
731
743
  ie: ['@1+', '@1/', '@1\\', ...]
732
744
 
733
- Note that moves are not guaranteed to be valid.
734
745
  @returns {string[]} all valid move notations from the current position
735
746
  */
736
747
  possibleMoves() {
@@ -803,11 +814,12 @@ export class Trax {
803
814
  const bad = { type: 'x', loc: zero }
804
815
  const match = notation.match(notationRegex)
805
816
  if (match === null) return bad
817
+ const { col, row, slash: slashType } = notationGroups(match)
806
818
  const loc = new Point(
807
- this.left - 1 + Trax.decodeCol(match.groups.col),
808
- this.top - 1 + Number(match.groups.row),
819
+ this.left - 1 + Trax.decodeCol(col),
820
+ this.top - 1 + Number(row),
809
821
  )
810
- const possible = this.possibleTiles(loc, match.groups.slash)
822
+ const possible = this.possibleTiles(loc, slashType)
811
823
  if (possible.length > 0) return { type: possible[0], loc }
812
824
  return bad
813
825
  }
@@ -1062,11 +1074,11 @@ export class Trax {
1062
1074
  // Symmetry helper, rotate a move around the board
1063
1075
  const match = move.match(notationRegex)
1064
1076
  if (match === null) return []
1065
- const x = Trax.decodeCol(match.groups.col)
1066
- const y = Number(match.groups.row)
1077
+ const { col, row, slash: s } = notationGroups(match)
1078
+ const x = Trax.decodeCol(col)
1079
+ const y = Number(row)
1067
1080
  const X = this.height - x + 1
1068
1081
  const Y = this.width - y + 1
1069
- const s = match.groups.slash
1070
1082
  const slashes = s === '+' ? [s] : ['/', '\\']
1071
1083
  const moves = new Set()
1072
1084
  // This isn't super precise, but it limits the search space ... maybe #TODO?
@@ -1163,7 +1175,7 @@ export class Trax {
1163
1175
  @returns {string} an encoding of the position
1164
1176
  */
1165
1177
  positionCode(rightToLeft, bottomToTop, rotate, drops) {
1166
- return this.#encodePosition({ rightToLeft, bottomToTop, rotate, drops })
1178
+ return encodePosition(this, { rightToLeft, bottomToTop, rotate, drops })
1167
1179
  }
1168
1180
 
1169
1181
  /**
@@ -1177,7 +1189,7 @@ export class Trax {
1177
1189
  @returns {string} a position code that matches all symmetrical positions
1178
1190
  */
1179
1191
  normalize() {
1180
- return (this.color || 't').toUpperCase() + this.#minimalCode()
1192
+ return (this.color || 't').toUpperCase() + minimalCode(this)
1181
1193
  }
1182
1194
 
1183
1195
  /**
@@ -1201,6 +1213,6 @@ export class Trax {
1201
1213
  @returns {string} the canonical position code
1202
1214
  */
1203
1215
  get canonical() {
1204
- return this.#minimalCode(this.turn === 2)
1216
+ return minimalCode(this, this.turn === 2)
1205
1217
  }
1206
1218
  }
package/src/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // Generated by genversion.
2
- export const version = '1.3.0'
2
+ export const version = '1.3.1'
package/RELEASE.v1.3.0.md DELETED
@@ -1,12 +0,0 @@
1
- ## 1.3.0 - 2026-08-16
2
-
3
- - Add `canonical`: a position code that collapses color-swapped duplicates in
4
- addition to the rotation/mirror symmetry `normalize()` already handled
5
- - Deprecate `normalize()`/`normalized` in favor of `canonical`, to be removed in
6
- version 2
7
- - `provisionalMove()` now accepts either a `canonical` or a (deprecated)
8
- `normalized` code for `from`/`to`, so provisional moves saved before migrating
9
- to `canonical` keep resolving correctly
10
- - Update dependencies
11
- - Resolve all new xo rule findings
12
-