@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.
- package/RELEASE.v1.3.1.md +8 -0
- package/benchmark/benchmarks.json +128 -15
- package/benchmark/engine.bench.js +2 -2
- package/gl-sast-report.json +1 -1
- package/package.json +5 -4
- package/src/engine.d.ts +45 -6
- package/src/engine.js +207 -103
- package/src/version.js +1 -1
- package/RELEASE.v1.2.0.md +0 -10
|
@@ -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": {
|
|
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.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
|
+
"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": "^
|
|
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": "^
|
|
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.
|
|
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
|
|
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
|
/**
|
|
@@ -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
|
|
273
|
-
|
|
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 {
|
|
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,
|
|
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
|
|
413
|
-
|
|
414
|
-
|
|
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
|
|
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,
|
|
682
|
+
possibleTiles(loc, slashFilter) {
|
|
544
683
|
/** @type {TileType[]} */
|
|
545
684
|
const possibles = []
|
|
546
685
|
for (const t of tileTypes) {
|
|
547
|
-
if (
|
|
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(
|
|
676
|
-
this.top - 1 + Number(
|
|
819
|
+
this.left - 1 + Trax.decodeCol(col),
|
|
820
|
+
this.top - 1 + Number(row),
|
|
677
821
|
)
|
|
678
|
-
const possible = this.possibleTiles(loc,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
|
934
|
-
const
|
|
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
|
|
954
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
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
|
-
|