@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.
- package/RELEASE.v1.3.0.md +12 -0
- package/benchmark/benchmarks.json +128 -15
- package/benchmark/engine.bench.js +2 -2
- package/gl-sast-report.json +1 -1
- package/package.json +3 -3
- package/src/engine.d.ts +46 -5
- package/src/engine.js +188 -96
- package/src/version.js +1 -1
- package/RELEASE.v1.2.0.md +0 -10
|
@@ -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": {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
"
|
|
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": {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
"
|
|
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": {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
"
|
|
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
|
|
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
|
-
|
|
109
|
+
canonical,
|
|
110
110
|
}
|
|
111
111
|
|
|
112
112
|
/** @type {MetricRow[]} */
|
package/gl-sast-report.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":"15.2.
|
|
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.
|
|
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": "^
|
|
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": "^
|
|
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.
|
|
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
|
|
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,
|
|
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
|
|
273
|
-
|
|
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 {
|
|
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,
|
|
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
|
|
413
|
-
|
|
414
|
-
|
|
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
|
|
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,
|
|
670
|
+
possibleTiles(loc, slashFilter) {
|
|
544
671
|
/** @type {TileType[]} */
|
|
545
672
|
const possibles = []
|
|
546
673
|
for (const t of tileTypes) {
|
|
547
|
-
if (
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
|
954
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
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
|
-
|