@slugbugblue/trax 1.2.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,25 +1,138 @@
1
1
  {
2
2
  "1.1.1": {
3
3
  "tutorial.01": {
4
- "construction": { "count": 5000, "ms": 0.0843 },
5
- "save+restore": { "count": 1000, "ms": 0.0058 },
6
- "possibleMoves": { "count": 1000, "ms": 0.7045 },
7
- "play": { "count": 1000, "ms": 0.1158 },
8
- "normalized": { "count": 5000, "ms": 0.0092 }
4
+ "construction": {
5
+ "count": 5000,
6
+ "ms": 0.0843
7
+ },
8
+ "save+restore": {
9
+ "count": 1000,
10
+ "ms": 0.0058
11
+ },
12
+ "possibleMoves": {
13
+ "count": 1000,
14
+ "ms": 0.7045
15
+ },
16
+ "play": {
17
+ "count": 1000,
18
+ "ms": 0.1158
19
+ },
20
+ "normalized": {
21
+ "count": 5000,
22
+ "ms": 0.0092
23
+ }
9
24
  },
10
25
  "book.20": {
11
- "construction": { "count": 5000, "ms": 0.2955 },
12
- "save+restore": { "count": 1000, "ms": 0.0131 },
13
- "possibleMoves": { "count": 1000, "ms": 2.3677 },
14
- "play": { "count": 1000, "ms": 0.2953 },
15
- "normalized": { "count": 5000, "ms": 0.0433 }
26
+ "construction": {
27
+ "count": 5000,
28
+ "ms": 0.2955
29
+ },
30
+ "save+restore": {
31
+ "count": 1000,
32
+ "ms": 0.0131
33
+ },
34
+ "possibleMoves": {
35
+ "count": 1000,
36
+ "ms": 2.3677
37
+ },
38
+ "play": {
39
+ "count": 1000,
40
+ "ms": 0.2953
41
+ },
42
+ "normalized": {
43
+ "count": 5000,
44
+ "ms": 0.0433
45
+ }
16
46
  },
17
47
  "book.30": {
18
- "construction": { "count": 5000, "ms": 1.3156 },
19
- "save+restore": { "count": 1000, "ms": 0.0486 },
20
- "possibleMoves": { "count": 1000, "ms": 6.785 },
21
- "play": { "count": 1000, "ms": 1.3788 },
22
- "normalized": { "count": 5000, "ms": 0.1611 }
48
+ "construction": {
49
+ "count": 5000,
50
+ "ms": 1.3156
51
+ },
52
+ "save+restore": {
53
+ "count": 1000,
54
+ "ms": 0.0486
55
+ },
56
+ "possibleMoves": {
57
+ "count": 1000,
58
+ "ms": 6.785
59
+ },
60
+ "play": {
61
+ "count": 1000,
62
+ "ms": 1.3788
63
+ },
64
+ "normalized": {
65
+ "count": 5000,
66
+ "ms": 0.1611
67
+ }
68
+ }
69
+ },
70
+ "1.2.0": {
71
+ "tutorial.01": {
72
+ "construction": {
73
+ "count": 5000,
74
+ "ms": 0.0898032286
75
+ },
76
+ "save+restore": {
77
+ "count": 1000,
78
+ "ms": 0.008606527000000029
79
+ },
80
+ "possibleMoves": {
81
+ "count": 1000,
82
+ "ms": 0.897647975
83
+ },
84
+ "play": {
85
+ "count": 1000,
86
+ "ms": 0.12720280200000003
87
+ },
88
+ "normalized": {
89
+ "count": 5000,
90
+ "ms": 0.010315181600000006
91
+ }
92
+ },
93
+ "book.20": {
94
+ "construction": {
95
+ "count": 5000,
96
+ "ms": 0.3137343523999999
97
+ },
98
+ "save+restore": {
99
+ "count": 1000,
100
+ "ms": 0.016668513000000076
101
+ },
102
+ "possibleMoves": {
103
+ "count": 1000,
104
+ "ms": 2.691874038
105
+ },
106
+ "play": {
107
+ "count": 1000,
108
+ "ms": 0.3145682210000004
109
+ },
110
+ "normalized": {
111
+ "count": 5000,
112
+ "ms": 0.05063697260000008
113
+ }
114
+ },
115
+ "book.30": {
116
+ "construction": {
117
+ "count": 5000,
118
+ "ms": 1.125999694
119
+ },
120
+ "save+restore": {
121
+ "count": 1000,
122
+ "ms": 0.055884163000000625
123
+ },
124
+ "possibleMoves": {
125
+ "count": 1000,
126
+ "ms": 4.9472956880000005
127
+ },
128
+ "play": {
129
+ "count": 1000,
130
+ "ms": 1.125647703999999
131
+ },
132
+ "normalized": {
133
+ "count": 5000,
134
+ "ms": 0.17698205720000043
135
+ }
23
136
  }
24
137
  }
25
138
  }
@@ -98,7 +98,7 @@ function benchPosition(game, notation, baseline) {
98
98
  const g = new Trax(game, notation)
99
99
  g.play(/** @type {string} */ (lastMove))
100
100
  }, 1000)
101
- const normalized = bench(() => instance.normalized, 5000)
101
+ const canonical = bench(() => instance.canonical, 5000)
102
102
 
103
103
  /** @type {Record<string, { count: number, ms: number }>} */
104
104
  const results = {
@@ -106,7 +106,7 @@ function benchPosition(game, notation, baseline) {
106
106
  'save+restore': saveRestore,
107
107
  possibleMoves: possibleMovesResult,
108
108
  play,
109
- normalized,
109
+ canonical,
110
110
  }
111
111
 
112
112
  /** @type {MetricRow[]} */
@@ -1 +1 @@
1
- {"version":"15.2.2","vulnerabilities":[],"scan":{"analyzer":{"id":"semgrep","name":"Semgrep","url":"https://gitlab.com/gitlab-org/security-products/analyzers/semgrep","vendor":{"name":"GitLab"},"version":"6.18.1"},"scanner":{"id":"semgrep","name":"Semgrep","url":"https://github.com/returntocorp/semgrep","vendor":{"name":"GitLab"},"version":"1.145.0"},"type":"sast","start_time":"2026-06-26T00:29:40","end_time":"2026-06-26T00:29:52","status":"success","observability":{"events":[{"event":"collect_sast_scan_metrics_from_pipeline","property":"c0b9149f-1c82-4346-9e3c-34727b5f04e6","label":"semgrep","value":0,"version":"6.18.1","exit_code":0,"override_count":0,"passthrough_count":0,"custom_exclude_path_count":0,"time_s":12,"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.2.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,12 +41,13 @@
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
- "c8": "^11.0.0",
46
+ "c8": "^12.0.0",
46
47
  "genversion": "^3.0.2",
47
48
  "husky": "^9.1.7",
48
49
  "prettier": "^3.6.2",
49
- "xo": "^3.0.2"
50
+ "xo": "^4.0.0"
50
51
  },
51
52
  "type": "module",
52
53
  "engines": {
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.2.0'
4
+ static readonly version: '1.3.1'
5
5
  /**
6
6
  @readonly
7
7
  @type {Record<TraxVariant, string>}
@@ -147,10 +147,10 @@ export class Trax {
147
147
  /**
148
148
  Get a list of possible tiles that can be played in this location.
149
149
  @param {Point} loc - the location to check
150
- @param {string | boolean} [s=false] - if provided, a direction modifier, '/', '\\', or '+'
150
+ @param {string} [slashFilter] - if provided, a direction modifier, '/', '\\', or '+'
151
151
  @returns {TileType[]} the list of valid tile types for this location
152
152
  */
153
- possibleTiles(loc: Point, s?: string | boolean): TileType[]
153
+ possibleTiles(loc: Point, slashFilter?: string): TileType[]
154
154
  get height(): number
155
155
  get width(): number
156
156
  /**
@@ -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[]
@@ -269,8 +268,10 @@ export class Trax {
269
268
  moveRotations(move: string): string[]
270
269
  /**
271
270
  Play a provisional move if it is valid.
272
- @param {string} from - the normalized encoding of the starting position
273
- @param {string} to - the normalized encoding of the ending position
271
+ @param {string} from - the canonical encoding of the starting position (or,
272
+ during the `normalize()` deprecation period, a normalized encoding)
273
+ @param {string} to - the canonical (or normalized) encoding of the ending
274
+ position, in the same encoding as `from`
274
275
  @param {string} via - the move to be used to transition
275
276
  @returns {false|string} if the provisional move is invalid: false; if the
276
277
  provisional move will never be valid for any future moves:
@@ -309,15 +310,32 @@ export class Trax {
309
310
  /**
310
311
  Trax has the potential for symmetry, so this gives us the ability to
311
312
  examine horizontal, vertical, and rotational symmetry for a color.
313
+ @deprecated Prefer `canonical`, which also collapses color-swapped
314
+ duplicates - the same puzzle played by the other side. `normalize()` is
315
+ kept for callers (such as `provisionalMove()`) that need a code sensitive
316
+ to which color is to move, and is likely to be removed in a future major
317
+ version.
312
318
  @returns {string} a position code that matches all symmetrical positions
313
319
  */
314
320
  normalize(): string
315
321
  /**
316
322
  Get the normalized code for this position. All symmetrical positions will
317
323
  result in the same normalized code.
324
+ @deprecated Prefer `canonical`; see `normalize()`.
318
325
  @returns {string} the normalized position code
319
326
  */
320
327
  get normalized(): string
328
+ /**
329
+ Get the canonical code for this position: colors are swapped, if needed,
330
+ so the code always reads as though white were about to move (or, once the
331
+ game is over, as though white won), then minimized across all 8
332
+ rotations/mirrors. Two positions that are identical up to a color swap and
333
+ board symmetry - the same puzzle, played by the other side - produce the
334
+ same canonical code. A tied game has no winner to canonicalize around, so
335
+ its colors are left as-is.
336
+ @returns {string} the canonical position code
337
+ */
338
+ get canonical(): string
321
339
  }
322
340
  export type Color = 'w' | 'b'
323
341
  /**
@@ -452,5 +470,26 @@ export type TileDrop = {
452
470
  export type TileType = ValidTiles | 'x'
453
471
  export type TraxVariant = 'trax' | 'traxloop' | 'trax8'
454
472
  export type ValidTiles = 'a' | 'b' | 'c' | 'd' | 'e' | 'f'
473
+ /**
474
+ * Symmetry options for viewing a position from different angles.
475
+ */
476
+ export type SymmetryOptions = {
477
+ /**
478
+ * - mirror the tile
479
+ */
480
+ rightToLeft?: boolean | undefined
481
+ /**
482
+ * - flip the tile
483
+ */
484
+ bottomToTop?: boolean | undefined
485
+ /**
486
+ * - rotate the tile counterclockwise
487
+ */
488
+ rotate?: boolean | undefined
489
+ /**
490
+ * - swap the tile's colors
491
+ */
492
+ colorSwap?: boolean | undefined
493
+ }
455
494
  import { Point } from '@slugbugblue/point'
456
495
  import type { PointLike } from '@slugbugblue/point'
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
@@ -127,15 +136,24 @@ const symmetry = {
127
136
 
128
137
  // Fun trax helper functions
129
138
 
139
+ /**
140
+ Symmetry options for viewing a position from different angles.
141
+ @typedef {object} SymmetryOptions
142
+ @property {boolean} [rightToLeft] - mirror the tile
143
+ @property {boolean} [bottomToTop] - flip the tile
144
+ @property {boolean} [rotate] - rotate the tile counterclockwise
145
+ @property {boolean} [colorSwap] - swap the tile's colors
146
+ */
147
+
130
148
  /**
131
149
  Apply certain combinations of symmetry.
132
150
  @param {ValidTiles} tile - the tile to apply symmetry to
133
- @param {boolean} [rightToLeft] - mirror the tile
134
- @param {boolean} [bottomToTop] - flip the tile
135
- @param {boolean} [rotate] - rotate the tile counterclockwise
151
+ @param {SymmetryOptions} [options] - the symmetry to apply
136
152
  @returns {ValidTiles} the transformed tile
137
153
  */
138
- const applySymmetry = (tile, rightToLeft, bottomToTop, rotate) => {
154
+ const applySymmetry = (tile, options = {}) => {
155
+ const { rightToLeft, bottomToTop, rotate, colorSwap } = options
156
+ if (colorSwap) tile = symmetry.swap[tile]
139
157
  if (rightToLeft) tile = symmetry.mirror[tile]
140
158
  if (bottomToTop) tile = symmetry.flip[tile]
141
159
  if (rotate) tile = symmetry.counterclock[tile]
@@ -200,6 +218,128 @@ The length of an encoded position row.
200
218
  const codeRowLength = (row) =>
201
219
  row.replaceAll(/\d/gv, (n) => '.'.repeat(Number(n) + 1)).length
202
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
+
203
343
  // This is where the magic happens
204
344
  /** A digital representation of a Trax game. */
205
345
  export class Trax {
@@ -409,10 +549,9 @@ export class Trax {
409
549
  /** @type {Tile[]} */
410
550
  const deleted = []
411
551
  for (const [key, tile] of Object.entries(this.tiles)) {
412
- if (tile.move > cp.move) {
413
- deleted.push(tile)
414
- delete this.tiles[key]
415
- }
552
+ if (tile.move <= cp.move) continue
553
+ deleted.push(tile)
554
+ delete this.tiles[key]
416
555
  }
417
556
 
418
557
  for (const tile of deleted) {
@@ -537,14 +676,19 @@ export class Trax {
537
676
  /**
538
677
  Get a list of possible tiles that can be played in this location.
539
678
  @param {Point} loc - the location to check
540
- @param {string | boolean} [s=false] - if provided, a direction modifier, '/', '\\', or '+'
679
+ @param {string} [slashFilter] - if provided, a direction modifier, '/', '\\', or '+'
541
680
  @returns {TileType[]} the list of valid tile types for this location
542
681
  */
543
- possibleTiles(loc, s = false) {
682
+ possibleTiles(loc, slashFilter) {
544
683
  /** @type {TileType[]} */
545
684
  const possibles = []
546
685
  for (const t of tileTypes) {
547
- if ((!s || s === slash[t]) && this.validTile(t, loc)) possibles.push(t)
686
+ if (
687
+ (!slashFilter || slashFilter === slash[t]) &&
688
+ this.validTile(t, loc)
689
+ ) {
690
+ possibles.push(t)
691
+ }
548
692
  }
549
693
 
550
694
  return possibles
@@ -598,7 +742,6 @@ export class Trax {
598
742
  Find all possible moves as a list of notations,
599
743
  ie: ['@1+', '@1/', '@1\\', ...]
600
744
 
601
- Note that moves are not guaranteed to be valid.
602
745
  @returns {string[]} all valid move notations from the current position
603
746
  */
604
747
  possibleMoves() {
@@ -671,11 +814,12 @@ export class Trax {
671
814
  const bad = { type: 'x', loc: zero }
672
815
  const match = notation.match(notationRegex)
673
816
  if (match === null) return bad
817
+ const { col, row, slash: slashType } = notationGroups(match)
674
818
  const loc = new Point(
675
- this.left - 1 + Trax.decodeCol(match.groups.col),
676
- this.top - 1 + Number(match.groups.row),
819
+ this.left - 1 + Trax.decodeCol(col),
820
+ this.top - 1 + Number(row),
677
821
  )
678
- const possible = this.possibleTiles(loc, match.groups.slash)
822
+ const possible = this.possibleTiles(loc, slashType)
679
823
  if (possible.length > 0) return { type: possible[0], loc }
680
824
  return bad
681
825
  }
@@ -879,7 +1023,7 @@ export class Trax {
879
1023
  /** @type {Tile[]} */
880
1024
  let dropped = []
881
1025
  let notation = ''
882
- let valid = false
1026
+ let isValid = false
883
1027
  if (['timeout', 'resign', 'draw', 'puzzled'].includes(type)) {
884
1028
  // Handle special events
885
1029
  this.over = true
@@ -891,7 +1035,7 @@ export class Trax {
891
1035
 
892
1036
  this.moves.push(type)
893
1037
  notation = type
894
- valid = true
1038
+ isValid = true
895
1039
  } else {
896
1040
  if (type.search(notationRegex) === 0) {
897
1041
  // We were passed a notation
@@ -901,14 +1045,14 @@ export class Trax {
901
1045
  }
902
1046
 
903
1047
  if (loc && this.validLocation(loc) && this.validTile(type, loc)) {
904
- valid = true
1048
+ isValid = true
905
1049
  const cp = this.checkpoint() // Just in case we need to roll back
906
1050
  notation = this.notate(type, loc) // Determine notation BEFORE we play the move
907
1051
  this.moves.push(notation)
908
1052
  dropped.push(this.addTile(type, loc)) // Play the tile
909
1053
  dropped = [...dropped, ...this.forcedMoves(loc)] // Play all forced moves
910
1054
  if (this.invalid || tentative) {
911
- if (this.invalid) valid = false
1055
+ if (this.invalid) isValid = false
912
1056
  this.rewind(cp) // Abort! abort!
913
1057
  } else {
914
1058
  this.checkWin(dropped)
@@ -917,7 +1061,7 @@ export class Trax {
917
1061
  }
918
1062
  }
919
1063
 
920
- return { dropped, notation, valid }
1064
+ return { dropped, notation, valid: isValid }
921
1065
  }
922
1066
 
923
1067
  /**
@@ -930,11 +1074,11 @@ export class Trax {
930
1074
  // Symmetry helper, rotate a move around the board
931
1075
  const match = move.match(notationRegex)
932
1076
  if (match === null) return []
933
- const x = Trax.decodeCol(match.groups.col)
934
- 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)
935
1080
  const X = this.height - x + 1
936
1081
  const Y = this.width - y + 1
937
- const s = match.groups.slash
938
1082
  const slashes = s === '+' ? [s] : ['/', '\\']
939
1083
  const moves = new Set()
940
1084
  // This isn't super precise, but it limits the search space ... maybe #TODO?
@@ -950,8 +1094,10 @@ export class Trax {
950
1094
 
951
1095
  /**
952
1096
  Play a provisional move if it is valid.
953
- @param {string} from - the normalized encoding of the starting position
954
- @param {string} to - the normalized encoding of the ending position
1097
+ @param {string} from - the canonical encoding of the starting position (or,
1098
+ during the `normalize()` deprecation period, a normalized encoding)
1099
+ @param {string} to - the canonical (or normalized) encoding of the ending
1100
+ position, in the same encoding as `from`
955
1101
  @param {string} via - the move to be used to transition
956
1102
  @returns {false|string} if the provisional move is invalid: false; if the
957
1103
  provisional move will never be valid for any future moves:
@@ -959,8 +1105,19 @@ export class Trax {
959
1105
  notation, which may be symmetrically adjusted as needed
960
1106
  */
961
1107
  provisionalMove(from, to, via) {
1108
+ // A normalized code always starts with W/B/T (normalize() forces it);
1109
+ // a canonical code never does, since it's built purely from tile
1110
+ // letters, digits, and ':'. That makes the two formats
1111
+ // self-distinguishing, so provisional moves saved under the deprecated
1112
+ // normalize() keep resolving correctly without callers having to say
1113
+ // which encoding they used. Once normalize() is removed, this check (and
1114
+ // the corresponding slice below) can be deleted - `code` and `target`
1115
+ // become just `from`/`to` and `this.canonical`.
1116
+ const isLegacy = ['W', 'B', 'T'].includes(from[0])
1117
+ const code = isLegacy ? from.slice(1) : from
1118
+
962
1119
  // Test a provisional move against the current state
963
- const lines = from.slice(1).split(':')
1120
+ const lines = code.split(':')
964
1121
  let cols = 0
965
1122
  for (const line of lines) {
966
1123
  cols = Math.max(cols, codeRowLength(line))
@@ -974,13 +1131,13 @@ export class Trax {
974
1131
  return 'delete-provisional' // Current board is bigger than the provisional move
975
1132
  }
976
1133
 
977
- if (from === this.normalized) {
1134
+ if (from === (isLegacy ? this.normalized : this.canonical)) {
978
1135
  const cp = this.checkpoint() // Save this position
979
1136
  for (const move of this.moveRotations(via)) {
980
1137
  // Try all symmetrical moves
981
1138
  const drop = this.dropTile(move)
982
1139
  if (drop.valid) {
983
- if (to === this.normalized) return move // This move matched!
1140
+ if (to === (isLegacy ? this.normalized : this.canonical)) return move // This move matched!
984
1141
  this.rewind(cp) // Put the board back how it was
985
1142
  }
986
1143
  }
@@ -1018,97 +1175,44 @@ export class Trax {
1018
1175
  @returns {string} an encoding of the position
1019
1176
  */
1020
1177
  positionCode(rightToLeft, bottomToTop, rotate, drops) {
1021
- const startX = rightToLeft ? this.right : this.left
1022
- const startY = bottomToTop ? this.bottom : this.top
1023
- const incX = rightToLeft ? -1 : 1
1024
- const incY = bottomToTop ? -1 : 1
1025
- const code = []
1026
- let line = ''
1027
- let x = startX
1028
- let y = startY
1029
- do {
1030
- const loc = new Point(x, y)
1031
- const tile = this.tileAt(loc) // Normal tile for normal orientation
1032
- if (tile) {
1033
- /** @type {string} */
1034
- let encoded = applySymmetry(tile, rightToLeft, bottomToTop, rotate)
1035
- if (this.path.includes(this.tileId(loc))) {
1036
- encoded = String.fromCodePoint(
1037
- /** @type {number} */ (encoded.codePointAt(0)) + this.turn * 6,
1038
- )
1039
- }
1040
-
1041
- if (
1042
- drops &&
1043
- drops.dropped &&
1044
- drops.dropped.some((t) => loc.eq(t.loc))
1045
- ) {
1046
- encoded = encoded.toUpperCase()
1047
- }
1048
-
1049
- line += encoded
1050
- } else {
1051
- line = addBlank(line)
1052
- }
1053
-
1054
- if (rotate) {
1055
- // Increment y more often than x
1056
- y += incY
1057
- if (y < this.top || y > this.bottom) {
1058
- y = startY
1059
- x += incX
1060
- // Remove final number and add to code
1061
- code.push(line.replace(/\d$/v, ''))
1062
- line = ''
1063
- }
1064
- } else {
1065
- // This is the standard order
1066
- x += incX
1067
- if (x < this.left || x > this.right) {
1068
- x = startX
1069
- y += incY
1070
- // Remove final number and add to code
1071
- code.push(line.replace(/\d$/v, ''))
1072
- line = ''
1073
- }
1074
- }
1075
- } while (
1076
- x >= this.left &&
1077
- x <= this.right &&
1078
- y >= this.top &&
1079
- y <= this.bottom
1080
- )
1081
-
1082
- if (rotate) code.reverse()
1083
- return code.join(':')
1178
+ return encodePosition(this, { rightToLeft, bottomToTop, rotate, drops })
1084
1179
  }
1085
1180
 
1086
1181
  /**
1087
1182
  Trax has the potential for symmetry, so this gives us the ability to
1088
1183
  examine horizontal, vertical, and rotational symmetry for a color.
1184
+ @deprecated Prefer `canonical`, which also collapses color-swapped
1185
+ duplicates - the same puzzle played by the other side. `normalize()` is
1186
+ kept for callers (such as `provisionalMove()`) that need a code sensitive
1187
+ to which color is to move, and is likely to be removed in a future major
1188
+ version.
1089
1189
  @returns {string} a position code that matches all symmetrical positions
1090
1190
  */
1091
1191
  normalize() {
1092
- let norm = 'z'
1093
- const bools = [true, false]
1094
- for (const rightToLeft of bools) {
1095
- for (const bottomToTop of bools) {
1096
- for (const rotate of bools) {
1097
- const code = this.positionCode(rightToLeft, bottomToTop, rotate)
1098
- if (code < norm) norm = code
1099
- }
1100
- }
1101
- }
1102
-
1103
- return (this.color || 't').toUpperCase() + norm
1192
+ return (this.color || 't').toUpperCase() + minimalCode(this)
1104
1193
  }
1105
1194
 
1106
1195
  /**
1107
1196
  Get the normalized code for this position. All symmetrical positions will
1108
1197
  result in the same normalized code.
1198
+ @deprecated Prefer `canonical`; see `normalize()`.
1109
1199
  @returns {string} the normalized position code
1110
1200
  */
1111
1201
  get normalized() {
1112
1202
  return this.normalize()
1113
1203
  }
1204
+
1205
+ /**
1206
+ Get the canonical code for this position: colors are swapped, if needed,
1207
+ so the code always reads as though white were about to move (or, once the
1208
+ game is over, as though white won), then minimized across all 8
1209
+ rotations/mirrors. Two positions that are identical up to a color swap and
1210
+ board symmetry - the same puzzle, played by the other side - produce the
1211
+ same canonical code. A tied game has no winner to canonicalize around, so
1212
+ its colors are left as-is.
1213
+ @returns {string} the canonical position code
1214
+ */
1215
+ get canonical() {
1216
+ return minimalCode(this, this.turn === 2)
1217
+ }
1114
1218
  }
package/src/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // Generated by genversion.
2
- export const version = '1.2.0'
2
+ export const version = '1.3.1'
package/RELEASE.v1.2.0.md DELETED
@@ -1,10 +0,0 @@
1
- ## 1.2.0 - 2026-06-25
2
-
3
- - Implement an initial benchmarking record
4
- - Swap the notation / moves storage (store moves, calculate notation)
5
- - Maintain the tile count and possible locations candidate set incrementally
6
- - Combined speedup of about 30% on new games and per-move play
7
- - Around 35% for `possibleMoves` on large boards vs 1.1.1
8
- - Update docs to reflect changes
9
- - Update dependencies
10
-