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