@slugbugblue/trax 0.17.0 → 0.18.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.
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env sh
2
+ . "$(dirname -- "$0")/_/husky.sh"
3
+
4
+ npm test
package/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # @slugbugblue/trax
2
2
 
3
+ ## 0.18.0 - 2023-02-05
4
+
5
+ - Analysis improvements:
6
+
7
+ - Add `count.b(level)` and `count.w(level)` methods to an analysis to quickly
8
+ count the number of threats at a given level
9
+ - Remove faulty threats from an analysis
10
+ - Add additional threats for more puzzle solving mojo
11
+
12
+ - Development environment enhancements:
13
+
14
+ - Added an initial benchmarking framework with puzzle benchmarks so far
15
+ - Added husky pre-commit hook to ensure npm test is run before a commit
16
+
17
+ - CLI improvements:
18
+
19
+ - Puzzle information is now stored as notes, to make it searchable
20
+
21
+ - More puzzles:
22
+
23
+ - Martin M. S. Pedersen contributed eleven new puzzles
24
+
3
25
  ## 0.17.0 - 2022-12-29
4
26
 
5
27
  - The `import` CLI command now loads comments from a .trx file
@@ -26,9 +48,9 @@
26
48
  - Adjust puzzle category levels for harder puzzles
27
49
  - Add multiple hints when the puzzle can be solved multiple ways
28
50
 
29
- - Analysis engine improvements:
51
+ - Analysis improvements:
30
52
 
31
- - Analysis engine suggestions now "solve" all tough puzzles
53
+ - Analysis suggestions now "solve" all tough puzzles
32
54
  - Reworked threats to be more precise
33
55
  - Reworked scoring to add more nuance
34
56
 
@@ -39,9 +61,9 @@
39
61
  - `trax puzzles ls`: see all the puzzles
40
62
  - `trax puzzle new`: start a new puzzle
41
63
 
42
- - Analysis engine tests now include correctly suggesting the first move for
43
- solving all tutorial and easy puzzles. As the threats database improves, we
44
- will add more puzzles to the tests.
64
+ - Analysis tests now include correctly suggesting the first move for solving all
65
+ tutorial and easy puzzles. As the threats database improves, we will add more
66
+ puzzles to the tests.
45
67
 
46
68
  ## 0.13.0 - 2022-11-25
47
69
 
@@ -134,7 +156,7 @@ variables from snake_case to camelCase.
134
156
  - `to`: the normalized position code of the board after the move
135
157
  - `via`: the move notation to go from `from` to `to`
136
158
 
137
- ### 0.3.1 - 2022-01-07
159
+ ## 0.3.1 - 2022-01-07
138
160
 
139
161
  - Make a faster implementation of `trax.possible_locations()`
140
162
  - Display updates for tentative moves
package/Dockerfile ADDED
@@ -0,0 +1,5 @@
1
+ FROM node:19.5.0-slim
2
+ COPY . /src
3
+ WORKDIR /src
4
+ RUN npm install -g @slugbugblue/trax
5
+ ENTRYPOINT ["/usr/local/bin/trax"]
package/README.md CHANGED
@@ -187,9 +187,14 @@ always ready to share his insights on the mechanics of the game, as well as
187
187
  various approaches to encoding its complexities. His undeserved kindness and
188
188
  infinite patience in indulging my many questions can never be repaid.
189
189
 
190
+ Thanks for [Martin M. S. Pedersen][traxplayer] for contributing ideas, code, and
191
+ puzzles to this effort. He's been [curating Trax puzzles][puzzlebook] and
192
+ [coding solutions for Trax][gnutrax] for over two decades, and brings a lot of
193
+ expertise.
194
+
190
195
  ## License
191
196
 
192
- Copyright 2019-2022 Chad Transtrum
197
+ Copyright 2019-2023 Chad Transtrum
193
198
 
194
199
  Licensed under the Apache License, Version 2.0 (the "License"); you may not use
195
200
  the files in this project except in compliance with the License. You may obtain
@@ -217,12 +222,15 @@ David Smith and heirs, and are not to be used without permission.
217
222
  [docs-engine]: docs/engine.md
218
223
  [docs-analyst]: docs/analyst.md
219
224
  [env-path]: https://www.npmjs.com/package/env-paths
225
+ [gnutrax]: https://gnutrax.com
220
226
  [goldtoken]: https://goldtoken.com/
227
+ [puzzlebook]: https://gnutrax.com/book
221
228
  [repo]: https://gitlab.com/slugbugblue/trax
222
229
  [sbb]: https://slugbugblue.com/
223
230
  [traxbook]: http://traxgame.com/shop_book.php
224
231
  [traxgame]: http://traxgame.com/
225
232
  [traxhistory]: http://www.traxgame.com/about_history.php
233
+ [traxplayer]: mailto:traxplayer@gmail.com
226
234
  [traxpuzzles]: http://www.traxgame.com/games_puzzles.php
227
235
  [traxrules]: http://www.traxgame.com/about_rules.php
228
236
  [xdg]:
@@ -0,0 +1,42 @@
1
+ {
2
+ "0.17.0": {
3
+ "puzzles.firstmove.tutorial": {
4
+ "count": 16,
5
+ "ms": 2007
6
+ },
7
+ "puzzles.firstmove.beginner": {
8
+ "count": 16,
9
+ "ms": 2664
10
+ },
11
+ "puzzles.firstmove.tough": {
12
+ "count": 1,
13
+ "ms": 76
14
+ },
15
+ "puzzles.firstmove.total": {
16
+ "count": 33,
17
+ "ms": 4838
18
+ }
19
+ },
20
+ "0.18.0": {
21
+ "puzzles.firstmove.tutorial": {
22
+ "count": 16,
23
+ "ms": 10164
24
+ },
25
+ "puzzles.firstmove.beginner": {
26
+ "count": 15,
27
+ "ms": 17864
28
+ },
29
+ "puzzles.firstmove.tough": {
30
+ "count": 26,
31
+ "ms": 30349
32
+ },
33
+ "puzzles.firstmove.advanced": {
34
+ "count": 1,
35
+ "ms": 427
36
+ },
37
+ "puzzles.firstmove.total": {
38
+ "count": 58,
39
+ "ms": 59031
40
+ }
41
+ }
42
+ }
@@ -0,0 +1,190 @@
1
+ #!/usr/bin/env node
2
+
3
+ /** Let's try to solve the puzzles
4
+ * @copyright 2023
5
+ * @author Chad Transtrum <chad@transtrum.net>
6
+ * @license Apache-2.0
7
+ */
8
+
9
+ import fs from 'node:fs'
10
+ import process from 'node:process'
11
+ import { fileURLToPath } from 'node:url'
12
+
13
+ import { Trax } from '@slugbugblue/trax'
14
+ import { suggest } from '@slugbugblue/trax/analyst'
15
+ import { puzzles } from '@slugbugblue/trax/puzzles'
16
+ import { version } from '@slugbugblue/trax/version'
17
+
18
+ import * as tty from '@slugbugblue/trax/tty'
19
+
20
+ let STUMPED = false
21
+ const FINAL = {}
22
+
23
+ const record = (name, data) => {
24
+ FINAL['puzzles.firstmove.' + name] = data
25
+ }
26
+
27
+ const timeString = (ms) => {
28
+ if (ms < 1000) {
29
+ return (ms === Math.floor(ms) ? String(ms) : ms.toFixed(2)) + ' ms'
30
+ }
31
+
32
+ ms /= 1000
33
+ if (ms < 60) return ms.toFixed(3) + ' sec'
34
+
35
+ const min = Math.floor(ms / 60)
36
+ ms -= min * 60
37
+ return String(min) + ' min ' + ms.toFixed(3) + ' sec'
38
+ }
39
+
40
+ const time = (ms) => timeString(Date.now() - ms)
41
+
42
+ const out = (messages) => {
43
+ tty.outty(messages.join(' '))
44
+ }
45
+
46
+ const section = (title) => {
47
+ out([tty.color('===', 250), tty.color(title, 39), tty.color('===', 250)])
48
+ }
49
+
50
+ const success = (puzzle, ms) => {
51
+ out([tty.color('✓', 35), tty.color(puzzle, 250), tty.color(ms, 240)])
52
+ }
53
+
54
+ const puzzled = (puzzle, move) => {
55
+ tty.display(new Trax(puzzle.game, puzzle.notation))
56
+ tty.outty('Puzzled by ' + puzzle.id, 205)
57
+ out([
58
+ tty.color(' played', 250),
59
+ tty.color(move, 127),
60
+ tty.color('instead of', 250),
61
+ tty.color(puzzle.hint || puzzle.hints.join(' '), 39),
62
+ ])
63
+ STUMPED = true
64
+ }
65
+
66
+ const finalize = (name, ms, count) => {
67
+ out([
68
+ tty.color('Finished', 195),
69
+ tty.color(String(count) + ' ' + name + ' puzzles', 123),
70
+ tty.color('in ' + time(ms), 195),
71
+ ])
72
+ }
73
+
74
+ const solvePuzzleLevel = (name, level) => {
75
+ if (STUMPED) return 0
76
+ section(name + ' puzzles')
77
+ name = name.toLowerCase()
78
+ let count = 0
79
+ const begin = Date.now()
80
+ for (const puzzle of puzzles) {
81
+ if (puzzle.level !== level) continue
82
+ const start = Date.now()
83
+ const game = new Trax(puzzle.game, puzzle.notation)
84
+ const { move } = suggest(game).pick
85
+ const solved = puzzle.hint
86
+ ? move === puzzle.hint
87
+ : puzzle.hints
88
+ ? puzzle.hints.includes(move)
89
+ : false
90
+ if (solved) {
91
+ success(puzzle.id, time(start))
92
+ count++
93
+ record(name, { count, ms: Date.now() - begin })
94
+ } else {
95
+ puzzled(puzzle, move)
96
+ process.exitCode = level + 1
97
+ return count
98
+ }
99
+ }
100
+
101
+ finalize(name, begin, count)
102
+ return count
103
+ }
104
+
105
+ // Gather the benchmark data
106
+ let total = 0
107
+ const timer = Date.now()
108
+ total += solvePuzzleLevel('Tutorial', 0)
109
+ total += solvePuzzleLevel('Beginner', 1)
110
+ total += solvePuzzleLevel('Tough', 2)
111
+ total += solvePuzzleLevel('Advanced', 3)
112
+ total += solvePuzzleLevel('Genius', 4)
113
+
114
+ record('total', { count: total, ms: Date.now() - timer })
115
+ tty.outty(
116
+ 'Benchmark: ' + total + ' puzzles in ' + time(timer),
117
+ STUMPED ? 205 : 35,
118
+ )
119
+
120
+ tty.outty()
121
+
122
+ // Load the previous benchmarks
123
+ const FILE = new URL('benchmarks.json', import.meta.url)
124
+ let CONTENTS
125
+ try {
126
+ CONTENTS = fs.readFileSync(FILE, 'utf8')
127
+ } catch {
128
+ CONTENTS = '{}'
129
+ }
130
+
131
+ const BENCHMARK = JSON.parse(CONTENTS)
132
+
133
+ const PREV = Object.values(BENCHMARK).pop() || {}
134
+
135
+ // Report the benchmark data
136
+ const size = [0, 0, 0, 0, 0]
137
+ const REPORT = []
138
+ for (const name of Object.keys(FINAL)) {
139
+ const report = [
140
+ name,
141
+ String(FINAL[name].count) + ' puzzles',
142
+ 'in ' + timeString(FINAL[name].ms),
143
+ 'avg ' + timeString(FINAL[name].ms / FINAL[name].count),
144
+ ]
145
+ if (PREV[name]) {
146
+ const diff =
147
+ FINAL[name].ms / FINAL[name].count - PREV[name].ms / PREV[name].count
148
+ if (diff <= 0) {
149
+ report.push('-' + timeString(Math.abs(diff)), '35')
150
+ } else {
151
+ report.push('+' + timeString(diff), '208')
152
+ }
153
+ } else {
154
+ report.push('new', '250')
155
+ }
156
+
157
+ size[0] = Math.max(size[0], report[0].length)
158
+ size[1] = Math.max(size[1], report[1].length)
159
+ size[2] = Math.max(size[2], report[2].length)
160
+ size[3] = Math.max(size[3], report[3].length)
161
+ size[4] = Math.max(size[4], report[4].length)
162
+ REPORT.push(report)
163
+ }
164
+
165
+ for (const line of REPORT) {
166
+ out([
167
+ tty.color(line[0].padEnd(size[0]), 39),
168
+ tty.color(line[1].padStart(size[1]), 123),
169
+ tty.color(line[2].padStart(size[2]), 195),
170
+ tty.color(line[3].padEnd(size[3]), 245),
171
+ tty.color(line[4].padStart(size[4]), Number(line[5])),
172
+ ])
173
+ }
174
+
175
+ tty.outty()
176
+
177
+ // Save the benchmark data to a file
178
+ if (BENCHMARK[version]) {
179
+ tty.outty('Not overwriting existing benchmark data')
180
+ } else {
181
+ BENCHMARK[version] = FINAL
182
+ try {
183
+ fs.writeFileSync(FILE, JSON.stringify(BENCHMARK, null, 2), 'utf8')
184
+ const filename = fileURLToPath(FILE)
185
+ tty.outty('Saved benchmark data to ' + tty.color(filename, 74))
186
+ } catch (error) {
187
+ tty.outty('Error saving benchmark data', 205)
188
+ tty.outty(String(error.code) + ' ' + error.message, 127)
189
+ }
190
+ }
package/docs/analyst.md CHANGED
@@ -79,6 +79,7 @@ themselves. The following keys are present in each edge space representation:
79
79
  connectable pair of lines with this color.
80
80
  - `zb` and `zw`: The character used in the black or white string representation
81
81
  of this space.
82
+ - `idx`: The index of this edge space in the edge array.
82
83
 
83
84
  For the edge string, the following characters are used as a hopefully useful
84
85
  shorthand summary of the tiles surrounding the space. To make regex pattern
@@ -90,7 +91,7 @@ so that it appears as though all black tiles are white tiles.
90
91
  - `l`: both a black and a white line enter this space.
91
92
  - `a`: a white line enters this space, and the next white line in the edge is
92
93
  the other end of this line.
93
- - 'c': a white line enters this space, and the next white line in the edge forms
94
+ - `c`: a white line enters this space, and the next white line in the edge forms
94
95
  a connectable pair with this line.
95
96
  - `m`: both a black and a white line enter this space, and the next white line
96
97
  in the edge is the other end of the white line.
@@ -119,6 +120,11 @@ following keys:
119
120
  may or may not be exactly the same as the pattern.
120
121
  - `at`: The index of the edge string at which the pattern match began.
121
122
 
123
+ ##### `faulty`
124
+
125
+ Contains the same information as `threats` but for removed threats that were
126
+ found to be faulty.
127
+
122
128
  ##### `score`
123
129
 
124
130
  Computes a rough "score" of the current position using the list of threats. A
@@ -135,6 +141,14 @@ because the scores can change depending on who has the initiative.
135
141
 
136
142
  #### methods
137
143
 
144
+ ##### `count.w(level)`
145
+
146
+ Returns the number of threats for white at the given level.
147
+
148
+ ##### `count.b(level)`
149
+
150
+ Returns the number of threats for black at the given level.
151
+
138
152
  ##### `inCave(loc)`
139
153
 
140
154
  For any given location, returns true if that location is found within a cave.
@@ -188,7 +202,7 @@ suggestion.
188
202
 
189
203
  ## License
190
204
 
191
- Copyright 2019-2022 Chad Transtrum
205
+ Copyright 2022-2023 Chad Transtrum
192
206
 
193
207
  Licensed under the Apache License, Version 2.0 (the "License"); you may not use
194
208
  the files in this project except in compliance with the License. You may obtain
package/docs/engine.md CHANGED
@@ -42,7 +42,7 @@ class properties and methods:
42
42
  - `Trax.point(x, y)`: quick access to the [Point class][point-docs], for
43
43
  two-dimensional locations used internally by the Trax engine and which are
44
44
  required for some of the lower-level engine functions. Operates as a
45
- constructor (without `new`) and returns a Point instance.
45
+ constructor (without `new`) and returns a [Point instance][point-docs].
46
46
 
47
47
  - `Trax.colorOf(playerNumber)`: Player 1 is white and Player 2 is black. So this
48
48
  is a quick function to turn `1` into `'w'` and `2` into `'b'`. Quick and
@@ -414,7 +414,7 @@ tie.
414
414
 
415
415
  ## License
416
416
 
417
- Copyright 2019-2022 Chad Transtrum
417
+ Copyright 2019-2023 Chad Transtrum
418
418
 
419
419
  Licensed under the Apache License, Version 2.0 (the "License"); you may not use
420
420
  the files in this project except in compliance with the License. You may obtain
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@slugbugblue/trax",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "description": "Trax game engine and friends",
5
5
  "keywords": [
6
6
  "trax",
@@ -26,9 +26,11 @@
26
26
  "trax": "./src/cli.js"
27
27
  },
28
28
  "scripts": {
29
+ "benchmark": "node benchmark/puzzles.bench.js",
29
30
  "test": "xo && c8 ava",
31
+ "prepare": "husky install",
30
32
  "preversion": "npm test",
31
- "version": "genversion --es6 src/version.js && git add src/version.js",
33
+ "version": "genversion --es6 src/version.js && npm run benchmark || git add src/version.js benchmark/benchmarks.json",
32
34
  "postversion": "git push && git push --tags && npm publish"
33
35
  },
34
36
  "dependencies": {
@@ -40,6 +42,7 @@
40
42
  "ava": "^5.1.0",
41
43
  "c8": "^7.11.0",
42
44
  "genversion": "^3.0.2",
45
+ "husky": "^8.0.3",
43
46
  "prettier": "^2.6.1",
44
47
  "xo": "0.*"
45
48
  },
package/src/analyst.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /** Analyst module: provides analysis functions for Trax games.
2
- * @copyright 2022
2
+ * @copyright 2022-2023
3
3
  * @author Chad Transtrum <chad@transtrum.net>
4
4
  * @license Apache-2.0
5
5
  */
@@ -170,6 +170,7 @@ const pairUp = (rawEdge) => {
170
170
  */
171
171
  const label = (rawEdge) => {
172
172
  const final = { w: '', b: '', edge: rawEdge }
173
+ let index = 0
173
174
  for (const space of rawEdge) {
174
175
  for (const bw of ['b', 'w']) {
175
176
  const colors = new Set([bw, 'l'])
@@ -186,6 +187,9 @@ const label = (rawEdge) => {
186
187
  space['z' + bw] = z
187
188
  final[bw] += z
188
189
  }
190
+
191
+ space.idx = index
192
+ index += 1
189
193
  }
190
194
 
191
195
  return final
@@ -260,14 +264,21 @@ export const Analysis = class {
260
264
  #game
261
265
  #scores = {}
262
266
  #threats = undefined
267
+ #faulty = { b: [], w: [] }
268
+ #partial = false
263
269
 
264
- constructor(game) {
270
+ constructor(game, partial = false) {
265
271
  // Make a copy of the game
266
272
  this.#rules = game.rules
267
273
  this.#moves = game.moves
268
274
  this.#id = id()
269
275
  this.#notation = game.notation
270
276
  this.#game = new Trax(this.#rules, this.#moves, this.#id)
277
+ this.count = {
278
+ w: (level) => this.threats.w[level]?.length || 0,
279
+ b: (level) => this.threats.b[level]?.length || 0,
280
+ }
281
+ this.#partial = Boolean(partial)
271
282
  }
272
283
 
273
284
  get game() {
@@ -288,12 +299,20 @@ export const Analysis = class {
288
299
  return this.#edge
289
300
  }
290
301
 
302
+ /** Any faulty threats
303
+ * @returns {FaultyThreats}
304
+ */
305
+ get faulty() {
306
+ return this.#faulty
307
+ }
308
+
291
309
  /** All threats present in this position.
292
310
  * @returns {FoundThreatsCollection}
293
311
  */
294
312
  get threats() {
295
313
  if (!this.#threats) {
296
314
  this.#threats = { b: this.#applyThreats('b'), w: this.#applyThreats('w') }
315
+ if (!this.#partial) this.#validateThreats()
297
316
  }
298
317
 
299
318
  return this.#threats
@@ -528,7 +547,41 @@ export const Analysis = class {
528
547
  match: match[1], // The match is in the capturing group
529
548
  at: match.index,
530
549
  value: threat.value,
550
+ level: threat.depth,
551
+ }
552
+ }
553
+
554
+ /**
555
+ * @arg {FoundThreat} threat - a threat
556
+ * @arg {string} color - the player color
557
+ * @returns {InstanceType<typeof Point>[]} - locations relevant to the threat
558
+ */
559
+ #threatLocs(threat, color) {
560
+ const { edge } = this.edge
561
+ const max = edge.length
562
+ let index = threat.at
563
+ const locs = new Set([index])
564
+ while (threat.match.length > locs.size) {
565
+ index += 1
566
+ if (index >= max) index -= max
567
+ locs.add(index)
568
+ // Check to see if we have a connectable pair
569
+ if (edge[index]['p' + color]) {
570
+ const line = 'n' + color
571
+ const end = edge.find(
572
+ (space) =>
573
+ space.idx !== edge[index].idx && space[line] === edge[index][line],
574
+ )
575
+ if (end) {
576
+ // Add the opposite end and the space right before it, just in case
577
+ locs.add(end.idx)
578
+ locs.add(end.idx - 1)
579
+ }
580
+ }
531
581
  }
582
+
583
+ const value = [...locs].map((i) => new Point(edge[i]))
584
+ return value
532
585
  }
533
586
 
534
587
  /** Apply the threats to a color's regex-ready edge.
@@ -544,11 +597,10 @@ export const Analysis = class {
544
597
  const find = matches[threat.depth] || []
545
598
  for (const match of edge.matchAll(threat.rx)) {
546
599
  if (match.index >= length) break
600
+
601
+ // Avoid saving duplicates
547
602
  if (find.every((f) => f.at !== match.index || f.match !== match[1])) {
548
603
  find.push(this.#foundThreat(threat, match))
549
- } else {
550
- const duplicate = matches[99] || []
551
- duplicate.push(this.#foundThreat(threat, match))
552
604
  }
553
605
  }
554
606
 
@@ -558,6 +610,74 @@ export const Analysis = class {
558
610
  return matches
559
611
  }
560
612
 
613
+ /** Clean up the list of matches, removing any false positives. */
614
+ #validateThreats() {
615
+ for (const bw of ['w', 'b']) {
616
+ /** @type FoundThreats */
617
+ const matches = this.#threats[bw]
618
+ for (const key of Object.keys(matches)) {
619
+ const level = Number(key)
620
+ if (level < 2) continue
621
+ /** @type {FoundThreat[]} */
622
+ const verified = []
623
+ const threats = matches[key]
624
+ for (const threat of threats) {
625
+ if (this.#canActivateThreat(threat, bw, level)) {
626
+ verified.push(threat)
627
+ } else {
628
+ this.#faulty[bw].push(threat)
629
+ }
630
+ }
631
+
632
+ if (verified.length > 0) {
633
+ matches[key] = verified
634
+ } else {
635
+ delete matches[key]
636
+ }
637
+ }
638
+ }
639
+ }
640
+
641
+ /** Determine if the given threat can be activated for this color
642
+ * @param {FoundThreat} threat - The threat to try to activate
643
+ * @param {string} color - The color this match belongs to
644
+ * @param {number} level - The depth level of this threat
645
+ * @returns {boolean}
646
+ */
647
+ #canActivateThreat(threat, color, level) {
648
+ const attacks = this.#threats[color][1]?.length ?? 0
649
+ const other = this.#threats[Trax.other(color)][1]?.length ?? 0
650
+ const higher = this.#threats[color][level - 1]?.length ?? 0
651
+ for (const loc of this.#threatLocs(threat, color)) {
652
+ for (const type of this.game.possibleTiles(loc)) {
653
+ const game = new Trax(this.game.rules, this.game.moves)
654
+ const result = game.dropTile(type, loc)
655
+ if (!result.valid) continue
656
+
657
+ // This might be a bad shortcut, but if we already have an attack here
658
+ // we just assume that this threat is also valid ...
659
+ if (game.over && game.turn === Trax.playerNumber(color)) return true
660
+
661
+ const { threats } = new Analysis(game, true)
662
+
663
+ // If we give our opponent an attack, this is a faulty move
664
+ const faulty = (threats[Trax.other(color)][1]?.length ?? 0) - other
665
+ if (faulty > 0) continue
666
+
667
+ // Activating the threat means we get an attack and a threat
668
+ const newAttacks = (threats[color][1]?.length ?? 0) - attacks
669
+ if (level === 2) {
670
+ if (newAttacks > 1) return true
671
+ } else {
672
+ const newThreats = (threats[color][level - 1]?.length ?? 0) - higher
673
+ if (newAttacks > 0 && newThreats > 0) return true
674
+ }
675
+ }
676
+ }
677
+
678
+ return false
679
+ }
680
+
561
681
  // Make a reasonable inspect representation of the analysis
562
682
  [Symbol.for('nodejs.util.inspect.custom')](depth, options, inspect) {
563
683
  const sub = (object) => inspect(object, options).replace(/\n/g, '\n ')
package/src/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- /* Copyright 2022 Chad Transtrum
2
+ /* Copyright 2022-2023 Chad Transtrum
3
3
  *
4
4
  * Licensed under the Apache License, Version 2.0 (the "License");
5
5
  * you may not use this file except in compliance with the License.
@@ -326,6 +326,14 @@ const CLI = {
326
326
  const id = newId(DATA)
327
327
  const trax = new Trax(puzzle.game, puzzle.notation, 'cli')
328
328
  const players = ['challenger', 'puzzlebot']
329
+ const notes = []
330
+ if (puzzle.title) notes.push({ move: 0, note: puzzle.title })
331
+ if (puzzle.desc) notes.push({ move: 0, note: puzzle.desc })
332
+ const player = [0, 'white', 'black'][puzzle.player]
333
+ notes.push(
334
+ { move: 0, note: 'Starting position ' + puzzle.notation },
335
+ { move: 0, note: `${puzzle.id}: ${player} to win by move ${puzzle.max}` },
336
+ )
329
337
  DATA.games[id] = {
330
338
  id,
331
339
  puzzle: puzzle.id,
@@ -335,6 +343,7 @@ const CLI = {
335
343
  max: puzzle.max,
336
344
  players: trax.turn === 1 ? players : players.reverse(),
337
345
  moves: trax.moves.join(' '),
346
+ notes,
338
347
  }
339
348
 
340
349
  if (DATA.puzzles[puzzle.id]) {
@@ -1,4 +1,4 @@
1
- /* Copyright 2022 Chad Transtrum
1
+ /* Copyright 2022-2023 Chad Transtrum
2
2
  *
3
3
  * Licensed under the Apache License, Version 2.0 (the "License");
4
4
  * you may not use this file except in compliance with the License.
@@ -54,6 +54,17 @@ const listThreats = (threats, detailed) => {
54
54
  return summary.join(' ')
55
55
  }
56
56
 
57
+ const listFaulty = (faulty) => {
58
+ const summary = []
59
+ for (const threat of faulty) {
60
+ summary.push(
61
+ `[L${threat.level} ${threat.at}:${threat.threat}:${threat.match}]`,
62
+ )
63
+ }
64
+
65
+ return 'Faulty: ' + summary.join(' ')
66
+ }
67
+
57
68
  analyzeCmd.fn = (CLI, move) => {
58
69
  if (!String(CLI.GAME?.id)) {
59
70
  return CLI.error('No active game. Type "new" to start a game.')
@@ -75,18 +86,27 @@ analyzeCmd.fn = (CLI, move) => {
75
86
  return CLI.error('Game has ended.')
76
87
  }
77
88
 
78
- const { edge, threats, scores } = analyze(trax)
89
+ const { edge, threats, scores, faulty } = analyze(trax)
79
90
 
80
- if (detailed) CLI.out(edge.w)
91
+ if (detailed) CLI.out(CLI.color.white(edge.w))
81
92
  CLI.out(
82
93
  CLI.bubble('w', scores.w) +
83
94
  CLI.color.white(' ') +
84
95
  listThreats(threats.w, detailed),
85
96
  )
86
- if (detailed) CLI.out(edge.b)
97
+
98
+ if (detailed) {
99
+ if (faulty.w.length > 0) CLI.out(CLI.color.white('') + listFaulty(faulty.w))
100
+ CLI.out(CLI.color.black(edge.b))
101
+ }
102
+
87
103
  CLI.out(
88
104
  CLI.bubble('b', scores.b) +
89
105
  CLI.color.black(' ') +
90
106
  listThreats(threats.b, detailed),
91
107
  )
108
+
109
+ if (detailed && faulty.b.length > 0) {
110
+ CLI.out(CLI.color.black('') + listFaulty(faulty.b))
111
+ }
92
112
  }
@@ -1,4 +1,4 @@
1
- /* Copyright 2022 Chad Transtrum
1
+ /* Copyright 2022-2023 Chad Transtrum
2
2
  *
3
3
  * Licensed under the Apache License, Version 2.0 (the "License");
4
4
  * you may not use this file except in compliance with the License.
@@ -332,12 +332,7 @@ exportCmd.fn = (CLI, id, filename) => {
332
332
  if (source.url) content += '\n; ' + source.url
333
333
  }
334
334
 
335
- if (puzzle.title) content += '\n; ' + puzzle.title
336
- if (puzzle.desc) content += '\n; ' + puzzle.desc
337
- content += '\n; Starting position ' + puzzle.notation + '\n; '
338
- const players = [0, 'white', 'black']
339
- content += players[puzzle.player] + ' to win by move '
340
- content += String(puzzle.max) + '\n'
335
+ content += '\n'
341
336
  }
342
337
  }
343
338
 
package/src/cmds/view.js CHANGED
@@ -1,4 +1,4 @@
1
- /* Copyright 2022 Chad Transtrum
1
+ /* Copyright 2022-2023 Chad Transtrum
2
2
  *
3
3
  * Licensed under the Apache License, Version 2.0 (the "License");
4
4
  * you may not use this file except in compliance with the License.
@@ -53,6 +53,9 @@ viewCmd.fn = (CLI, move) => {
53
53
  CLI.bubble('b' + (trax.turn === 2 ? 'h' : ''), players[1]),
54
54
  )
55
55
  CLI.display(trax, players, undefined, show)
56
- if (puzzle) CLI.out(CLI.color('Puzzle ' + puzzle))
56
+ if (puzzle && (!note || !note.includes(puzzle))) {
57
+ CLI.out(CLI.color('Puzzle ' + puzzle))
58
+ }
59
+
57
60
  if (note) CLI.out(CLI.color.help(note))
58
61
  }
package/src/engine.js CHANGED
@@ -33,6 +33,13 @@ const notationRegex = /^([@a-z]+)(\d+)([/\\+])$/i
33
33
  const tileTypes = ['a', 'b', 'c', 'd', 'e', 'f']
34
34
 
35
35
  /** Tiles are represented by the letters a-f.
36
+ * a b c d e f
37
+ * +--#--+ +--#--+ +--#--+ +--o--+ +--o--+ +--o--+
38
+ * | # | | # | | # | | o | | o | | o |
39
+ * oo ## ooo#ooo ## oo oo ## ####### ## oo
40
+ * | o | | # | | o | | # | | o | | # |
41
+ * +--o--+ +--#--+ +--o--+ +--#--+ +--o--+ +--#--+
42
+ *
36
43
  * Slashes are represented by / \ or +.
37
44
  * @readonly
38
45
  * @type {Record<ValidTiles, Slash>}
package/src/puzzles.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /** This is a collection of puzzles.
2
- * @copyright 2022
2
+ * @copyright 2022-2023
3
3
  * @author Chad Transtrum <chad@transtrum.net>
4
4
  * @license Apache-2.0
5
5
  */
@@ -23,6 +23,10 @@ export const sources = {
23
23
  name: 'Donald G. Bailey',
24
24
  url: 'http://www.traxgame.com/games_puzzles.php',
25
25
  },
26
+ book1: {
27
+ name: 'Martin M. S. Pedersen',
28
+ url: 'https://gnutrax.com/book',
29
+ },
26
30
  }
27
31
 
28
32
  /** @type {Puzzle[]} */
@@ -396,7 +400,7 @@ export const puzzles = [
396
400
  level: 2,
397
401
  player: 2,
398
402
  max: 10,
399
- hint: 'A0\\',
403
+ hints: ['A0\\', 'D0/'],
400
404
  },
401
405
  {
402
406
  id: 'eb8.trx',
@@ -440,7 +444,7 @@ export const puzzles = [
440
444
  level: 2,
441
445
  player: 1,
442
446
  max: 11,
443
- hints: ['A0/', 'C4\\', 'D5/'],
447
+ hints: ['A0/', 'C4\\', 'D4/'],
444
448
  },
445
449
  {
446
450
  id: 'iw3.trx',
@@ -598,14 +602,15 @@ export const puzzles = [
598
602
  },
599
603
  {
600
604
  id: 'ib10.trx',
605
+ title: "Sucker's Opening",
601
606
  src: 'traxgame',
602
607
  game: 'traxloop',
603
- notation: '@0/ A0\\ @2/',
608
+ notation: '@0/ @1/ B0\\',
604
609
  icon: '0a:cd',
605
610
  level: 3,
606
611
  player: 2,
607
- max: 12,
608
- hint: 'C1/',
612
+ max: 14,
613
+ hint: '@2\\',
609
614
  },
610
615
  {
611
616
  id: 'ib11.trx',
@@ -745,7 +750,7 @@ export const puzzles = [
745
750
  game: 'traxloop',
746
751
  notation: '@0/ A2+ A3+ A4\\ B3\\ B4+ B5\\ C5/ C4+ D4/ D5+ E5/ E4/',
747
752
  icon: 'd:b:ba:aeecd:0fdec',
748
- level: 1,
753
+ level: 2,
749
754
  player: 2,
750
755
  max: 18,
751
756
  hints: ['B1+', 'B2/', 'D3+'],
@@ -758,8 +763,8 @@ export const puzzles = [
758
763
  icon: 'ecd',
759
764
  level: 3,
760
765
  player: 2,
761
- max: 14,
762
- hint: 'C2\\',
766
+ max: 16,
767
+ hints: ['A0+', 'C2\\'],
763
768
  },
764
769
  {
765
770
  id: 'hb4.trx',
@@ -805,6 +810,127 @@ export const puzzles = [
805
810
  max: 16,
806
811
  hint: 'B1+',
807
812
  },
813
+ {
814
+ id: 'book_01.trx',
815
+ src: 'book1',
816
+ game: 'traxloop',
817
+ notation: '@0/ @1+ B0/ C1+ D2/ C3/',
818
+ icon: 'dcba:bdcd:1dc',
819
+ level: 2,
820
+ player: 1,
821
+ max: 13,
822
+ hint: '@2/',
823
+ },
824
+ {
825
+ id: 'book_02.trx',
826
+ src: 'book1',
827
+ game: 'traxloop',
828
+ notation: '@0+ B1+ C1/ B0/ B0/ B0/ B0/ D5/ E5/ E6+ C4/ C3+ B6+',
829
+ icon: '0df:0ca:0df:cd:eecdc:0efae',
830
+ level: 2,
831
+ player: 2,
832
+ max: 18,
833
+ hints: ['D2\\', 'D3/'],
834
+ },
835
+ {
836
+ id: 'book_03.trx',
837
+ src: 'book1',
838
+ game: 'traxloop',
839
+ notation: '@0/ @1/ @1+ @1/ @1+ C0/ D1/ E1+ D0+ E1+ F1\\ E0+',
840
+ icon: '3e:1deef:1cdec:bdecd',
841
+ level: 3,
842
+ player: 1,
843
+ max: 19,
844
+ hint: 'C1\\',
845
+ },
846
+ {
847
+ id: 'book_04.trx',
848
+ src: 'book1',
849
+ game: 'traxloop',
850
+ notation: '@0+ B1/ @1/ A0/ C3/ A4+ @4+ @3/ a5\\',
851
+ icon: '1caf:1dec:dcafd:aeec:fd',
852
+ level: 3,
853
+ player: 2,
854
+ max: 16,
855
+ hint: 'C0/',
856
+ },
857
+ {
858
+ id: 'book_05.trx',
859
+ src: 'book1',
860
+ game: 'traxloop',
861
+ notation: '@0/ @1/ B0+ @2+ A0/ @2+',
862
+ icon: 'cd:ecde:0ecd',
863
+ level: 2,
864
+ player: 1,
865
+ max: 15,
866
+ hints: ['B4\\', 'C4/'],
867
+ },
868
+ {
869
+ id: 'book_07.trx',
870
+ src: 'book1',
871
+ game: 'traxloop',
872
+ notation: '@0/ A2+ A0+ @2\\ B0+ C1+ B0/ B0/ A2+',
873
+ icon: 'cdf:eca:0ee:0e:fd:0b',
874
+ level: 2,
875
+ player: 2,
876
+ max: 14,
877
+ hint: 'C6+',
878
+ },
879
+ {
880
+ id: 'book_08.trx',
881
+ src: 'book1',
882
+ game: 'traxloop',
883
+ notation: '@0/ @1/ A2/ A0/ B1/ @1+ @1/ A0/ @1/',
884
+ icon: 'cdfaf:0cbdc:0facd:0afdc',
885
+ level: 3,
886
+ player: 2,
887
+ max: 18,
888
+ hint: 'A0\\',
889
+ },
890
+ {
891
+ id: 'book_09.trx',
892
+ src: 'book1',
893
+ game: 'traxloop',
894
+ notation: '@0/ @1/ B0/ B0/ A0/ A5/ @3\\ @4/',
895
+ icon: '1d:1cd:dfdc:cacd:0fdc',
896
+ level: 3,
897
+ player: 1,
898
+ max: 15,
899
+ hint: 'A2\\',
900
+ },
901
+ {
902
+ id: 'book_10.trx',
903
+ src: 'book1',
904
+ game: 'traxloop',
905
+ notation: '@0/ A0+ A0/ A4+ A0/ A0/ B4+ C4/ @4+ @4/ @4/',
906
+ icon: '2caf:2dfa:2caf:cdeeec:fafd:2b',
907
+ level: 2,
908
+ player: 2,
909
+ max: 18,
910
+ hint: 'E6+',
911
+ },
912
+ {
913
+ id: 'book_11.trx',
914
+ src: 'book1',
915
+ game: 'traxloop',
916
+ notation: '@0+ B1+ C1/ C0/ B0/ @3+',
917
+ icon: '1d:1cd:eeec',
918
+ level: 1,
919
+ player: 1,
920
+ max: 9,
921
+ hint: 'B1\\',
922
+ },
923
+ {
924
+ id: 'book_12.trx',
925
+ src: 'book1',
926
+ game: 'traxloop',
927
+ notation: '@0+ B1+ C1/ C0/ B0/ D2+',
928
+ icon: '0d:0cde:eecd',
929
+ level: 3,
930
+ player: 1,
931
+ max: 15,
932
+ hint: 'B4\\',
933
+ },
808
934
  ]
809
935
 
810
936
  puzzles.sort(
package/src/threats.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /** Threats database: hand-coded threats
2
- * @copyright 2022
2
+ * @copyright 2022-2023
3
3
  * @author Chad Transtrum <chad@transtrum.net>
4
4
  * @license Apache-2.0
5
5
  */
@@ -89,7 +89,7 @@ export const ThreatsDB = class {
89
89
 
90
90
  add(depth, pattern, reforms = false) {
91
91
  this.#depth = Math.max(depth, this.#depth)
92
- if (pattern in this.#db) throw new Error('Duplicate pattern:' + pattern)
92
+ if (pattern in this.#db) throw new Error('Duplicate pattern: ' + pattern)
93
93
  this.#db[pattern] = new Threat(depth, pattern, reforms)
94
94
  return this.#db[pattern]
95
95
  }
@@ -175,6 +175,8 @@ for (const one of Object.keys(corners)) {
175
175
  threats.compose(3, one + '!bbrmW') // Same, but slightly different layout
176
176
  threats.compose(3, 'ab*lrb¡' + one) // Reverse
177
177
  threats.compose(3, 'alrbb¡' + one) // Reverse of different layout
178
+ threats.compose(4, one + '!b*b¡ra?lr') // Partial edge with attack into hollow
179
+ threats.compose(4, 'rm?wr!b*b¡' + one) // Reverse
178
180
 
179
181
  for (const two of Object.keys(corners)) {
180
182
  end = two.replace(/[aA]/, 'm').replace('C', 'p')
@@ -188,9 +190,14 @@ for (const one of Object.keys(corners)) {
188
190
  if (post) {
189
191
  threats.compose(2, one + post + '!' + end) // L
190
192
  threats.compose(3, one + post + '!br' + end) // Three-stage
193
+ threats.compose(3, one + post + 'r' + end) // Defense forms an L
191
194
  threats.compose(3, one + '!br¡?' + end) // Attack to form an L threat
192
195
  }
193
196
 
197
+ if (pre && post) {
198
+ threats.compose(3, start + 'r' + end) // Defense forms an L
199
+ }
200
+
194
201
  threats.compose(3, one + '!b*b¡' + two) // Edge threat
195
202
  }
196
203
  }
@@ -200,3 +207,17 @@ threats.add(3, 'Arlbmwr') // Reversed
200
207
 
201
208
  threats.add(4, 'rb¡c?w!br') // Connectable pair on an edge
202
209
  threats.add(4, 'ArlbmrW') // Wide cave ready for an attack
210
+
211
+ // Threats to solve puzzle iw1.trx
212
+ threats.add(5, 'A?rwarlrar?W') // Back to back white corners next to a corner
213
+ threats.add(4, 'A?rlr!bralrar?W') // ... activated ... becomes:
214
+ threats.add(4, 'A?rlrbrmrwar?W') // Corner surrounded by corners
215
+ threats.add(4, 'A?rlr!bramrar?W') // ... activated ...
216
+ threats.add(3, 'A?rlr!brm?wr!brmrW') // ... defended ...
217
+
218
+ // Threats to solve puzzle ib10.trx
219
+ threats.add(6, 'Arw!b?rarlr') // Sucker's Opening
220
+ threats.add(5, 'Arw!b?brbralr') // ... activated ... becomes:
221
+ threats.add(4, 'Arw!b?brb¡arbW') // Wide corner opposite nearly formed edge
222
+ threats.add(5, 'Arw!b?brb¡rmrW') // ... or hollow corner opposite same edge
223
+ threats.add(4, 'Arw!b?brb¡rmwr') // ... activated ...
package/src/types.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  /** Type information.
2
+ * @copyright 2022-2023
2
3
  * @author Chad Transtrum <chad@transtrum.net>
3
4
  * @license Apache-2.0
4
5
  */
@@ -44,6 +45,7 @@ type EdgeLocation = {
44
45
  pw?: boolean
45
46
  zb?: string
46
47
  zw?: string
48
+ idx?: number
47
49
  }
48
50
 
49
51
  type EdgeObject = {
@@ -53,7 +55,19 @@ type EdgeObject = {
53
55
  }
54
56
 
55
57
  /** Object representing a concrete threat found in a position. */
56
- type FoundThreat = { threat: string; match: string; at: number; value: number }
58
+ type FoundThreat = {
59
+ threat: string
60
+ match: string
61
+ at: number
62
+ value: number
63
+ level: number
64
+ }
65
+
66
+ /** Threats found for each color that cannot actually be activated. */
67
+ type FaultyThreats = {
68
+ b: FoundThreat[]
69
+ w: FoundThreat[]
70
+ }
57
71
 
58
72
  /** An object with keys representing each level, with arrays for each threat of
59
73
  * that level. */
package/src/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // Generated by genversion.
2
- export const version = '0.17.0'
2
+ export const version = '0.18.0'