@slugbugblue/trax 0.11.0 → 0.13.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,25 @@
1
1
  # @slugbugblue/trax
2
2
 
3
+ ## 0.13.0 - 2022-11-25
4
+
5
+ - Add move suggestions to `analyst.js` for CLI and bot use
6
+
7
+ - use `suggest(trax)` to get a suggestion for the current position
8
+ - added the `suggest` CLI command for quick access to a random suggestion
9
+
10
+ - Use font glyphs for text bubbles in `tty.js` only if one of the following
11
+ environment variables are defined and non-empty:
12
+
13
+ - `NERDFONT`, `POWERLINE`, `P9K_TTY`, `P9K_SSH`
14
+
15
+ - Begin work on documenting the javascript files using jsdoc, with the initial
16
+ focus on types so typescript-powered autocomplete can be more useful
17
+
18
+ ## 0.12.0 - 2022-10-28
19
+
20
+ - `point.in()` now accepts any two opposing corners to define the bounding box
21
+ - Fleshing out testing and debugging of `analyst.js`
22
+
3
23
  ## 0.11.0 - 2022-09-22
4
24
 
5
25
  - Breaking change: Refactor `play()` and `playMove()` into a single function
package/README.md CHANGED
@@ -5,8 +5,8 @@ Trax game-playing engine and etc.
5
5
  ## Description
6
6
 
7
7
  Trax is a two-player boardless board game invented by David Smith. For more
8
- information about Trax, including its rules and history, see the official
9
- website at [traxgame.com][traxgame].
8
+ information about Trax, including its [rules][traxrules] and
9
+ [history][traxhistory], see the official website at [traxgame.com][traxgame].
10
10
 
11
11
  This goal of this javascript project is to allow Trax to be played
12
12
  programmatically. It can be used as an engine in a web browser, for example at
@@ -95,7 +95,9 @@ Examples:
95
95
 
96
96
  ## API Usage
97
97
 
98
- The main Trax engine is provided as `engine.js`, which can be used as follows:
98
+ ### Trax engine
99
+
100
+ The main Trax engine is provided in `engine.js`, which can be used as follows:
99
101
 
100
102
  ```javascript
101
103
  import { Trax } from '@slugbugblue/trax'
@@ -115,12 +117,45 @@ let puzzle = new Trax('trax', '@0/ a0\\ @2/')
115
117
  puzzle.play('@2\\')
116
118
  ```
117
119
 
118
- For specifics, see the [engine.js documentation][engine-docs].
120
+ For specifics, see the [engine.js documentation][docs-engine].
121
+
122
+ ### Analysis
123
+
124
+ The analysis engine is provided in `analyst.js`, and it can be used as follows:
125
+
126
+ ```javascript
127
+ import { Trax } from '@slugbugblue/trax'
128
+ import { analyze } from '@slugbugblue/trax/analyst'
129
+
130
+ let trax = new Trax('trax', '@0/ @1\\')
131
+
132
+ let analysis = analyze(trax)
133
+
134
+ console.log('Edge analysis:', analysis.edge)
135
+ console.log('Threat analysis:', analysis.threats)
136
+ console.log('Score analysis:', analysis.scores)
137
+ ```
138
+
139
+ For additional information, refer to the [analyst.js
140
+ documentation][docs-analyst].
141
+
142
+ ### Threats database
143
+
144
+ The threats database is currently hand-coded in `threats.js`, so refer to that
145
+ file if you want to try to update the analysis engine's understanding of
146
+ threats.
147
+
148
+ ### Puzzles database
149
+
150
+ The puzzles database is provided in `puzzles.js`, and so far just contains
151
+ puzzles that I've been using to test the analysis engine, along with the list of
152
+ publicly available puzzles at [the traxgame.com puzzles page][traxpuzzles].
119
153
 
120
154
  ## Roadmap
121
155
 
122
- - Game position analysis
123
- - Puzzles and Puzzlebot
156
+ - Improve the threats database / analysis engine.
157
+ - Add a puzzle interface to the CLI.
158
+ - Add more puzzles.
124
159
 
125
160
  ## Support
126
161
 
@@ -174,12 +209,16 @@ David Smith and heirs, and are not to be used without permission.
174
209
 
175
210
  [ctrans]: mailto:chad@transtrum.net
176
211
  [dgb]: mailto:donald@traxgame.com
177
- [engine-docs]: docs/engine.md
212
+ [docs-engine]: docs/engine.md
213
+ [docs-analyst]: docs/analyst.md
178
214
  [env-path]: https://www.npmjs.com/package/env-paths
179
215
  [goldtoken]: https://goldtoken.com/
180
216
  [repo]: https://gitlab.com/slugbugblue/trax
181
217
  [sbb]: https://slugbugblue.com/
182
218
  [traxbook]: http://traxgame.com/shop_book.php
183
219
  [traxgame]: http://traxgame.com/
220
+ [traxhistory]: http://www.traxgame.com/about_history.php
221
+ [traxpuzzles]: http://www.traxgame.com/games_puzzles.php
222
+ [traxrules]: http://www.traxgame.com/about_rules.php
184
223
  [xdg]:
185
224
  https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html
package/docs/analyst.md CHANGED
@@ -7,7 +7,7 @@ CLI and for bots.
7
7
 
8
8
  ```javascript
9
9
  import { Trax } from '@slugbugblue/trax'
10
- import { analyze } from '@slugbugblue/trax/analyst'
10
+ import { analyze, suggest } from '@slugbugblue/trax/analyst'
11
11
 
12
12
  const trax = new Trax('trax', '@0/ @1/ B0\\')
13
13
 
@@ -16,6 +16,12 @@ const analysis = analyze(trax)
16
16
  console.log(JSON.stringify(analysis.edge, null, 2))
17
17
  console.log(JSON.stringify(analysis.threats, null, 2))
18
18
  console.log(JSON.stringify(analysis.scores, null, 2))
19
+
20
+ const suggestion = suggest(trax)
21
+
22
+ console.log(JSON.stringify(suggestion.options, null, 2))
23
+
24
+ trax.play(suggestion.pick.move)
19
25
  ```
20
26
 
21
27
  ## API
@@ -107,7 +113,7 @@ simple count of the threats at each level can give a good first guess at the
107
113
  "score" of the current position. Each element of the array is an object with the
108
114
  following keys:
109
115
 
110
- - `pattern`: The threat pattern as it was fed into the threats database. This is
116
+ - `threat`: The threat pattern as it was fed into the threats database. This is
111
117
  a simple edge string.
112
118
  - `match`: The portion of the actual edge string that matched the pattern, which
113
119
  may or may not be exactly the same as the pattern.
@@ -144,6 +150,42 @@ For any given location, determine the type of playable space. Returns one of:
144
150
  - `C`: This location is in a cave, and certain plays at this position result in
145
151
  illegal moves.
146
152
 
153
+ ### Suggestion class
154
+
155
+ Accessible from `suggest(game)`, this class is returned with several useful
156
+ pieces of information.
157
+
158
+ #### properties
159
+
160
+ ##### `all`
161
+
162
+ A list of all the moves analyzed, ordered by highest scoring move first.
163
+
164
+ Each move is an object with the following properties:
165
+
166
+ - `move`: the notation of the move
167
+ - `score`: the score of the move
168
+ - `analysis`: the analysis object of the position for this move
169
+
170
+ ##### `options`
171
+
172
+ A smaller subset of the `all` list, with only the moves that are worth
173
+ considering.
174
+
175
+ ##### `pick`
176
+
177
+ A random selection of one of the `options`.
178
+
179
+ ##### `ms`
180
+
181
+ The number of milliseconds spent performing the analyses required to create the
182
+ suggestion.
183
+
184
+ #### computed properties
185
+
186
+ - `best`: quick access to the top-scoring move
187
+ - `analyzed`: quick access to a count of the number of moves analyzed
188
+
147
189
  ## License
148
190
 
149
191
  Copyright 2019-2022 Chad Transtrum
package/docs/point.md CHANGED
@@ -89,9 +89,10 @@ Returns the distance between two points. This will never be a negative number.
89
89
  Returns `true` if the given point or point-like object is at the same location
90
90
  as the existing point.
91
91
 
92
- ##### `in(topLeft, bottomRight)`
92
+ ##### `in(a, b)`
93
93
 
94
94
  Pass in two points (or point-like objects) to define a rectangular bounding box.
95
+ Each point represents opposite corners of the box.
95
96
 
96
97
  Returns `true` if the calling point is located inside the box or along any of
97
98
  its edges (ie, both bounding points are considered "inside").
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@slugbugblue/trax",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "description": "Trax game engine and friends",
5
5
  "keywords": [
6
6
  "trax",
@@ -11,16 +11,15 @@
11
11
  "bugs": "https://gitlab.com/slugbugblue/trax/issues",
12
12
  "license": "Apache-2.0",
13
13
  "author": "Chad Transtrum <chad@transtrum.net>",
14
+ "types": "./src/types.d.ts",
14
15
  "exports": {
15
16
  ".": "./src/engine.js",
16
- "./analyst": "./src/analyst.js",
17
- "./analyst.js": "./src/analyst.js",
18
17
  "./point": "./src/point.js",
19
- "./point.js": "./src/point.js",
18
+ "./analyst": "./src/analyst.js",
19
+ "./puzzles": "./src/puzzles.js",
20
20
  "./threats": "./src/threats.js",
21
- "./threats.js": "./src/threats.js",
22
21
  "./tty": "./src/tty.js",
23
- "./tty.js": "./src/tty.js"
22
+ "./version": "./src/version.js"
24
23
  },
25
24
  "repository": "gitlab:slugbugblue/trax",
26
25
  "bin": {
@@ -38,7 +37,7 @@
38
37
  "yaml": "^2.0.0-11"
39
38
  },
40
39
  "devDependencies": {
41
- "ava": "^4.1.0",
40
+ "ava": "^5.1.0",
42
41
  "c8": "^7.11.0",
43
42
  "genversion": "^3.0.2",
44
43
  "prettier": "^2.6.1",
package/src/analyst.js CHANGED
@@ -15,14 +15,33 @@
15
15
 
16
16
  // Analyst module: provides analysis functions for Trax games
17
17
 
18
- import { Point } from '@slugbugblue/trax/point.js'
18
+ import { Point } from '@slugbugblue/trax/point'
19
19
  import { Trax } from '@slugbugblue/trax'
20
20
 
21
- import { threats } from '@slugbugblue/trax/threats.js'
21
+ import { threats } from '@slugbugblue/trax/threats'
22
22
 
23
23
  // Helpers
24
+ const id = () => {
25
+ const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'
26
+ let code = ''
27
+ while (code.length < 6) {
28
+ code += chars[Math.floor(Math.random() * chars.length)]
29
+ }
30
+
31
+ return 'analysis-' + code
32
+ }
33
+
34
+ // A sort function for hashes with a numeric score property
35
+ const byScoreDesc = (a, b) => b.score - a.score
36
+
37
+ // A filter function factory to remove scores not within x points of the highest score
38
+ const withinX = (highest, tolerance) => (s) =>
39
+ s.score === highest || highest - s.score <= tolerance
24
40
 
25
- // Make parseInt Array.map()-friendly
41
+ // Return a random element of an array
42
+ const randomDraw = (array) => array[Math.floor(Math.random() * array.length)]
43
+
44
+ // Make parseInt a little shorter
26
45
  const int = (s) => Number.parseInt(s, 10)
27
46
 
28
47
  const leftTurn = { d: 'r', l: 'd', r: 'u', u: 'l' }
@@ -151,36 +170,63 @@ const label = (rawEdge) => {
151
170
  return final
152
171
  }
153
172
 
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
+ }
180
+
154
181
  const attackScore = (from, level) => {
155
- const count = from[level]?.length ?? 0
182
+ const count = threatValues(from[level] || [])
156
183
  if (level < 1) return count
157
184
  return count * 10 ** (threats.maxDepth - level + 1)
158
185
  }
159
186
 
160
- // Shortcut to get the number of active threats
161
- const threatCount = (from) =>
162
- Object.entries(from).filter(([d, l]) => d > 0 && l.length > 0).length
187
+ // Shortcut to get the number of attacks
188
+ const attackCount = (from) => threatValues(from['1'] || [])
189
+
190
+ // Shortcut to get the number of passive threats
191
+ const threatCount = (from) => {
192
+ let count = 0
193
+ for (const [depth, array] of Object.entries(from)) {
194
+ count += int(depth) > 1 ? threatValues(array) : 0
195
+ }
196
+
197
+ return count
198
+ }
199
+
200
+ const passiveAttack = (from) =>
201
+ attackCount(from) > 1 || (attackCount(from) === 1 && threatCount(from) > 0)
202
+
203
+ const underAttack = (from) => attackCount(from) + threatCount(from) > 0
163
204
 
164
205
  // Analysis class
165
206
  export const Analysis = class {
207
+ /** @type TraxVariant */
208
+ #rules = 'trax'
209
+ #id = 'analysis'
210
+ #moves = []
211
+ #notation = ''
166
212
  #edge = undefined
167
213
  #game = {}
168
- #move = 0
169
- #save = {}
170
214
  #scores = {}
171
215
  #threats = undefined
172
216
 
173
217
  constructor(game) {
174
218
  // Make a copy of the game
175
- this.#game = game
176
- this.#save = game.save()
177
- this.#move = game.move
219
+ this.#rules = game.rules
220
+ this.#moves = game.moves
221
+ this.#id = id()
222
+ this.#notation = game.notation
223
+ this.#game = new Trax(this.#rules, this.#moves, this.#id)
178
224
  }
179
225
 
180
226
  get game() {
181
227
  // Return a fresh copy of the game as presented on init
182
- if (this.#game.move !== this.#move) {
183
- this.#game = new Trax().restore(this.#save)
228
+ if (this.#game.notation !== this.#notation) {
229
+ this.#game = new Trax(this.#rules, this.#moves, this.#id)
184
230
  }
185
231
 
186
232
  return this.#game
@@ -217,7 +263,7 @@ export const Analysis = class {
217
263
 
218
264
  for (const dir of Point.dirs) {
219
265
  let look = loc.dir(dir)
220
- while (look.in(topLeft, bottomRight) && !game.tileAt(loc)) {
266
+ while (look.in(topLeft, bottomRight) && !game.tileAt(look)) {
221
267
  look = look.dir(dir)
222
268
  }
223
269
 
@@ -242,17 +288,14 @@ export const Analysis = class {
242
288
  const inactive = this.threats[Trax.other(game.color)]
243
289
  let advantage = Number.POSITIVE_INFINITY
244
290
 
245
- // First examine the player who has the initiative
291
+ // First examine the active player
246
292
  for (const level of Object.keys(active).map((i) => int(i))) {
247
293
  if (level > 0 && level < advantage) advantage = level
248
294
  score += attackScore(active, level)
249
295
  }
250
296
 
251
297
  const passive = game.color !== color
252
- const calc =
253
- !passive ||
254
- threatCount(this.threats[color]) > 1 ||
255
- threatCount(this.threats[Trax.other(color)]) > 0
298
+ const calc = !passive || passiveAttack(inactive) || underAttack(active)
256
299
 
257
300
  // Calculate the effects of the passive player first
258
301
  for (const level of Object.keys(inactive).map((i) => int(i))) {
@@ -287,7 +330,7 @@ export const Analysis = class {
287
330
  // c = in a cave but has no restrictions (converted to n in edge)
288
331
  // C = a cave where some moves are not allowed (converted to c in edge)
289
332
  // n = normal space
290
- // x = we cannot play here
333
+ // x = no tile can be played here
291
334
  spaceType(loc) {
292
335
  const { game } = this
293
336
 
@@ -300,13 +343,25 @@ export const Analysis = class {
300
343
  }
301
344
 
302
345
  // Check to see if we have an invalid move
346
+ let someValid = false
347
+ let someInvalid = false
303
348
  for (const tile of game.possibleTiles(loc)) {
304
349
  const drop = game.dropTile(tile, loc, 'tentative')
305
- if (!drop.valid) {
306
- return 'C'
350
+ if (drop.valid) {
351
+ someValid = true
352
+ } else {
353
+ someInvalid = true
307
354
  }
308
355
  }
309
356
 
357
+ if (!someValid) {
358
+ return 'x'
359
+ }
360
+
361
+ if (someInvalid) {
362
+ return 'C'
363
+ }
364
+
310
365
  return 'c'
311
366
  }
312
367
 
@@ -381,6 +436,7 @@ export const Analysis = class {
381
436
  threat: threat.pattern,
382
437
  match: match[0],
383
438
  at: match.index,
439
+ value: threat.value,
384
440
  })
385
441
  }
386
442
  }
@@ -390,6 +446,90 @@ export const Analysis = class {
390
446
 
391
447
  return matches
392
448
  }
449
+
450
+ // Make a reasonable inspect representation of the analysis
451
+ [Symbol.for('nodejs.util.inspect.custom')](depth, options, inspect) {
452
+ const sub = (object) => inspect(object, options).replace(/\n/g, '\n ')
453
+ const style = options.stylize
454
+ if (depth < 0) return style('<Analysis>', 'special')
455
+ if (options.depth !== null) options.depth -= 1
456
+ const tbd = style('tbd', 'undefined')
457
+ let custom = style('Analysis', 'special') + ' {\n'
458
+ custom += ' game: ' + style(this.#rules, 'special')
459
+ custom += '(' + style(this.#moves, 'string') + '),\n'
460
+ custom += ' edge: '
461
+ if (this.#edge) {
462
+ custom += '{\n w: ' + style(this.#edge.w, 'string')
463
+ custom += ',\n b: ' + style(this.#edge.b, 'string')
464
+ custom += ',\n edge: ' + style('[rawEdge]', 'special')
465
+ custom += '\n }'
466
+ } else {
467
+ custom += tbd
468
+ }
469
+
470
+ custom += ',\n threats: '
471
+ custom += this.#threats ? sub(this.#threats) : tbd
472
+ custom += ',\n scores: '
473
+ custom += 'w' in this.#scores ? sub(this.#scores) : tbd
474
+ custom += ',\n score: '
475
+ const score = 'w' in this.#scores ? this.score : null
476
+ custom += score === null ? tbd : style(score, 'number')
477
+
478
+ return custom + '\n}'
479
+ }
393
480
  }
394
481
 
395
482
  export const analyze = (game) => new Analysis(game)
483
+
484
+ export const Suggestion = class {
485
+ all = []
486
+ options = []
487
+ pick = {}
488
+ ms = 0
489
+
490
+ constructor(analyzed, ms) {
491
+ this.all = analyzed.sort(byScoreDesc)
492
+ const best = analyzed[0] || { move: '', score: 0 }
493
+ this.options = analyzed.filter(withinX(best.score, 5))
494
+ this.pick = randomDraw(this.options) || best
495
+ this.ms = ms
496
+ }
497
+
498
+ get best() {
499
+ return this.all[0]
500
+ }
501
+
502
+ get analyzed() {
503
+ return this.all.length
504
+ }
505
+
506
+ // Make a nice representation for the nodejs REPL
507
+ [Symbol.for('nodejs.util.inspect.custom')](_, options) {
508
+ const style = options.stylize
509
+ let out = style(this.pick.move, 'string')
510
+ out += ' ' + style(this.pick.score, 'number') + ' ['
511
+ out += this.options
512
+ .slice(0, 5)
513
+ .map((s) => style(s.move, 'string') + ' ' + style(s.score, 'number'))
514
+ .join(', ')
515
+ out += '] ' + this.analyzed + ' in ' + this.ms + 'ms'
516
+ return out
517
+ }
518
+ }
519
+
520
+ export const suggest = (game) => {
521
+ const start = Date.now()
522
+ const analyzed = {}
523
+ const { color, moves, rules } = game
524
+ for (const move of game.possibleMoves()) {
525
+ const trax = new Trax(rules, moves)
526
+ trax.play(move)
527
+ const unique = trax.normalized
528
+ if (!analyzed[unique]) {
529
+ const analysis = analyze(trax)
530
+ analyzed[unique] = { move, score: analysis.scores[color], analysis }
531
+ }
532
+ }
533
+
534
+ return new Suggestion(Object.values(analyzed), Date.now() - start)
535
+ }