@slugbugblue/trax 0.13.0 → 0.15.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,34 @@
1
1
  # @slugbugblue/trax
2
2
 
3
+ ## 0.15.0 - 2022-12-14
4
+
5
+ - Added shared `puzzles` commands to the CLI:
6
+
7
+ - `trax ls puzzles`: see all the puzzles
8
+ - `trax new puzzle`: start a new puzzle
9
+
10
+ - Puzzle improvements:
11
+
12
+ - Adjust puzzle category levels for harder puzzles
13
+ - Add multiple hints when the puzzle can be solved multiple ways
14
+
15
+ - Analysis engine improvements:
16
+
17
+ - Analysis engine suggestions now "solve" all tough puzzles
18
+ - Reworked threats to be more precise
19
+ - Reworked scoring to add more nuance
20
+
21
+ ## 0.14.0 - 2022-12-05
22
+
23
+ - Added `puzzles` to the CLI:
24
+
25
+ - `trax puzzles ls`: see all the puzzles
26
+ - `trax puzzle new`: start a new puzzle
27
+
28
+ - Analysis engine tests now include correctly suggesting the first move for
29
+ solving all tutorial and easy puzzles. As the threats database improves, we
30
+ will add more puzzles to the tests.
31
+
3
32
  ## 0.13.0 - 2022-11-25
4
33
 
5
34
  - Add move suggestions to `analyst.js` for CLI and bot use
package/README.md CHANGED
@@ -1,6 +1,11 @@
1
1
  # Trax
2
2
 
3
- Trax game-playing engine and etc.
3
+ This project aims to provide tools for working with Trax games in Javascript.
4
+
5
+ - Trax game-playing engine
6
+ - Position analysis engine
7
+ - Over 50 puzzles to help hone your Trax skills
8
+ - Command-line interface for ease of use
4
9
 
5
10
  ## Description
6
11
 
@@ -32,10 +37,10 @@ The command-line interface is provided as `trax`, available in your path if
32
37
  installed globally, or in the `node_modules/.bin` directory and runnable using
33
38
  `npx trax` if installed locally.
34
39
 
35
- The CLI can be used interactively by passing the `--interactive` or `-i` flag.
36
- When run interactively, you can avoid worries about the need to escape certain
37
- characters from the shell, and it can be easier to run multiple commands back to
38
- back.
40
+ The CLI can be used interactively by running the command with no parameters or
41
+ by passing the `--interactive` or `-i` flag. When run interactively, you can
42
+ avoid worries about the need to escape certain characters from the shell, and it
43
+ can be easier to run multiple commands back to back.
39
44
 
40
45
  Help is available for the CLI through the use of the `help` command. Type
41
46
  `trax help` to see a list of the available commands, and `trax help play` to see
@@ -154,7 +159,6 @@ publicly available puzzles at [the traxgame.com puzzles page][traxpuzzles].
154
159
  ## Roadmap
155
160
 
156
161
  - Improve the threats database / analysis engine.
157
- - Add a puzzle interface to the CLI.
158
162
  - Add more puzzles.
159
163
 
160
164
  ## Support
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@slugbugblue/trax",
3
- "version": "0.13.0",
3
+ "version": "0.15.0",
4
4
  "description": "Trax game engine and friends",
5
5
  "keywords": [
6
6
  "trax",
package/src/analyst.js CHANGED
@@ -1,26 +1,15 @@
1
- /* Copyright 2022 Chad Transtrum
2
- *
3
- * Licensed under the Apache License, Version 2.0 (the "License");
4
- * you may not use this file except in compliance with the License.
5
- * You may obtain a copy of the License at
6
- *
7
- * http://www.apache.org/licenses/LICENSE-2.0
8
- *
9
- * Unless required by applicable law or agreed to in writing, software
10
- * distributed under the License is distributed on an "AS IS" BASIS,
11
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
- * See the License for the specific language governing permissions and
13
- * limitations under the License.
1
+ /** Analyst module: provides analysis functions for Trax games.
2
+ * @copyright 2022
3
+ * @author Chad Transtrum <chad@transtrum.net>
4
+ * @license Apache-2.0
14
5
  */
15
6
 
16
- // Analyst module: provides analysis functions for Trax games
17
-
18
7
  import { Point } from '@slugbugblue/trax/point'
19
8
  import { Trax } from '@slugbugblue/trax'
20
9
 
21
10
  import { threats } from '@slugbugblue/trax/threats'
22
11
 
23
- // Helpers
12
+ /** Helper function to generate a unique id for the analysis. */
24
13
  const id = () => {
25
14
  const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'
26
15
  let code = ''
@@ -31,14 +20,27 @@ const id = () => {
31
20
  return 'analysis-' + code
32
21
  }
33
22
 
34
- // A sort function for hashes with a numeric score property
23
+ /** A sort function for hashes with a numeric score property.
24
+ * @arg {PositionScore} a - The first Position Score to be compared
25
+ * @arg {PositionScore} b - The second Position Score to be compared
26
+ * @returns {number} a.score <=> b.score
27
+ */
35
28
  const byScoreDesc = (a, b) => b.score - a.score
36
29
 
37
- // A filter function factory to remove scores not within x points of the highest score
30
+ /** A filter function factory to remove scores not within x points of the
31
+ * highest score.
32
+ * @arg highest {number} - The highest score
33
+ * @arg tolerance {number} - How close another score must be to be included
34
+ * @returns {(s:PositionScore)=>boolean} a filter function
35
+ */
38
36
  const withinX = (highest, tolerance) => (s) =>
39
37
  s.score === highest || highest - s.score <= tolerance
40
38
 
41
- // Return a random element of an array
39
+ /** Return a random element of an array.
40
+ * @template T
41
+ * @arg {T[]} array - an array of elements
42
+ * @returns {T} one of the elements at random
43
+ */
42
44
  const randomDraw = (array) => array[Math.floor(Math.random() * array.length)]
43
45
 
44
46
  // Make parseInt a little shorter
@@ -56,12 +58,16 @@ const edgeColor = (tile, dir) => {
56
58
 
57
59
  const encodeNumber = (n) => String.fromCodePoint(48 + n)
58
60
 
59
- // Explore a raw edge and update cave information
61
+ /** Explore a raw edge and update cave information.
62
+ * @arg {RawEdge} rawEdge - the raw edge information
63
+ * @returns {RawEdge}
64
+ */
60
65
  const spelunk = (rawEdge) => {
61
66
  for (let index = 1; index < rawEdge.length - 1; index++) {
62
67
  if (rawEdge[index].t === 'C') {
63
- const previous = rawEdge[index - 1] || {}
64
- const next = rawEdge[index + 1] || {}
68
+ const blank = () => ({ x: 0, y: 0, c: '', t: '' })
69
+ const previous = rawEdge[index - 1] || blank()
70
+ const next = rawEdge[index + 1] || blank()
65
71
  if (next.t === 'c') next.t = 'C'
66
72
  if (previous.t === 'c') {
67
73
  previous.t = 'C'
@@ -78,7 +84,10 @@ const spelunk = (rawEdge) => {
78
84
  return rawEdge
79
85
  }
80
86
 
81
- // Combine hollows into a single item in the raw edge array
87
+ /** Combine hollows into a single item in the raw edge array.
88
+ * @arg {RawEdge} rawEdge
89
+ * @returns {RawEdge}
90
+ */
82
91
  const hollowOut = (rawEdge) => {
83
92
  for (let index = 1; index < rawEdge.length - 1; index++) {
84
93
  const space = rawEdge[index]
@@ -93,8 +102,13 @@ const hollowOut = (rawEdge) => {
93
102
  return rawEdge
94
103
  }
95
104
 
96
- // Supposedly deep nesting is bad, so we broke this out of "pairUp" for
97
- // "better" code, even though this is useless on its own ...
105
+ /** Supposedly deep nesting is bad, so we broke this out of "pairUp" for
106
+ * "better" code, even though this is useless on its own ...
107
+ * @arg {RawEdge} rawEdge
108
+ * @arg {number} index
109
+ * @arg {string} color
110
+ * @pairs {Record<string, number>}
111
+ */
98
112
  const findPair = (rawEdge, index, color, pairs) => {
99
113
  const colors = new Set([color, 'l'])
100
114
  const space = rawEdge[index]
@@ -131,8 +145,12 @@ const findPair = (rawEdge, index, color, pairs) => {
131
145
  }
132
146
  }
133
147
 
134
- // Find connectable pairs in the raw edge
148
+ /** Find connectable pairs in the raw edge.
149
+ * @arg {RawEdge} rawEdge
150
+ * @returns {RawEdge}
151
+ */
135
152
  const pairUp = (rawEdge) => {
153
+ /** @type Record<string, number> */
136
154
  const pairs = {}
137
155
  for (const [index, space] of rawEdge.entries()) {
138
156
  for (const bw of ['w', 'b']) {
@@ -146,7 +164,10 @@ const pairUp = (rawEdge) => {
146
164
  return rawEdge
147
165
  }
148
166
 
149
- // Label and finalize the raw edge
167
+ /** Label and finalize the raw edge.
168
+ * @arg {RawEdge} rawEdge
169
+ * @returns {EdgeObject}
170
+ */
150
171
  const label = (rawEdge) => {
151
172
  const final = { w: '', b: '', edge: rawEdge }
152
173
  for (const space of rawEdge) {
@@ -170,47 +191,73 @@ const label = (rawEdge) => {
170
191
  return final
171
192
  }
172
193
 
173
- const threatValues = (threatsArray) => {
174
- return threatsArray.length
175
- // Hm. Maybe we don't need this ...
176
- // let count = 0
177
- // for (const threat of threatsArray) count += threat.value
178
- // return count
179
- }
194
+ /** Quick way to get the number of items in an array.
195
+ * @returns {number} - length of the array
196
+ */
197
+ const length = (array) => array?.length || 0
180
198
 
181
- const attackScore = (from, level) => {
182
- const count = threatValues(from[level] || [])
183
- if (level < 1) return count
184
- return count * 10 ** (threats.maxDepth - level + 1)
199
+ /** Calculate the points for the number of threats at a particular level.
200
+ * @arg {FoundThreats} from - the threats found for a certain player
201
+ * @arg {number} level - the level to calculate the points for
202
+ * @arg {number} [as] - if included, the level the points should be calculated as
203
+ * @return {number} the number of points for the level
204
+ */
205
+ const attackScore = (from, level, as) => {
206
+ const count = length(from[level])
207
+ const calculateAs = Math.min(as === undefined ? level : as, threats.maxDepth)
208
+ if (calculateAs < 1) return count
209
+ return count * 10 ** (threats.maxDepth - calculateAs + 1)
185
210
  }
186
211
 
187
- // Shortcut to get the number of attacks
188
- const attackCount = (from) => threatValues(from['1'] || [])
212
+ /** Shortcut to get the number of attacks.
213
+ * @arg {FoundThreats} from - the threats found for a certain player
214
+ * @returns {number} the number of attacks (win on next move) present
215
+ */
216
+ const attackCount = (from) => length(from['1'])
189
217
 
190
- // Shortcut to get the number of passive threats
218
+ /** Shortcut to get the number of passive threats
219
+ * @arg {FoundThreats} from - the threats found for a player
220
+ * @returns {number} the number of passive threats (win in 2 or more moves)
221
+ */
191
222
  const threatCount = (from) => {
192
223
  let count = 0
193
224
  for (const [depth, array] of Object.entries(from)) {
194
- count += int(depth) > 1 ? threatValues(array) : 0
225
+ count += int(depth) > 1 ? length(array) : 0
195
226
  }
196
227
 
197
228
  return count
198
229
  }
199
230
 
200
- const passiveAttack = (from) =>
231
+ /** Shortcut to determine if we have an active attack.
232
+ * @arg {FoundThreats} from - the threats found for a player
233
+ * @returns {boolean}
234
+ */
235
+ const activeAttack = (from) =>
201
236
  attackCount(from) > 1 || (attackCount(from) === 1 && threatCount(from) > 0)
202
237
 
238
+ /** Shortcut to determine if we have a passive attack.
239
+ * @arg {FoundThreats} from - the threats found for a player
240
+ * @returns {boolean}
241
+ */
242
+ const passiveAttack = (from) => threatCount(from) > 1
243
+
244
+ /** Shortcut to determine if we are under attack.
245
+ * @arg {FoundThreats} from - the threats found for a player
246
+ * @returns {boolean}
247
+ */
203
248
  const underAttack = (from) => attackCount(from) + threatCount(from) > 0
204
249
 
205
250
  // Analysis class
206
251
  export const Analysis = class {
207
- /** @type TraxVariant */
252
+ /** @type {TraxVariant} */
208
253
  #rules = 'trax'
209
254
  #id = 'analysis'
255
+ /** @type {string[]} - List of moves to arrive at this position */
210
256
  #moves = []
211
257
  #notation = ''
212
258
  #edge = undefined
213
- #game = {}
259
+ /** @type {Trax} */
260
+ #game
214
261
  #scores = {}
215
262
  #threats = undefined
216
263
 
@@ -241,6 +288,9 @@ export const Analysis = class {
241
288
  return this.#edge
242
289
  }
243
290
 
291
+ /** All threats present in this position.
292
+ * @returns {FoundThreatsCollection}
293
+ */
244
294
  get threats() {
245
295
  if (!this.#threats) {
246
296
  this.#threats = { b: this.#applyThreats('b'), w: this.#applyThreats('w') }
@@ -273,6 +323,69 @@ export const Analysis = class {
273
323
  return count > 2
274
324
  }
275
325
 
326
+ /** Determine the points of the currently active player.
327
+ * @returns {[number, number]} - the points for this player, and the level
328
+ * of the most dangerous threat
329
+ */
330
+ #activeScore() {
331
+ let score = 0
332
+ let threatLevel = Number.POSITIVE_INFINITY
333
+ const { game } = this
334
+ const active = this.threats[game.color]
335
+
336
+ // First examine the active player
337
+ for (const level of Object.keys(active).map((i) => int(i))) {
338
+ if (level > 0) threatLevel = Math.min(threatLevel, level)
339
+ score += attackScore(active, level)
340
+ }
341
+
342
+ return [score, threatLevel]
343
+ }
344
+
345
+ /** Determine the points of the waiting player.
346
+ * @arg {Color} color - the color for which we are generating the score
347
+ * @arg {number} threatLevel - the lowest level for which the active player
348
+ * has an active threat
349
+ * @returns {[number, number]} - the points for this player and the multiplier
350
+ * to be applied to the other player's points
351
+ */
352
+ #inactiveScore(color, threatLevel) {
353
+ const { game } = this
354
+ const active = this.threats[game.color]
355
+ const inactive = this.threats[Trax.other(game.color)]
356
+
357
+ // Is the color we are looking at the passive player?
358
+ const passive = game.color !== color
359
+
360
+ // Is the inactive player activating known threats?
361
+ const attacking = activeAttack(inactive)
362
+
363
+ // Was the inactive player unable to clear all threats?
364
+ const defending = !attacking && underAttack(active)
365
+
366
+ // Does the inactive player have multiple threats
367
+ const threatening = !attacking && passiveAttack(inactive)
368
+
369
+ // If calc is false, attacks and threats are treated as corners
370
+ const calc = !passive || attacking || defending || threatening
371
+
372
+ // If the current player does not hold the initiative, reduce the score
373
+ const multiplier = passive && attacking && threatLevel > 1 ? 0.1 : 1
374
+
375
+ let score = 0
376
+
377
+ // Now calculate the effects of the passive player
378
+ for (const level of Object.keys(inactive).map((i) => int(i))) {
379
+ const calcLevel = level > 0 && defending ? level + threatLevel - 1 : level
380
+ let points = attackScore(inactive, level, calc ? calcLevel : 0)
381
+ if (level === threatLevel) points = passive ? Math.floor(points / 2) : 0
382
+ if (threatening) points = Math.floor(points / 2)
383
+ score += points
384
+ }
385
+
386
+ return [score, multiplier]
387
+ }
388
+
276
389
  #score(color) {
277
390
  if (this.#scores[color] === undefined) {
278
391
  const { game } = this
@@ -282,30 +395,11 @@ export const Analysis = class {
282
395
  return this.#scores[color]
283
396
  }
284
397
 
285
- let score = 0
398
+ const [currentPlayer, threatLevel] = this.#activeScore()
399
+ const [nextPlayer, multiplier] = this.#inactiveScore(color, threatLevel)
286
400
 
287
- const active = this.threats[game.color]
288
- const inactive = this.threats[Trax.other(game.color)]
289
- let advantage = Number.POSITIVE_INFINITY
290
-
291
- // First examine the active player
292
- for (const level of Object.keys(active).map((i) => int(i))) {
293
- if (level > 0 && level < advantage) advantage = level
294
- score += attackScore(active, level)
295
- }
296
-
297
- const passive = game.color !== color
298
- const calc = !passive || passiveAttack(inactive) || underAttack(active)
299
-
300
- // Calculate the effects of the passive player first
301
- for (const level of Object.keys(inactive).map((i) => int(i))) {
302
- if (level > advantage) continue
303
- let points = calc || level === 0 ? attackScore(inactive, level) : 1
304
- if (level === advantage) points = passive ? Math.floor(points / 2) : 0
305
- score -= points
306
- }
307
-
308
- if (passive) score *= -1
401
+ let score = Math.floor(currentPlayer * multiplier) - nextPlayer
402
+ if (score && game.color !== color) score *= -1
309
403
 
310
404
  this.#scores[color] = score
311
405
  }
@@ -368,6 +462,7 @@ export const Analysis = class {
368
462
  // Trace the edge of the playing area
369
463
  #traceEdge() {
370
464
  const { game } = this
465
+ /** @type {RawEdge} */
371
466
  const rawEdge = []
372
467
  const lines = {}
373
468
  const count = { w: 0, b: 0 }
@@ -422,8 +517,26 @@ export const Analysis = class {
422
517
  return label(pairUp(hollowOut(spelunk(rawEdge))))
423
518
  }
424
519
 
425
- // Apply the threats to a color's regex-ready edge
520
+ /** Compose the threat into a FoundThreat object.
521
+ * @arg {Threat} threat - the threat that has been found
522
+ * @arg {RegExpMatchArray} match - the matching item
523
+ * @returns {FoundThreat}
524
+ */
525
+ #foundThreat(threat, match) {
526
+ return {
527
+ threat: threat.pattern,
528
+ match: match[1], // The match is in the capturing group
529
+ at: match.index,
530
+ value: threat.value,
531
+ }
532
+ }
533
+
534
+ /** Apply the threats to a color's regex-ready edge.
535
+ * @arg {Color} color - the color to find threats for
536
+ * @returns {FoundThreats}
537
+ */
426
538
  #applyThreats(color) {
539
+ /** @type {FoundThreats} */
427
540
  const matches = {}
428
541
  const { length } = this.edge[color]
429
542
  const edge = this.edge[color].repeat(2)
@@ -431,13 +544,11 @@ export const Analysis = class {
431
544
  const find = matches[threat.depth] || []
432
545
  for (const match of edge.matchAll(threat.rx)) {
433
546
  if (match.index >= length) break
434
- if (find.every((f) => f.at !== match.index || f.match !== match[0])) {
435
- find.push({
436
- threat: threat.pattern,
437
- match: match[0],
438
- at: match.index,
439
- value: threat.value,
440
- })
547
+ if (find.every((f) => f.at !== match.index || f.match !== match[1])) {
548
+ find.push(this.#foundThreat(threat, match))
549
+ } else {
550
+ const duplicate = matches[99] || []
551
+ duplicate.push(this.#foundThreat(threat, match))
441
552
  }
442
553
  }
443
554
 
@@ -479,12 +590,19 @@ export const Analysis = class {
479
590
  }
480
591
  }
481
592
 
593
+ /** Analyze a game position.
594
+ * @arg {Trax} game - the position to analyze
595
+ * @returns {Analysis}
596
+ */
482
597
  export const analyze = (game) => new Analysis(game)
483
598
 
484
599
  export const Suggestion = class {
600
+ /** @type {PositionScore[]} */
485
601
  all = []
602
+ /** @type {PositionScore[]} */
486
603
  options = []
487
- pick = {}
604
+ /** @type {PositionScore} */
605
+ pick
488
606
  ms = 0
489
607
 
490
608
  constructor(analyzed, ms) {
package/src/cli.js CHANGED
@@ -39,6 +39,7 @@ import {
39
39
  import { listCmd } from './cmds/list.js'
40
40
  import { newCmd } from './cmds/new.js'
41
41
  import { playCmd, tryCmd } from './cmds/play-try.js'
42
+ import { puzzlesCmd } from './cmds/puzzles.js'
42
43
  import { selectCmd } from './cmds/select.js'
43
44
  import { suggestCmd } from './cmds/suggest.js'
44
45
  import { undoCmd } from './cmds/undo.js'
@@ -87,6 +88,7 @@ const plugins = [
87
88
  listCmd,
88
89
  newCmd,
89
90
  playCmd,
91
+ puzzlesCmd,
90
92
  quitCmd,
91
93
  selectCmd,
92
94
  suggestCmd,
@@ -190,8 +192,8 @@ const color = (text, def) => {
190
192
  }
191
193
 
192
194
  // Internal representations of files
193
- const CONFIG = { id: '' }
194
- const DATA = { games: {} }
195
+ const CONFIG = { id: '', puzzleLevel: 0 }
196
+ const DATA = { games: {}, puzzles: {} }
195
197
 
196
198
  // CLI context object for plugins
197
199
  const CLI = {
@@ -215,6 +217,10 @@ const CLI = {
215
217
  get ID() {
216
218
  return CONFIG.id
217
219
  },
220
+ // The current puzzle level
221
+ get puzzleLevel() {
222
+ return CONFIG.puzzleLevel
223
+ },
218
224
 
219
225
  // Provide access to the current Trax object
220
226
  TRAX: new Trax(),
@@ -231,6 +237,7 @@ const CLI = {
231
237
  variable: 123,
232
238
  optional: 110,
233
239
  short: 153,
240
+ success: 35,
234
241
  white: 250,
235
242
  },
236
243
 
@@ -298,11 +305,57 @@ const CLI = {
298
305
  return id
299
306
  },
300
307
 
308
+ newPuzzle(puzzle) {
309
+ // Starts a new puzzle
310
+ const id = newId(DATA)
311
+ const trax = new Trax(puzzle.game, puzzle.notation, 'cli')
312
+ const players = ['challenger', 'puzzlebot']
313
+ DATA.games[id] = {
314
+ id,
315
+ puzzle: puzzle.id,
316
+ rules: trax.rules,
317
+ name: trax.name,
318
+ turn: trax.turn,
319
+ max: puzzle.max,
320
+ players: trax.turn === 1 ? players : players.reverse(),
321
+ moves: trax.moves.join(' '),
322
+ }
323
+
324
+ if (DATA.puzzles[puzzle.id]) {
325
+ DATA.puzzles[puzzle.id].attempts += 1
326
+ } else {
327
+ DATA.puzzles[puzzle.id] = { attempts: 1, solved: 0 }
328
+ }
329
+
330
+ CONFIG.id = id
331
+ CONFIG.puzzleLevel = puzzle.level
332
+ CLI.TRAX = trax
333
+
334
+ saveConfig()
335
+ saveData()
336
+
337
+ return id
338
+ },
339
+
301
340
  out(text) {
302
341
  // Prints to stdout
303
342
  console.info(text + tty.color(''))
304
343
  },
305
344
 
345
+ plural(n, noun, nouns) {
346
+ return String(n) + ' ' + (Math.abs(n) === 1 ? noun : nouns || noun + 's')
347
+ },
348
+
349
+ puzzle(id) {
350
+ return DATA.puzzles?.[id] || { attempts: 0, solved: 0 }
351
+ },
352
+
353
+ puzzleSolved(id) {
354
+ DATA.puzzles = DATA.puzzles || {}
355
+ DATA.puzzles[id] = DATA.puzzles[id] || { attempts: 1, solved: 0 }
356
+ DATA.puzzles[id].solved += 1
357
+ },
358
+
306
359
  resolveCommand(word) {
307
360
  // Determine if an entered word is a valid command.
308
361
  // Returns the command if a single match is found, an array if multiple
@@ -22,7 +22,7 @@ const notationRx = /^[@a-z]+\d+[bps/\\+]$/i
22
22
 
23
23
  export const analyzeCmd = {
24
24
  name: 'analyze',
25
- args: '[<move>]',
25
+ args: '[move]',
26
26
  comp: '<play>',
27
27
  desc: 'analyze a position',
28
28
  help: [
@@ -44,7 +44,7 @@ const listThreats = (threats, detailed) => {
44
44
  const items = []
45
45
  if (detailed) {
46
46
  for (const item of threats[level]) {
47
- items.push('[' + item.at + ':' + item.match + ']')
47
+ items.push('[' + item.at + ':' + item.threat + ':' + item.match + ']')
48
48
  }
49
49
  }
50
50
 
@@ -22,6 +22,7 @@ import envPaths from 'env-paths'
22
22
  import makeDir from 'make-dir'
23
23
 
24
24
  import { Trax } from '@slugbugblue/trax'
25
+ import { puzzles, sources } from '@slugbugblue/trax/puzzles'
25
26
 
26
27
  const paths = envPaths('trax', { suffix: '' })
27
28
  const PATHS = {}
@@ -30,7 +31,12 @@ const PATHS = {}
30
31
  let cli = {}
31
32
 
32
33
  const fsOuch = (fsError) => {
33
- cli.out(fsError, cli.COLORS.fatal)
34
+ if (cli.out) {
35
+ cli.out(fsError, cli.COLORS.fatal)
36
+ } else {
37
+ console.log(fsError)
38
+ }
39
+
34
40
  throw fsError
35
41
  }
36
42
 
@@ -72,7 +78,12 @@ export const getFile = async (filetype, filename, variable, decode) => {
72
78
  }
73
79
  } catch (fsError) {
74
80
  if (fsError instanceof TypeError || fsError instanceof SyntaxError) {
75
- cli.error(`Unable to read ${filetype} file ${path}/${filename}`)
81
+ const error = `Unable to read ${filetype} file ${path}/${filename}`
82
+ if (cli.error) {
83
+ cli.error(error)
84
+ } else {
85
+ console.log(error)
86
+ }
76
87
  } else if (fsError.code !== 'ENOENT') {
77
88
  fsOuch(fsError)
78
89
  }
@@ -200,6 +211,25 @@ exportCmd.fn = (CLI, id, filename) => {
200
211
  content += ' vs '
201
212
  content += game.players?.[1] || 'black'
202
213
  content += '\n; @slugbugblue/trax cli.js v' + CLI.version + '\n'
214
+ if (game.puzzle) {
215
+ const puzzle = puzzles.find((p) => p.id === game.puzzle)
216
+ if (puzzle) {
217
+ const source = sources[puzzle.src]
218
+ content += '; Puzzle ' + game.puzzle
219
+ if (source) {
220
+ content += ' provided courtesy of ' + source.name
221
+ if (source.url) content += ' (' + source.url + ')'
222
+ }
223
+
224
+ if (puzzle.title) content += '\n; ' + puzzle.title
225
+ if (puzzle.desc) content += '\n; ' + puzzle.desc
226
+ content += '\n; Starting position ' + puzzle.notation + '\n; '
227
+ const players = [0, 'white', 'black']
228
+ content += players[puzzle.player] + ' to win by move '
229
+ content += String(puzzle.max) + '\n'
230
+ }
231
+ }
232
+
203
233
  content += trax.notation + '\n'
204
234
  if (filename) filename = filename.replace(/[^ a-z\d.~/]/g, '')
205
235
  if (filename) {