@slugbugblue/trax 0.14.0 → 0.15.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 CHANGED
@@ -1,5 +1,23 @@
1
1
  # @slugbugblue/trax
2
2
 
3
+ ## 0.15.0 - 2022-12-14
4
+
5
+ - Added shared `puzzles` commands to the CLI:
6
+
7
+ - `trax ls puzzles`: see all the puzzles
8
+ - `trax new puzzle`: start a new puzzle
9
+
10
+ - Puzzle improvements:
11
+
12
+ - Adjust puzzle category levels for harder puzzles
13
+ - Add multiple hints when the puzzle can be solved multiple ways
14
+
15
+ - Analysis engine improvements:
16
+
17
+ - Analysis engine suggestions now "solve" all tough puzzles
18
+ - Reworked threats to be more precise
19
+ - Reworked scoring to add more nuance
20
+
3
21
  ## 0.14.0 - 2022-12-05
4
22
 
5
23
  - Added `puzzles` to the CLI:
@@ -7,8 +25,9 @@
7
25
  - `trax puzzles ls`: see all the puzzles
8
26
  - `trax puzzle new`: start a new puzzle
9
27
 
10
- - Bot tests now include solving all tutorial and easy puzzles. As the bot
11
- improves, we will add more puzzles to the tests.
28
+ - Analysis engine tests now include correctly suggesting the first move for
29
+ solving all tutorial and easy puzzles. As the threats database improves, we
30
+ will add more puzzles to the tests.
12
31
 
13
32
  ## 0.13.0 - 2022-11-25
14
33
 
package/README.md CHANGED
@@ -1,6 +1,11 @@
1
1
  # Trax
2
2
 
3
- Trax game-playing engine and etc.
3
+ This project aims to provide tools for working with Trax games in Javascript.
4
+
5
+ - Trax game-playing engine
6
+ - Position analysis engine
7
+ - Over 50 puzzles to help hone your Trax skills
8
+ - Command-line interface for ease of use
4
9
 
5
10
  ## Description
6
11
 
@@ -32,10 +37,10 @@ The command-line interface is provided as `trax`, available in your path if
32
37
  installed globally, or in the `node_modules/.bin` directory and runnable using
33
38
  `npx trax` if installed locally.
34
39
 
35
- The CLI can be used interactively by passing the `--interactive` or `-i` flag.
36
- When run interactively, you can avoid worries about the need to escape certain
37
- characters from the shell, and it can be easier to run multiple commands back to
38
- back.
40
+ The CLI can be used interactively by running the command with no parameters or
41
+ by passing the `--interactive` or `-i` flag. When run interactively, you can
42
+ avoid worries about the need to escape certain characters from the shell, and it
43
+ can be easier to run multiple commands back to back.
39
44
 
40
45
  Help is available for the CLI through the use of the `help` command. Type
41
46
  `trax help` to see a list of the available commands, and `trax help play` to see
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@slugbugblue/trax",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "Trax game engine and friends",
5
5
  "keywords": [
6
6
  "trax",
package/src/analyst.js CHANGED
@@ -1,26 +1,15 @@
1
- /* Copyright 2022 Chad Transtrum
2
- *
3
- * Licensed under the Apache License, Version 2.0 (the "License");
4
- * you may not use this file except in compliance with the License.
5
- * You may obtain a copy of the License at
6
- *
7
- * http://www.apache.org/licenses/LICENSE-2.0
8
- *
9
- * Unless required by applicable law or agreed to in writing, software
10
- * distributed under the License is distributed on an "AS IS" BASIS,
11
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
- * See the License for the specific language governing permissions and
13
- * limitations under the License.
1
+ /** Analyst module: provides analysis functions for Trax games.
2
+ * @copyright 2022
3
+ * @author Chad Transtrum <chad@transtrum.net>
4
+ * @license Apache-2.0
14
5
  */
15
6
 
16
- // Analyst module: provides analysis functions for Trax games
17
-
18
7
  import { Point } from '@slugbugblue/trax/point'
19
8
  import { Trax } from '@slugbugblue/trax'
20
9
 
21
10
  import { threats } from '@slugbugblue/trax/threats'
22
11
 
23
- // Helpers
12
+ /** Helper function to generate a unique id for the analysis. */
24
13
  const id = () => {
25
14
  const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'
26
15
  let code = ''
@@ -31,14 +20,27 @@ const id = () => {
31
20
  return 'analysis-' + code
32
21
  }
33
22
 
34
- // A sort function for hashes with a numeric score property
23
+ /** A sort function for hashes with a numeric score property.
24
+ * @arg {PositionScore} a - The first Position Score to be compared
25
+ * @arg {PositionScore} b - The second Position Score to be compared
26
+ * @returns {number} a.score <=> b.score
27
+ */
35
28
  const byScoreDesc = (a, b) => b.score - a.score
36
29
 
37
- // A filter function factory to remove scores not within x points of the highest score
30
+ /** A filter function factory to remove scores not within x points of the
31
+ * highest score.
32
+ * @arg highest {number} - The highest score
33
+ * @arg tolerance {number} - How close another score must be to be included
34
+ * @returns {(s:PositionScore)=>boolean} a filter function
35
+ */
38
36
  const withinX = (highest, tolerance) => (s) =>
39
37
  s.score === highest || highest - s.score <= tolerance
40
38
 
41
- // Return a random element of an array
39
+ /** Return a random element of an array.
40
+ * @template T
41
+ * @arg {T[]} array - an array of elements
42
+ * @returns {T} one of the elements at random
43
+ */
42
44
  const randomDraw = (array) => array[Math.floor(Math.random() * array.length)]
43
45
 
44
46
  // Make parseInt a little shorter
@@ -56,12 +58,16 @@ const edgeColor = (tile, dir) => {
56
58
 
57
59
  const encodeNumber = (n) => String.fromCodePoint(48 + n)
58
60
 
59
- // Explore a raw edge and update cave information
61
+ /** Explore a raw edge and update cave information.
62
+ * @arg {RawEdge} rawEdge - the raw edge information
63
+ * @returns {RawEdge}
64
+ */
60
65
  const spelunk = (rawEdge) => {
61
66
  for (let index = 1; index < rawEdge.length - 1; index++) {
62
67
  if (rawEdge[index].t === 'C') {
63
- const previous = rawEdge[index - 1] || {}
64
- const next = rawEdge[index + 1] || {}
68
+ const blank = () => ({ x: 0, y: 0, c: '', t: '' })
69
+ const previous = rawEdge[index - 1] || blank()
70
+ const next = rawEdge[index + 1] || blank()
65
71
  if (next.t === 'c') next.t = 'C'
66
72
  if (previous.t === 'c') {
67
73
  previous.t = 'C'
@@ -78,7 +84,10 @@ const spelunk = (rawEdge) => {
78
84
  return rawEdge
79
85
  }
80
86
 
81
- // Combine hollows into a single item in the raw edge array
87
+ /** Combine hollows into a single item in the raw edge array.
88
+ * @arg {RawEdge} rawEdge
89
+ * @returns {RawEdge}
90
+ */
82
91
  const hollowOut = (rawEdge) => {
83
92
  for (let index = 1; index < rawEdge.length - 1; index++) {
84
93
  const space = rawEdge[index]
@@ -93,8 +102,13 @@ const hollowOut = (rawEdge) => {
93
102
  return rawEdge
94
103
  }
95
104
 
96
- // Supposedly deep nesting is bad, so we broke this out of "pairUp" for
97
- // "better" code, even though this is useless on its own ...
105
+ /** Supposedly deep nesting is bad, so we broke this out of "pairUp" for
106
+ * "better" code, even though this is useless on its own ...
107
+ * @arg {RawEdge} rawEdge
108
+ * @arg {number} index
109
+ * @arg {string} color
110
+ * @pairs {Record<string, number>}
111
+ */
98
112
  const findPair = (rawEdge, index, color, pairs) => {
99
113
  const colors = new Set([color, 'l'])
100
114
  const space = rawEdge[index]
@@ -131,8 +145,12 @@ const findPair = (rawEdge, index, color, pairs) => {
131
145
  }
132
146
  }
133
147
 
134
- // Find connectable pairs in the raw edge
148
+ /** Find connectable pairs in the raw edge.
149
+ * @arg {RawEdge} rawEdge
150
+ * @returns {RawEdge}
151
+ */
135
152
  const pairUp = (rawEdge) => {
153
+ /** @type Record<string, number> */
136
154
  const pairs = {}
137
155
  for (const [index, space] of rawEdge.entries()) {
138
156
  for (const bw of ['w', 'b']) {
@@ -146,7 +164,10 @@ const pairUp = (rawEdge) => {
146
164
  return rawEdge
147
165
  }
148
166
 
149
- // Label and finalize the raw edge
167
+ /** Label and finalize the raw edge.
168
+ * @arg {RawEdge} rawEdge
169
+ * @returns {EdgeObject}
170
+ */
150
171
  const label = (rawEdge) => {
151
172
  const final = { w: '', b: '', edge: rawEdge }
152
173
  for (const space of rawEdge) {
@@ -170,48 +191,73 @@ const label = (rawEdge) => {
170
191
  return final
171
192
  }
172
193
 
173
- const threatValues = (threatsArray) => {
174
- return threatsArray.length
175
- // Hm. Maybe we don't need this ...
176
- // let count = 0
177
- // for (const threat of threatsArray) count += threat.value
178
- // return count
179
- }
194
+ /** Quick way to get the number of items in an array.
195
+ * @returns {number} - length of the array
196
+ */
197
+ const length = (array) => array?.length || 0
180
198
 
181
- const attackScore = (from, level) => {
182
- const count = threatValues(from[level] || [])
183
- if (level < 1) return count
184
- return count * 10 ** (threats.maxDepth - level + 1)
199
+ /** Calculate the points for the number of threats at a particular level.
200
+ * @arg {FoundThreats} from - the threats found for a certain player
201
+ * @arg {number} level - the level to calculate the points for
202
+ * @arg {number} [as] - if included, the level the points should be calculated as
203
+ * @return {number} the number of points for the level
204
+ */
205
+ const attackScore = (from, level, as) => {
206
+ const count = length(from[level])
207
+ const calculateAs = Math.min(as === undefined ? level : as, threats.maxDepth)
208
+ if (calculateAs < 1) return count
209
+ return count * 10 ** (threats.maxDepth - calculateAs + 1)
185
210
  }
186
211
 
187
- // Shortcut to get the number of attacks
188
- const attackCount = (from) => threatValues(from['1'] || [])
212
+ /** Shortcut to get the number of attacks.
213
+ * @arg {FoundThreats} from - the threats found for a certain player
214
+ * @returns {number} the number of attacks (win on next move) present
215
+ */
216
+ const attackCount = (from) => length(from['1'])
189
217
 
190
- // Shortcut to get the number of passive threats
218
+ /** Shortcut to get the number of passive threats
219
+ * @arg {FoundThreats} from - the threats found for a player
220
+ * @returns {number} the number of passive threats (win in 2 or more moves)
221
+ */
191
222
  const threatCount = (from) => {
192
223
  let count = 0
193
224
  for (const [depth, array] of Object.entries(from)) {
194
- count += int(depth) > 1 ? threatValues(array) : 0
225
+ count += int(depth) > 1 ? length(array) : 0
195
226
  }
196
227
 
197
228
  return count
198
229
  }
199
230
 
200
- const passiveAttack = (from) =>
231
+ /** Shortcut to determine if we have an active attack.
232
+ * @arg {FoundThreats} from - the threats found for a player
233
+ * @returns {boolean}
234
+ */
235
+ const activeAttack = (from) =>
201
236
  attackCount(from) > 1 || (attackCount(from) === 1 && threatCount(from) > 0)
202
237
 
203
- // Maybe we don't need this one
204
- // const underAttack = (from) => attackCount(from) + threatCount(from) > 0
238
+ /** Shortcut to determine if we have a passive attack.
239
+ * @arg {FoundThreats} from - the threats found for a player
240
+ * @returns {boolean}
241
+ */
242
+ const passiveAttack = (from) => threatCount(from) > 1
243
+
244
+ /** Shortcut to determine if we are under attack.
245
+ * @arg {FoundThreats} from - the threats found for a player
246
+ * @returns {boolean}
247
+ */
248
+ const underAttack = (from) => attackCount(from) + threatCount(from) > 0
205
249
 
206
250
  // Analysis class
207
251
  export const Analysis = class {
208
- /** @type TraxVariant */
252
+ /** @type {TraxVariant} */
209
253
  #rules = 'trax'
210
254
  #id = 'analysis'
255
+ /** @type {string[]} - List of moves to arrive at this position */
211
256
  #moves = []
212
257
  #notation = ''
213
258
  #edge = undefined
214
- #game = {}
259
+ /** @type {Trax} */
260
+ #game
215
261
  #scores = {}
216
262
  #threats = undefined
217
263
 
@@ -242,6 +288,9 @@ export const Analysis = class {
242
288
  return this.#edge
243
289
  }
244
290
 
291
+ /** All threats present in this position.
292
+ * @returns {FoundThreatsCollection}
293
+ */
245
294
  get threats() {
246
295
  if (!this.#threats) {
247
296
  this.#threats = { b: this.#applyThreats('b'), w: this.#applyThreats('w') }
@@ -274,6 +323,69 @@ export const Analysis = class {
274
323
  return count > 2
275
324
  }
276
325
 
326
+ /** Determine the points of the currently active player.
327
+ * @returns {[number, number]} - the points for this player, and the level
328
+ * of the most dangerous threat
329
+ */
330
+ #activeScore() {
331
+ let score = 0
332
+ let threatLevel = Number.POSITIVE_INFINITY
333
+ const { game } = this
334
+ const active = this.threats[game.color]
335
+
336
+ // First examine the active player
337
+ for (const level of Object.keys(active).map((i) => int(i))) {
338
+ if (level > 0) threatLevel = Math.min(threatLevel, level)
339
+ score += attackScore(active, level)
340
+ }
341
+
342
+ return [score, threatLevel]
343
+ }
344
+
345
+ /** Determine the points of the waiting player.
346
+ * @arg {Color} color - the color for which we are generating the score
347
+ * @arg {number} threatLevel - the lowest level for which the active player
348
+ * has an active threat
349
+ * @returns {[number, number]} - the points for this player and the multiplier
350
+ * to be applied to the other player's points
351
+ */
352
+ #inactiveScore(color, threatLevel) {
353
+ const { game } = this
354
+ const active = this.threats[game.color]
355
+ const inactive = this.threats[Trax.other(game.color)]
356
+
357
+ // Is the color we are looking at the passive player?
358
+ const passive = game.color !== color
359
+
360
+ // Is the inactive player activating known threats?
361
+ const attacking = activeAttack(inactive)
362
+
363
+ // Was the inactive player unable to clear all threats?
364
+ const defending = !attacking && underAttack(active)
365
+
366
+ // Does the inactive player have multiple threats
367
+ const threatening = !attacking && passiveAttack(inactive)
368
+
369
+ // If calc is false, attacks and threats are treated as corners
370
+ const calc = !passive || attacking || defending || threatening
371
+
372
+ // If the current player does not hold the initiative, reduce the score
373
+ const multiplier = passive && attacking && threatLevel > 1 ? 0.1 : 1
374
+
375
+ let score = 0
376
+
377
+ // Now calculate the effects of the passive player
378
+ for (const level of Object.keys(inactive).map((i) => int(i))) {
379
+ const calcLevel = level > 0 && defending ? level + threatLevel - 1 : level
380
+ let points = attackScore(inactive, level, calc ? calcLevel : 0)
381
+ if (level === threatLevel) points = passive ? Math.floor(points / 2) : 0
382
+ if (threatening) points = Math.floor(points / 2)
383
+ score += points
384
+ }
385
+
386
+ return [score, multiplier]
387
+ }
388
+
277
389
  #score(color) {
278
390
  if (this.#scores[color] === undefined) {
279
391
  const { game } = this
@@ -283,30 +395,11 @@ export const Analysis = class {
283
395
  return this.#scores[color]
284
396
  }
285
397
 
286
- let score = 0
398
+ const [currentPlayer, threatLevel] = this.#activeScore()
399
+ const [nextPlayer, multiplier] = this.#inactiveScore(color, threatLevel)
287
400
 
288
- const active = this.threats[game.color]
289
- const inactive = this.threats[Trax.other(game.color)]
290
- let advantage = Number.POSITIVE_INFINITY
291
-
292
- // First examine the active player
293
- for (const level of Object.keys(active).map((i) => int(i))) {
294
- if (level > 0 && level < advantage) advantage = level
295
- score += attackScore(active, level)
296
- }
297
-
298
- const passive = game.color !== color
299
- const calc = !passive || passiveAttack(inactive)
300
-
301
- // Calculate the effects of the passive player first
302
- for (const level of Object.keys(inactive).map((i) => int(i))) {
303
- if (level > advantage) continue
304
- let points = calc || level === 0 ? attackScore(inactive, level) : 1
305
- if (level === advantage) points = passive ? Math.floor(points / 2) : 0
306
- score -= points
307
- }
308
-
309
- if (passive) score *= -1
401
+ let score = Math.floor(currentPlayer * multiplier) - nextPlayer
402
+ if (score && game.color !== color) score *= -1
310
403
 
311
404
  this.#scores[color] = score
312
405
  }
@@ -369,6 +462,7 @@ export const Analysis = class {
369
462
  // Trace the edge of the playing area
370
463
  #traceEdge() {
371
464
  const { game } = this
465
+ /** @type {RawEdge} */
372
466
  const rawEdge = []
373
467
  const lines = {}
374
468
  const count = { w: 0, b: 0 }
@@ -423,8 +517,26 @@ export const Analysis = class {
423
517
  return label(pairUp(hollowOut(spelunk(rawEdge))))
424
518
  }
425
519
 
426
- // Apply the threats to a color's regex-ready edge
520
+ /** Compose the threat into a FoundThreat object.
521
+ * @arg {Threat} threat - the threat that has been found
522
+ * @arg {RegExpMatchArray} match - the matching item
523
+ * @returns {FoundThreat}
524
+ */
525
+ #foundThreat(threat, match) {
526
+ return {
527
+ threat: threat.pattern,
528
+ match: match[1], // The match is in the capturing group
529
+ at: match.index,
530
+ value: threat.value,
531
+ }
532
+ }
533
+
534
+ /** Apply the threats to a color's regex-ready edge.
535
+ * @arg {Color} color - the color to find threats for
536
+ * @returns {FoundThreats}
537
+ */
427
538
  #applyThreats(color) {
539
+ /** @type {FoundThreats} */
428
540
  const matches = {}
429
541
  const { length } = this.edge[color]
430
542
  const edge = this.edge[color].repeat(2)
@@ -432,13 +544,11 @@ export const Analysis = class {
432
544
  const find = matches[threat.depth] || []
433
545
  for (const match of edge.matchAll(threat.rx)) {
434
546
  if (match.index >= length) break
435
- if (find.every((f) => f.at !== match.index || f.match !== match[0])) {
436
- find.push({
437
- threat: threat.pattern,
438
- match: match[0],
439
- at: match.index,
440
- value: threat.value,
441
- })
547
+ if (find.every((f) => f.at !== match.index || f.match !== match[1])) {
548
+ find.push(this.#foundThreat(threat, match))
549
+ } else {
550
+ const duplicate = matches[99] || []
551
+ duplicate.push(this.#foundThreat(threat, match))
442
552
  }
443
553
  }
444
554
 
@@ -480,12 +590,19 @@ export const Analysis = class {
480
590
  }
481
591
  }
482
592
 
593
+ /** Analyze a game position.
594
+ * @arg {Trax} game - the position to analyze
595
+ * @returns {Analysis}
596
+ */
483
597
  export const analyze = (game) => new Analysis(game)
484
598
 
485
599
  export const Suggestion = class {
600
+ /** @type {PositionScore[]} */
486
601
  all = []
602
+ /** @type {PositionScore[]} */
487
603
  options = []
488
- pick = {}
604
+ /** @type {PositionScore} */
605
+ pick
489
606
  ms = 0
490
607
 
491
608
  constructor(analyzed, ms) {
@@ -22,7 +22,7 @@ const notationRx = /^[@a-z]+\d+[bps/\\+]$/i
22
22
 
23
23
  export const analyzeCmd = {
24
24
  name: 'analyze',
25
- args: '[<move>]',
25
+ args: '[move]',
26
26
  comp: '<play>',
27
27
  desc: 'analyze a position',
28
28
  help: [
@@ -44,7 +44,7 @@ const listThreats = (threats, detailed) => {
44
44
  const items = []
45
45
  if (detailed) {
46
46
  for (const item of threats[level]) {
47
- items.push('[' + item.at + ':' + item.match + ']')
47
+ items.push('[' + item.at + ':' + item.threat + ':' + item.match + ']')
48
48
  }
49
49
  }
50
50
 
package/src/cmds/list.js CHANGED
@@ -101,6 +101,9 @@ listCmd.fn = (CLI, ...filters) => {
101
101
  return CLI.error('No games. Type "new" to start a new game.')
102
102
  }
103
103
 
104
+ const puzzle = filters.find((f) => f.length > 1 && 'puzzles'.startsWith(f))
105
+ if (puzzle) return CLI.do('puzzles', ...filters.filter((f) => f !== puzzle))
106
+
104
107
  let list = []
105
108
  const size = {
106
109
  id: 1,
package/src/cmds/new.js CHANGED
@@ -98,6 +98,9 @@ newCmd.fn = (CLI, variant, ...names) => {
98
98
  let rules = ''
99
99
  if (variant) {
100
100
  variant = variant.toLowerCase()
101
+ if (variant.length > 1 && 'puzzles'.startsWith(variant)) {
102
+ return CLI.do('puzzles', 'new', ...names)
103
+ }
101
104
 
102
105
  if (['8x8', '8', 'trax8x8', '8trax', '8x8trax'].includes(variant)) {
103
106
  rules = 'trax8'
@@ -19,7 +19,7 @@ const completions = [...validLevels, ...puzzleIds].join('|')
19
19
 
20
20
  export const puzzlesCmd = {
21
21
  name: 'puzzles',
22
- args: '["list"|"start" [<difficulty>|<puzzleID>]]',
22
+ args: '["list"|"start"] [level|puzzle]',
23
23
  comp: 'list|ls|new|show|start|view ' + completions,
24
24
  desc: 'play puzzles',
25
25
  help: [
@@ -28,6 +28,11 @@ export const puzzlesCmd = {
28
28
  ],
29
29
  }
30
30
 
31
+ const an = (word = '') => {
32
+ const a = /^[aeiou]/.test(word) ? 'an' : 'a'
33
+ return a + ' ' + word
34
+ }
35
+
31
36
  const showPuzzle = (CLI, id) => {
32
37
  const puzzle = puzzles.find((p) => p.id === id)
33
38
  if (!puzzle) return CLI.error(`Puzzle ${id} not found`)
@@ -35,7 +40,7 @@ const showPuzzle = (CLI, id) => {
35
40
  const players = ['white', 'black']
36
41
  CLI.display(trax, players)
37
42
  let out = CLI.color.short(puzzle.id)
38
- out += CLI.color(' is a ' + levels[puzzle.level] + ' puzzle') + '\n'
43
+ out += CLI.color(` is ${an(levels[puzzle.level])} puzzle`) + '\n'
39
44
  if (puzzle.title) out += CLI.color(puzzle.title) + '\n'
40
45
  if (puzzle.desc) out += CLI.color.help(puzzle.desc) + '\n'
41
46
  out += CLI.color(players[puzzle.player - 1] + ' to win by move ' + puzzle.max)
@@ -143,8 +148,9 @@ puzzlesCmd.fn = (CLI, action, id) => {
143
148
  }
144
149
 
145
150
  if (action) {
146
- if (validLevels.includes(id)) return listLevel(CLI, id)
147
- if (puzzleIds.includes(id)) return showPuzzle(CLI, id)
151
+ const x = id || action
152
+ if (validLevels.includes(x)) return listLevel(CLI, x)
153
+ if (puzzleIds.includes(x)) return showPuzzle(CLI, x)
148
154
  if (id) return CLI.error('Could not find puzzle ' + id)
149
155
  }
150
156
 
package/src/puzzles.js CHANGED
@@ -440,7 +440,7 @@ export const puzzles = [
440
440
  level: 2,
441
441
  player: 1,
442
442
  max: 11,
443
- hint: 'A0/',
443
+ hints: ['A0/', 'C4\\', 'D5/'],
444
444
  },
445
445
  {
446
446
  id: 'iw3.trx',
@@ -451,7 +451,7 @@ export const puzzles = [
451
451
  level: 2,
452
452
  player: 1,
453
453
  max: 11,
454
- hint: 'B0/',
454
+ hints: ['B0/', 'C0\\'],
455
455
  },
456
456
  {
457
457
  id: 'iw4.trx',
@@ -462,7 +462,7 @@ export const puzzles = [
462
462
  level: 2,
463
463
  player: 1,
464
464
  max: 11,
465
- hint: '@2/',
465
+ hints: ['@2/', 'C0/'],
466
466
  },
467
467
  {
468
468
  id: 'iw5.trx',
@@ -495,7 +495,7 @@ export const puzzles = [
495
495
  level: 2,
496
496
  player: 1,
497
497
  max: 13,
498
- hint: 'A1+',
498
+ hints: ['A1+', 'D1/'],
499
499
  },
500
500
  {
501
501
  id: 'ib1.trx',
@@ -517,7 +517,7 @@ export const puzzles = [
517
517
  level: 2,
518
518
  player: 2,
519
519
  max: 12,
520
- hint: 'A1\\',
520
+ hints: ['A1\\', 'E1/'],
521
521
  },
522
522
  {
523
523
  id: 'ib3.trx',
@@ -539,7 +539,7 @@ export const puzzles = [
539
539
  level: 2,
540
540
  player: 2,
541
541
  max: 12,
542
- hint: 'B0/',
542
+ hints: ['B0/', 'C0+'],
543
543
  },
544
544
  {
545
545
  id: 'ib5.trx',
@@ -572,7 +572,7 @@ export const puzzles = [
572
572
  level: 2,
573
573
  player: 2,
574
574
  max: 10,
575
- hint: 'A0/',
575
+ hints: ['A0/', 'D3/'],
576
576
  },
577
577
  {
578
578
  id: 'ib8.trx',
@@ -648,8 +648,8 @@ export const puzzles = [
648
648
  icon: '0ca:deef:0d',
649
649
  level: 2,
650
650
  player: 1,
651
- max: 13,
652
- hint: 'B0/',
651
+ max: 15,
652
+ hints: ['B0/', 'C0\\'],
653
653
  },
654
654
  {
655
655
  id: 'hw4.trx',
@@ -747,8 +747,8 @@ export const puzzles = [
747
747
  icon: 'd:b:ba:aeecd:0fdec',
748
748
  level: 1,
749
749
  player: 2,
750
- max: 16,
751
- hint: 'B1+',
750
+ max: 18,
751
+ hints: ['B1+', 'B2/', 'D3+'],
752
752
  },
753
753
  {
754
754
  id: 'hb3.trx',
@@ -767,7 +767,7 @@ export const puzzles = [
767
767
  game: 'traxloop',
768
768
  notation: '@0+ A0/ A0/ A4/ A5+ @5/ @5/ @5+ A4/ @5/',
769
769
  icon: '3d:3c:3e:0d1d:cbdcb',
770
- level: 2,
770
+ level: 3,
771
771
  player: 1,
772
772
  max: 15,
773
773
  hint: 'D1\\',
@@ -778,7 +778,7 @@ export const puzzles = [
778
778
  game: 'traxloop',
779
779
  notation: '@0+ B1/ C1+ C0/ D1\\ E1\\ F1+ E0/ F1/',
780
780
  icon: '2cdc:1dfae:ecb',
781
- level: 2,
781
+ level: 3,
782
782
  player: 2,
783
783
  max: 16,
784
784
  hint: 'F0/',
@@ -789,7 +789,7 @@ export const puzzles = [
789
789
  game: 'traxloop',
790
790
  notation: '@0/ @1+ A0/ A0/ B2+ @2\\ @2\\ E3\\ E4+',
791
791
  icon: '1ca:afde:1bdf:3b',
792
- level: 2,
792
+ level: 3,
793
793
  player: 2,
794
794
  max: 16,
795
795
  hint: 'A1\\',
@@ -800,9 +800,13 @@ export const puzzles = [
800
800
  game: 'traxloop',
801
801
  notation: '@0/ B1+ C1/ C0/ C0\\ @3/ C1+ E2/ F2\\',
802
802
  icon: '1baf:1cdca:cdecd',
803
- level: 2,
803
+ level: 3,
804
804
  player: 2,
805
805
  max: 16,
806
806
  hint: 'B1+',
807
807
  },
808
808
  ]
809
+
810
+ puzzles.sort(
811
+ (a, b) => a.level - b.level || a.max - b.max || (a.id < b.id ? -1 : 1),
812
+ )
package/src/threats.js CHANGED
@@ -5,20 +5,26 @@
5
5
  */
6
6
 
7
7
  const REGEX = {
8
- a: '[am]', // White that pairs with the next white (m=hollow)
9
- c: '[cp]', // White that forms a connectable pair with next white (p=hollow)
10
- l: '[lmp]', // White in a hollow
11
- w: '[aclmpw]', // Any white
8
+ // Some of these are one for one:
9
+ // a: White that pairs with the next white
10
+ A: '[am]', // White maybe in a hollow that pairs with next white
11
+ // c: White that forms a connectable pair with next white
12
+ C: '[cp]', // White maybe in a hollow connectable pair with next white
13
+ l: '[lmp]', // Any White in a hollow
14
+ // m: "a" in a hollow
15
+ // p: "c" in a hollow)
16
+ w: '[acw]', // Any white except in hollows
17
+ W: '[aclmpw]', // Any white at all
12
18
  '!': '(?:b[acw])*?', // Ripple black/white
13
19
  '¡': '(?:[acw]b)*?', // Ripple white/black
14
20
  '*': 'b*?', // Zero or more black pieces
15
21
  '?': 'b?', // Zero or one black piece
16
22
  }
17
23
 
18
- class Threat {
24
+ export const Threat = class {
19
25
  #depth = 0
20
26
  #pattern = ''
21
- #rx = null
27
+ #rx
22
28
  #reforms = false
23
29
 
24
30
  constructor(depth, pattern, reforms = false) {
@@ -31,7 +37,8 @@ class Threat {
31
37
  rx += REGEX[char] || char
32
38
  }
33
39
 
34
- this.#rx = new RegExp(rx, 'g')
40
+ // Create the regex with lookahead assertions to allow overlapping matches
41
+ this.#rx = new RegExp('(?=(' + rx + '))', 'g')
35
42
  }
36
43
 
37
44
  get depth() {
@@ -65,7 +72,7 @@ class Threat {
65
72
  }
66
73
  }
67
74
 
68
- class ThreatsDB {
75
+ export const ThreatsDB = class {
69
76
  #db = {}
70
77
  #depth = 0
71
78
 
@@ -75,6 +82,11 @@ class ThreatsDB {
75
82
  }
76
83
  }
77
84
 
85
+ compose(depth, pattern) {
86
+ pattern = pattern.replaceAll(/(?<=.)A/g, 'a').replaceAll(/W(?=.)/g, 'w')
87
+ return this.add(depth, pattern)
88
+ }
89
+
78
90
  add(depth, pattern, reforms = false) {
79
91
  this.#depth = Math.max(depth, this.#depth)
80
92
  if (pattern in this.#db) throw new Error('Duplicate pattern:' + pattern)
@@ -97,37 +109,38 @@ class ThreatsDB {
97
109
  // To make life at the REPL a little easier
98
110
  [Symbol.for('nodejs.util.inspect.custom')](_, out) {
99
111
  const style = out.stylize
100
- const inspection = '[' + style('Threats', 'special') + '] { '
112
+ const inspection = '[' + style('Threats', 'special') + ':'
101
113
  let total = 0
102
- const keys = []
114
+ const keys = ['{']
103
115
 
104
116
  for (let x = 0; x <= this.#depth; x++) {
105
117
  const count = this.level(x).length
106
- keys.push(style(x, 'string') + ': ' + style(count, 'number'))
118
+ keys.push(style(x, 'string') + ':' + style(count, 'number'))
107
119
  total += count
108
120
  }
109
121
 
110
- return inspection + keys.join(', ') + ' } total: ' + total
122
+ return inspection + style(total, 'number') + '] ' + keys.join(' ') + ' }'
111
123
  }
112
124
  }
113
125
 
114
126
  const pre = true // This pattern can appear first in an L threat
115
127
  const post = true // This pattern can come second in an L threat
116
128
  const corners = {
117
- arw: { pre, post }, // Simple corner
118
- abrw: { post }, // Wide-first corner
119
- arbw: { pre }, // Wide-second corner
120
- abrbw: {}, // Wide corner
121
- 'ab?bw': { pre, post }, // Flat corner and wide flat corner
122
- cw: { pre, post }, // Connectable pair
123
- cbw: {}, // Wide connectable pair
129
+ ArW: { pre, post }, // Simple corner
130
+ AbrW: { post }, // Wide-first corner
131
+ ArbW: { pre }, // Wide-second corner
132
+ AbrbW: {}, // Wide corner
133
+ 'ab?bW': { pre, post }, // Flat corner and wide flat corner
134
+ CW: { pre, post }, // Connectable pair
135
+ CbW: {}, // Wide connectable pair
124
136
  }
125
137
 
126
138
  export const threats = new ThreatsDB(Object.keys(corners))
127
139
 
128
140
  // Attacks
129
141
  const attacks = [
130
- 'a?w', // Simple attack and wide attack
142
+ 'a?W', // Simple attack and wide attack
143
+ 'm?W', // Simple and wide attack in a corner
131
144
  ]
132
145
  attacks.map((a) => threats.add(1, a))
133
146
 
@@ -135,34 +148,54 @@ attacks.map((a) => threats.add(1, a))
135
148
  const reformAttacks = [
136
149
  'rm?lr', // Cave/wide cave attack
137
150
  'rbm?lbr', // Deep cave/deep wide cave attack (impossibly rare?)
138
- 'ralmrw', // Attack at top of cave
151
+ 'ralmrW', // Attack at top of cave
139
152
  'arlmwr', // Reversed
140
153
  ]
141
154
  reformAttacks.map((ra) => threats.add(1, ra, true))
142
155
 
143
156
  const reformThreats = [
144
157
  'rp?lr', // Cave/wide cave threat
145
- 'rbp?lbr', // Deep cave/deept wide cave attach (impossibly rare?)
158
+ 'rbp?lbr', // Deep cave/deep wide cave threat (impossibly rare?)
159
+ 'rmbb???w', // Black corner with a long white loop potential
160
+ 'abb???lr', // Reverse
146
161
  ]
147
162
  reformThreats.map((rt) => threats.add(2, rt))
148
163
 
149
164
  // Create multi-stage threats
150
165
  for (const one of Object.keys(corners)) {
166
+ const start = one.replace('W', 'l')
167
+ let end = one.replace(/[aA]/, 'm').replace('C', 'p')
168
+ const pre = corners[one].post ? '?' : ''
169
+ let post = corners[one].pre ? '?' : ''
170
+ threats.compose(3, start + pre + '¡rm?W') // Attacking 3-stage threat
171
+ threats.compose(3, 'a?lr!' + post + end) // Reverse
172
+ threats.compose(3, one + '¡ab*bwr') // Corner by reforming hollow potential
173
+ threats.compose(3, 'rab*bw!' + one) // Reverse
174
+ threats.compose(3, one + '!brm*bW') // Corner by hollow attack, defends to edge
175
+ threats.compose(3, one + '!bbrmW') // Same, but slightly different layout
176
+ threats.compose(3, 'ab*lrb¡' + one) // Reverse
177
+ threats.compose(3, 'alrbb¡' + one) // Reverse of different layout
178
+
151
179
  for (const two of Object.keys(corners)) {
152
- // L threats
153
- const [before, after] = [corners[one].post, corners[two].pre]
154
- if (before || after) {
155
- const start = one.replace('w', 'l')
156
- const pre = before ? '?' : ''
157
- const post = after ? '?' : ''
158
- threats.add(2, start + pre + '¡' + two) // Make an L
159
- threats.add(3, start + pre + 'rb¡' + two) // Make a three-stage threat
160
- const end = two.replace('a', 'm').replace('c', 'p')
161
- threats.add(2, one + post + '!' + end) // L
162
- threats.add(3, one + post + '!br' + end) // Three-stage
180
+ end = two.replace(/[aA]/, 'm').replace('C', 'p')
181
+ post = corners[two].pre ? '?' : ''
182
+ if (pre) {
183
+ threats.compose(2, start + pre + '¡' + two) // Make an L
184
+ threats.compose(3, start + pre + 'rb¡' + two) // Make a three-stage threat
185
+ threats.compose(3, start + '?!rb¡' + two) // Attack to form an L threat
163
186
  }
164
187
 
165
- // Edge threats
166
- threats.add(3, one + '!b*b¡' + two)
188
+ if (post) {
189
+ threats.compose(2, one + post + '!' + end) // L
190
+ threats.compose(3, one + post + '!br' + end) // Three-stage
191
+ threats.compose(3, one + '!br¡?' + end) // Attack to form an L threat
192
+ }
193
+
194
+ threats.compose(3, one + '!b*b¡' + two) // Edge threat
167
195
  }
168
196
  }
197
+
198
+ threats.add(3, 'ralbmrW') // Attack into a wide cave
199
+ threats.add(3, 'Arlbmwr') // Reversed
200
+
201
+ threats.add(4, 'ArlbmrW') // Wide cave ready for an attack
package/src/tty.js CHANGED
@@ -22,7 +22,7 @@ import { Trax } from '@slugbugblue/trax'
22
22
  const esc = '\u001B[' // Start of console color escape code
23
23
  const off = esc + 'm' // Color reset code
24
24
  const ENV = process.env
25
- const NERD = ENV.NERDFONT
25
+ const NERD = ENV.NERDFONT || ENV.NERDFONTS
26
26
  const POWER = ENV.POWERLINE || ENV.P9K_TTY || ENV.P9K_SSH
27
27
  const leftBubble = NERD ? '' : POWER ? '' : '▐'
28
28
  const rightBubble = NERD ? '' : POWER ? '' : '▌'
package/src/types.d.ts CHANGED
@@ -25,12 +25,56 @@ type Colorer = {
25
25
  white: Colorize
26
26
  }
27
27
 
28
+ /** An object representing a space on the perimeter of a Trax position.
29
+ * x,y: the coordinates of the space
30
+ * c: the color present beside the space (w, b, l: both w and b, r: none)
31
+ * t: the type of possible moves: n-normal, x-none, c-cave, C-restricted cave
32
+ * b,w: if present, the number of the line to match with its other end
33
+ * pb, pw: if present, can pair with the next line in one turn
34
+ * xb, xw: the label of this space for each color
35
+ */
36
+ type EdgeLocation = {
37
+ x: number
38
+ y: number
39
+ c: string
40
+ t: string
41
+ b?: number
42
+ w?: number
43
+ pb?: boolean
44
+ pw?: boolean
45
+ zb?: string
46
+ zw?: string
47
+ }
48
+
49
+ type EdgeObject = {
50
+ b: string
51
+ w: string
52
+ edge: RawEdge
53
+ }
54
+
55
+ /** Object representing a concrete threat found in a position. */
56
+ type FoundThreat = { threat: string; match: string; at: number; value: number }
57
+
58
+ /** An object with keys representing each level, with arrays for each threat of
59
+ * that level. */
60
+ type FoundThreats = Record<string, FoundThreat[]>
61
+
62
+ /** All the threats found for both the white and black player. */
63
+ type FoundThreatsCollection = { b: ColorThreats; w: ColorThreats }
64
+
28
65
  /** A point-like object has numerical x and y properties. */
29
66
  type PointLike = {
30
67
  x: number
31
68
  y: number
32
69
  }
33
70
 
71
+ /** An object used to track an analysis on a position. */
72
+ type PositionScore = {
73
+ move: string
74
+ score: number
75
+ analysis?: Analysis
76
+ }
77
+
34
78
  /** A puzzle. */
35
79
  type Puzzle = {
36
80
  id: string
@@ -53,6 +97,10 @@ type PuzzleSource = {
53
97
  url?: string
54
98
  }
55
99
 
100
+ /** Array of objects representing the spaces surrounding the perimeter of a
101
+ * Trax position. */
102
+ type RawEdge = EdgeLocation[]
103
+
56
104
  /** Treat the save state as an opaque object,
57
105
  * produced by save() and fed into restore().
58
106
  */
@@ -76,6 +124,14 @@ type SaveState = {
76
124
  */
77
125
  type Slash = '/' | '\\' | '+'
78
126
 
127
+ /** Threat definition. */
128
+ type Threat = {
129
+ depth: number
130
+ pattern: string
131
+ rx: RegExp
132
+ value: number
133
+ }
134
+
79
135
  /** A single tile on the board. */
80
136
  type Tile = {
81
137
  id: TileId
package/src/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // Generated by genversion.
2
- export const version = '0.14.0'
2
+ export const version = '0.15.0'