@slugbugblue/trax 0.19.0 → 0.21.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/src/analyst.js CHANGED
@@ -12,7 +12,9 @@ import { threats } from '@slugbugblue/trax/threats'
12
12
 
13
13
  const cache = new LRU(2750)
14
14
 
15
- /** Helper function to generate a unique id for the analysis. */
15
+ /** Helper function to generate a unique id for the analysis.
16
+ * @returns {string} a 6 character random string
17
+ */
16
18
  const id = () => {
17
19
  const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'
18
20
  let code = ''
@@ -46,19 +48,35 @@ const withinX = (highest, tolerance) => (s) =>
46
48
  */
47
49
  const randomDraw = (array) => array[Math.floor(Math.random() * array.length)]
48
50
 
49
- // Make parseInt a little shorter
51
+ /** Make parseInt a little shorter with decent defaults.
52
+ * @arg s {string} - A string representation of a decimal number
53
+ * @returns {number} the parsed number
54
+ */
50
55
  const int = (s) => Number.parseInt(s, 10)
51
56
 
57
+ /** The direction after a left turn. */
52
58
  const leftTurn = { d: 'r', l: 'd', r: 'u', u: 'l' }
59
+ /** The direction after a right turn. */
53
60
  const rightTurn = { d: 'l', l: 'u', r: 'd', u: 'r' }
61
+ /** Which way to follow. */
54
62
  const follow = { d: 'right', l: 'bottom', r: 'top', u: 'left' }
55
63
 
64
+ /** Given a tile and a direction, return the color of the line going that
65
+ * direction.
66
+ * @arg tile {TileType} - the tile in question
67
+ * @arg dir {string} - direction u, d, l, or r
68
+ * @returns {Color}
69
+ */
56
70
  const edgeColor = (tile, dir) => {
57
71
  return { d: 'ace', l: 'abd', r: 'bcf', u: 'def' }[dir].includes(tile)
58
72
  ? 'w'
59
73
  : 'b'
60
74
  }
61
75
 
76
+ /** Encode a number as a character.
77
+ * @arg n {number} - the number to encode
78
+ * @returns {string} the encoded number
79
+ */
62
80
  const encodeNumber = (n) => String.fromCodePoint(48 + n)
63
81
 
64
82
  /** Explore a raw edge and update cave information.
@@ -204,7 +222,7 @@ const label = (rawEdge) => {
204
222
  const length = (array) => array?.length || 0
205
223
 
206
224
  /** Calculate the points for the number of threats at a particular level.
207
- * @arg {FoundThreats} from - the threats found for a certain player
225
+ * @arg {ColorThreats} from - the threats found for a certain player
208
226
  * @arg {number} level - the level to calculate the points for
209
227
  * @arg {number} [as] - if included, the level the points should be calculated as
210
228
  * @return {number} the number of points for the level
@@ -217,16 +235,23 @@ const attackScore = (from, level, as) => {
217
235
  }
218
236
 
219
237
  /** Shortcut to get the number of attacks.
220
- * @arg {FoundThreats} from - the threats found for a certain player
238
+ * @arg {ColorThreats} from - the threats found for a certain player
221
239
  * @returns {number} the number of attacks (win on next move) present
222
240
  */
223
241
  const attackCount = (from) => length(from['1'])
224
242
 
243
+ /** Shortcut to get the number of threats at a certain level.
244
+ * @arg {ColorThreats} from - the threats found for a player
245
+ * @arg {number} level - the level for the threats to look for
246
+ * @returns {number} the number of threats at the given level
247
+ */
248
+ const threatCount = (from, level) => length(from[level])
249
+
225
250
  /** Shortcut to get the number of passive threats
226
- * @arg {FoundThreats} from - the threats found for a player
251
+ * @arg {ColorThreats} from - the threats found for a player
227
252
  * @returns {number} the number of passive threats (win in 2 or more moves)
228
253
  */
229
- const threatCount = (from) => {
254
+ const passiveCount = (from) => {
230
255
  let count = 0
231
256
  for (const [depth, array] of Object.entries(from)) {
232
257
  count += int(depth) > 1 ? length(array) : 0
@@ -236,25 +261,31 @@ const threatCount = (from) => {
236
261
  }
237
262
 
238
263
  /** Shortcut to determine if we have an active attack.
239
- * @arg {FoundThreats} from - the threats found for a player
264
+ * @arg {ColorThreats} from - the threats found for a player
240
265
  * @returns {boolean}
241
266
  */
242
267
  const activeAttack = (from) =>
243
- attackCount(from) > 1 || (attackCount(from) === 1 && threatCount(from) > 0)
268
+ attackCount(from) > 1 || (attackCount(from) === 1 && passiveCount(from) > 0)
244
269
 
245
270
  /** Shortcut to determine if we have a passive attack.
246
- * @arg {FoundThreats} from - the threats found for a player
271
+ * @arg {ColorThreats} from - the threats found for a player
247
272
  * @returns {boolean}
248
273
  */
249
- const passiveAttack = (from) => threatCount(from) > 1
274
+ const passiveAttack = (from) => passiveCount(from) > 1
250
275
 
251
- /** Shortcut to determine if we are under attack.
252
- * @arg {FoundThreats} from - the threats found for a player
276
+ // /** Shortcut to determine if we are under attack.
277
+ // * @arg {ColorThreats} from - the threats found for a player
278
+ // * @returns {boolean}
279
+ // */
280
+ // const underAttack = (from) => attackCount(from) > 0
281
+
282
+ /** Shortcut to determine if we are under threat.
283
+ * @arg {ColorThreats} from - the threats found for a player
253
284
  * @returns {boolean}
254
285
  */
255
- const underAttack = (from) => attackCount(from) + threatCount(from) > 0
286
+ const underThreat = (from) => attackCount(from) + passiveCount(from) > 0
256
287
 
257
- // Analysis class
288
+ /** Trax position analysis. */
258
289
  export class Analysis {
259
290
  /** @type {TraxVariant} */
260
291
  #rules = 'trax'
@@ -262,15 +293,18 @@ export class Analysis {
262
293
  /** @type {string[]} - List of moves to arrive at this position */
263
294
  #moves = []
264
295
  #notation = ''
296
+ /** @type EdgeObject */
265
297
  #edge = undefined
266
298
  /** @type {Trax} */
267
299
  #game
268
- #scores = {}
300
+ /** @type Scores */
301
+ #scores = undefined
302
+ /** @type FoundThreatsCollection */
269
303
  #threats = undefined
270
- #faulty = { b: [], w: [] }
271
- #partial = false
304
+ #validated = false
272
305
 
273
- constructor(game, partial = false) {
306
+ /** @arg game {Trax} - an in-progress trax game */
307
+ constructor(game) {
274
308
  // Make a copy of the game
275
309
  this.#rules = game.rules
276
310
  this.#moves = game.moves
@@ -281,11 +315,18 @@ export class Analysis {
281
315
  w: (level) => this.threats.w[level]?.length || 0,
282
316
  b: (level) => this.threats.b[level]?.length || 0,
283
317
  }
284
- this.#partial = Boolean(partial)
318
+ /** @type FaultyThreats */
319
+ this.faulty = { b: [], w: [] }
320
+ this.#validated = false
321
+ if (this.#moves.length === 0 || game.over) {
322
+ /** @type EdgeObject */
323
+ this.#edge = { b: '', w: '', edge: [] }
324
+ this.#threats = { b: {}, w: {} }
325
+ }
285
326
  }
286
327
 
328
+ /** A fresh copy of the game as presented on init. */
287
329
  get game() {
288
- // Return a fresh copy of the game as presented on init
289
330
  if (this.#game.notation !== this.#notation) {
290
331
  this.#game = new Trax(this.#rules, this.#moves, this.#id)
291
332
  }
@@ -293,6 +334,7 @@ export class Analysis {
293
334
  return this.#game
294
335
  }
295
336
 
337
+ /** The edge of the playing area. */
296
338
  get edge() {
297
339
  if (!this.#edge) {
298
340
  // Cache the edge
@@ -302,20 +344,12 @@ export class Analysis {
302
344
  return this.#edge
303
345
  }
304
346
 
305
- /** Any faulty threats
306
- * @returns {FaultyThreats}
307
- */
308
- get faulty() {
309
- return this.#faulty
310
- }
311
-
312
347
  /** All threats present in this position.
313
348
  * @returns {FoundThreatsCollection}
314
349
  */
315
350
  get threats() {
316
351
  if (!this.#threats) {
317
352
  this.#threats = { b: this.#applyThreats('b'), w: this.#applyThreats('w') }
318
- if (!this.#partial) this.#validateThreats()
319
353
  }
320
354
 
321
355
  return this.#threats
@@ -383,7 +417,7 @@ export class Analysis {
383
417
  const attacking = activeAttack(inactive)
384
418
 
385
419
  // Was the inactive player unable to clear all threats?
386
- const defending = !attacking && underAttack(active)
420
+ const defending = !attacking && underThreat(active)
387
421
 
388
422
  // Does the inactive player have multiple threats
389
423
  const threatening = !attacking && passiveAttack(inactive)
@@ -408,45 +442,56 @@ export class Analysis {
408
442
  return [score, multiplier]
409
443
  }
410
444
 
445
+ /** Calculate the score for a player.
446
+ * @arg {Color} color - Color of the player
447
+ * @returns number
448
+ */
411
449
  #score(color) {
412
- if (this.#scores[color] === undefined) {
413
- const { game } = this
414
- if (game.over) {
415
- this.#scores[game.color] = Number.POSITIVE_INFINITY
416
- this.#scores[Trax.other(game.color)] = Number.NEGATIVE_INFINITY
417
- return this.#scores[color]
418
- }
419
-
420
- const [currentPlayer, threatLevel] = this.#activeScore()
421
- const [nextPlayer, multiplier] = this.#inactiveScore(color, threatLevel)
450
+ const { game } = this
451
+ if (game.over) {
452
+ return game.color === color
453
+ ? Number.POSITIVE_INFINITY
454
+ : game.color === color
455
+ ? Number.NEGATIVE_INFINITY
456
+ : -100_000
457
+ }
422
458
 
423
- let score = Math.floor(currentPlayer * multiplier) - nextPlayer
424
- if (score && game.color !== color) score *= -1
459
+ const [currentPlayer, threatLevel] = this.#activeScore()
460
+ const [nextPlayer, multiplier] = this.#inactiveScore(color, threatLevel)
425
461
 
426
- this.#scores[color] = score
427
- }
462
+ let score = Math.floor(currentPlayer * multiplier) - nextPlayer
463
+ if (score && game.color !== color) score *= -1
428
464
 
429
- return this.#scores[color]
465
+ return score
430
466
  }
431
467
 
468
+ /** Get the scores for each player. */
432
469
  get scores() {
433
- return {
434
- w: this.#score('w'),
435
- b: this.#score('b'),
470
+ if (this.#scores === undefined) {
471
+ this.#scores = {
472
+ w: this.#score('w'),
473
+ b: this.#score('b'),
474
+ }
436
475
  }
476
+
477
+ return this.#scores
437
478
  }
438
479
 
480
+ /** Get the score for the active player. */
439
481
  get score() {
440
482
  return this.scores[this.game.color]
441
483
  }
442
484
 
443
- // Space type
444
- //
445
- // determine which type of space we have:
446
- // c = in a cave but has no restrictions (converted to n in edge)
447
- // C = a cave where some moves are not allowed (converted to c in edge)
448
- // n = normal space
449
- // x = no tile can be played here
485
+ /** Determine which type of space we have:
486
+ *
487
+ * determine which type of space we have:
488
+ * c = in a cave but has no restrictions (converted to n in edge)
489
+ * C = a cave where some moves are not allowed (converted to c in edge)
490
+ * n = normal space
491
+ * x = no tile can be played here
492
+ * @arg {Point} loc - The location of the space
493
+ * @returns string
494
+ */
450
495
  spaceType(loc) {
451
496
  const { game } = this
452
497
 
@@ -481,7 +526,7 @@ export class Analysis {
481
526
  return 'c'
482
527
  }
483
528
 
484
- // Trace the edge of the playing area
529
+ /** Trace the edge of the playing area. */
485
530
  #traceEdge() {
486
531
  const { game } = this
487
532
  /** @type {RawEdge} */
@@ -559,15 +604,17 @@ export class Analysis {
559
604
  * @arg {string} color - the player color
560
605
  * @returns {Point[]} - locations relevant to the threat
561
606
  */
562
- #threatLocs(threat, color) {
607
+ threatLocs(threat, color) {
563
608
  const { edge } = this.edge
564
609
  const max = edge.length
565
610
  let index = threat.at
611
+ let count = 0
566
612
  const locs = new Set([index])
567
- while (threat.match.length > locs.size) {
613
+ while (count < threat.match.length) {
568
614
  index += 1
569
615
  if (index >= max) index -= max
570
616
  locs.add(index)
617
+ count += 1
571
618
  // Check to see if we have a connectable pair
572
619
  if (edge[index]['p' + color]) {
573
620
  const line = 'n' + color
@@ -583,16 +630,15 @@ export class Analysis {
583
630
  }
584
631
  }
585
632
 
586
- const value = [...locs].map((i) => new Point(edge[i]))
587
- return value
633
+ return [...locs].map((i) => new Point(edge[i] || { x: 0, y: 0 }))
588
634
  }
589
635
 
590
636
  /** Apply the threats to a color's regex-ready edge.
591
637
  * @arg {Color} color - the color to find threats for
592
- * @returns {FoundThreats}
638
+ * @returns {ColorThreats}
593
639
  */
594
640
  #applyThreats(color) {
595
- /** @type {FoundThreats} */
641
+ /** @type {ColorThreats} */
596
642
  const matches = {}
597
643
  const { length } = this.edge[color]
598
644
  const edge = this.edge[color].repeat(2)
@@ -614,10 +660,12 @@ export class Analysis {
614
660
  }
615
661
 
616
662
  /** Clean up the list of matches, removing any false positives. */
617
- #validateThreats() {
663
+ validateThreats() {
664
+ if (this.#validated) return
665
+ let someFaulty = false
618
666
  for (const bw of ['w', 'b']) {
619
- /** @type FoundThreats */
620
- const matches = this.#threats[bw]
667
+ /** @type ColorThreats */
668
+ const matches = this.threats[bw]
621
669
  for (const key of Object.keys(matches)) {
622
670
  const level = Number(key)
623
671
  if (level < 2) continue
@@ -628,7 +676,8 @@ export class Analysis {
628
676
  if (this.#canActivateThreat(threat, bw, level)) {
629
677
  verified.push(threat)
630
678
  } else {
631
- this.#faulty[bw].push(threat)
679
+ this.faulty[bw].push(threat)
680
+ someFaulty = true
632
681
  }
633
682
  }
634
683
 
@@ -639,6 +688,9 @@ export class Analysis {
639
688
  }
640
689
  }
641
690
  }
691
+
692
+ if (someFaulty) this.#scores = undefined
693
+ this.#validated = true
642
694
  }
643
695
 
644
696
  /** Determine if the given threat can be activated for this color
@@ -648,10 +700,12 @@ export class Analysis {
648
700
  * @returns {boolean}
649
701
  */
650
702
  #canActivateThreat(threat, color, level) {
651
- const attacks = this.#threats[color][1]?.length ?? 0
652
- const other = this.#threats[Trax.other(color)][1]?.length ?? 0
653
- const higher = this.#threats[color][level - 1]?.length ?? 0
654
- for (const loc of this.#threatLocs(threat, color)) {
703
+ const attacks = attackCount(this.threats[color])
704
+ const player = Trax.playerNumber(color)
705
+ const other = Trax.other(color)
706
+ const otherAttacks = attackCount(this.threats[other])
707
+ const higher = threatCount(this.threats[color], level - 1)
708
+ for (const loc of this.threatLocs(threat, color)) {
655
709
  for (const type of this.game.possibleTiles(loc)) {
656
710
  const game = new Trax(this.game.rules, this.game.moves)
657
711
  const result = game.dropTile(type, loc)
@@ -659,20 +713,23 @@ export class Analysis {
659
713
 
660
714
  // This might be a bad shortcut, but if we already have an attack here
661
715
  // we just assume that this threat is also valid ...
662
- if (game.over && game.turn === Trax.playerNumber(color)) return true
716
+ if (game.over && game.turn === player) return true
717
+
718
+ // Otherwise, we cannot evaluate an ended game
719
+ if (game.over) continue
663
720
 
664
- const { threats } = analyze(game, true)
721
+ const { threats } = analyze(game)
665
722
 
666
723
  // If we give our opponent an attack, this is a faulty move
667
- const faulty = (threats[Trax.other(color)][1]?.length ?? 0) - other
724
+ const faulty = attackCount(threats[other]) - otherAttacks
668
725
  if (faulty > 0) continue
669
726
 
670
727
  // Activating the threat means we get an attack and a threat
671
- const newAttacks = (threats[color][1]?.length ?? 0) - attacks
728
+ const newAttacks = attackCount(threats[color]) - attacks
672
729
  if (level === 2) {
673
730
  if (newAttacks > 1) return true
674
731
  } else {
675
- const newThreats = (threats[color][level - 1]?.length ?? 0) - higher
732
+ const newThreats = threatCount(threats[color], level - 1) - higher
676
733
  if (newAttacks > 0 && newThreats > 0) return true
677
734
  }
678
735
  }
@@ -701,12 +758,12 @@ export class Analysis {
701
758
  custom += tbd
702
759
  }
703
760
 
761
+ const score = this.#scores ? this.score : null
704
762
  custom += ',\n threats: '
705
763
  custom += this.#threats ? sub(this.#threats) : tbd
706
764
  custom += ',\n scores: '
707
- custom += 'w' in this.#scores ? sub(this.#scores) : tbd
765
+ custom += this.#scores ? sub(this.#scores) : tbd
708
766
  custom += ',\n score: '
709
- const score = 'w' in this.#scores ? this.score : null
710
767
  custom += score === null ? tbd : style(score, 'number')
711
768
 
712
769
  return custom + '\n}'
@@ -715,14 +772,13 @@ export class Analysis {
715
772
 
716
773
  /** Analyze a game position.
717
774
  * @arg {Trax} game - the position to analyze
718
- * @arg {boolean} partial - if the position should only be partially analyzed
719
775
  * @returns {Analysis}
720
776
  */
721
- export const analyze = (game, partial = false) => {
722
- const id = (partial ? 'y' : 'n') + game.rules + game.normalized
777
+ export const analyze = (game) => {
778
+ const id = game.rules + game.normalized
723
779
  const cached = cache.get(id)
724
780
  if (cached) return cached
725
- const analysis = new Analysis(game, partial)
781
+ const analysis = new Analysis(game)
726
782
  cache.set(id, analysis)
727
783
  return analysis
728
784
  }
@@ -736,9 +792,23 @@ export class Suggestion {
736
792
  pick
737
793
  ms = 0
738
794
 
739
- constructor(analyzed, ms) {
795
+ constructor(color, analyzed, ms) {
740
796
  this.all = analyzed.sort(byScoreDesc)
741
- const best = analyzed[0] || { move: '', score: 0 }
797
+ let best = analyzed[0] || { move: '', score: 0 }
798
+ let score = Number.NEGATIVE_INFINITY
799
+
800
+ while (score !== best.score) {
801
+ score = best.score
802
+ const options = analyzed.filter(withinX(best.score, 5))
803
+ for (const option of options) {
804
+ option.analysis.validateThreats()
805
+ option.score = option.analysis.scores[color]
806
+ }
807
+
808
+ analyzed.sort(byScoreDesc)
809
+ best = analyzed[0] || { move: '', score: 0 }
810
+ }
811
+
742
812
  this.options = analyzed.filter(withinX(best.score, 5))
743
813
  this.pick = randomDraw(this.options) || best
744
814
  this.ms = ms
@@ -776,9 +846,13 @@ export const suggest = (game) => {
776
846
  const unique = trax.normalized
777
847
  if (!analyzed[unique]) {
778
848
  const analysis = analyze(trax)
779
- analyzed[unique] = { move, score: analysis.scores[color], analysis }
849
+ analyzed[unique] = {
850
+ move,
851
+ analysis,
852
+ score: analysis.scores[color],
853
+ }
780
854
  }
781
855
  }
782
856
 
783
- return new Suggestion(Object.values(analyzed), Date.now() - start)
857
+ return new Suggestion(color, Object.values(analyzed), Date.now() - start)
784
858
  }
@@ -248,7 +248,7 @@ const gameNotes = (move, notes) => {
248
248
  if (moveNotes.length === 0) return ''
249
249
  return (
250
250
  '; ' +
251
- moveNotes.map((n) => n.note.replaceAll('\n', '\n; ')).join('\n; ') +
251
+ moveNotes.map((n) => n.note.replace(/\n/g, '\n; ')).join('\n; ') +
252
252
  '\n'
253
253
  )
254
254
  }
@@ -328,8 +328,15 @@ exportCmd.fn = (CLI, id, filename) => {
328
328
  const source = sources[puzzle.src]
329
329
  content += '; Puzzle ' + game.puzzle
330
330
  if (source) {
331
- content += ' provided courtesy of ' + source.name
331
+ content += source.copyright
332
+ ? ' ©' + source.copyright + ' by '
333
+ : ' provided courtesy of '
334
+ content += source.name
332
335
  if (source.url) content += '\n; ' + source.url
336
+ if (source.license) {
337
+ content += '\n; Licensed under ' + source.license
338
+ content += source.licenseUrl ? ' ' + source.licenseUrl : ''
339
+ }
333
340
  }
334
341
 
335
342
  content += '\n'
package/src/cmds/notes.js CHANGED
@@ -25,6 +25,9 @@ notesCmd.fn = (CLI, id, ...note) => {
25
25
  if (id.startsWith('#')) id = id.slice(1)
26
26
 
27
27
  const game = CLI.GAMES[id]
28
+
29
+ if (!game) return CLI.error('Game not found.')
30
+
28
31
  const notes = game.notes || []
29
32
  const move = game.moves.length > 0 ? game.moves.split(' ').length : 0
30
33
 
@@ -1,4 +1,4 @@
1
- /* Copyright 2022 Chad Transtrum
1
+ /* Copyright 2022-2023 Chad Transtrum
2
2
  *
3
3
  * Licensed under the Apache License, Version 2.0 (the "License");
4
4
  * you may not use this file except in compliance with the License.
@@ -16,6 +16,7 @@
16
16
  // CLI play/try commands
17
17
 
18
18
  import { suggest } from '@slugbugblue/trax/analyst'
19
+ import { timeString } from '@slugbugblue/trax/utils'
19
20
 
20
21
  const moveRx = /^\d+\.?$/
21
22
  const notationRx = /^[@a-z]+\d+[bps/\\+]$/i
@@ -70,9 +71,14 @@ const bot = (CLI) => {
70
71
 
71
72
  CLI.do('view')
72
73
  CLI.out(CLI.bubble(trax.color + 'd', `${name} thinking...`))
74
+ const start = Date.now()
73
75
  const move = suggest(trax)?.pick?.move
76
+ const ms = Date.now() - start
74
77
  if (move) {
75
- CLI.out(CLI.bubble(trax.color + 'd', `${name} chooses ${move}`))
78
+ CLI.out(
79
+ CLI.bubble(trax.color + 'd', `${name} chooses ${move}`) +
80
+ CLI.color.black(' in ' + timeString(ms)),
81
+ )
76
82
  trax.play(move)
77
83
  }
78
84
 
@@ -1,5 +1,5 @@
1
1
  /** @file Puzzles CLI
2
- * @copyright 2022
2
+ * @copyright 2022-2023
3
3
  * @author Chad Transtrum <chad@transtrum.net>
4
4
  * @license Apache-2.0
5
5
  */
@@ -17,11 +17,14 @@ for (const puzzle of puzzles) {
17
17
  const validLevels = levels.slice(0, maxLevel + 1)
18
18
  const completions = [...validLevels, ...puzzleIds].join('|')
19
19
 
20
+ const listAliases = ['display', 'list', 'ls', 'show', 'view']
21
+ const startAliases = ['new', 'start']
22
+
20
23
  export const puzzlesCmd = {
21
24
  name: 'puzzles',
22
25
  args: '["list"|"start"] [level|puzzle]',
23
- comp: 'list|ls|new|show|start|view ' + completions,
24
- desc: 'play puzzles',
26
+ comp: 'display|list|ls|new|show|start|view ' + completions,
27
+ desc: 'view and play puzzles',
25
28
  help: [
26
29
  'Practice your skills with puzzles. Use "list" to see the puzzle',
27
30
  'categories, or "start" to play a puzzle.',
@@ -33,9 +36,15 @@ const an = (word = '') => {
33
36
  return a + ' ' + word
34
37
  }
35
38
 
36
- const showPuzzle = (CLI, id) => {
37
- const puzzle = puzzles.find((p) => p.id === id)
39
+ const showPuzzle = (CLI, id, started = false) => {
40
+ let puzzle = puzzles.find((p) => p.id === id)
41
+ if (!puzzle) {
42
+ const possibles = puzzles.filter((p) => p.id.includes(id))
43
+ if (possibles.length === 1) puzzle = possibles[0]
44
+ }
45
+
38
46
  if (!puzzle) return CLI.error(`Puzzle ${id} not found`)
47
+
39
48
  const trax = new Trax(puzzle.game, puzzle.notation, 'cli')
40
49
  const players = ['white', 'black']
41
50
  CLI.display(trax, players)
@@ -45,17 +54,30 @@ const showPuzzle = (CLI, id) => {
45
54
  if (puzzle.desc) out += CLI.color.help(puzzle.desc) + '\n'
46
55
  out += CLI.color(players[puzzle.player - 1] + ' to win by move ' + puzzle.max)
47
56
  const src = sources[puzzle.src]
57
+
48
58
  if (src) {
49
59
  let source = '\nPuzzle '
50
60
  source += src.copyright
51
61
  ? 'copyright ' + src.copyright + ' by'
52
62
  : 'courtesy of'
53
63
  source += ' ' + src.name
54
- const url = src.url ? ' ' + CLI.color.white(src.url) : ''
55
- out += CLI.color.black(source) + url
64
+ const url = src.url ? ' ' + CLI.color.help(src.url) : ''
65
+ out += CLI.color.white(source) + url
66
+ if (src.license) {
67
+ out += '\n' + CLI.color.optional('License: ' + src.license)
68
+ if (src.licenseUrl) out += CLI.color.black(' ' + src.licenseUrl)
69
+ }
56
70
  }
57
71
 
58
72
  CLI.out(out)
73
+
74
+ if (started) return
75
+
76
+ CLI.out(
77
+ CLI.color.short(
78
+ `Want to try this puzzle? Enter: "puzzles start ${puzzle.id}"`,
79
+ ),
80
+ )
59
81
  }
60
82
 
61
83
  const listPuzzles = (CLI) => {
@@ -115,7 +137,7 @@ const listLevel = (CLI, levelName) => {
115
137
  const startPuzzle = (CLI, puzzle) => {
116
138
  const id = CLI.newPuzzle(puzzle)
117
139
  CLI.out(CLI.color(`Started puzzle ${puzzle.id} as game #${id}`))
118
- showPuzzle(CLI, puzzle.id)
140
+ showPuzzle(CLI, puzzle.id, true)
119
141
  }
120
142
 
121
143
  const startNextPuzzle = (CLI, level) => {
@@ -132,7 +154,7 @@ const startNextPuzzle = (CLI, level) => {
132
154
 
133
155
  puzzlesCmd.fn = (CLI, action, id) => {
134
156
  action = action?.toLowerCase() ?? 'list'
135
- if ('start'.startsWith(action) || 'new'.startsWith(action)) {
157
+ if (startAliases.some((alias) => alias.startsWith(action))) {
136
158
  if (!id) return startNextPuzzle(CLI)
137
159
 
138
160
  const puzzle = puzzles.find((p) => p.id === id)
@@ -144,14 +166,22 @@ puzzlesCmd.fn = (CLI, action, id) => {
144
166
  return startNextPuzzle(CLI, levels.indexOf(id))
145
167
  }
146
168
 
147
- return CLI.error('Could not start puzzle ' + id)
169
+ const possibles = puzzles.filter((p) => p.id.includes(id))
170
+ if (possibles.length === 1) {
171
+ return startPuzzle(CLI, possibles[0])
172
+ }
173
+
174
+ return CLI.error('Could not find puzzle ' + id)
148
175
  }
149
176
 
150
177
  if (action) {
151
178
  const x = id || action
152
179
  if (validLevels.includes(x)) return listLevel(CLI, x)
153
- if (puzzleIds.includes(x)) return showPuzzle(CLI, x)
154
- if (id) return CLI.error('Could not find puzzle ' + id)
180
+ if (listAliases.some((alias) => alias.startsWith(x))) {
181
+ return listPuzzles(CLI)
182
+ }
183
+
184
+ return showPuzzle(CLI, x)
155
185
  }
156
186
 
157
187
  listPuzzles(CLI)