@slugbugblue/trax 0.14.0 → 0.16.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,33 @@
1
1
  # @slugbugblue/trax
2
2
 
3
+ ## 0.16.0 - 2022-12-27
4
+
5
+ - Added a `notes` command to the CLI to add comments to a game
6
+
7
+ - can be used as a text filter by the `list` command
8
+ - will be displayed by the `view` command
9
+ - will be included in the output generated by the `export` command
10
+
11
+ - Minor analysis improvements
12
+
13
+ ## 0.15.0 - 2022-12-14
14
+
15
+ - Added shared `puzzles` commands to the CLI:
16
+
17
+ - `trax ls puzzles`: see all the puzzles
18
+ - `trax new puzzle`: start a new puzzle
19
+
20
+ - Puzzle improvements:
21
+
22
+ - Adjust puzzle category levels for harder puzzles
23
+ - Add multiple hints when the puzzle can be solved multiple ways
24
+
25
+ - Analysis engine improvements:
26
+
27
+ - Analysis engine suggestions now "solve" all tough puzzles
28
+ - Reworked threats to be more precise
29
+ - Reworked scoring to add more nuance
30
+
3
31
  ## 0.14.0 - 2022-12-05
4
32
 
5
33
  - Added `puzzles` to the CLI:
@@ -7,8 +35,9 @@
7
35
  - `trax puzzles ls`: see all the puzzles
8
36
  - `trax puzzle new`: start a new puzzle
9
37
 
10
- - Bot tests now include solving all tutorial and easy puzzles. As the bot
11
- improves, we will add more puzzles to the tests.
38
+ - Analysis engine tests now include correctly suggesting the first move for
39
+ solving all tutorial and easy puzzles. As the threats database improves, we
40
+ will add more puzzles to the tests.
12
41
 
13
42
  ## 0.13.0 - 2022-11-25
14
43
 
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.16.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 (passive && 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) {
package/src/cli.js CHANGED
@@ -38,6 +38,7 @@ import {
38
38
  } from './cmds/import-export.js'
39
39
  import { listCmd } from './cmds/list.js'
40
40
  import { newCmd } from './cmds/new.js'
41
+ import { notesCmd } from './cmds/notes.js'
41
42
  import { playCmd, tryCmd } from './cmds/play-try.js'
42
43
  import { puzzlesCmd } from './cmds/puzzles.js'
43
44
  import { selectCmd } from './cmds/select.js'
@@ -87,6 +88,7 @@ const plugins = [
87
88
  importCmd,
88
89
  listCmd,
89
90
  newCmd,
91
+ notesCmd,
90
92
  playCmd,
91
93
  puzzlesCmd,
92
94
  quitCmd,
@@ -197,14 +199,6 @@ const DATA = { games: {}, puzzles: {} }
197
199
 
198
200
  // CLI context object for plugins
199
201
  const CLI = {
200
- // Notation manipulation
201
- fixNotation(move) {
202
- if (move.endsWith('b')) return move.slice(0, -1) + '\\'
203
- if (move.endsWith('s')) return move.slice(0, -1) + '/'
204
- if (move.endsWith('p')) return move.slice(0, -1) + '+'
205
- return move
206
- },
207
-
208
202
  // Quick access to all stored games
209
203
  get GAMES() {
210
204
  return DATA.games || {}
@@ -283,6 +277,28 @@ const CLI = {
283
277
  CLI.out(CLI.color.error(text))
284
278
  },
285
279
 
280
+ // Notation manipulation
281
+ fixNotation(move) {
282
+ if (move.endsWith('b')) return move.slice(0, -1) + '\\'
283
+ if (move.endsWith('s')) return move.slice(0, -1) + '/'
284
+ if (move.endsWith('p')) return move.slice(0, -1) + '+'
285
+ return move
286
+ },
287
+
288
+ load(id) {
289
+ if (!id) id = Object.keys(DATA.games || {})[0]
290
+ if (id && DATA.games?.[id] && DATA.games[id].rules) {
291
+ if (id !== String(CONFIG.id)) {
292
+ CONFIG.id = id
293
+ saveConfig()
294
+ }
295
+
296
+ const game = CLI.GAME
297
+ if (!game.players) game.players = ['white', 'black']
298
+ CLI.TRAX = new Trax(game.rules, game.moves, 'cli')
299
+ }
300
+ },
301
+
286
302
  newGame(rules, players, moves) {
287
303
  // Creates a new game
288
304
  const id = newId(DATA)
@@ -367,6 +383,11 @@ const CLI = {
367
383
  return undefined
368
384
  },
369
385
 
386
+ /** Write out the current data to disk. */
387
+ save() {
388
+ saveData()
389
+ },
390
+
370
391
  setGame(moves) {
371
392
  // Updates the moves for the current game
372
393
  const game = CLI.GAME
@@ -390,20 +411,6 @@ const CLI = {
390
411
  }
391
412
  },
392
413
 
393
- load(id) {
394
- if (!id) id = Object.keys(DATA.games || {})[0]
395
- if (id && DATA.games?.[id] && DATA.games[id].rules) {
396
- if (id !== String(CONFIG.id)) {
397
- CONFIG.id = id
398
- saveConfig()
399
- }
400
-
401
- const game = CLI.GAME
402
- if (!game.players) game.players = ['white', 'black']
403
- CLI.TRAX = new Trax(game.rules, game.moves, 'cli')
404
- }
405
- },
406
-
407
414
  // Pass through functions
408
415
  bubble: tty.bubble,
409
416
  display: tty.display,
@@ -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
 
@@ -188,6 +188,63 @@ importCmd.fn = async (CLI, filename) => {
188
188
  }
189
189
  }
190
190
 
191
+ /** Get all the notes for a certain move.
192
+ * @arg {number} move - The move number
193
+ * @arg {GameNotes} notes? - The notes for a game
194
+ * @returns {string} - Notes formatted for this move, or empty string if none
195
+ */
196
+ const gameNotes = (move, notes) => {
197
+ notes = notes || []
198
+ const moveNotes = notes.filter((n) => n.move === move)
199
+ if (moveNotes.length === 0) return ''
200
+ return (
201
+ '; ' +
202
+ moveNotes.map((n) => n.note.replaceAll('\n', '\n; ')).join('\n; ') +
203
+ '\n'
204
+ )
205
+ }
206
+
207
+ /** Interleave the game moves with any notes, formatted for the export file.
208
+ * @arg {string[]} moves - an array of move notations
209
+ * @arg {GameNotes} notes? - The notes for a game
210
+ * @returns {string} - A string that can be placed into an export file
211
+ */
212
+ const interleaveNotation = (moves, notes) => {
213
+ let content = ''
214
+ let moveNumber = 0
215
+ let annotation = ''
216
+ for (const move of moves) {
217
+ const comments = gameNotes(moveNumber, notes)
218
+ if (comments) {
219
+ const [lengthA, lengthC] = [annotation.length, comments.length]
220
+ const space = lengthA > 0 ? (lengthA + lengthC < 78 ? ' ' : '\n') : ''
221
+ content += space + annotation + comments
222
+ annotation = ''
223
+ }
224
+
225
+ moveNumber += 1
226
+ const notation = String(moveNumber) + '. ' + move
227
+ if (annotation.length + notation.length < 79) {
228
+ annotation += (annotation.length > 0 ? ' ' : '') + notation
229
+ } else {
230
+ content += '\n' + annotation
231
+ annotation = notation
232
+ }
233
+ }
234
+
235
+ const comments = gameNotes(moveNumber, notes)
236
+ if (comments) {
237
+ const [lengthA, lengthC] = [annotation.length, comments.length]
238
+ const space = lengthA > 0 ? (lengthA + lengthC < 78 ? ' ' : '\n') : ''
239
+ content += space + annotation + comments
240
+ annotation = ''
241
+ }
242
+
243
+ content += annotation
244
+ if (!content.endsWith('\n')) content += '\n'
245
+ return content
246
+ }
247
+
191
248
  exportCmd.fn = (CLI, id, filename) => {
192
249
  cli = CLI
193
250
  let game = CLI.GAME
@@ -218,7 +275,7 @@ exportCmd.fn = (CLI, id, filename) => {
218
275
  content += '; Puzzle ' + game.puzzle
219
276
  if (source) {
220
277
  content += ' provided courtesy of ' + source.name
221
- if (source.url) content += ' (' + source.url + ')'
278
+ if (source.url) content += '\n; ' + source.url
222
279
  }
223
280
 
224
281
  if (puzzle.title) content += '\n; ' + puzzle.title
@@ -230,7 +287,8 @@ exportCmd.fn = (CLI, id, filename) => {
230
287
  }
231
288
  }
232
289
 
233
- content += trax.notation + '\n'
290
+ content += interleaveNotation(trax.moves, game.notes)
291
+
234
292
  if (filename) filename = filename.replace(/[^ a-z\d.~/]/g, '')
235
293
  if (filename) {
236
294
  const fname = filename.split('/').pop()
package/src/cmds/list.js CHANGED
@@ -15,6 +15,8 @@
15
15
 
16
16
  // CLI list command
17
17
 
18
+ import process from 'node:process'
19
+
18
20
  import { Trax } from '@slugbugblue/trax'
19
21
 
20
22
  export const listCmd = {
@@ -35,7 +37,7 @@ export const listCmd = {
35
37
  ' #id: match a game or a certain number of moves',
36
38
  ' "active" or "over": whether a game is in progress or not',
37
39
  ' "loop" or "8x8" or "trax": filter by game type',
38
- " <other text>: match a player's name",
40
+ " <other text>: match a player's name or the latest game note",
39
41
  'Add the "reverse" option to reverse the sort order.',
40
42
  ],
41
43
  }
@@ -80,14 +82,16 @@ const listSort = (list, filters) => {
80
82
  } else if (['over', 'active'].includes(by)) {
81
83
  list = list.filter((g) => (by === 'over' ? g.over : !g.over))
82
84
  } else {
83
- list = list.filter(
84
- (g) =>
85
+ list = list.filter((g) => {
86
+ return (
85
87
  g.id.endsWith(by) ||
86
88
  Trax.names[g.rules].toLowerCase().includes(by) ||
87
89
  g.p1.toLowerCase().includes(by) ||
88
90
  g.p2.toLowerCase().includes(by) ||
89
- g.moves.startsWith(by + ' '),
90
- )
91
+ g.moves.startsWith(by + ' ') ||
92
+ g.note.includes(by)
93
+ )
94
+ })
91
95
  }
92
96
  }
93
97
 
@@ -96,19 +100,37 @@ const listSort = (list, filters) => {
96
100
  return list
97
101
  }
98
102
 
103
+ /** Find how many columns are left over after all the other columns arefilled.
104
+ * @arg {Record<string, number>} sizes
105
+ * @returns {number} number of columns left over
106
+ */
107
+ const leftover = (sizes) => {
108
+ let left = process.stdout.columns
109
+ for (const size of Object.values(sizes)) {
110
+ left -= size
111
+ }
112
+
113
+ return left
114
+ }
115
+
99
116
  listCmd.fn = (CLI, ...filters) => {
100
117
  if (Object.keys(CLI.GAMES).length === 0) {
101
118
  return CLI.error('No games. Type "new" to start a new game.')
102
119
  }
103
120
 
121
+ const puzzle = filters.find((f) => f.length > 1 && 'puzzles'.startsWith(f))
122
+ if (puzzle) return CLI.do('puzzles', ...filters.filter((f) => f !== puzzle))
123
+
104
124
  let list = []
105
125
  const size = {
126
+ spacing: 13, // Amount of space taken up by fixed-length and between columns
106
127
  id: 1,
107
128
  name: 1,
108
129
  moves: 1,
109
130
  p1: 1,
110
131
  p2: 1,
111
132
  }
133
+ let noteSize = Number.POSITIVE_INFINITY
112
134
 
113
135
  for (const game of Object.values(CLI.GAMES)) {
114
136
  const moves = game.moves.length === 0 ? 0 : game.moves.split(' ').length
@@ -117,13 +139,14 @@ listCmd.fn = (CLI, ...filters) => {
117
139
  id: (game.id === CLI.GAME.id ? '* #' : '#') + String(game.id),
118
140
  name: game.name?.length || 0,
119
141
  rules: game.rules,
120
- moves: CLI.plural(moves, 'move'),
142
+ moves: game.over ? 'game over' : CLI.plural(moves, 'move'),
121
143
  smoves: game.over ? '9999' : String(moves).padStart(9, '0'),
122
144
  sturn: String(game.turn),
123
145
  p1: game.players?.[0] || 'white',
124
146
  p2: game.players?.[1] || 'black',
125
147
  turn: game.turn,
126
148
  over: game.over,
149
+ note: game.notes?.[game.notes.length - 1]?.note || '',
127
150
  })
128
151
  }
129
152
 
@@ -135,6 +158,7 @@ listCmd.fn = (CLI, ...filters) => {
135
158
  size.moves = Math.max(size.moves, game.moves.length)
136
159
  size.p1 = Math.max(size.p1, game.p1.length)
137
160
  size.p2 = Math.max(size.p2, game.p2.length)
161
+ noteSize = Math.min(noteSize, leftover(size))
138
162
  }
139
163
 
140
164
  for (const game of list) {
@@ -146,7 +170,8 @@ listCmd.fn = (CLI, ...filters) => {
146
170
  CLI.color('vs '.padStart(size.p1 - game.p1.length + 4)) +
147
171
  CLI.bubble(game.turn === 2 ? 'bh' : 'b', game.p2) +
148
172
  CLI.color(' '.padStart(size.p2 - game.p2.length + 1)) +
149
- CLI.color(game.over ? 'game over' : game.moves),
173
+ CLI.color(game.moves.padEnd(size.moves)) +
174
+ CLI.color.help(' ' + game.note.slice(0, noteSize)),
150
175
  )
151
176
  }
152
177
  }
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'
@@ -0,0 +1,34 @@
1
+ /** Notes CLI command.
2
+ * @copyright 2022
3
+ * @author Chad Transtrum <chad@transtrum.net>
4
+ * @license Apache-2.0
5
+ */
6
+
7
+ export const notesCmd = {
8
+ name: 'notes',
9
+ args: '[#id] <text>',
10
+ desc: 'add a note to a game',
11
+ help: [
12
+ 'Add a short note to a game that will be shown when the game is',
13
+ 'displayed, listed, or exported.',
14
+ ],
15
+ }
16
+
17
+ notesCmd.fn = (CLI, id, ...note) => {
18
+ const current = String(CLI.GAME.id)
19
+ if (!id) id = current
20
+ if (!/^#?\d+$/.test(id)) {
21
+ note.unshift(id)
22
+ id = current
23
+ }
24
+
25
+ if (id.startsWith('#')) id = id.slice(1)
26
+
27
+ const game = CLI.GAMES[id]
28
+ const notes = game.notes || []
29
+ const move = game.moves.length > 0 ? game.moves.split(' ').length : 0
30
+
31
+ notes.push({ move, note: note.join(' ') })
32
+ game.notes = notes
33
+ CLI.save()
34
+ }
@@ -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/cmds/view.js CHANGED
@@ -39,7 +39,10 @@ viewCmd.fn = (CLI, move) => {
39
39
  show = Number(move)
40
40
  }
41
41
 
42
- const players = CLI.GAME.players || ['white', 'black']
42
+ const game = CLI.GAME
43
+ const players = game.players || ['white', 'black']
44
+ const puzzle = game.puzzle || ''
45
+ const { note } = [...(game.notes || [{}])].pop()
43
46
 
44
47
  CLI.out(
45
48
  CLI.color('#' + CLI.ID + ' ') +
@@ -50,4 +53,6 @@ viewCmd.fn = (CLI, move) => {
50
53
  CLI.bubble('b' + (trax.turn === 2 ? 'h' : ''), players[1]),
51
54
  )
52
55
  CLI.display(trax, players, undefined, show)
56
+ if (puzzle) CLI.out(CLI.color('Puzzle ' + puzzle))
57
+ if (note) CLI.out(CLI.color.help(note))
53
58
  }
package/src/engine.js CHANGED
@@ -617,7 +617,7 @@ export const Trax = class {
617
617
  return this.notation
618
618
  .replace(/\n/g, ' ')
619
619
  .split(' ')
620
- .filter((n) => !n.endsWith('.'))
620
+ .filter((n) => Boolean(n) && !n.endsWith('.'))
621
621
  }
622
622
 
623
623
  /** Drop a tile onto the board. This is a lower level call. Use play() if possible instead.
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,55 @@ 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, 'rb¡c?w!br') // Connectable pair on an edge
202
+ 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,65 @@ 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
+
65
+ /** A note with a move number */
66
+ type GameNote = {
67
+ move: number
68
+ note: string
69
+ }
70
+
71
+ /** Notes saved in the game object in the CLI. */
72
+ type GameNotes = GameNote[]
73
+
28
74
  /** A point-like object has numerical x and y properties. */
29
75
  type PointLike = {
30
76
  x: number
31
77
  y: number
32
78
  }
33
79
 
80
+ /** An object used to track an analysis on a position. */
81
+ type PositionScore = {
82
+ move: string
83
+ score: number
84
+ analysis?: Analysis
85
+ }
86
+
34
87
  /** A puzzle. */
35
88
  type Puzzle = {
36
89
  id: string
@@ -53,6 +106,10 @@ type PuzzleSource = {
53
106
  url?: string
54
107
  }
55
108
 
109
+ /** Array of objects representing the spaces surrounding the perimeter of a
110
+ * Trax position. */
111
+ type RawEdge = EdgeLocation[]
112
+
56
113
  /** Treat the save state as an opaque object,
57
114
  * produced by save() and fed into restore().
58
115
  */
@@ -76,6 +133,14 @@ type SaveState = {
76
133
  */
77
134
  type Slash = '/' | '\\' | '+'
78
135
 
136
+ /** Threat definition. */
137
+ type Threat = {
138
+ depth: number
139
+ pattern: string
140
+ rx: RegExp
141
+ value: number
142
+ }
143
+
79
144
  /** A single tile on the board. */
80
145
  type Tile = {
81
146
  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.16.0'