@slugbugblue/trax 0.20.0 → 0.22.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 DELETED
@@ -1,807 +0,0 @@
1
- /** Analyst module: provides analysis functions for Trax games.
2
- * @copyright 2022-2023
3
- * @author Chad Transtrum <chad@transtrum.net>
4
- * @license Apache-2.0
5
- */
6
-
7
- import { Point } from '@slugbugblue/trax/point'
8
- import { Trax } from '@slugbugblue/trax'
9
- import { LRU } from '@slugbugblue/trax/lru'
10
-
11
- import { threats } from '@slugbugblue/trax/threats'
12
-
13
- const cache = new LRU(2750)
14
-
15
- /** Helper function to generate a unique id for the analysis. */
16
- const id = () => {
17
- const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'
18
- let code = ''
19
- while (code.length < 6) {
20
- code += chars[Math.floor(Math.random() * chars.length)]
21
- }
22
-
23
- return 'analysis-' + code
24
- }
25
-
26
- /** A sort function for hashes with a numeric score property.
27
- * @arg {PositionScore} a - The first Position Score to be compared
28
- * @arg {PositionScore} b - The second Position Score to be compared
29
- * @returns {number} a.score <=> b.score
30
- */
31
- const byScoreDesc = (a, b) => b.score - a.score
32
-
33
- /** A filter function factory to remove scores not within x points of the
34
- * highest score.
35
- * @arg highest {number} - The highest score
36
- * @arg tolerance {number} - How close another score must be to be included
37
- * @returns {(s:PositionScore)=>boolean} a filter function
38
- */
39
- const withinX = (highest, tolerance) => (s) =>
40
- s.score === highest || highest - s.score <= tolerance
41
-
42
- /** Return a random element of an array.
43
- * @template T
44
- * @arg {T[]} array - an array of elements
45
- * @returns {T} one of the elements at random
46
- */
47
- const randomDraw = (array) => array[Math.floor(Math.random() * array.length)]
48
-
49
- // Make parseInt a little shorter
50
- const int = (s) => Number.parseInt(s, 10)
51
-
52
- const leftTurn = { d: 'r', l: 'd', r: 'u', u: 'l' }
53
- const rightTurn = { d: 'l', l: 'u', r: 'd', u: 'r' }
54
- const follow = { d: 'right', l: 'bottom', r: 'top', u: 'left' }
55
-
56
- const edgeColor = (tile, dir) => {
57
- return { d: 'ace', l: 'abd', r: 'bcf', u: 'def' }[dir].includes(tile)
58
- ? 'w'
59
- : 'b'
60
- }
61
-
62
- const encodeNumber = (n) => String.fromCodePoint(48 + n)
63
-
64
- /** Explore a raw edge and update cave information.
65
- * @arg {RawEdge} rawEdge - the raw edge information
66
- * @returns {RawEdge}
67
- */
68
- const spelunk = (rawEdge) => {
69
- for (let index = 1; index < rawEdge.length - 1; index++) {
70
- if (rawEdge[index].t === 'C') {
71
- const blank = () => ({ x: 0, y: 0, c: '', t: '' })
72
- const previous = rawEdge[index - 1] || blank()
73
- const next = rawEdge[index + 1] || blank()
74
- if (next.t === 'c') next.t = 'C'
75
- if (previous.t === 'c') {
76
- previous.t = 'C'
77
- if (index > 1) index -= 2
78
- }
79
- }
80
- }
81
-
82
- for (const space of rawEdge) {
83
- if (space.t === 'c') space.t = 'n'
84
- if (space.t === 'C') space.t = 'c'
85
- }
86
-
87
- return rawEdge
88
- }
89
-
90
- /** Combine hollows into a single item in the raw edge array.
91
- * @arg {RawEdge} rawEdge
92
- * @returns {RawEdge}
93
- */
94
- const hollowOut = (rawEdge) => {
95
- for (let index = 1; index < rawEdge.length - 1; index++) {
96
- const space = rawEdge[index]
97
- if (space.c === 'l') {
98
- Object.assign(space, rawEdge[index - 1], rawEdge[index + 1])
99
- space.c = 'l'
100
- rawEdge.splice(index + 1, 1)
101
- rawEdge.splice(index - 1, 1)
102
- }
103
- }
104
-
105
- return rawEdge
106
- }
107
-
108
- /** Supposedly deep nesting is bad, so we broke this out of "pairUp" for
109
- * "better" code, even though this is useless on its own ...
110
- * @arg {RawEdge} rawEdge
111
- * @arg {number} index
112
- * @arg {string} color
113
- * @pairs {Record<string, number>}
114
- */
115
- const findPair = (rawEdge, index, color, pairs) => {
116
- const colors = new Set([color, 'l'])
117
- const space = rawEdge[index]
118
- const nColor = 'n' + color
119
- for (const x of [1, 2]) {
120
- let j = x + index
121
- if (j >= rawEdge.length) j -= rawEdge.length
122
- const next = rawEdge[j]
123
- if (colors.has(next.c) && space[nColor] !== next[nColor]) {
124
- const one = color + space[nColor]
125
- const two = color + next[nColor]
126
- pairs[one + '-' + two] = index
127
- if (two + '-' + one in pairs) {
128
- for (const y of [index, pairs[two + '-' + one]]) {
129
- rawEdge[y]['p' + color] = true
130
- }
131
- }
132
-
133
- // Only process one connection
134
- break
135
- } else if (next.c === 'r') {
136
- // Cannot connect past an r space
137
- break
138
- }
139
- }
140
-
141
- for (let x = 1; x < rawEdge.length; x++) {
142
- let j = index + x
143
- if (j >= rawEdge.length) j -= rawEdge.length
144
- if (colors.has(rawEdge[j].c)) {
145
- space[color] = rawEdge[j][nColor] === space[nColor]
146
- break
147
- }
148
- }
149
- }
150
-
151
- /** Find connectable pairs in the raw edge.
152
- * @arg {RawEdge} rawEdge
153
- * @returns {RawEdge}
154
- */
155
- const pairUp = (rawEdge) => {
156
- /** @type Record<string, number> */
157
- const pairs = {}
158
- for (const [index, space] of rawEdge.entries()) {
159
- for (const bw of ['w', 'b']) {
160
- const colors = [bw, 'l']
161
- if (colors.includes(space.c)) {
162
- findPair(rawEdge, index, bw, pairs)
163
- }
164
- }
165
- }
166
-
167
- return rawEdge
168
- }
169
-
170
- /** Label and finalize the raw edge.
171
- * @arg {RawEdge} rawEdge
172
- * @returns {EdgeObject}
173
- */
174
- const label = (rawEdge) => {
175
- const final = { w: '', b: '', edge: rawEdge }
176
- let index = 0
177
- for (const space of rawEdge) {
178
- for (const bw of ['b', 'w']) {
179
- const colors = new Set([bw, 'l'])
180
- let z = 'w'
181
- if (colors.has(space.c)) {
182
- if (space['p' + bw]) z = 'c'
183
- if (space[bw]) z = 'a'
184
- if (space.c === 'l') z = { w: 'l', a: 'm', c: 'p' }[z]
185
- } else {
186
- z = space.c === 'r' ? 'r' : 'b'
187
- }
188
-
189
- if (space.t === 'x') z = 'x'
190
- space['z' + bw] = z
191
- final[bw] += z
192
- }
193
-
194
- space.idx = index
195
- index += 1
196
- }
197
-
198
- return final
199
- }
200
-
201
- /** Quick way to get the number of items in an array.
202
- * @returns {number} - length of the array
203
- */
204
- const length = (array) => array?.length || 0
205
-
206
- /** Calculate the points for the number of threats at a particular level.
207
- * @arg {ColorThreats} from - the threats found for a certain player
208
- * @arg {number} level - the level to calculate the points for
209
- * @arg {number} [as] - if included, the level the points should be calculated as
210
- * @return {number} the number of points for the level
211
- */
212
- const attackScore = (from, level, as) => {
213
- const count = length(from[level])
214
- const calculateAs = Math.min(as === undefined ? level : as, threats.maxDepth)
215
- if (calculateAs < 1) return count
216
- return count * 10 ** (threats.maxDepth - calculateAs + 1)
217
- }
218
-
219
- /** Shortcut to get the number of attacks.
220
- * @arg {ColorThreats} from - the threats found for a certain player
221
- * @returns {number} the number of attacks (win on next move) present
222
- */
223
- const attackCount = (from) => length(from['1'])
224
-
225
- /** Shortcut to get the number of threats at a certain level.
226
- * @arg {ColorThreats} from - the threats found for a player
227
- * @arg {number} level - the level for the threats to look for
228
- * @returns {number} the number of threats at the given level
229
- */
230
- const threatCount = (from, level) => length(from[level])
231
-
232
- /** Shortcut to get the number of passive threats
233
- * @arg {ColorThreats} from - the threats found for a player
234
- * @returns {number} the number of passive threats (win in 2 or more moves)
235
- */
236
- const passiveCount = (from) => {
237
- let count = 0
238
- for (const [depth, array] of Object.entries(from)) {
239
- count += int(depth) > 1 ? length(array) : 0
240
- }
241
-
242
- return count
243
- }
244
-
245
- /** Shortcut to determine if we have an active attack.
246
- * @arg {ColorThreats} from - the threats found for a player
247
- * @returns {boolean}
248
- */
249
- const activeAttack = (from) =>
250
- attackCount(from) > 1 || (attackCount(from) === 1 && passiveCount(from) > 0)
251
-
252
- /** Shortcut to determine if we have a passive attack.
253
- * @arg {ColorThreats} from - the threats found for a player
254
- * @returns {boolean}
255
- */
256
- const passiveAttack = (from) => passiveCount(from) > 1
257
-
258
- // /** Shortcut to determine if we are under attack.
259
- // * @arg {ColorThreats} from - the threats found for a player
260
- // * @returns {boolean}
261
- // */
262
- // const underAttack = (from) => attackCount(from) > 0
263
-
264
- /** Shortcut to determine if we are under threat.
265
- * @arg {ColorThreats} from - the threats found for a player
266
- * @returns {boolean}
267
- */
268
- const underThreat = (from) => attackCount(from) + passiveCount(from) > 0
269
-
270
- // Analysis class
271
- export class Analysis {
272
- /** @type {TraxVariant} */
273
- #rules = 'trax'
274
- #id = 'analysis'
275
- /** @type {string[]} - List of moves to arrive at this position */
276
- #moves = []
277
- #notation = ''
278
- #edge = undefined
279
- /** @type {Trax} */
280
- #game
281
- #scores = {}
282
- #threats = undefined
283
- #faulty = { b: [], w: [] }
284
- #partial = false
285
-
286
- constructor(game, partial = false) {
287
- // Make a copy of the game
288
- this.#rules = game.rules
289
- this.#moves = game.moves
290
- this.#id = id()
291
- this.#notation = game.notation
292
- this.#game = new Trax(this.#rules, this.#moves, this.#id)
293
- this.count = {
294
- w: (level) => this.threats.w[level]?.length || 0,
295
- b: (level) => this.threats.b[level]?.length || 0,
296
- }
297
- this.#partial = Boolean(partial)
298
- if (this.#moves.length === 0 || game.over) {
299
- this.#edge = { b: '', w: '', rawEdge: [] }
300
- this.#threats = { b: {}, w: {} }
301
- }
302
- }
303
-
304
- get game() {
305
- // Return a fresh copy of the game as presented on init
306
- if (this.#game.notation !== this.#notation) {
307
- this.#game = new Trax(this.#rules, this.#moves, this.#id)
308
- }
309
-
310
- return this.#game
311
- }
312
-
313
- get edge() {
314
- if (!this.#edge) {
315
- // Cache the edge
316
- this.#edge = this.#traceEdge()
317
- }
318
-
319
- return this.#edge
320
- }
321
-
322
- /** Any faulty threats
323
- * @returns {FaultyThreats}
324
- */
325
- get faulty() {
326
- return this.#faulty
327
- }
328
-
329
- /** All threats present in this position.
330
- * @returns {FoundThreatsCollection}
331
- */
332
- get threats() {
333
- if (!this.#threats) {
334
- this.#threats = { b: this.#applyThreats('b'), w: this.#applyThreats('w') }
335
- if (!this.#partial) this.#validateThreats()
336
- }
337
-
338
- return this.#threats
339
- }
340
-
341
- // Is a given location in a cave?
342
- inCave(loc) {
343
- const { game } = this
344
-
345
- if (game.tileAt(loc)) {
346
- return false
347
- }
348
-
349
- const topLeft = new Point(game.left, game.top)
350
- const bottomRight = new Point(game.right, game.bottom)
351
- let count = 0
352
-
353
- for (const dir of Point.dirs) {
354
- let look = loc.dir(dir)
355
- while (look.in(topLeft, bottomRight) && !game.tileAt(look)) {
356
- look = look.dir(dir)
357
- }
358
-
359
- if (game.tileAt(look)) count++
360
- }
361
-
362
- return count > 2
363
- }
364
-
365
- /** Determine the points of the currently active player.
366
- * @returns {[number, number]} - the points for this player, and the level
367
- * of the most dangerous threat
368
- */
369
- #activeScore() {
370
- let score = 0
371
- let threatLevel = Number.POSITIVE_INFINITY
372
- const { game } = this
373
- const active = this.threats[game.color]
374
-
375
- // First examine the active player
376
- for (const level of Object.keys(active).map((i) => int(i))) {
377
- if (level > 0) threatLevel = Math.min(threatLevel, level)
378
- score += attackScore(active, level)
379
- }
380
-
381
- return [score, threatLevel]
382
- }
383
-
384
- /** Determine the points of the waiting player.
385
- * @arg {Color} color - the color for which we are generating the score
386
- * @arg {number} threatLevel - the lowest level for which the active player
387
- * has an active threat
388
- * @returns {[number, number]} - the points for this player and the multiplier
389
- * to be applied to the other player's points
390
- */
391
- #inactiveScore(color, threatLevel) {
392
- const { game } = this
393
- const active = this.threats[game.color]
394
- const inactive = this.threats[Trax.other(game.color)]
395
-
396
- // Is the color we are looking at the passive player?
397
- const passive = game.color !== color
398
-
399
- // Is the inactive player activating known threats?
400
- const attacking = activeAttack(inactive)
401
-
402
- // Was the inactive player unable to clear all threats?
403
- const defending = !attacking && underThreat(active)
404
-
405
- // Does the inactive player have multiple threats
406
- const threatening = !attacking && passiveAttack(inactive)
407
-
408
- // If calc is false, attacks and threats are treated as corners
409
- const calc = !passive || attacking || defending || threatening
410
-
411
- // If the current player does not hold the initiative, reduce the score
412
- const multiplier = passive && attacking && threatLevel > 1 ? 0.1 : 1
413
-
414
- let score = 0
415
-
416
- // Now calculate the effects of the passive player
417
- for (const level of Object.keys(inactive).map((i) => int(i))) {
418
- const calcLevel = level > 0 && defending ? level + threatLevel - 1 : level
419
- let points = attackScore(inactive, level, calc ? calcLevel : 0)
420
- if (level === threatLevel) points = passive ? Math.floor(points / 2) : 0
421
- if (passive && threatening) points = Math.floor(points / 2)
422
- score += points
423
- }
424
-
425
- return [score, multiplier]
426
- }
427
-
428
- #score(color) {
429
- if (this.#scores[color] === undefined) {
430
- const { game } = this
431
- if (game.over) {
432
- this.#scores[game.color] = Number.POSITIVE_INFINITY
433
- this.#scores[Trax.other(game.color)] = Number.NEGATIVE_INFINITY
434
- return this.#scores[color]
435
- }
436
-
437
- const [currentPlayer, threatLevel] = this.#activeScore()
438
- const [nextPlayer, multiplier] = this.#inactiveScore(color, threatLevel)
439
-
440
- let score = Math.floor(currentPlayer * multiplier) - nextPlayer
441
- if (score && game.color !== color) score *= -1
442
-
443
- this.#scores[color] = score
444
- }
445
-
446
- return this.#scores[color]
447
- }
448
-
449
- get scores() {
450
- return {
451
- w: this.#score('w'),
452
- b: this.#score('b'),
453
- }
454
- }
455
-
456
- get score() {
457
- return this.scores[this.game.color]
458
- }
459
-
460
- // Space type
461
- //
462
- // determine which type of space we have:
463
- // c = in a cave but has no restrictions (converted to n in edge)
464
- // C = a cave where some moves are not allowed (converted to c in edge)
465
- // n = normal space
466
- // x = no tile can be played here
467
- spaceType(loc) {
468
- const { game } = this
469
-
470
- if (!game.validLocation(loc)) {
471
- return 'x'
472
- }
473
-
474
- if (!this.inCave(loc)) {
475
- return 'n'
476
- }
477
-
478
- // Check to see if we have an invalid move
479
- let someValid = false
480
- let someInvalid = false
481
- for (const tile of game.possibleTiles(loc)) {
482
- const drop = game.dropTile(tile, loc, 'tentative')
483
- if (drop.valid) {
484
- someValid = true
485
- } else {
486
- someInvalid = true
487
- }
488
- }
489
-
490
- if (!someValid) {
491
- return 'x'
492
- }
493
-
494
- if (someInvalid) {
495
- return 'C'
496
- }
497
-
498
- return 'c'
499
- }
500
-
501
- // Trace the edge of the playing area
502
- #traceEdge() {
503
- const { game } = this
504
- /** @type {RawEdge} */
505
- const rawEdge = []
506
- const lines = {}
507
- const count = { w: 0, b: 0 }
508
- let start = new Point(game.left, game.top)
509
- let dir = 'r'
510
-
511
- while (!game.tileAt(start)) {
512
- start = start.right
513
- }
514
-
515
- let loc = start
516
-
517
- while (game.move > 0 && !game.over) {
518
- let space = loc.dir(leftTurn[dir])
519
- if (rawEdge.length > 0 && space.eq(rawEdge[0])) break
520
-
521
- const color = edgeColor(game.tileAt(loc), leftTurn[dir])
522
- const end = game.follow(color, loc, follow[dir]).loc
523
- const type = this.spaceType(space)
524
- const tile = { x: space.x, y: space.y, c: color, t: type }
525
- const id = color + end.x + ':' + end.y
526
- const nColor = 'n' + color
527
-
528
- if (id in lines) {
529
- tile[nColor] = lines[id][nColor]
530
- } else {
531
- tile[nColor] = encodeNumber(count[color])
532
- count[color]++
533
- }
534
-
535
- lines[color + space.x + ':' + space.y] = tile
536
- rawEdge.push(tile)
537
-
538
- const next = loc.dir(dir)
539
-
540
- if (game.tileAt(next)) {
541
- loc = next.dir(leftTurn[dir])
542
- if (game.tileAt(loc)) {
543
- rawEdge.push({ x: space.x, y: space.y, c: 'l', t: type })
544
- dir = leftTurn[dir]
545
- } else {
546
- loc = next
547
- }
548
- } else {
549
- space = next.dir(leftTurn[dir])
550
- const t = this.inCave(space) ? 'c' : 'n'
551
- rawEdge.push({ x: space.x, y: space.y, c: 'r', t })
552
- dir = rightTurn[dir]
553
- }
554
- }
555
-
556
- return label(pairUp(hollowOut(spelunk(rawEdge))))
557
- }
558
-
559
- /** Compose the threat into a FoundThreat object.
560
- * @arg {Threat} threat - the threat that has been found
561
- * @arg {RegExpMatchArray} match - the matching item
562
- * @returns {FoundThreat}
563
- */
564
- #foundThreat(threat, match) {
565
- return {
566
- threat: threat.pattern,
567
- match: match[1], // The match is in the capturing group
568
- at: match.index,
569
- value: threat.value,
570
- level: threat.depth,
571
- }
572
- }
573
-
574
- /**
575
- * @arg {FoundThreat} threat - a threat
576
- * @arg {string} color - the player color
577
- * @returns {Point[]} - locations relevant to the threat
578
- */
579
- threatLocs(threat, color) {
580
- const { edge } = this.edge
581
- const max = edge.length
582
- let index = threat.at
583
- let count = 0
584
- const locs = new Set([index])
585
- while (count < threat.match.length) {
586
- index += 1
587
- if (index >= max) index -= max
588
- locs.add(index)
589
- count += 1
590
- // Check to see if we have a connectable pair
591
- if (edge[index]['p' + color]) {
592
- const line = 'n' + color
593
- const end = edge.find(
594
- (space) =>
595
- space.idx !== edge[index].idx && space[line] === edge[index][line],
596
- )
597
- if (end) {
598
- // Add the opposite end and the space right before it, just in case
599
- locs.add(end.idx)
600
- locs.add(end.idx - 1)
601
- }
602
- }
603
- }
604
-
605
- return [...locs].map((i) => new Point(edge[i] || { x: 0, y: 0 }))
606
- }
607
-
608
- /** Apply the threats to a color's regex-ready edge.
609
- * @arg {Color} color - the color to find threats for
610
- * @returns {ColorThreats}
611
- */
612
- #applyThreats(color) {
613
- /** @type {ColorThreats} */
614
- const matches = {}
615
- const { length } = this.edge[color]
616
- const edge = this.edge[color].repeat(2)
617
- for (const threat of threats.list) {
618
- const find = matches[threat.depth] || []
619
- for (const match of edge.matchAll(threat.rx)) {
620
- if (match.index >= length) break
621
-
622
- // Avoid saving duplicates
623
- if (find.every((f) => f.at !== match.index || f.match !== match[1])) {
624
- find.push(this.#foundThreat(threat, match))
625
- }
626
- }
627
-
628
- if (find.length > 0) matches[threat.depth] = find
629
- }
630
-
631
- return matches
632
- }
633
-
634
- /** Clean up the list of matches, removing any false positives. */
635
- #validateThreats() {
636
- for (const bw of ['w', 'b']) {
637
- /** @type ColorThreats */
638
- const matches = this.#threats[bw]
639
- for (const key of Object.keys(matches)) {
640
- const level = Number(key)
641
- if (level < 2) continue
642
- /** @type {FoundThreat[]} */
643
- const verified = []
644
- const threats = matches[key]
645
- for (const threat of threats) {
646
- if (this.#canActivateThreat(threat, bw, level)) {
647
- verified.push(threat)
648
- } else {
649
- this.#faulty[bw].push(threat)
650
- }
651
- }
652
-
653
- if (verified.length > 0) {
654
- matches[key] = verified
655
- } else {
656
- delete matches[key]
657
- }
658
- }
659
- }
660
- }
661
-
662
- /** Determine if the given threat can be activated for this color
663
- * @param {FoundThreat} threat - The threat to try to activate
664
- * @param {string} color - The color this match belongs to
665
- * @param {number} level - The depth level of this threat
666
- * @returns {boolean}
667
- */
668
- #canActivateThreat(threat, color, level) {
669
- const attacks = attackCount(this.#threats[color])
670
- const player = Trax.playerNumber(color)
671
- const other = Trax.other(color)
672
- const otherAttacks = attackCount(this.#threats[other])
673
- const higher = threatCount(this.#threats[color], level - 1)
674
- for (const loc of this.threatLocs(threat, color)) {
675
- for (const type of this.game.possibleTiles(loc)) {
676
- const game = new Trax(this.game.rules, this.game.moves)
677
- const result = game.dropTile(type, loc)
678
- if (!result.valid) continue
679
-
680
- // This might be a bad shortcut, but if we already have an attack here
681
- // we just assume that this threat is also valid ...
682
- if (game.over && game.turn === player) return true
683
-
684
- // Otherwise, we cannot evaluate an ended game
685
- if (game.over) continue
686
-
687
- const { threats } = analyze(game, true)
688
-
689
- // If we give our opponent an attack, this is a faulty move
690
- const faulty = attackCount(threats[other]) - otherAttacks
691
- if (faulty > 0) continue
692
-
693
- // Activating the threat means we get an attack and a threat
694
- const newAttacks = attackCount(threats[color]) - attacks
695
- if (level === 2) {
696
- if (newAttacks > 1) return true
697
- } else {
698
- const newThreats = threatCount(threats[color], level - 1) - higher
699
- if (newAttacks > 0 && newThreats > 0) return true
700
- }
701
- }
702
- }
703
-
704
- return false
705
- }
706
-
707
- // Make a reasonable inspect representation of the analysis
708
- [Symbol.for('nodejs.util.inspect.custom')](depth, options, inspect) {
709
- const sub = (object) => inspect(object, options).replace(/\n/g, '\n ')
710
- const style = options.stylize
711
- if (depth < 0) return style('<Analysis>', 'special')
712
- if (options.depth !== null) options.depth -= 1
713
- const tbd = style('tbd', 'undefined')
714
- let custom = style('Analysis', 'special') + ' {\n'
715
- custom += ' game: ' + style(this.#rules, 'special')
716
- custom += '(' + style(this.#moves, 'string') + '),\n'
717
- custom += ' edge: '
718
- if (this.#edge) {
719
- custom += '{\n w: ' + style(this.#edge.w, 'string')
720
- custom += ',\n b: ' + style(this.#edge.b, 'string')
721
- custom += ',\n edge: ' + style('[rawEdge]', 'special')
722
- custom += '\n }'
723
- } else {
724
- custom += tbd
725
- }
726
-
727
- custom += ',\n threats: '
728
- custom += this.#threats ? sub(this.#threats) : tbd
729
- custom += ',\n scores: '
730
- custom += 'w' in this.#scores ? sub(this.#scores) : tbd
731
- custom += ',\n score: '
732
- const score = 'w' in this.#scores ? this.score : null
733
- custom += score === null ? tbd : style(score, 'number')
734
-
735
- return custom + '\n}'
736
- }
737
- }
738
-
739
- /** Analyze a game position.
740
- * @arg {Trax} game - the position to analyze
741
- * @arg {boolean} partial - if the position should only be partially analyzed
742
- * @returns {Analysis}
743
- */
744
- export const analyze = (game, partial = false) => {
745
- const id = (partial ? 'y' : 'n') + game.rules + game.normalized
746
- const cached = cache.get(id)
747
- if (cached) return cached
748
- const analysis = new Analysis(game, partial)
749
- cache.set(id, analysis)
750
- return analysis
751
- }
752
-
753
- export class Suggestion {
754
- /** @type {PositionScore[]} */
755
- all = []
756
- /** @type {PositionScore[]} */
757
- options = []
758
- /** @type {PositionScore} */
759
- pick
760
- ms = 0
761
-
762
- constructor(analyzed, ms) {
763
- this.all = analyzed.sort(byScoreDesc)
764
- const best = analyzed[0] || { move: '', score: 0 }
765
- this.options = analyzed.filter(withinX(best.score, 5))
766
- this.pick = randomDraw(this.options) || best
767
- this.ms = ms
768
- }
769
-
770
- get best() {
771
- return this.all[0]
772
- }
773
-
774
- get analyzed() {
775
- return this.all.length
776
- }
777
-
778
- // Make a nice representation for the nodejs REPL
779
- [Symbol.for('nodejs.util.inspect.custom')](_, options) {
780
- const style = options.stylize
781
- let out = style(this.pick.move, 'string')
782
- out += ' ' + style(this.pick.score, 'number') + ' ['
783
- out += this.options
784
- .slice(0, 5)
785
- .map((s) => style(s.move, 'string') + ' ' + style(s.score, 'number'))
786
- .join(', ')
787
- out += '] ' + this.analyzed + ' in ' + this.ms + 'ms'
788
- return out
789
- }
790
- }
791
-
792
- export const suggest = (game, partial = false) => {
793
- const start = Date.now()
794
- const analyzed = {}
795
- const { color, moves, rules } = game
796
- for (const move of game.possibleMoves()) {
797
- const trax = new Trax(rules, moves)
798
- trax.play(move)
799
- const unique = trax.normalized
800
- if (!analyzed[unique]) {
801
- const analysis = analyze(trax, partial)
802
- analyzed[unique] = { move, score: analysis.scores[color], analysis }
803
- }
804
- }
805
-
806
- return new Suggestion(Object.values(analyzed), Date.now() - start)
807
- }