@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/CHANGELOG.md +20 -0
- package/README.md +9 -154
- package/docs/engine.md +1 -1
- package/package.json +12 -23
- package/src/engine.js +7 -12
- package/src/types.d.ts +11 -128
- package/src/version.js +1 -1
- package/.dockerignore +0 -11
- package/.gitlab-ci.yml +0 -90
- package/.husky/pre-commit +0 -5
- package/Dockerfile +0 -14
- package/docs/analyst.md +0 -227
- package/docs/lru.md +0 -151
- package/docs/point.md +0 -148
- package/src/analyst.js +0 -807
- package/src/cli.js +0 -732
- package/src/cmds/analyze.js +0 -112
- package/src/cmds/delete.js +0 -71
- package/src/cmds/help.js +0 -81
- package/src/cmds/import-export.js +0 -394
- package/src/cmds/list.js +0 -177
- package/src/cmds/new.js +0 -136
- package/src/cmds/notes.js +0 -37
- package/src/cmds/play-try.js +0 -175
- package/src/cmds/puzzles.js +0 -177
- package/src/cmds/select.js +0 -47
- package/src/cmds/suggest.js +0 -55
- package/src/cmds/undo.js +0 -40
- package/src/cmds/view.js +0 -61
- package/src/lru.js +0 -153
- package/src/point.js +0 -173
- package/src/puzzles.js +0 -1207
- package/src/threats.js +0 -223
- package/src/tty.js +0 -317
- package/src/utils.js +0 -22
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
|
-
}
|