@slugbugblue/trax 1.1.1 → 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
+
@@ -0,0 +1,138 @@
1
+ {
2
+ "1.1.1": {
3
+ "tutorial.01": {
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
+ }
24
+ },
25
+ "book.20": {
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
+ }
46
+ },
47
+ "book.30": {
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
+ }
136
+ }
137
+ }
138
+ }
@@ -0,0 +1,228 @@
1
+ import { readFileSync, writeFileSync } from 'node:fs'
2
+ import path from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+ import { version } from '../src/version.js'
5
+ import { Trax } from '../src/engine.js'
6
+
7
+ /** @import { TraxVariant } from '../src/engine.js' */
8
+
9
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
10
+ const RESULTS_PATH = path.join(__dirname, 'benchmarks.json')
11
+
12
+ /** @type {{ id: string, game: TraxVariant, notation: string, gatesRegressions: boolean }[]} */
13
+ const POSITIONS = [
14
+ {
15
+ id: 'tutorial.01',
16
+ game: 'trax',
17
+ notation: '@0/ @1\\',
18
+ gatesRegressions: false,
19
+ },
20
+ {
21
+ id: 'book.20',
22
+ game: 'traxloop',
23
+ notation: '@0/ @1/ B0+ B0/ C3+ D3+ E3\\',
24
+ gatesRegressions: false,
25
+ },
26
+ {
27
+ id: 'book.30',
28
+ game: 'traxloop',
29
+ notation:
30
+ '@0/ @1/ @1/ D1+ A2+ E1+ E2+ E0+ F1\\ G1+ G2\\ F3+ E4\\ G3+ H2\\ C5\\ D5+ D6\\ D7\\',
31
+ gatesRegressions: true,
32
+ },
33
+ ]
34
+
35
+ const ESC = String.fromCodePoint(27)
36
+ const GREEN = `${ESC}[38;5;35m`
37
+ const BLUE = `${ESC}[38;5;39m`
38
+ const ORANGE = `${ESC}[38;5;208m`
39
+ const RED = `${ESC}[38;5;196m`
40
+ const GRAY = `${ESC}[38;5;240m`
41
+ const RESET = `${ESC}[0m`
42
+
43
+ /**
44
+ Wrap a string with an ANSI color escape sequence.
45
+ @param {string} string_ - the string to colorize
46
+ @param {string} ansi - the ANSI escape code to apply
47
+ @returns {string} the colorized string
48
+ */
49
+ const colorize = (string_, ansi) => `${ansi}${string_}${RESET}`
50
+
51
+ /**
52
+ Pick an ANSI color for a benchmark regression percentage.
53
+ @param {number} pct - the percentage change, positive means slower
54
+ @returns {string} an ANSI escape code for the appropriate color
55
+ */
56
+ function diffColor(pct) {
57
+ if (pct > 20) return RED
58
+ if (pct > 5) return ORANGE
59
+ if (pct < -20) return GREEN
60
+ if (pct < -5) return BLUE
61
+ return GRAY
62
+ }
63
+
64
+ /**
65
+ Run a function repeatedly and return timing results.
66
+ @param {() => unknown} fn - the function to benchmark
67
+ @param {number} iterations - how many times to run fn
68
+ @returns {{ count: number, ms: number }} average time per iteration
69
+ */
70
+ function bench(fn, iterations) {
71
+ const start = performance.now()
72
+ for (let i = 0; i < iterations; i++) fn()
73
+ const ms = (performance.now() - start) / iterations
74
+ return { count: iterations, ms }
75
+ }
76
+
77
+ /** @typedef {{ name: string, current: string, prior: string, diff: string, percent: string, color: string, isRegression: boolean }} MetricRow */
78
+
79
+ /**
80
+ Benchmark a single game position across all metrics.
81
+ @param {TraxVariant} game - the game variant to use
82
+ @param {string} notation - the move sequence to set up the position
83
+ @param {Record<string, { count: number, ms: number }> | undefined} baseline - prior results to compare against
84
+ @returns {{ results: Record<string, { count: number, ms: number }>, rows: MetricRow[] }} timing results and formatted comparison rows
85
+ */
86
+ function benchPosition(game, notation, baseline) {
87
+ const instance = new Trax(game, notation)
88
+ const { moves } = instance
89
+ const lastMove = moves.at(-1)
90
+
91
+ const construction = bench(() => new Trax(game, notation), 5000)
92
+ const saveRestore = bench(() => {
93
+ const saved = instance.save()
94
+ instance.restore(saved)
95
+ }, 1000)
96
+ const possibleMovesResult = bench(() => instance.possibleMoves(), 1000)
97
+ const play = bench(() => {
98
+ const g = new Trax(game, notation)
99
+ g.play(/** @type {string} */ (lastMove))
100
+ }, 1000)
101
+ const canonical = bench(() => instance.canonical, 5000)
102
+
103
+ /** @type {Record<string, { count: number, ms: number }>} */
104
+ const results = {
105
+ construction,
106
+ 'save+restore': saveRestore,
107
+ possibleMoves: possibleMovesResult,
108
+ play,
109
+ canonical,
110
+ }
111
+
112
+ /** @type {MetricRow[]} */
113
+ const rows = []
114
+ for (const [name, { ms }] of Object.entries(results)) {
115
+ const prior = baseline?.[name]
116
+ const current = `${ms.toFixed(4)} ms`
117
+ if (prior) {
118
+ const delta = ms - prior.ms
119
+ const pct = (delta / prior.ms) * 100
120
+ const sign = delta >= 0 ? '+' : ''
121
+ rows.push({
122
+ name,
123
+ current,
124
+ prior: `${prior.ms.toFixed(4)} ms`,
125
+ diff: `${sign}${delta.toFixed(4)} ms`,
126
+ percent: `${sign}${pct.toFixed(1)}%`,
127
+ color: diffColor(pct),
128
+ isRegression: pct > 20,
129
+ })
130
+ } else {
131
+ rows.push({
132
+ name,
133
+ current,
134
+ prior: '',
135
+ diff: '',
136
+ percent: '',
137
+ color: GRAY,
138
+ isRegression: false,
139
+ })
140
+ }
141
+ }
142
+
143
+ return { results, rows }
144
+ }
145
+
146
+ /**
147
+ Load prior benchmark results from disk.
148
+ @returns {Record<string, Record<string, Record<string, { count: number, ms: number }>>>} all saved results keyed by version
149
+ */
150
+ function loadResults() {
151
+ try {
152
+ return JSON.parse(readFileSync(RESULTS_PATH, 'utf8'))
153
+ } catch {
154
+ return {}
155
+ }
156
+ }
157
+
158
+ /**
159
+ Print benchmark results to the console.
160
+ @param {MetricRow[][]} allRows - one array of rows per position
161
+ */
162
+ function printResults(allRows) {
163
+ const flat = allRows.flat()
164
+ const hasComparison = flat.some((r) => r.prior !== '')
165
+ const nameWidth = Math.max(...flat.map((r) => r.name.length))
166
+ const currentWidth = Math.max(...flat.map((r) => r.current.length))
167
+ const priorWidth = Math.max(...flat.map((r) => r.prior.length))
168
+ const diffWidth = Math.max(...flat.map((r) => r.diff.length))
169
+ const pctWidth = Math.max(...flat.map((r) => r.percent.length))
170
+
171
+ for (const [index, position] of POSITIONS.entries()) {
172
+ console.log(`\n ${position.id}`)
173
+ for (const row of allRows[index]) {
174
+ const name = row.name.padEnd(nameWidth)
175
+ const current = row.current.padStart(currentWidth)
176
+ if (hasComparison && row.prior) {
177
+ const prior = `was ${row.prior.padStart(priorWidth)}`
178
+ const diff = colorize(row.diff.padStart(diffWidth), row.color)
179
+ const pct = colorize(row.percent.padStart(pctWidth), row.color)
180
+ console.log(` ${name} ${current} (${prior}, ${diff}, ${pct})`)
181
+ } else {
182
+ console.log(` ${name} ${current}`)
183
+ }
184
+ }
185
+ }
186
+ }
187
+
188
+ console.log(`\n@slugbugblue/trax benchmark v${version}`)
189
+ console.log('='.repeat(50))
190
+
191
+ const allResults = loadResults()
192
+ const baseline = allResults[version]
193
+ /** @type {Record<string, Record<string, { count: number, ms: number }>>} */
194
+ const versionResults = {}
195
+ /** @type {MetricRow[][]} */
196
+ const allRows = []
197
+ const allRegressions = []
198
+
199
+ for (const { id, game, notation, gatesRegressions } of POSITIONS) {
200
+ const { results, rows } = benchPosition(game, notation, baseline?.[id])
201
+ versionResults[id] = results
202
+ allRows.push(rows)
203
+ if (gatesRegressions) {
204
+ allRegressions.push(
205
+ ...rows
206
+ .filter((r) => r.isRegression)
207
+ .map(
208
+ (r) =>
209
+ ` ${id}/${r.name}: ${r.prior} → ${r.current} (${r.percent})`,
210
+ ),
211
+ )
212
+ }
213
+ }
214
+
215
+ printResults(allRows)
216
+ console.log('')
217
+
218
+ if (!baseline) {
219
+ allResults[version] = versionResults
220
+ writeFileSync(RESULTS_PATH, JSON.stringify(allResults, null, 2) + '\n')
221
+ console.log('Results written to benchmark/benchmarks.json')
222
+ }
223
+
224
+ if (allRegressions.length > 0) {
225
+ console.log('\nRegressions detected:')
226
+ for (const message of allRegressions) console.log(message)
227
+ throw new Error('Benchmark regressions detected')
228
+ }
@@ -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.15.1"},"scanner":{"id":"semgrep","name":"Semgrep","url":"https://github.com/returntocorp/semgrep","vendor":{"name":"GitLab"},"version":"1.145.0"},"type":"sast","start_time":"2026-02-28T20:29:59","end_time":"2026-02-28T20:30:11","status":"success","observability":{"events":[{"event":"collect_sast_scan_metrics_from_pipeline","property":"54b1ad92-5d39-416b-9075-916f3da22681","label":"semgrep","value":0,"version":"6.15.1","exit_code":0,"override_count":0,"passthrough_count":0,"custom_exclude_path_count":0,"time_s":12,"file_count":7}]}}}
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.1.1",
3
+ "version": "1.3.0",
4
4
  "description": "Trax game engine",
5
5
  "keywords": [
6
6
  "trax",
@@ -26,8 +26,9 @@
26
26
  "url": "git+https://gitlab.com/slugbugblue/trax.git"
27
27
  },
28
28
  "scripts": {
29
+ "benchmark": "node benchmark/engine.bench.js",
29
30
  "genversion": "genversion --esm src/version.js",
30
- "git-add": "git add src/version.js src/engine.d.ts",
31
+ "git-add": "git add src/version.js src/engine.d.ts benchmark/benchmarks.json",
31
32
  "ts-build": "tsc -p tsconfig.build.json && rm -f src/version.d.ts",
32
33
  "test": "xo && c8 ava",
33
34
  "prepare": "[ -n \"$CI\" ] || husky",
@@ -40,12 +41,12 @@
40
41
  "@slugbugblue/point": "^1.0.0"
41
42
  },
42
43
  "devDependencies": {
43
- "ava": "^7.0.0",
44
- "c8": "^11.0.0",
44
+ "ava": "^8.0.1",
45
+ "c8": "^12.0.0",
45
46
  "genversion": "^3.0.2",
46
47
  "husky": "^9.1.7",
47
48
  "prettier": "^3.6.2",
48
- "xo": "^1.2.3"
49
+ "xo": "^4.0.0"
49
50
  },
50
51
  "type": "module",
51
52
  "engines": {