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