@slugbugblue/trax 0.10.1 → 0.11.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,12 +1,22 @@
1
1
  # @slugbugblue/trax
2
2
 
3
- ## 0.10.1 - 2022-06-12
3
+ ## 0.11.0 - 2022-09-22
4
+
5
+ - Breaking change: Refactor `play()` and `playMove()` into a single function
6
+ `play()`. `playMove()` is no longer available.
7
+ - Add a new function `playMoves()` to submit multiple moves at once
8
+ - Initial work on `analyst.js`, with documentation and a start on tests
9
+
10
+ - use `analyze(trax)` to perform an analysis
11
+ - added the `analyze` CLI command to see an analysis
12
+
13
+ ## 0.10.1 - 0.10.2 - 2022-06-12
4
14
 
5
15
  - Added an entry point for the `Point` class at `@slugbugblue/trax/point`
6
16
 
7
17
  ## 0.10.0 - 2022-06-12
8
18
 
9
- - Added `point.in(topLoft, bottomRight)` to see if a point is inside a
19
+ - Added `point.in(topLeft, bottomRight)` to see if a point is inside a
10
20
  rectangular bounded area
11
21
  - Minor coding style changes due to updated `xo` rules
12
22
 
package/README.md CHANGED
@@ -117,9 +117,10 @@ puzzle.play('@2\\')
117
117
 
118
118
  For specifics, see the [engine.js documentation][engine-docs].
119
119
 
120
- ## Related Projects
120
+ ## Roadmap
121
121
 
122
- - [TraxBot][traxbot]: Game position analysis and puzzlebot
122
+ - Game position analysis
123
+ - Puzzles and Puzzlebot
123
124
 
124
125
  ## Support
125
126
 
@@ -139,7 +140,7 @@ his generosity in allowing me to add the game to [GoldToken.com][goldtoken].
139
140
  Thanks to his widow Colleen Foley-Smith for extending that courtesy to allow me
140
141
  to include Trax on [slugbugblue.com][sbb] as well.
141
142
 
142
- I would also like to extend a huge heartfelt appreciation to the magnanimous
143
+ I would also like to express my huge heartfelt appreciation to the magnanimous
143
144
  [Donald G. Bailey][dgb], who, through his excellent book [Trax Strategy for
144
145
  Beginners][traxbook], taught me the basics (and more!) of Trax. I found him
145
146
  always ready to share his insights on the mechanics of the game, as well as
@@ -179,7 +180,6 @@ David Smith and heirs, and are not to be used without permission.
179
180
  [repo]: https://gitlab.com/slugbugblue/trax
180
181
  [sbb]: https://slugbugblue.com/
181
182
  [traxbook]: http://traxgame.com/shop_book.php
182
- [traxbot]: https://gitlab.com/slugbugblue/traxbot
183
183
  [traxgame]: http://traxgame.com/
184
184
  [xdg]:
185
185
  https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html
@@ -0,0 +1,160 @@
1
+ # analyst.js API documentation
2
+
3
+ This module provides additional analysis of Trax games, which can be used in the
4
+ CLI and for bots.
5
+
6
+ ## Example usage
7
+
8
+ ```javascript
9
+ import { Trax } from '@slugbugblue/trax'
10
+ import { analyze } from '@slugbugblue/trax/analyst'
11
+
12
+ const trax = new Trax('trax', '@0/ @1/ B0\\')
13
+
14
+ const analysis = analyze(trax)
15
+
16
+ console.log(JSON.stringify(analysis.edge, null, 2))
17
+ console.log(JSON.stringify(analysis.threats, null, 2))
18
+ console.log(JSON.stringify(analysis.scores, null, 2))
19
+ ```
20
+
21
+ ## API
22
+
23
+ ### Analysis class
24
+
25
+ Available either as `Analysis` to be used with `new`, or it can also be returned
26
+ using the `analyze` shortcut function.
27
+
28
+ Provides important properties and functions to understand the current state of a
29
+ Trax game.
30
+
31
+ #### constructor
32
+
33
+ ##### `new Analysis(game)`
34
+
35
+ ##### `analyze(game)`
36
+
37
+ Provide a Trax `game`, ideally with at least one move and not yet completed.
38
+
39
+ #### computed properties
40
+
41
+ ##### `game`
42
+
43
+ The Trax object that represents the position originally analyzed.
44
+
45
+ ##### `edge`
46
+
47
+ An object with two properties, `b` and `w`, which are string representations of
48
+ the traced edge, with one character per space, along with an array named `edge`
49
+ which contains the full internal details of each edge space. This object is
50
+ tightly tied to the internal representation of the edge tracing algorithm and as
51
+ such is subject to change in future iterations.
52
+
53
+ Edge tracing begins in the top left of the tiles (ie, space A0 or the nearest
54
+ playable space to it) and walks clockwise around the perimeter of the played
55
+ tiles, recording information about the lines that lead to each space. Note that
56
+ the edge spaces are just outside the existing tiles, not on the tiles
57
+ themselves. The following keys are present in each edge space representation:
58
+
59
+ - `x` and `y`: The location of the edge space, which can be directly used to
60
+ create a `Point` for use in any function that takes a location.
61
+ - `c`: The color of the line that leads into this space. This can be `b` or `w`
62
+ or `l` or `r`, the latter two representing both black and white lines ("left
63
+ turn") or neither ("right turn").
64
+ - `t`: The type of space, which is `n` for a normal space, `c` for a space
65
+ inside a cave that has limits on which tiles can be legally played in it, or
66
+ `x` if there are no legal moves available for this space, which can happen
67
+ both in caves or in 8x8 Trax when the game size limits have been reached.
68
+ - `w` and `b`: True if the next line of the same color is the other end of this
69
+ line.
70
+ - `nw` and `nb`: The number of the white and/or black lines coming to this
71
+ space, which can then be used to find the other end of the line.
72
+ - `pb` and `pw`: True if the next line of the same color is part of a
73
+ connectable pair of lines with this color.
74
+ - `zb` and `zw`: The character used in the black or white string representation
75
+ of this space.
76
+
77
+ For the edge string, the following characters are used as a hopefully useful
78
+ shorthand summary of the tiles surrounding the space. To make regex pattern
79
+ matching simpler, the black string is constructed by first swapping the colors,
80
+ so that it appears as though all black tiles are white tiles.
81
+
82
+ - `b`: a black line enters this space.
83
+ - `w`: a white line enters this space.
84
+ - `l`: both a black and a white line enter this space.
85
+ - `a`: a white line enters this space, and the next white line in the edge is
86
+ the other end of this line.
87
+ - 'c': a white line enters this space, and the next white line in the edge forms
88
+ a connectable pair with this line.
89
+ - `m`: both a black and a white line enter this space, and the next white line
90
+ in the edge is the other end of the white line.
91
+ - `p`: both a black and a white line enter this space, and the next while line
92
+ in the edge forms a connectable pair with the white line.
93
+ - `r`: no lines enter this space, but it represents a right-corner in the edge.
94
+
95
+ ##### `threats`
96
+
97
+ Analyzes the existing threats for the current game position. Returns an object
98
+ with both `b` and `w` keys, where each is an object with zero or more "depth"
99
+ keys. Threats of depth 0 are corners or connectable-pairs. Threats of depth 1
100
+ are immediate attacks. Threats of depth 2 are Ls. etc. Note that the threats are
101
+ not checked for faultiness at this time. In other words, there may be a faulty L
102
+ listed as a threat even if it is not currently possible to activate the L
103
+ successfully.
104
+
105
+ The "depth" keys are arrays of the threats at that level, which means that a
106
+ simple count of the threats at each level can give a good first guess at the
107
+ "score" of the current position. Each element of the array is an object with the
108
+ following keys:
109
+
110
+ - `pattern`: The threat pattern as it was fed into the threats database. This is
111
+ a simple edge string.
112
+ - `match`: The portion of the actual edge string that matched the pattern, which
113
+ may or may not be exactly the same as the pattern.
114
+ - `at`: The index of the edge string at which the pattern match began.
115
+
116
+ ##### `score`
117
+
118
+ Computes a rough "score" of the current position using the list of threats. A
119
+ positive score means that the player whose turn it is has a potential advantage,
120
+ with the larger the number of higher the advantage. A negative score indicates a
121
+ disadvantage.
122
+
123
+ ##### `scores`
124
+
125
+ Returns the same score as above, but packaged into an object with both a `b` and
126
+ `w` key, to more easily grab the score of a particular player without regard to
127
+ whose turn it is. Note that `b` will not necessarily be equal to `w` times -1
128
+ because the scores can change depending on who has the initiative.
129
+
130
+ #### methods
131
+
132
+ ##### `inCave(loc)`
133
+
134
+ For any given location, returns true if that location is found within a cave.
135
+
136
+ ##### `spaceType(loc)`
137
+
138
+ For any given location, determine the type of playable space. Returns one of:
139
+
140
+ - `n`: This location can be played in normally.
141
+ - `x`: A move is not valid in this location.
142
+ - `c`: This location is in a cave, but otherwise has no restrictions on its
143
+ play.
144
+ - `C`: This location is in a cave, and certain plays at this position result in
145
+ illegal moves.
146
+
147
+ ## License
148
+
149
+ Copyright 2019-2022 Chad Transtrum
150
+
151
+ Licensed under the Apache License, Version 2.0 (the "License"); you may not use
152
+ the files in this project except in compliance with the License. You may obtain
153
+ a copy of the License at
154
+
155
+ http://www.apache.org/licenses/LICENSE-2.0
156
+
157
+ Unless required by applicable law or agreed to in writing, software distributed
158
+ under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
159
+ CONDITIONS OF ANY KIND, either express or implied. See the License for the
160
+ specific language governing permissions and limitations under the License.
package/docs/engine.md CHANGED
@@ -167,8 +167,16 @@ each move that can be played will be legal.
167
167
 
168
168
  ##### `play(notation)`
169
169
 
170
- Attempts to play the given `notation`. Updates the game instance if the notation
171
- is valid for the current position.
170
+ ##### `play(moveNumber, notation)`
171
+
172
+ Attempts to play the given `notation`. If `moveNumber` is provided, add a
173
+ safeguard around the move to ensure that the move is being submitted against the
174
+ expected position, which can be useful in the context of asynchronous
175
+ communication and retries, such as when this engine is being used as the back
176
+ end of a web interface. For example, if no moves have been made yet in a game,
177
+ this can be called either as `play(1, '@0/')` or `play(1, '@0+')`.
178
+
179
+ Updates the game instance if the notation is valid for the current position.
172
180
 
173
181
  Returns an object with at minimum the following key/value pair:
174
182
 
@@ -179,16 +187,24 @@ If the move is valid, the following will also be part of the returned object:
179
187
  - `dropped`: a list of the tiles that were played by this move
180
188
  - `notation`: the resulting notation of the move
181
189
 
182
- ##### `playMove(moveNumber, notation)`
190
+ ##### `playMoves(moves)`
183
191
 
184
- Used to play a move, but with an extra safeguard around the status of the game
185
- so that if this is called multiple times with the same values, it will ignore
186
- subsequent calls, which can be useful in the context of asynchronous
187
- communication and retries, such as when this engine is being used as the back
188
- end of a web interface. For example, if no moves have been made yet in a game,
189
- this can be called either as `playMove(1, '@0/')` or `playMove(1, '@0+')`.
192
+ Can be used to enter multiple `moves` at once. Pass in either a string of
193
+ space-separated notations or an array of notations. In both cases move numbers
194
+ can be optionally included, in which case the moves are not made if the move
195
+ numbers do not match the board position. This function tries to be as forgiving
196
+ as possible of format.
197
+
198
+ For example, if a new trax object with zero moves were just created, each of the
199
+ following statements would put the game in the same position:
190
200
 
191
- Returns the same object structure as the `play()` method.
201
+ ```javascript
202
+ trax.playMoves('@0/ A0+ B1/')
203
+ trax.playMoves(['@0/', 'a0+', 'B1/'])
204
+ trax.playMoves('1 @0/ 2. a0+ 3 b1/')
205
+ trax.playMoves([1, '@0/', '2', 'A0+', '3.', 'B1/')
206
+ trax.playMoves(['1. @0/', 'a0+', 3, 'b1/')
207
+ ```
192
208
 
193
209
  ##### `provisionalMove(from, to, via)`
194
210
 
package/docs/point.md CHANGED
@@ -9,7 +9,7 @@ functionality here.
9
9
  ## Example usage
10
10
 
11
11
  ```javascript
12
- import { Point } from '@slugbugblue/trax/point'
12
+ import { Point } from '@slugbugblue/trax/point.js'
13
13
 
14
14
  const zero = new Point(0, 0)
15
15
 
@@ -49,6 +49,8 @@ an `x` and a `y` key with numeric values), or from individual `x` and `y`
49
49
  numbers. Once a point is created, it cannot be changed; however, you can use its
50
50
  functions to return other points relative to it.
51
51
 
52
+ #### properties
53
+
52
54
  ##### `x` and `y` immutable properties
53
55
 
54
56
  You can return the individual `x` or `y` property of a point by accessing it
@@ -64,6 +66,8 @@ points.
64
66
 
65
67
  Or, get an array of all four cardinal points around the existing point.
66
68
 
69
+ #### methods
70
+
67
71
  ##### `dir(direction)`
68
72
 
69
73
  Pass in a string named direction (ie, one of `up`, `down`, `left`, or `right`)
@@ -111,11 +115,13 @@ default. For example:
111
115
 
112
116
  ```javascript
113
117
  const zero = new Point(0, 0)
114
- const json = JSON.stringify(zero)
115
- const jsonzero = JSON.parse(json)
118
+ const json = JSON.stringify(zero) // '{"x":0,"y":0}'
119
+ const jsonzero = JSON.parse(json) // { x: 0, y: 0 }
116
120
 
117
121
  console.log(zero.eq(jsonzero)) // true
118
122
  jsonzero.eq(zero) // TypeError: jsonzero.eq is not a function
123
+
124
+ // However, the object can easily be turned back into a Point:
119
125
  const reconstituted = new Point(jsonzero)
120
126
  console.log(reconstituted.eq(zero)) // true
121
127
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@slugbugblue/trax",
3
- "version": "0.10.1",
3
+ "version": "0.11.0",
4
4
  "description": "Trax game engine and friends",
5
5
  "keywords": [
6
6
  "trax",
@@ -13,7 +13,14 @@
13
13
  "author": "Chad Transtrum <chad@transtrum.net>",
14
14
  "exports": {
15
15
  ".": "./src/engine.js",
16
- "./point": "./src/point.js"
16
+ "./analyst": "./src/analyst.js",
17
+ "./analyst.js": "./src/analyst.js",
18
+ "./point": "./src/point.js",
19
+ "./point.js": "./src/point.js",
20
+ "./threats": "./src/threats.js",
21
+ "./threats.js": "./src/threats.js",
22
+ "./tty": "./src/tty.js",
23
+ "./tty.js": "./src/tty.js"
17
24
  },
18
25
  "repository": "gitlab:slugbugblue/trax",
19
26
  "bin": {
@@ -22,7 +29,8 @@
22
29
  "scripts": {
23
30
  "test": "xo && c8 ava",
24
31
  "preversion": "npm test",
25
- "postversion": "hooks/postversion.sh src/version.js"
32
+ "version": "genversion --es6 src/version.js && git add src/version.js",
33
+ "postversion": "git push && git push --tags && npm publish"
26
34
  },
27
35
  "dependencies": {
28
36
  "env-paths": "^3.0.0",
package/src/analyst.js ADDED
@@ -0,0 +1,395 @@
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.
14
+ */
15
+
16
+ // Analyst module: provides analysis functions for Trax games
17
+
18
+ import { Point } from '@slugbugblue/trax/point.js'
19
+ import { Trax } from '@slugbugblue/trax'
20
+
21
+ import { threats } from '@slugbugblue/trax/threats.js'
22
+
23
+ // Helpers
24
+
25
+ // Make parseInt Array.map()-friendly
26
+ const int = (s) => Number.parseInt(s, 10)
27
+
28
+ const leftTurn = { d: 'r', l: 'd', r: 'u', u: 'l' }
29
+ const rightTurn = { d: 'l', l: 'u', r: 'd', u: 'r' }
30
+ const follow = { d: 'right', l: 'bottom', r: 'top', u: 'left' }
31
+
32
+ const edgeColor = (tile, dir) => {
33
+ return { d: 'ace', l: 'abd', r: 'bcf', u: 'def' }[dir].includes(tile)
34
+ ? 'w'
35
+ : 'b'
36
+ }
37
+
38
+ const encodeNumber = (n) => String.fromCodePoint(48 + n)
39
+
40
+ // Explore a raw edge and update cave information
41
+ const spelunk = (rawEdge) => {
42
+ for (let index = 1; index < rawEdge.length - 1; index++) {
43
+ if (rawEdge[index].t === 'C') {
44
+ const previous = rawEdge[index - 1] || {}
45
+ const next = rawEdge[index + 1] || {}
46
+ if (next.t === 'c') next.t = 'C'
47
+ if (previous.t === 'c') {
48
+ previous.t = 'C'
49
+ if (index > 1) index -= 2
50
+ }
51
+ }
52
+ }
53
+
54
+ for (const space of rawEdge) {
55
+ if (space.t === 'c') space.t = 'n'
56
+ if (space.t === 'C') space.t = 'c'
57
+ }
58
+
59
+ return rawEdge
60
+ }
61
+
62
+ // Combine hollows into a single item in the raw edge array
63
+ const hollowOut = (rawEdge) => {
64
+ for (let index = 1; index < rawEdge.length - 1; index++) {
65
+ const space = rawEdge[index]
66
+ if (space.c === 'l') {
67
+ Object.assign(space, rawEdge[index - 1], rawEdge[index + 1])
68
+ space.c = 'l'
69
+ rawEdge.splice(index + 1, 1)
70
+ rawEdge.splice(index - 1, 1)
71
+ }
72
+ }
73
+
74
+ return rawEdge
75
+ }
76
+
77
+ // Supposedly deep nesting is bad, so we broke this out of "pairUp" for
78
+ // "better" code, even though this is useless on its own ...
79
+ const findPair = (rawEdge, index, color, pairs) => {
80
+ const colors = new Set([color, 'l'])
81
+ const space = rawEdge[index]
82
+ const nColor = 'n' + color
83
+ for (const x of [1, 2]) {
84
+ let j = x + index
85
+ if (j >= rawEdge.length) j -= rawEdge.length
86
+ const next = rawEdge[j]
87
+ if (colors.has(next.c) && space[nColor] !== next[nColor]) {
88
+ const one = color + space[nColor]
89
+ const two = color + next[nColor]
90
+ pairs[one + '-' + two] = index
91
+ if (two + '-' + one in pairs) {
92
+ for (const y of [index, pairs[two + '-' + one]]) {
93
+ rawEdge[y]['p' + color] = true
94
+ }
95
+ }
96
+
97
+ // Only process one connection
98
+ break
99
+ } else if (next.c === 'r') {
100
+ // Cannot connect past an r space
101
+ break
102
+ }
103
+ }
104
+
105
+ for (let x = 1; x < rawEdge.length; x++) {
106
+ let j = index + x
107
+ if (j >= rawEdge.length) j -= rawEdge.length
108
+ if (colors.has(rawEdge[j].c)) {
109
+ space[color] = rawEdge[j][nColor] === space[nColor]
110
+ break
111
+ }
112
+ }
113
+ }
114
+
115
+ // Find connectable pairs in the raw edge
116
+ const pairUp = (rawEdge) => {
117
+ const pairs = {}
118
+ for (const [index, space] of rawEdge.entries()) {
119
+ for (const bw of ['w', 'b']) {
120
+ const colors = [bw, 'l']
121
+ if (colors.includes(space.c)) {
122
+ findPair(rawEdge, index, bw, pairs)
123
+ }
124
+ }
125
+ }
126
+
127
+ return rawEdge
128
+ }
129
+
130
+ // Label and finalize the raw edge
131
+ const label = (rawEdge) => {
132
+ const final = { w: '', b: '', edge: rawEdge }
133
+ for (const space of rawEdge) {
134
+ for (const bw of ['b', 'w']) {
135
+ const colors = new Set([bw, 'l'])
136
+ let z = 'w'
137
+ if (colors.has(space.c)) {
138
+ if (space['p' + bw]) z = 'c'
139
+ if (space[bw]) z = 'a'
140
+ if (space.c === 'l') z = { w: 'l', a: 'm', c: 'p' }[z]
141
+ } else {
142
+ z = space.c === 'r' ? 'r' : 'b'
143
+ }
144
+
145
+ if (space.t === 'x') z = 'x'
146
+ space['z' + bw] = z
147
+ final[bw] += z
148
+ }
149
+ }
150
+
151
+ return final
152
+ }
153
+
154
+ const attackScore = (from, level) => {
155
+ const count = from[level]?.length ?? 0
156
+ if (level < 1) return count
157
+ return count * 10 ** (threats.maxDepth - level + 1)
158
+ }
159
+
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
163
+
164
+ // Analysis class
165
+ export const Analysis = class {
166
+ #edge = undefined
167
+ #game = {}
168
+ #move = 0
169
+ #save = {}
170
+ #scores = {}
171
+ #threats = undefined
172
+
173
+ constructor(game) {
174
+ // Make a copy of the game
175
+ this.#game = game
176
+ this.#save = game.save()
177
+ this.#move = game.move
178
+ }
179
+
180
+ get game() {
181
+ // 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)
184
+ }
185
+
186
+ return this.#game
187
+ }
188
+
189
+ get edge() {
190
+ if (!this.#edge) {
191
+ // Cache the edge
192
+ this.#edge = this.#traceEdge()
193
+ }
194
+
195
+ return this.#edge
196
+ }
197
+
198
+ get threats() {
199
+ if (!this.#threats) {
200
+ this.#threats = { b: this.#applyThreats('b'), w: this.#applyThreats('w') }
201
+ }
202
+
203
+ return this.#threats
204
+ }
205
+
206
+ // Is a given location in a cave?
207
+ inCave(loc) {
208
+ const { game } = this
209
+
210
+ if (game.tileAt(loc)) {
211
+ return false
212
+ }
213
+
214
+ const topLeft = new Point(game.left, game.top)
215
+ const bottomRight = new Point(game.right, game.bottom)
216
+ let count = 0
217
+
218
+ for (const dir of Point.dirs) {
219
+ let look = loc.dir(dir)
220
+ while (look.in(topLeft, bottomRight) && !game.tileAt(loc)) {
221
+ look = look.dir(dir)
222
+ }
223
+
224
+ if (game.tileAt(look)) count++
225
+ }
226
+
227
+ return count > 2
228
+ }
229
+
230
+ #score(color) {
231
+ if (this.#scores[color] === undefined) {
232
+ const { game } = this
233
+ if (game.over) {
234
+ this.#scores[game.color] = Number.POSITIVE_INFINITY
235
+ this.#scores[Trax.other(game.color)] = Number.NEGATIVE_INFINITY
236
+ return this.#scores[color]
237
+ }
238
+
239
+ let score = 0
240
+
241
+ const active = this.threats[game.color]
242
+ const inactive = this.threats[Trax.other(game.color)]
243
+ let advantage = Number.POSITIVE_INFINITY
244
+
245
+ // First examine the player who has the initiative
246
+ for (const level of Object.keys(active).map((i) => int(i))) {
247
+ if (level > 0 && level < advantage) advantage = level
248
+ score += attackScore(active, level)
249
+ }
250
+
251
+ const passive = game.color !== color
252
+ const calc =
253
+ !passive ||
254
+ threatCount(this.threats[color]) > 1 ||
255
+ threatCount(this.threats[Trax.other(color)]) > 0
256
+
257
+ // Calculate the effects of the passive player first
258
+ for (const level of Object.keys(inactive).map((i) => int(i))) {
259
+ if (level > advantage) continue
260
+ let points = calc || level === 0 ? attackScore(inactive, level) : 1
261
+ if (level === advantage) points = passive ? Math.floor(points / 2) : 0
262
+ score -= points
263
+ }
264
+
265
+ if (passive) score *= -1
266
+
267
+ this.#scores[color] = score
268
+ }
269
+
270
+ return this.#scores[color]
271
+ }
272
+
273
+ get scores() {
274
+ return {
275
+ w: this.#score('w'),
276
+ b: this.#score('b'),
277
+ }
278
+ }
279
+
280
+ get score() {
281
+ return this.scores[this.game.color]
282
+ }
283
+
284
+ // Space type
285
+ //
286
+ // determine which type of space we have:
287
+ // c = in a cave but has no restrictions (converted to n in edge)
288
+ // C = a cave where some moves are not allowed (converted to c in edge)
289
+ // n = normal space
290
+ // x = we cannot play here
291
+ spaceType(loc) {
292
+ const { game } = this
293
+
294
+ if (!game.validLocation(loc)) {
295
+ return 'x'
296
+ }
297
+
298
+ if (!this.inCave(loc)) {
299
+ return 'n'
300
+ }
301
+
302
+ // Check to see if we have an invalid move
303
+ for (const tile of game.possibleTiles(loc)) {
304
+ const drop = game.dropTile(tile, loc, 'tentative')
305
+ if (!drop.valid) {
306
+ return 'C'
307
+ }
308
+ }
309
+
310
+ return 'c'
311
+ }
312
+
313
+ // Trace the edge of the playing area
314
+ #traceEdge() {
315
+ const { game } = this
316
+ const rawEdge = []
317
+ const lines = {}
318
+ const count = { w: 0, b: 0 }
319
+ let start = new Point(game.left, game.top)
320
+ let dir = 'r'
321
+
322
+ while (!game.tileAt(start)) {
323
+ start = start.right
324
+ }
325
+
326
+ let loc = start
327
+
328
+ while (game.move > 0 && !game.over) {
329
+ let space = loc.dir(leftTurn[dir])
330
+ if (rawEdge.length > 0 && space.eq(rawEdge[0])) break
331
+
332
+ const color = edgeColor(game.tileAt(loc), leftTurn[dir])
333
+ const end = game.follow(color, loc, follow[dir]).loc
334
+ const type = this.spaceType(space)
335
+ const tile = { x: space.x, y: space.y, c: color, t: type }
336
+ const id = color + end.x + ':' + end.y
337
+ const nColor = 'n' + color
338
+
339
+ if (id in lines) {
340
+ tile[nColor] = lines[id][nColor]
341
+ } else {
342
+ tile[nColor] = encodeNumber(count[color])
343
+ count[color]++
344
+ }
345
+
346
+ lines[color + space.x + ':' + space.y] = tile
347
+ rawEdge.push(tile)
348
+
349
+ const next = loc.dir(dir)
350
+
351
+ if (game.tileAt(next)) {
352
+ loc = next.dir(leftTurn[dir])
353
+ if (game.tileAt(loc)) {
354
+ rawEdge.push({ x: space.x, y: space.y, c: 'l', t: type })
355
+ dir = leftTurn[dir]
356
+ } else {
357
+ loc = next
358
+ }
359
+ } else {
360
+ space = next.dir(leftTurn[dir])
361
+ const t = this.inCave(space) ? 'c' : 'n'
362
+ rawEdge.push({ x: space.x, y: space.y, c: 'r', t })
363
+ dir = rightTurn[dir]
364
+ }
365
+ }
366
+
367
+ return label(pairUp(hollowOut(spelunk(rawEdge))))
368
+ }
369
+
370
+ // Apply the threats to a color's regex-ready edge
371
+ #applyThreats(color) {
372
+ const matches = {}
373
+ const { length } = this.edge[color]
374
+ const edge = this.edge[color].repeat(2)
375
+ for (const threat of threats.list) {
376
+ const find = matches[threat.depth] || []
377
+ for (const match of edge.matchAll(threat.rx)) {
378
+ if (match.index >= length) break
379
+ if (find.every((f) => f.at !== match.index || f.match !== match[0])) {
380
+ find.push({
381
+ threat: threat.pattern,
382
+ match: match[0],
383
+ at: match.index,
384
+ })
385
+ }
386
+ }
387
+
388
+ if (find.length > 0) matches[threat.depth] = find
389
+ }
390
+
391
+ return matches
392
+ }
393
+ }
394
+
395
+ export const analyze = (game) => new Analysis(game)
package/src/cli.js CHANGED
@@ -26,6 +26,7 @@ import { version } from './version.js'
26
26
  import * as tty from './tty.js'
27
27
 
28
28
  // Load all the commands as individual plugins
29
+ import { analyzeCmd } from './cmds/analyze.js'
29
30
  import { deleteCmd } from './cmds/delete.js'
30
31
  import { helpCmd } from './cmds/help.js'
31
32
  import {
@@ -77,6 +78,7 @@ const quitCmd = {
77
78
  }
78
79
 
79
80
  const plugins = [
81
+ analyzeCmd,
80
82
  deleteCmd,
81
83
  exportCmd,
82
84
  helpCmd,
@@ -173,6 +175,14 @@ const DATA = { games: {} }
173
175
 
174
176
  // CLI context object for plugins
175
177
  const CLI = {
178
+ // Notation manipulation
179
+ fixNotation(move) {
180
+ if (move.endsWith('b')) return move.slice(0, -1) + '\\'
181
+ if (move.endsWith('s')) return move.slice(0, -1) + '/'
182
+ if (move.endsWith('p')) return move.slice(0, -1) + '+'
183
+ return move
184
+ },
185
+
176
186
  // Quick access to all stored games
177
187
  get GAMES() {
178
188
  return DATA.games || {}
@@ -0,0 +1,92 @@
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.
14
+ */
15
+
16
+ // CLI analyze command
17
+
18
+ import { analyze } from '../analyst.js'
19
+ import { Trax } from '../engine.js'
20
+
21
+ const notationRx = /^[@a-z]+\d+[bps/\\+]$/i
22
+
23
+ export const analyzeCmd = {
24
+ name: 'analyze',
25
+ args: '[<move>]',
26
+ comp: '<play>',
27
+ desc: 'analyze a position',
28
+ help: [
29
+ 'Analyze the current position of the currently selected game, or pass in a',
30
+ 'move to analyze the result of that move.',
31
+ ],
32
+ }
33
+
34
+ const threatNames = {
35
+ 0: 'corners',
36
+ 1: 'attacks',
37
+ 2: 'Ls',
38
+ }
39
+
40
+ const listThreats = (threats, detailed) => {
41
+ const summary = []
42
+ for (const level of Object.keys(threats || {})) {
43
+ const name = (threatNames[level] || `stage-${level} threats`) + ': '
44
+ const items = []
45
+ if (detailed) {
46
+ for (const item of threats[level]) {
47
+ items.push('[' + item.at + ':' + item.match + ']')
48
+ }
49
+ }
50
+
51
+ summary.push(name + threats[level].length + ' ' + items.join(' '))
52
+ }
53
+
54
+ return summary.join(' ')
55
+ }
56
+
57
+ analyzeCmd.fn = (CLI, move) => {
58
+ if (!String(CLI.GAME?.id)) {
59
+ return CLI.error('No active game. Type "new" to start a game.')
60
+ }
61
+
62
+ let trax = CLI.TRAX
63
+
64
+ const detailed = 'detail'.startsWith(move) || 'debug'.startsWith(move)
65
+
66
+ if (notationRx.test(move)) {
67
+ move = CLI.fixNotation(move)
68
+ CLI.do('try', move)
69
+ trax = new Trax(trax.rules, trax.moves, 'cli')
70
+ const play = trax.dropTile(move)
71
+ if (!play.valid || trax.over) return
72
+ }
73
+
74
+ if (trax.over) {
75
+ return CLI.error('Game has ended.')
76
+ }
77
+
78
+ const { edge, threats, scores } = analyze(trax)
79
+
80
+ if (detailed) CLI.out(edge.w)
81
+ CLI.out(
82
+ CLI.bubble('w', scores.w) +
83
+ CLI.color.white(' ') +
84
+ listThreats(threats.w, detailed),
85
+ )
86
+ if (detailed) CLI.out(edge.b)
87
+ CLI.out(
88
+ CLI.bubble('b', scores.b) +
89
+ CLI.color.black(' ') +
90
+ listThreats(threats.b, detailed),
91
+ )
92
+ }
@@ -46,13 +46,6 @@ export const tryCmd = {
46
46
  ],
47
47
  }
48
48
 
49
- const fixNotation = (move) => {
50
- if (move.endsWith('b')) return move.slice(0, -1) + '\\'
51
- if (move.endsWith('s')) return move.slice(0, -1) + '/'
52
- if (move.endsWith('p')) return move.slice(0, -1) + '+'
53
- return move
54
- }
55
-
56
49
  playCmd.fn = (CLI, ...moves) => {
57
50
  if (moves.length === 0 || !moves[0]) {
58
51
  CLI.error('You must provide a move.')
@@ -73,9 +66,9 @@ playCmd.fn = (CLI, ...moves) => {
73
66
  for (let move of moves) {
74
67
  if (notationRx.test(move)) {
75
68
  const moveNumber = CLI.TRAX.move
76
- move = fixNotation(move)
69
+ move = CLI.fixNotation(move)
77
70
  if (checkMove) {
78
- CLI.TRAX.playMove(checkMove, move)
71
+ CLI.TRAX.play(checkMove, move)
79
72
  } else {
80
73
  CLI.TRAX.play(move)
81
74
  }
@@ -116,7 +109,7 @@ tryCmd.fn = (CLI, move) => {
116
109
  }
117
110
 
118
111
  if (notationRx.test(move)) {
119
- move = fixNotation(move)
112
+ move = CLI.fixNotation(move)
120
113
  const play = CLI.TRAX.dropTile(move, undefined, 'tentative')
121
114
  if (play.valid || play.dropped.length > 0) {
122
115
  CLI.display(CLI.TRAX, CLI.GAME.players, move)
package/src/engine.js CHANGED
@@ -15,15 +15,17 @@
15
15
 
16
16
  // Trax internals. Woot.
17
17
 
18
- // Tile type names are determined by listing the color of line at each edge, clockwise
19
- // and then sorted alphabetically and given a single letter name, so 'bbww' becomes 'a'
20
- // which gives us six different tile names: a-f
18
+ // Tile type names are determined by listing the line color at each edge,
19
+ // starting from the top and going clockwise, and then sorted alphabetically
20
+ // and given a single letter name, so 'bbww' becomes 'a', which gives us six
21
+ // different tile names: a-f
21
22
 
22
23
  import { Point } from './point.js'
23
24
 
24
25
  // Fun trax helper constants
25
26
 
26
27
  const zero = new Point(0, 0)
28
+ const moveNumberRegex = /^(\d+)[.):]?$/
27
29
  const notationRegex = /^([@a-z]+)(\d+)([/\\+])$/i
28
30
 
29
31
  const slash = { a: '\\', b: '+', c: '/', d: '/', e: '+', f: '\\' }
@@ -127,12 +129,7 @@ export const Trax = class {
127
129
  this.invalid = false
128
130
  // Moves might be a notation string
129
131
  if (moves) {
130
- if (typeof moves === 'string') moves = moves.split(/\s+/)
131
- for (const move of moves) {
132
- if (notationRegex.test(move)) {
133
- this.dropTile(move)
134
- }
135
- }
132
+ this.playMoves(moves)
136
133
  }
137
134
  }
138
135
 
@@ -435,15 +432,43 @@ export const Trax = class {
435
432
  }
436
433
  }
437
434
 
438
- play(notation) {
439
- // Fast and easy way to play a move
435
+ play(moveNumber, notation) {
436
+ // Generic API call for playing a move with move number safety
437
+ // or, alternately, pass only a single value, notation, for a quick play
438
+ if (notation) {
439
+ if (moveNumber !== this.move + 1) return { valid: false }
440
+ } else {
441
+ notation = moveNumber
442
+ }
443
+
440
444
  return this.dropTile(notation)
441
445
  }
442
446
 
443
- playMove(moveNumber, notation) {
444
- // Generic API call for playing a move with move number safety
445
- if (moveNumber === this.move + 1) return this.dropTile(notation)
446
- return { valid: false }
447
+ playMoves(moves) {
448
+ // Play one or more moves, from a string or a list of moves
449
+ // If move numbers are included, ensure they are accurate
450
+ if (Array.isArray(moves)) {
451
+ moves = moves.join(' ')
452
+ }
453
+
454
+ moves = moves.replace(/\n/g, ' ').split(/\s+/)
455
+
456
+ let moveNumber = 0
457
+ for (const move of moves) {
458
+ if (moveNumberRegex.test(move)) {
459
+ moveNumber = Number(move.replace(/\D/g, ''))
460
+ }
461
+
462
+ if (notationRegex.test(move)) {
463
+ if (moveNumber) {
464
+ this.play(moveNumber, move)
465
+ } else {
466
+ this.play(move)
467
+ }
468
+
469
+ moveNumber = 0
470
+ }
471
+ }
447
472
  }
448
473
 
449
474
  updateNotation(notation) {
package/src/threats.js ADDED
@@ -0,0 +1,164 @@
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.
14
+ */
15
+
16
+ // Threats database: hand-coded threats
17
+
18
+ const REGEX = {
19
+ a: '[am]', // White that pairs with the next white
20
+ c: '[cp]', // White that forms a connectable pair with next white
21
+ l: '[lmp]', // White in a hollow
22
+ w: '[aclmpw]', // Any white
23
+ '!': '(?:b[acw])*?', // Ripple black/white
24
+ '¡': '(?:[acw]b)*?', // Ripple white/black
25
+ '*': 'b*?', // Zero or more black pieces
26
+ '?': 'b?', // Zero or one black piece
27
+ }
28
+
29
+ class CustomInspectableThreat {
30
+ #depth = 0
31
+ #pattern = ''
32
+ #rx = null
33
+
34
+ constructor(depth, pattern) {
35
+ this.#depth = depth
36
+ this.#pattern = pattern
37
+
38
+ let rx = ''
39
+ for (const char of pattern) {
40
+ rx += REGEX[char] || char
41
+ }
42
+
43
+ this.#rx = new RegExp(rx, 'g')
44
+ }
45
+
46
+ get depth() {
47
+ return this.#depth
48
+ }
49
+
50
+ get pattern() {
51
+ return this.#pattern
52
+ }
53
+
54
+ get rx() {
55
+ return this.#rx
56
+ }
57
+
58
+ // To make life at the REPL a little easier
59
+ [Symbol.for('nodejs.util.inspect.custom')](_, out) {
60
+ const style = out.stylize
61
+ return [
62
+ '[' + style('Threat', 'special') + ']',
63
+ '{',
64
+ style(this.#pattern, 'string') + ':',
65
+ style(this.#depth, 'number'),
66
+ '}',
67
+ ].join(' ')
68
+ }
69
+ }
70
+
71
+ class CustomInspectableThreatsDB {
72
+ #db = {}
73
+ #depth = 0
74
+
75
+ constructor(corners) {
76
+ for (const corner of corners) {
77
+ this.add(0, corner)
78
+ }
79
+ }
80
+
81
+ add(depth, pattern) {
82
+ this.#depth = Math.max(depth, this.#depth)
83
+ this.#db[pattern] = new CustomInspectableThreat(depth, pattern)
84
+ return this.#db[pattern]
85
+ }
86
+
87
+ level(depth) {
88
+ return Object.values(this.#db).filter((p) => p.depth === depth)
89
+ }
90
+
91
+ get list() {
92
+ return Object.values(this.#db)
93
+ }
94
+
95
+ get maxDepth() {
96
+ return this.#depth
97
+ }
98
+
99
+ // To make life at the REPL a little easier
100
+ [Symbol.for('nodejs.util.inspect.custom')](_, out) {
101
+ const style = out.stylize
102
+ const inspection = '[' + style('Threats', 'special') + '] { '
103
+ let total = 0
104
+ const keys = []
105
+
106
+ for (let x = 0; x <= this.#depth; x++) {
107
+ const count = this.level(x).length
108
+ keys.push(style(x, 'string') + ': ' + style(count, 'number'))
109
+ total += count
110
+ }
111
+
112
+ return inspection + keys.join(', ') + ' } total: ' + total
113
+ }
114
+ }
115
+
116
+ const pre = true
117
+ const post = true
118
+ const corners = {
119
+ arw: { pre, post }, // Simple corner
120
+ abrw: { post }, // Wide-first corner
121
+ arbw: { pre }, // Wide-second corner
122
+ abrbw: {}, // Wide corner
123
+ 'ab?bw': { pre, post }, // Flat corner and wide flat corner
124
+ cw: { pre, post }, // Connectable pair
125
+ cbw: {}, // Wide connectable pair
126
+ }
127
+
128
+ export const threats = new CustomInspectableThreatsDB(Object.keys(corners))
129
+
130
+ // Attacks
131
+ const attacks = [
132
+ 'a?w', // Simple attack and wide attack
133
+ ]
134
+ attacks.map((a) => threats.add(1, a))
135
+
136
+ // Re-forming attacks
137
+ const reform = [
138
+ 'rm?lr', // Cave/wide cave attack
139
+ 'rp?lr',
140
+ 'rbm?lbr', // Deep cave/deep wide cave attack (impossibly rare?)
141
+ 'rbp?lbr',
142
+ 'ralmrw', // Attack at top of cave
143
+ 'arlmwr', // Reversed
144
+ ]
145
+ reform.map((r) => threats.add(2, r))
146
+
147
+ // Create multi-stage threats
148
+ for (const one of Object.keys(corners)) {
149
+ for (const two of Object.keys(corners)) {
150
+ // L threats
151
+ const [before, after] = [corners[one].post, corners[two].pre]
152
+ if (before || after) {
153
+ const start = one.replace('w', 'l')
154
+ threats.add(2, start + (before ? '?¡' : '¡') + two)
155
+ const end = two.replace('a', 'm').replace('c', 'p')
156
+ threats.add(2, one + (after ? '?!' : '!') + end)
157
+ }
158
+
159
+ // Edge threats
160
+ threats.add(3, one + '!b*b¡' + two)
161
+ }
162
+ }
163
+
164
+ // Create edge threats
package/src/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // Generated by genversion.
2
- export const version = '0.10.1'
2
+ export const version = '0.11.0'