@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.
- package/RELEASE.v1.2.0.md +10 -0
- package/benchmark/benchmarks.json +25 -0
- package/benchmark/engine.bench.js +228 -0
- package/gl-sast-report.json +1 -1
- package/package.json +5 -4
- package/src/engine.d.ts +321 -178
- package/src/engine.js +460 -336
- package/src/version.js +1 -1
- package/RELEASE.v1.1.1.md +0 -5
|
@@ -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
|
+
}
|
package/gl-sast-report.json
CHANGED
|
@@ -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.
|
|
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.
|
|
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": "^
|
|
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": "^
|
|
49
|
+
"xo": "^3.0.2"
|
|
49
50
|
},
|
|
50
51
|
"type": "module",
|
|
51
52
|
"engines": {
|