@slugbugblue/trax-cli 0.13.0 → 0.14.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/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @slugbugblue/trax
2
2
 
3
+ ## 0.14.0 - 2026-06-05
4
+
5
+ - Update to latest puzzles version with new defensive move format
6
+ - Change trax game export attribution
7
+ - Break out inner logic to new functions in a few complex functions
8
+
3
9
  ## 0.13.0 - 2026-05-20
4
10
 
5
11
  - Update to latest puzzles version and add defensive moves
package/README.md CHANGED
@@ -77,7 +77,7 @@ simply `#1` at the interactive prompt.
77
77
  For ease of working at the command line, the symbols used as the last character
78
78
  of the Trax notation can optionally be replaced with letters. Use `s` for slash
79
79
  symbol (`/`), `b` for backslash (`\`), and `p` for plus (`+`). While there is no
80
- need to escape at the interactive prompt, these substitutions will be avilable
80
+ need to escape at the interactive prompt, these substitutions will be available
81
81
  there as well.
82
82
 
83
83
  ### Examples
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@slugbugblue/trax-cli",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Trax command line interface",
5
5
  "keywords": [
6
6
  "trax",
@@ -32,7 +32,7 @@
32
32
  "dependencies": {
33
33
  "@slugbugblue/trax": "^1.0.0",
34
34
  "@slugbugblue/trax-analyst": "^0.14.0",
35
- "@slugbugblue/trax-puzzles": "^0.8.0",
35
+ "@slugbugblue/trax-puzzles": "^0.9.0",
36
36
  "@slugbugblue/trax-tty": "^1.0.0",
37
37
  "env-paths": "^4.0.0",
38
38
  "yaml": "^2.0.0-11"
@@ -169,6 +169,31 @@ export const importCmd = {
169
169
  },
170
170
  }
171
171
 
172
+ /** Generate puzzle attribution comment lines for export.
173
+ * @param {CLIContext['GAME']} game - the current game
174
+ * @returns {string} - formatted attribution string, or empty if no puzzle
175
+ */
176
+ const puzzleAttribution = (game) => {
177
+ if (!game.puzzle) return ''
178
+ const puzzle = puzzles.find((p) => p.id === game.puzzle)
179
+ if (!puzzle) return ''
180
+ const source = sources[puzzle.src]
181
+ let attr = '; Puzzle ' + game.puzzle
182
+ if (source) {
183
+ attr += source.copyright
184
+ ? ' ©' + source.copyright + ' by '
185
+ : ' provided courtesy of '
186
+ attr += source.name
187
+ if (source.url) attr += '\n; ' + source.url
188
+ if (source.license) {
189
+ attr += '\n; Licensed under ' + source.license
190
+ attr += source.licenseUrl ? ' ' + source.licenseUrl : ''
191
+ }
192
+ }
193
+
194
+ return attr + '\n'
195
+ }
196
+
172
197
  export const exportCmd = {
173
198
  name: 'export',
174
199
  alt: ['save', 'keep', 'share'],
@@ -202,28 +227,8 @@ export const exportCmd = {
202
227
  content += game.players?.[0] || 'white'
203
228
  content += ' vs '
204
229
  content += game.players?.[1] || 'black'
205
- content += '\n; @slugbugblue/trax cli.js v' + CLI.version + '\n'
206
- if (game.puzzle) {
207
- const puzzle = puzzles.find((p) => p.id === game.puzzle)
208
- if (puzzle) {
209
- const source = sources[puzzle.src]
210
- content += '; Puzzle ' + game.puzzle
211
- if (source) {
212
- content += source.copyright
213
- ? ' ©' + source.copyright + ' by '
214
- : ' provided courtesy of '
215
- content += source.name
216
- if (source.url) content += '\n; ' + source.url
217
- if (source.license) {
218
- content += '\n; Licensed under ' + source.license
219
- content += source.licenseUrl ? ' ' + source.licenseUrl : ''
220
- }
221
- }
222
-
223
- content += '\n'
224
- }
225
- }
226
-
230
+ content += '\n; @slugbugblue/trax-cli v' + CLI.version + '\n'
231
+ content += puzzleAttribution(game)
227
232
  content += interleaveNotation(trax.moves, game.notes)
228
233
 
229
234
  filename &&= filename.replaceAll(/[^ a-z\d.~\/]/gv, '')
@@ -250,21 +255,48 @@ export const exportCmd = {
250
255
  const getRules = (r) =>
251
256
  r.includes('loop') ? 'traxloop' : r.includes('8x8') ? 'trax8' : 'trax'
252
257
 
253
- /** Decode a .trx formatted string into a game, preserving players and comments.
254
- * @param {CLIContext} CLI - the CLI object
258
+ /** Apply pending moves to the game, or record them as notes if the game is over.
259
+ * @param {string[]} moves - pending move tokens (mutated in place)
260
+ * @param {Trax} trax - the current game instance
261
+ * @param {GameNotes} notes - notes accumulator
262
+ */
263
+ const applyMoves = (moves, trax, notes) => {
264
+ if (trax.gameOver) {
265
+ notes.push({ note: moves.map((c) => c.trim()).join(' '), move: trax.move })
266
+ } else {
267
+ trax.playMoves(moves.filter(Boolean))
268
+ moves.splice(0)
269
+ }
270
+ }
271
+
272
+ /** Record a comment segment as a note, unless it is the generator attribution.
273
+ * @param {string[]} comment - comment tokens from after the # or ; delimiter
274
+ * @param {Trax | undefined} trax - the current game instance, if initialized
275
+ * @param {GameNotes} notes - notes accumulator
276
+ */
277
+ const collectComment = (comment, trax, notes) => {
278
+ if (comment.length === 0) return
279
+ const note = comment.map((c) => c.trim()).join(' ')
280
+ if (!/^@slugbugblue\/trax-cli v/v.test(note)) {
281
+ notes.push({ note, move: trax?.move || 0 })
282
+ }
283
+ }
284
+
285
+ /** Parse a .trx formatted string into structured game data.
255
286
  * @param {string} content - the contents of the .trx file
256
- * @returns {boolean} - true if a game was created
287
+ * @returns {{ rules: TraxVariant, players: string[], trax: Trax, notes: GameNotes } | null} - parsed game data, or null if invalid
257
288
  */
258
- const interpretFile = (CLI, content) => {
289
+ const parseFile = (content) => {
259
290
  /** @type {TraxVariant | undefined} */
260
291
  let rules
292
+ /** @type {Trax | undefined} */
261
293
  let trax
262
294
  /** @type {string[]} */
263
295
  let players = []
264
296
  /** @type {string[]} */
265
297
  const moves = []
266
298
  /** @type {GameNotes} */
267
- const comments = []
299
+ const notes = []
268
300
  for (const line of content.split('\n')) {
269
301
  if (!line) continue
270
302
  const [ln, ...comment] = line.split(/[#;]/v)
@@ -284,33 +316,29 @@ const interpretFile = (CLI, content) => {
284
316
  }
285
317
 
286
318
  if (moves.length > 0) {
287
- if (!trax) return false
288
- if (trax.gameOver) {
289
- comments.push({
290
- note: moves.map((c) => c.trim()).join(' '),
291
- move: trax.move,
292
- })
293
- } else {
294
- trax.playMoves(moves.filter(Boolean))
295
- moves.splice(0)
296
- }
319
+ if (!trax) return null
320
+ applyMoves(moves, trax, notes)
297
321
  }
298
322
 
299
- if (comment.length > 0) {
300
- const note = comment.map((c) => c.trim()).join(' ')
301
- if (!/^@slugbugblue\/trax cli\.js v/v.test(note)) {
302
- comments.push({
303
- note,
304
- move: trax?.move || 0,
305
- })
306
- }
307
- }
323
+ collectComment(comment, trax, notes)
308
324
  }
309
325
 
310
- if (!trax) return false
326
+ if (!trax || !rules) return null
327
+ return { rules, players, trax, notes }
328
+ }
329
+
330
+ /** Decode a .trx formatted string into a game, preserving players and comments.
331
+ * @param {CLIContext} CLI - the CLI object
332
+ * @param {string} content - the contents of the .trx file
333
+ * @returns {boolean} - true if a game was created
334
+ */
335
+ const interpretFile = (CLI, content) => {
336
+ const parsed = parseFile(content)
337
+ if (!parsed) return false
311
338
 
339
+ const { rules, players, trax, notes } = parsed
312
340
  CLI.do('new', rules, ...players)
313
- CLI.GAMES[String(CLI.GAME.id)].notes = comments
341
+ CLI.GAMES[String(CLI.GAME.id)].notes = notes
314
342
  if (trax.moves.length > 0) {
315
343
  CLI.do('play', ...trax.moves)
316
344
  } else {
package/src/cmds/list.js CHANGED
@@ -49,7 +49,6 @@ export const listCmd = {
49
49
  p1: 1,
50
50
  p2: 1,
51
51
  }
52
- let noteSize = Number.POSITIVE_INFINITY
53
52
 
54
53
  for (const game of Object.values(CLI.GAMES)) {
55
54
  const moves = game.moves.length === 0 ? 0 : game.moves.split(' ').length
@@ -70,32 +69,41 @@ export const listCmd = {
70
69
  }
71
70
 
72
71
  list = listSort(list, filters)
72
+ renderList(CLI, list, size)
73
+ },
74
+ }
73
75
 
74
- for (const game of list) {
75
- size.id = Math.max(size.id, String(game.id).length)
76
- size.name = Math.max(size.name, Number(game.name))
77
- size.moves = Math.max(size.moves, String(game.moves).length)
78
- size.p1 = Math.max(size.p1, String(game.p1).length)
79
- size.p2 = Math.max(size.p2, String(game.p2).length)
80
- noteSize = Math.min(noteSize, leftover(size))
81
- }
76
+ /**
77
+ * @param {CLIContext} CLI - the CLI object
78
+ * @param {Array<Record<string, string | number | boolean | undefined>>} list - sorted game list
79
+ * @param {Record<string, number>} size - initial column width minimums
80
+ */
81
+ const renderList = (CLI, list, size) => {
82
+ let noteSize = Number.POSITIVE_INFINITY
83
+ for (const game of list) {
84
+ size.id = Math.max(size.id, String(game.id).length)
85
+ size.name = Math.max(size.name, Number(game.name))
86
+ size.moves = Math.max(size.moves, String(game.moves).length)
87
+ size.p1 = Math.max(size.p1, String(game.p1).length)
88
+ size.p2 = Math.max(size.p2, String(game.p2).length)
89
+ noteSize = Math.min(noteSize, leftover(size))
90
+ }
82
91
 
83
- for (const game of list) {
84
- CLI.out(
85
- CLI.color(String(game.id).padStart(size.id) + ' ') +
86
- Trax.names[
87
- /** @type {import('../cli.js').TraxVariant} */ (String(game.rules))
88
- ] +
89
- ' '.repeat(size.name - Number(game.name) + 1) +
90
- CLI.bubble(game.turn === 1 ? 'wh' : 'w', String(game.p1)) +
91
- CLI.color('vs '.padStart(size.p1 - String(game.p1).length + 4)) +
92
- CLI.bubble(game.turn === 2 ? 'bh' : 'b', String(game.p2)) +
93
- CLI.color(' '.padStart(size.p2 - String(game.p2).length + 1)) +
94
- CLI.color(String(game.moves).padEnd(size.moves)) +
95
- CLI.color.help(' ' + String(game.note).slice(0, noteSize)),
96
- )
97
- }
98
- },
92
+ for (const game of list) {
93
+ CLI.out(
94
+ CLI.color(String(game.id).padStart(size.id) + ' ') +
95
+ Trax.names[
96
+ /** @type {import('../cli.js').TraxVariant} */ (String(game.rules))
97
+ ] +
98
+ ' '.repeat(size.name - Number(game.name) + 1) +
99
+ CLI.bubble(game.turn === 1 ? 'wh' : 'w', String(game.p1)) +
100
+ CLI.color('vs '.padStart(size.p1 - String(game.p1).length + 4)) +
101
+ CLI.bubble(game.turn === 2 ? 'bh' : 'b', String(game.p2)) +
102
+ CLI.color(' '.padStart(size.p2 - String(game.p2).length + 1)) +
103
+ CLI.color(String(game.moves).padEnd(size.moves)) +
104
+ CLI.color.help(' ' + String(game.note).slice(0, noteSize)),
105
+ )
106
+ }
99
107
  }
100
108
 
101
109
  /** @param {string} a @param {string} b @returns {number} */
package/src/cmds/new.js CHANGED
@@ -68,6 +68,7 @@ const pairs = [
68
68
  ['Aang', 'Katara'],
69
69
  ['Abbott', 'Costello'],
70
70
  ['Adam', 'Eve'],
71
+ ['Ali', 'Frazier'],
71
72
  ['Anne Boleyn', 'Henry VIII'],
72
73
  ['Ash', 'Pikachu'],
73
74
  ['Barbie', 'Ken'],
@@ -80,12 +81,15 @@ const pairs = [
80
81
  ['Chip', 'Dale'],
81
82
  ['Donald', 'Daisy'],
82
83
  ['Depp', 'Heard'],
84
+ ['Dorothy', 'Toto'],
83
85
  ['Fred', 'Barney'],
84
86
  ['Fred', 'George'],
87
+ ['Frida', 'Diego'],
85
88
  ['Frodo', 'Samwise'],
86
89
  ['Hamilton', 'Burr'],
87
90
  ['Han', 'Chewie'],
88
91
  ['Harry', 'Ron'],
92
+ ['Hatfield', 'McCoy'],
89
93
  ['Holmes', 'Moriarty'],
90
94
  ['Holmes', 'Watson'],
91
95
  ['Iñigo', 'Fezzik'],
@@ -109,6 +113,7 @@ const pairs = [
109
113
  ['Pan', 'Hook'],
110
114
  ['Phineas', 'Ferb'],
111
115
  ['player 1', 'player 2'],
116
+ ['p1', 'p2'],
112
117
  ['Potter', 'Voldemort'],
113
118
  ['R2-D2', 'C-3PO'],
114
119
  ['Ren', 'Stimpy'],
@@ -121,6 +126,7 @@ const pairs = [
121
126
  ['Snoopy', 'Woodstock'],
122
127
  ['Sonny', 'Cher'],
123
128
  ['Spongebob', 'Patrick'],
129
+ ['Sully', 'Neytiri'],
124
130
  ['Tarzan', 'Jane'],
125
131
  ['Thelma', 'Louise'],
126
132
  ['Thing 1', 'Thing 2'],
@@ -149,18 +149,24 @@ const matchesToken = (move, token) => {
149
149
 
150
150
  /**
151
151
  * Find the defense move for the current puzzle state, if one exists.
152
+ * First checks position-code keys (exact board-state match, O(1)), then
153
+ * falls back to wildcard notation keys for paths containing '*'.
152
154
  * @param {import('@slugbugblue/trax-puzzles').Defense} defense
155
+ * @param {string} positionCode
153
156
  * @param {string[]} moves
154
157
  * @returns {string | undefined}
155
158
  */
156
- const getDefenseMove = (defense, moves) => {
157
- const key = Object.keys(defense).find((k) => {
158
- const tokens = k.split(' ')
159
- return (
160
- tokens.length === moves.length &&
161
- tokens.every((token, i) => matchesToken(moves[i], token))
162
- )
163
- })
159
+ const getDefenseMove = (defense, positionCode, moves) => {
160
+ if (Object.hasOwn(defense, positionCode)) return defense[positionCode]
161
+ const key = Object.keys(defense)
162
+ .filter((k) => k.includes('*'))
163
+ .find((k) => {
164
+ const tokens = k.split(' ')
165
+ return (
166
+ tokens.length === moves.length &&
167
+ tokens.every((token, i) => matchesToken(moves[i], token))
168
+ )
169
+ })
164
170
  return key ? defense[key] : undefined
165
171
  }
166
172
 
@@ -194,7 +200,11 @@ const bot = (CLI) => {
194
200
  const puzzle = puzzles.find((p) => p.id === game.puzzle)
195
201
  if (puzzle?.defense) {
196
202
  const offset = puzzle.notation.split(' ').length
197
- move = getDefenseMove(puzzle.defense, trax.moves.slice(offset))
203
+ move = getDefenseMove(
204
+ puzzle.defense,
205
+ trax.positionCode(),
206
+ trax.moves.slice(offset),
207
+ )
198
208
  }
199
209
  }
200
210
 
package/src/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // Generated by genversion.
2
- export const version = '0.13.0'
2
+ export const version = '0.14.0'