@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/.dockerignore +11 -0
- package/CHANGELOG.md +19 -0
- package/CONTRIBUTING.md +113 -0
- package/Dockerfile +10 -5
- package/LICENSE +1 -1
- package/README.md +54 -15
- package/docs/analyst.md +26 -6
- package/package.json +13 -3
- package/src/analyst.js +158 -84
- package/src/cmds/import-export.js +9 -2
- package/src/cmds/notes.js +3 -0
- package/src/cmds/play-try.js +8 -2
- package/src/cmds/puzzles.js +42 -12
- package/src/cmds/suggest.js +5 -2
- package/src/cmds/view.js +5 -1
- package/src/puzzles.js +314 -16
- package/src/threats.js +1 -1
- package/src/types.d.ts +10 -2
- package/src/utils.js +22 -0
- package/src/version.js +1 -1
- package/.husky/pre-commit +0 -4
- package/benchmark/benchmarks.json +0 -60
- package/benchmark/puzzles.bench.js +0 -190
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
|
-
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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
|
|
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 {
|
|
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 &&
|
|
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 {
|
|
271
|
+
* @arg {ColorThreats} from - the threats found for a player
|
|
247
272
|
* @returns {boolean}
|
|
248
273
|
*/
|
|
249
|
-
const passiveAttack = (from) =>
|
|
274
|
+
const passiveAttack = (from) => passiveCount(from) > 1
|
|
250
275
|
|
|
251
|
-
/** Shortcut to determine if we are under attack.
|
|
252
|
-
|
|
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
|
|
286
|
+
const underThreat = (from) => attackCount(from) + passiveCount(from) > 0
|
|
256
287
|
|
|
257
|
-
|
|
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
|
-
|
|
300
|
+
/** @type Scores */
|
|
301
|
+
#scores = undefined
|
|
302
|
+
/** @type FoundThreatsCollection */
|
|
269
303
|
#threats = undefined
|
|
270
|
-
#
|
|
271
|
-
#partial = false
|
|
304
|
+
#validated = false
|
|
272
305
|
|
|
273
|
-
|
|
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
|
-
|
|
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 &&
|
|
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
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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
|
-
|
|
424
|
-
|
|
459
|
+
const [currentPlayer, threatLevel] = this.#activeScore()
|
|
460
|
+
const [nextPlayer, multiplier] = this.#inactiveScore(color, threatLevel)
|
|
425
461
|
|
|
426
|
-
|
|
427
|
-
|
|
462
|
+
let score = Math.floor(currentPlayer * multiplier) - nextPlayer
|
|
463
|
+
if (score && game.color !== color) score *= -1
|
|
428
464
|
|
|
429
|
-
return
|
|
465
|
+
return score
|
|
430
466
|
}
|
|
431
467
|
|
|
468
|
+
/** Get the scores for each player. */
|
|
432
469
|
get scores() {
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 {
|
|
638
|
+
* @returns {ColorThreats}
|
|
593
639
|
*/
|
|
594
640
|
#applyThreats(color) {
|
|
595
|
-
/** @type {
|
|
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
|
-
|
|
663
|
+
validateThreats() {
|
|
664
|
+
if (this.#validated) return
|
|
665
|
+
let someFaulty = false
|
|
618
666
|
for (const bw of ['w', 'b']) {
|
|
619
|
-
/** @type
|
|
620
|
-
const matches = this
|
|
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
|
|
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
|
|
652
|
-
const
|
|
653
|
-
const
|
|
654
|
-
|
|
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 ===
|
|
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
|
|
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[
|
|
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]
|
|
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]
|
|
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 +=
|
|
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
|
|
722
|
-
const id =
|
|
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
|
|
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
|
-
|
|
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] = {
|
|
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.
|
|
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 +=
|
|
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
|
|
package/src/cmds/play-try.js
CHANGED
|
@@ -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(
|
|
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
|
|
package/src/cmds/puzzles.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
55
|
-
out += CLI.color.
|
|
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 (
|
|
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
|
-
|
|
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 (
|
|
154
|
-
|
|
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)
|