@slugbugblue/trax 1.1.1 → 1.2.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,10 @@
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
+
@@ -0,0 +1,25 @@
1
+ {
2
+ "1.1.1": {
3
+ "tutorial.01": {
4
+ "construction": { "count": 5000, "ms": 0.0843 },
5
+ "save+restore": { "count": 1000, "ms": 0.0058 },
6
+ "possibleMoves": { "count": 1000, "ms": 0.7045 },
7
+ "play": { "count": 1000, "ms": 0.1158 },
8
+ "normalized": { "count": 5000, "ms": 0.0092 }
9
+ },
10
+ "book.20": {
11
+ "construction": { "count": 5000, "ms": 0.2955 },
12
+ "save+restore": { "count": 1000, "ms": 0.0131 },
13
+ "possibleMoves": { "count": 1000, "ms": 2.3677 },
14
+ "play": { "count": 1000, "ms": 0.2953 },
15
+ "normalized": { "count": 5000, "ms": 0.0433 }
16
+ },
17
+ "book.30": {
18
+ "construction": { "count": 5000, "ms": 1.3156 },
19
+ "save+restore": { "count": 1000, "ms": 0.0486 },
20
+ "possibleMoves": { "count": 1000, "ms": 6.785 },
21
+ "play": { "count": 1000, "ms": 1.3788 },
22
+ "normalized": { "count": 5000, "ms": 0.1611 }
23
+ }
24
+ }
25
+ }
@@ -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 normalized = bench(() => instance.normalized, 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
+ normalized,
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.2","vulnerabilities":[],"scan":{"analyzer":{"id":"semgrep","name":"Semgrep","url":"https://gitlab.com/gitlab-org/security-products/analyzers/semgrep","vendor":{"name":"GitLab"},"version":"6.18.1"},"scanner":{"id":"semgrep","name":"Semgrep","url":"https://github.com/returntocorp/semgrep","vendor":{"name":"GitLab"},"version":"1.145.0"},"type":"sast","start_time":"2026-06-26T00:29:40","end_time":"2026-06-26T00:29:52","status":"success","observability":{"events":[{"event":"collect_sast_scan_metrics_from_pipeline","property":"c0b9149f-1c82-4346-9e3c-34727b5f04e6","label":"semgrep","value":0,"version":"6.18.1","exit_code":0,"override_count":0,"passthrough_count":0,"custom_exclude_path_count":0,"time_s":12,"file_count":8}]}}}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@slugbugblue/trax",
3
- "version": "1.1.1",
3
+ "version": "1.2.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
+ "ava": "^8.0.1",
44
45
  "c8": "^11.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": "^3.0.2"
49
50
  },
50
51
  "type": "module",
51
52
  "engines": {