@slugbugblue/trax 1.2.0 → 1.3.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.
@@ -0,0 +1,12 @@
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
+
@@ -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.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}]}}}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@slugbugblue/trax",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Trax game engine",
5
5
  "keywords": [
6
6
  "trax",
@@ -42,11 +42,11 @@
42
42
  },
43
43
  "devDependencies": {
44
44
  "ava": "^8.0.1",
45
- "c8": "^11.0.0",
45
+ "c8": "^12.0.0",
46
46
  "genversion": "^3.0.2",
47
47
  "husky": "^9.1.7",
48
48
  "prettier": "^3.6.2",
49
- "xo": "^3.0.2"
49
+ "xo": "^4.0.0"
50
50
  },
51
51
  "type": "module",
52
52
  "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.0'
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
  /**
@@ -269,8 +269,10 @@ export class Trax {
269
269
  moveRotations(move: string): string[]
270
270
  /**
271
271
  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
272
+ @param {string} from - the canonical encoding of the starting position (or,
273
+ during the `normalize()` deprecation period, a normalized encoding)
274
+ @param {string} to - the canonical (or normalized) encoding of the ending
275
+ position, in the same encoding as `from`
274
276
  @param {string} via - the move to be used to transition
275
277
  @returns {false|string} if the provisional move is invalid: false; if the
276
278
  provisional move will never be valid for any future moves:
@@ -309,15 +311,33 @@ export class Trax {
309
311
  /**
310
312
  Trax has the potential for symmetry, so this gives us the ability to
311
313
  examine horizontal, vertical, and rotational symmetry for a color.
314
+ @deprecated Prefer `canonical`, which also collapses color-swapped
315
+ duplicates - the same puzzle played by the other side. `normalize()` is
316
+ kept for callers (such as `provisionalMove()`) that need a code sensitive
317
+ to which color is to move, and is likely to be removed in a future major
318
+ version.
312
319
  @returns {string} a position code that matches all symmetrical positions
313
320
  */
314
321
  normalize(): string
315
322
  /**
316
323
  Get the normalized code for this position. All symmetrical positions will
317
324
  result in the same normalized code.
325
+ @deprecated Prefer `canonical`; see `normalize()`.
318
326
  @returns {string} the normalized position code
319
327
  */
320
328
  get normalized(): string
329
+ /**
330
+ Get the canonical code for this position: colors are swapped, if needed,
331
+ so the code always reads as though white were about to move (or, once the
332
+ game is over, as though white won), then minimized across all 8
333
+ rotations/mirrors. Two positions that are identical up to a color swap and
334
+ board symmetry - the same puzzle, played by the other side - produce the
335
+ same canonical code. A tied game has no winner to canonicalize around, so
336
+ its colors are left as-is.
337
+ @returns {string} the canonical position code
338
+ */
339
+ get canonical(): string
340
+ #private
321
341
  }
322
342
  export type Color = 'w' | 'b'
323
343
  /**
@@ -452,5 +472,26 @@ export type TileDrop = {
452
472
  export type TileType = ValidTiles | 'x'
453
473
  export type TraxVariant = 'trax' | 'traxloop' | 'trax8'
454
474
  export type ValidTiles = 'a' | 'b' | 'c' | 'd' | 'e' | 'f'
475
+ /**
476
+ * Symmetry options for viewing a position from different angles.
477
+ */
478
+ export type SymmetryOptions = {
479
+ /**
480
+ * - mirror the tile
481
+ */
482
+ rightToLeft?: boolean | undefined
483
+ /**
484
+ * - flip the tile
485
+ */
486
+ bottomToTop?: boolean | undefined
487
+ /**
488
+ * - rotate the tile counterclockwise
489
+ */
490
+ rotate?: boolean | undefined
491
+ /**
492
+ * - swap the tile's colors
493
+ */
494
+ colorSwap?: boolean | undefined
495
+ }
455
496
  import { Point } from '@slugbugblue/point'
456
497
  import type { PointLike } from '@slugbugblue/point'
package/src/engine.js CHANGED
@@ -127,15 +127,24 @@ const symmetry = {
127
127
 
128
128
  // Fun trax helper functions
129
129
 
130
+ /**
131
+ Symmetry options for viewing a position from different angles.
132
+ @typedef {object} SymmetryOptions
133
+ @property {boolean} [rightToLeft] - mirror the tile
134
+ @property {boolean} [bottomToTop] - flip the tile
135
+ @property {boolean} [rotate] - rotate the tile counterclockwise
136
+ @property {boolean} [colorSwap] - swap the tile's colors
137
+ */
138
+
130
139
  /**
131
140
  Apply certain combinations of symmetry.
132
141
  @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
142
+ @param {SymmetryOptions} [options] - the symmetry to apply
136
143
  @returns {ValidTiles} the transformed tile
137
144
  */
138
- const applySymmetry = (tile, rightToLeft, bottomToTop, rotate) => {
145
+ const applySymmetry = (tile, options = {}) => {
146
+ const { rightToLeft, bottomToTop, rotate, colorSwap } = options
147
+ if (colorSwap) tile = symmetry.swap[tile]
139
148
  if (rightToLeft) tile = symmetry.mirror[tile]
140
149
  if (bottomToTop) tile = symmetry.flip[tile]
141
150
  if (rotate) tile = symmetry.counterclock[tile]
@@ -321,6 +330,125 @@ export class Trax {
321
330
  }
322
331
  }
323
332
 
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
+
324
452
  /**
325
453
  Save the current game data to a variable.
326
454
  @returns {SaveState} an opaque save state object
@@ -409,10 +537,9 @@ export class Trax {
409
537
  /** @type {Tile[]} */
410
538
  const deleted = []
411
539
  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
- }
540
+ if (tile.move <= cp.move) continue
541
+ deleted.push(tile)
542
+ delete this.tiles[key]
416
543
  }
417
544
 
418
545
  for (const tile of deleted) {
@@ -537,14 +664,19 @@ export class Trax {
537
664
  /**
538
665
  Get a list of possible tiles that can be played in this location.
539
666
  @param {Point} loc - the location to check
540
- @param {string | boolean} [s=false] - if provided, a direction modifier, '/', '\\', or '+'
667
+ @param {string} [slashFilter] - if provided, a direction modifier, '/', '\\', or '+'
541
668
  @returns {TileType[]} the list of valid tile types for this location
542
669
  */
543
- possibleTiles(loc, s = false) {
670
+ possibleTiles(loc, slashFilter) {
544
671
  /** @type {TileType[]} */
545
672
  const possibles = []
546
673
  for (const t of tileTypes) {
547
- if ((!s || s === slash[t]) && this.validTile(t, loc)) possibles.push(t)
674
+ if (
675
+ (!slashFilter || slashFilter === slash[t]) &&
676
+ this.validTile(t, loc)
677
+ ) {
678
+ possibles.push(t)
679
+ }
548
680
  }
549
681
 
550
682
  return possibles
@@ -879,7 +1011,7 @@ export class Trax {
879
1011
  /** @type {Tile[]} */
880
1012
  let dropped = []
881
1013
  let notation = ''
882
- let valid = false
1014
+ let isValid = false
883
1015
  if (['timeout', 'resign', 'draw', 'puzzled'].includes(type)) {
884
1016
  // Handle special events
885
1017
  this.over = true
@@ -891,7 +1023,7 @@ export class Trax {
891
1023
 
892
1024
  this.moves.push(type)
893
1025
  notation = type
894
- valid = true
1026
+ isValid = true
895
1027
  } else {
896
1028
  if (type.search(notationRegex) === 0) {
897
1029
  // We were passed a notation
@@ -901,14 +1033,14 @@ export class Trax {
901
1033
  }
902
1034
 
903
1035
  if (loc && this.validLocation(loc) && this.validTile(type, loc)) {
904
- valid = true
1036
+ isValid = true
905
1037
  const cp = this.checkpoint() // Just in case we need to roll back
906
1038
  notation = this.notate(type, loc) // Determine notation BEFORE we play the move
907
1039
  this.moves.push(notation)
908
1040
  dropped.push(this.addTile(type, loc)) // Play the tile
909
1041
  dropped = [...dropped, ...this.forcedMoves(loc)] // Play all forced moves
910
1042
  if (this.invalid || tentative) {
911
- if (this.invalid) valid = false
1043
+ if (this.invalid) isValid = false
912
1044
  this.rewind(cp) // Abort! abort!
913
1045
  } else {
914
1046
  this.checkWin(dropped)
@@ -917,7 +1049,7 @@ export class Trax {
917
1049
  }
918
1050
  }
919
1051
 
920
- return { dropped, notation, valid }
1052
+ return { dropped, notation, valid: isValid }
921
1053
  }
922
1054
 
923
1055
  /**
@@ -950,8 +1082,10 @@ export class Trax {
950
1082
 
951
1083
  /**
952
1084
  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
1085
+ @param {string} from - the canonical encoding of the starting position (or,
1086
+ during the `normalize()` deprecation period, a normalized encoding)
1087
+ @param {string} to - the canonical (or normalized) encoding of the ending
1088
+ position, in the same encoding as `from`
955
1089
  @param {string} via - the move to be used to transition
956
1090
  @returns {false|string} if the provisional move is invalid: false; if the
957
1091
  provisional move will never be valid for any future moves:
@@ -959,8 +1093,19 @@ export class Trax {
959
1093
  notation, which may be symmetrically adjusted as needed
960
1094
  */
961
1095
  provisionalMove(from, to, via) {
1096
+ // A normalized code always starts with W/B/T (normalize() forces it);
1097
+ // a canonical code never does, since it's built purely from tile
1098
+ // letters, digits, and ':'. That makes the two formats
1099
+ // self-distinguishing, so provisional moves saved under the deprecated
1100
+ // normalize() keep resolving correctly without callers having to say
1101
+ // which encoding they used. Once normalize() is removed, this check (and
1102
+ // the corresponding slice below) can be deleted - `code` and `target`
1103
+ // become just `from`/`to` and `this.canonical`.
1104
+ const isLegacy = ['W', 'B', 'T'].includes(from[0])
1105
+ const code = isLegacy ? from.slice(1) : from
1106
+
962
1107
  // Test a provisional move against the current state
963
- const lines = from.slice(1).split(':')
1108
+ const lines = code.split(':')
964
1109
  let cols = 0
965
1110
  for (const line of lines) {
966
1111
  cols = Math.max(cols, codeRowLength(line))
@@ -974,13 +1119,13 @@ export class Trax {
974
1119
  return 'delete-provisional' // Current board is bigger than the provisional move
975
1120
  }
976
1121
 
977
- if (from === this.normalized) {
1122
+ if (from === (isLegacy ? this.normalized : this.canonical)) {
978
1123
  const cp = this.checkpoint() // Save this position
979
1124
  for (const move of this.moveRotations(via)) {
980
1125
  // Try all symmetrical moves
981
1126
  const drop = this.dropTile(move)
982
1127
  if (drop.valid) {
983
- if (to === this.normalized) return move // This move matched!
1128
+ if (to === (isLegacy ? this.normalized : this.canonical)) return move // This move matched!
984
1129
  this.rewind(cp) // Put the board back how it was
985
1130
  }
986
1131
  }
@@ -1018,97 +1163,44 @@ export class Trax {
1018
1163
  @returns {string} an encoding of the position
1019
1164
  */
1020
1165
  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(':')
1166
+ return this.#encodePosition({ rightToLeft, bottomToTop, rotate, drops })
1084
1167
  }
1085
1168
 
1086
1169
  /**
1087
1170
  Trax has the potential for symmetry, so this gives us the ability to
1088
1171
  examine horizontal, vertical, and rotational symmetry for a color.
1172
+ @deprecated Prefer `canonical`, which also collapses color-swapped
1173
+ duplicates - the same puzzle played by the other side. `normalize()` is
1174
+ kept for callers (such as `provisionalMove()`) that need a code sensitive
1175
+ to which color is to move, and is likely to be removed in a future major
1176
+ version.
1089
1177
  @returns {string} a position code that matches all symmetrical positions
1090
1178
  */
1091
1179
  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
1180
+ return (this.color || 't').toUpperCase() + this.#minimalCode()
1104
1181
  }
1105
1182
 
1106
1183
  /**
1107
1184
  Get the normalized code for this position. All symmetrical positions will
1108
1185
  result in the same normalized code.
1186
+ @deprecated Prefer `canonical`; see `normalize()`.
1109
1187
  @returns {string} the normalized position code
1110
1188
  */
1111
1189
  get normalized() {
1112
1190
  return this.normalize()
1113
1191
  }
1192
+
1193
+ /**
1194
+ Get the canonical code for this position: colors are swapped, if needed,
1195
+ so the code always reads as though white were about to move (or, once the
1196
+ game is over, as though white won), then minimized across all 8
1197
+ rotations/mirrors. Two positions that are identical up to a color swap and
1198
+ board symmetry - the same puzzle, played by the other side - produce the
1199
+ same canonical code. A tied game has no winner to canonicalize around, so
1200
+ its colors are left as-is.
1201
+ @returns {string} the canonical position code
1202
+ */
1203
+ get canonical() {
1204
+ return this.#minimalCode(this.turn === 2)
1205
+ }
1114
1206
  }
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.0'
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
-