@slugbugblue/trax 0.15.0 → 0.17.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,19 @@
1
1
  # @slugbugblue/trax
2
2
 
3
+ ## 0.17.0 - 2022-12-29
4
+
5
+ - The `import` CLI command now loads comments from a .trx file
6
+
7
+ ## 0.16.0 - 2022-12-27
8
+
9
+ - Added a `notes` command to the CLI to add comments to a game
10
+
11
+ - all comments are included in the output generated by the `export` command
12
+ - the latest comment can be used as a text filter by the `list` command
13
+ - the latest comment is displayed by the `view` and `list` commands
14
+
15
+ - Minor analysis improvements
16
+
3
17
  ## 0.15.0 - 2022-12-14
4
18
 
5
19
  - Added shared `puzzles` commands to the CLI:
package/README.md CHANGED
@@ -13,7 +13,7 @@ Trax is a two-player boardless board game invented by David Smith. For more
13
13
  information about Trax, including its [rules][traxrules] and
14
14
  [history][traxhistory], see the official website at [traxgame.com][traxgame].
15
15
 
16
- This goal of this javascript project is to allow Trax to be played
16
+ The goal of this javascript project is to allow Trax to be played
17
17
  programmatically. It can be used as an engine in a web browser, for example at
18
18
  [slugbugblue.com][sbb]; or in nodejs, for example by using the `trax` CLI
19
19
  provided as part of this project.
@@ -27,7 +27,7 @@ changes to the engine should meet the same standard.
27
27
  # Install into a javascript project
28
28
  npm install @slugbugblue/trax
29
29
 
30
- # Or install globally to use the trax CLI
30
+ # Or install globally to use the trax CLI more easily
31
31
  npm install -g @slugbugblue/trax
32
32
  ```
33
33
 
@@ -43,11 +43,12 @@ avoid worries about the need to escape certain characters from the shell, and it
43
43
  can be easier to run multiple commands back to back.
44
44
 
45
45
  Help is available for the CLI through the use of the `help` command. Type
46
- `trax help` to see a list of the available commands, and `trax help play` to see
47
- information about the `play` command. Aliases and abbreviations are provided to
48
- minimize typing and to try to avoid the requirement of memorizing the commands.
49
- For example, the `list` command has an alias of `ls`, and the `select` command
50
- can be activated using `#`.
46
+ `trax help` (or `npx trax help` with a local installation, or simply `help` if
47
+ run interactively) to see a list of the available commands, and `trax help play`
48
+ to see information about the `play` command. Aliases and abbreviations are
49
+ provided to minimize typing and to try to avoid the requirement of memorizing
50
+ the commands. For example, the `list` command has an alias of `ls`, and the
51
+ `select` command can be activated using `#`.
51
52
 
52
53
  When you first launch the CLI, you can create a new game using the `new`
53
54
  command, and then enter moves for that game using `play`. Since `play` is likely
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@slugbugblue/trax",
3
- "version": "0.15.0",
3
+ "version": "0.17.0",
4
4
  "description": "Trax game engine and friends",
5
5
  "keywords": [
6
6
  "trax",
package/src/analyst.js CHANGED
@@ -379,7 +379,7 @@ export const Analysis = class {
379
379
  const calcLevel = level > 0 && defending ? level + threatLevel - 1 : level
380
380
  let points = attackScore(inactive, level, calc ? calcLevel : 0)
381
381
  if (level === threatLevel) points = passive ? Math.floor(points / 2) : 0
382
- if (threatening) points = Math.floor(points / 2)
382
+ if (passive && threatening) points = Math.floor(points / 2)
383
383
  score += points
384
384
  }
385
385
 
package/src/cli.js CHANGED
@@ -38,6 +38,7 @@ import {
38
38
  } from './cmds/import-export.js'
39
39
  import { listCmd } from './cmds/list.js'
40
40
  import { newCmd } from './cmds/new.js'
41
+ import { notesCmd } from './cmds/notes.js'
41
42
  import { playCmd, tryCmd } from './cmds/play-try.js'
42
43
  import { puzzlesCmd } from './cmds/puzzles.js'
43
44
  import { selectCmd } from './cmds/select.js'
@@ -87,6 +88,7 @@ const plugins = [
87
88
  importCmd,
88
89
  listCmd,
89
90
  newCmd,
91
+ notesCmd,
90
92
  playCmd,
91
93
  puzzlesCmd,
92
94
  quitCmd,
@@ -197,14 +199,6 @@ const DATA = { games: {}, puzzles: {} }
197
199
 
198
200
  // CLI context object for plugins
199
201
  const CLI = {
200
- // Notation manipulation
201
- fixNotation(move) {
202
- if (move.endsWith('b')) return move.slice(0, -1) + '\\'
203
- if (move.endsWith('s')) return move.slice(0, -1) + '/'
204
- if (move.endsWith('p')) return move.slice(0, -1) + '+'
205
- return move
206
- },
207
-
208
202
  // Quick access to all stored games
209
203
  get GAMES() {
210
204
  return DATA.games || {}
@@ -283,6 +277,28 @@ const CLI = {
283
277
  CLI.out(CLI.color.error(text))
284
278
  },
285
279
 
280
+ // Notation manipulation
281
+ fixNotation(move) {
282
+ if (move.endsWith('b')) return move.slice(0, -1) + '\\'
283
+ if (move.endsWith('s')) return move.slice(0, -1) + '/'
284
+ if (move.endsWith('p')) return move.slice(0, -1) + '+'
285
+ return move
286
+ },
287
+
288
+ load(id) {
289
+ if (!id) id = Object.keys(DATA.games || {})[0]
290
+ if (id && DATA.games?.[id] && DATA.games[id].rules) {
291
+ if (id !== String(CONFIG.id)) {
292
+ CONFIG.id = id
293
+ saveConfig()
294
+ }
295
+
296
+ const game = CLI.GAME
297
+ if (!game.players) game.players = ['white', 'black']
298
+ CLI.TRAX = new Trax(game.rules, game.moves, 'cli')
299
+ }
300
+ },
301
+
286
302
  newGame(rules, players, moves) {
287
303
  // Creates a new game
288
304
  const id = newId(DATA)
@@ -367,6 +383,11 @@ const CLI = {
367
383
  return undefined
368
384
  },
369
385
 
386
+ /** Write out the current data to disk. */
387
+ save() {
388
+ saveData()
389
+ },
390
+
370
391
  setGame(moves) {
371
392
  // Updates the moves for the current game
372
393
  const game = CLI.GAME
@@ -390,20 +411,6 @@ const CLI = {
390
411
  }
391
412
  },
392
413
 
393
- load(id) {
394
- if (!id) id = Object.keys(DATA.games || {})[0]
395
- if (id && DATA.games?.[id] && DATA.games[id].rules) {
396
- if (id !== String(CONFIG.id)) {
397
- CONFIG.id = id
398
- saveConfig()
399
- }
400
-
401
- const game = CLI.GAME
402
- if (!game.players) game.players = ['white', 'black']
403
- CLI.TRAX = new Trax(game.rules, game.moves, 'cli')
404
- }
405
- },
406
-
407
414
  // Pass through functions
408
415
  bubble: tty.bubble,
409
416
  display: tty.display,
@@ -137,6 +137,79 @@ export const exportCmd = {
137
137
  ],
138
138
  }
139
139
 
140
+ /** Interpret the rules string of a .trx file.
141
+ * @arg {string} r - rules string in lower case
142
+ * @returns {TraxVariant}
143
+ */
144
+ const getRules = (r) =>
145
+ r.includes('loop') ? 'traxloop' : r.includes('8x8') ? 'trax8' : 'trax'
146
+
147
+ /** Decode a .trx formatted string into a game, preserving players and comments.
148
+ * @arg {any} CLI - the CLI object
149
+ * @arg {string} content - the contents of the .trx file
150
+ * @returns {boolean} - true if a game was created
151
+ */
152
+ const interpretFile = (CLI, content) => {
153
+ let rules
154
+ let trax
155
+ let players = []
156
+ const moves = []
157
+ const comments = []
158
+ for (const line of content.split('\n')) {
159
+ if (!line) continue
160
+ const [ln, ...comment] = line.split(/[#;]/)
161
+ if (ln) {
162
+ const lower = ln.toLowerCase()
163
+ if (!rules && lower.includes('trax')) {
164
+ rules = getRules(lower)
165
+ } else if (players.length === 0 && lower.includes(' vs ')) {
166
+ players = ln.split(' ').filter(Boolean)
167
+ } else {
168
+ moves.push(...ln.split(' '))
169
+ }
170
+ }
171
+
172
+ if (!trax && rules && (players.length > 0 || moves.length > 0)) {
173
+ trax = new Trax(rules)
174
+ }
175
+
176
+ if (moves.length > 0) {
177
+ if (!trax) return false
178
+ if (trax.gameOver) {
179
+ comments.push({
180
+ note: moves.map((c) => c.trim()).join(' '),
181
+ move: trax.move,
182
+ })
183
+ } else {
184
+ trax.playMoves(moves.filter(Boolean))
185
+ moves.splice(0, moves.length)
186
+ }
187
+ }
188
+
189
+ if (comment.length > 0) {
190
+ const note = comment.map((c) => c.trim()).join(' ')
191
+ if (!/^@slugbugblue\/trax cli\.js v/.test(note)) {
192
+ comments.push({
193
+ note,
194
+ move: trax?.move || 0,
195
+ })
196
+ }
197
+ }
198
+ }
199
+
200
+ if (!trax) return false
201
+
202
+ CLI.do('new', rules, ...players)
203
+ CLI.GAMES[String(CLI.GAME.id)].notes = comments
204
+ if (trax.moves.length > 0) {
205
+ CLI.do('play', ...trax.moves)
206
+ } else {
207
+ CLI.save()
208
+ }
209
+
210
+ return true
211
+ }
212
+
140
213
  importCmd.fn = async (CLI, filename) => {
141
214
  cli = CLI
142
215
  if (!filename) {
@@ -156,36 +229,74 @@ importCmd.fn = async (CLI, filename) => {
156
229
  }
157
230
  }
158
231
 
159
- if (!content) CLI.error(fsError.toString())
232
+ if (!content) return CLI.error(fsError.toString())
160
233
  }
161
234
 
162
- if (content) {
163
- let game
164
- let players = []
165
- const moves = []
166
- for (const line of content.split('\n')) {
167
- if (!line) continue
168
- const ln = line.split(/[#;]/)[0]
169
- if (!ln) continue
170
- const lower = ln.toLowerCase()
171
- if (!game && lower.includes('trax')) {
172
- game = 'trax'
173
- if (lower.includes('loop')) game = 'traxloop'
174
- if (lower.includes('8x8')) game = 'trax8'
175
- } else if (players.length === 0 && lower.includes(' vs ')) {
176
- players = ln.split(' ').filter(Boolean)
177
- } else {
178
- moves.push(...ln.split(' '))
179
- }
235
+ if (!interpretFile(CLI, content)) {
236
+ CLI.error(filename + ' does not appear to be formatted correctly.')
237
+ }
238
+ }
239
+
240
+ /** Get all the notes for a certain move.
241
+ * @arg {number} move - The move number
242
+ * @arg {GameNotes} notes? - The notes for a game
243
+ * @returns {string} - Notes formatted for this move, or empty string if none
244
+ */
245
+ const gameNotes = (move, notes) => {
246
+ notes = notes || []
247
+ const moveNotes = notes.filter((n) => n.move === move)
248
+ if (moveNotes.length === 0) return ''
249
+ return (
250
+ '; ' +
251
+ moveNotes.map((n) => n.note.replaceAll('\n', '\n; ')).join('\n; ') +
252
+ '\n'
253
+ )
254
+ }
255
+
256
+ /** Join two strings together with a character with the appropriate joining
257
+ * character depending on the length of the strings.
258
+ * @arg {string} a - the first string
259
+ * @arg {string} b - the second string
260
+ * @returns {string} - both strings correctly joined
261
+ */
262
+ const join = (a, b) =>
263
+ a.length === 0 ? b : a.length + b.length > 78 ? a + '\n' + b : a + ' ' + b
264
+
265
+ /** Interleave the game moves with any notes, formatted for the export file.
266
+ * @arg {string[]} moves - an array of move notations
267
+ * @arg {GameNotes} notes? - The notes for a game
268
+ * @returns {string} - A string that can be placed into an export file
269
+ */
270
+ const interleaveNotation = (moves, notes) => {
271
+ let content = ''
272
+ let moveNumber = 0
273
+ let annotation = ''
274
+ for (const move of moves) {
275
+ const comments = gameNotes(moveNumber, notes)
276
+ if (comments) {
277
+ content += join(annotation, comments)
278
+ annotation = ''
180
279
  }
181
280
 
182
- if (game) {
183
- CLI.do('new', game, ...players)
184
- CLI.do('play', ...moves.filter(Boolean))
281
+ moveNumber += 1
282
+ const notation = String(moveNumber) + '. ' + move
283
+ if (annotation.length + notation.length < 79) {
284
+ annotation += (annotation.length > 0 ? ' ' : '') + notation
185
285
  } else {
186
- CLI.error(filename + ' does not appear to be formatted correctly.')
286
+ content += '\n' + annotation
287
+ annotation = notation
187
288
  }
188
289
  }
290
+
291
+ const comments = gameNotes(moveNumber, notes)
292
+ if (comments) {
293
+ content += join(annotation, comments)
294
+ annotation = ''
295
+ }
296
+
297
+ content += annotation
298
+ if (!content.endsWith('\n')) content += '\n'
299
+ return content
189
300
  }
190
301
 
191
302
  exportCmd.fn = (CLI, id, filename) => {
@@ -218,7 +329,7 @@ exportCmd.fn = (CLI, id, filename) => {
218
329
  content += '; Puzzle ' + game.puzzle
219
330
  if (source) {
220
331
  content += ' provided courtesy of ' + source.name
221
- if (source.url) content += ' (' + source.url + ')'
332
+ if (source.url) content += '\n; ' + source.url
222
333
  }
223
334
 
224
335
  if (puzzle.title) content += '\n; ' + puzzle.title
@@ -230,7 +341,8 @@ exportCmd.fn = (CLI, id, filename) => {
230
341
  }
231
342
  }
232
343
 
233
- content += trax.notation + '\n'
344
+ content += interleaveNotation(trax.moves, game.notes)
345
+
234
346
  if (filename) filename = filename.replace(/[^ a-z\d.~/]/g, '')
235
347
  if (filename) {
236
348
  const fname = filename.split('/').pop()
package/src/cmds/list.js CHANGED
@@ -15,6 +15,8 @@
15
15
 
16
16
  // CLI list command
17
17
 
18
+ import process from 'node:process'
19
+
18
20
  import { Trax } from '@slugbugblue/trax'
19
21
 
20
22
  export const listCmd = {
@@ -35,7 +37,7 @@ export const listCmd = {
35
37
  ' #id: match a game or a certain number of moves',
36
38
  ' "active" or "over": whether a game is in progress or not',
37
39
  ' "loop" or "8x8" or "trax": filter by game type',
38
- " <other text>: match a player's name",
40
+ " <other text>: match a player's name or the latest game note",
39
41
  'Add the "reverse" option to reverse the sort order.',
40
42
  ],
41
43
  }
@@ -80,14 +82,16 @@ const listSort = (list, filters) => {
80
82
  } else if (['over', 'active'].includes(by)) {
81
83
  list = list.filter((g) => (by === 'over' ? g.over : !g.over))
82
84
  } else {
83
- list = list.filter(
84
- (g) =>
85
+ list = list.filter((g) => {
86
+ return (
85
87
  g.id.endsWith(by) ||
86
88
  Trax.names[g.rules].toLowerCase().includes(by) ||
87
89
  g.p1.toLowerCase().includes(by) ||
88
90
  g.p2.toLowerCase().includes(by) ||
89
- g.moves.startsWith(by + ' '),
90
- )
91
+ g.moves.startsWith(by + ' ') ||
92
+ g.note.includes(by)
93
+ )
94
+ })
91
95
  }
92
96
  }
93
97
 
@@ -96,6 +100,19 @@ const listSort = (list, filters) => {
96
100
  return list
97
101
  }
98
102
 
103
+ /** Find how many columns are left over after all the other columns arefilled.
104
+ * @arg {Record<string, number>} sizes
105
+ * @returns {number} number of columns left over
106
+ */
107
+ const leftover = (sizes) => {
108
+ let left = process.stdout.columns
109
+ for (const size of Object.values(sizes)) {
110
+ left -= size
111
+ }
112
+
113
+ return left
114
+ }
115
+
99
116
  listCmd.fn = (CLI, ...filters) => {
100
117
  if (Object.keys(CLI.GAMES).length === 0) {
101
118
  return CLI.error('No games. Type "new" to start a new game.')
@@ -106,12 +123,14 @@ listCmd.fn = (CLI, ...filters) => {
106
123
 
107
124
  let list = []
108
125
  const size = {
126
+ spacing: 13, // Amount of space taken up by fixed-length and between columns
109
127
  id: 1,
110
128
  name: 1,
111
129
  moves: 1,
112
130
  p1: 1,
113
131
  p2: 1,
114
132
  }
133
+ let noteSize = Number.POSITIVE_INFINITY
115
134
 
116
135
  for (const game of Object.values(CLI.GAMES)) {
117
136
  const moves = game.moves.length === 0 ? 0 : game.moves.split(' ').length
@@ -120,13 +139,14 @@ listCmd.fn = (CLI, ...filters) => {
120
139
  id: (game.id === CLI.GAME.id ? '* #' : '#') + String(game.id),
121
140
  name: game.name?.length || 0,
122
141
  rules: game.rules,
123
- moves: CLI.plural(moves, 'move'),
142
+ moves: game.over ? 'game over' : CLI.plural(moves, 'move'),
124
143
  smoves: game.over ? '9999' : String(moves).padStart(9, '0'),
125
144
  sturn: String(game.turn),
126
145
  p1: game.players?.[0] || 'white',
127
146
  p2: game.players?.[1] || 'black',
128
147
  turn: game.turn,
129
148
  over: game.over,
149
+ note: game.notes?.[game.notes.length - 1]?.note || '',
130
150
  })
131
151
  }
132
152
 
@@ -138,6 +158,7 @@ listCmd.fn = (CLI, ...filters) => {
138
158
  size.moves = Math.max(size.moves, game.moves.length)
139
159
  size.p1 = Math.max(size.p1, game.p1.length)
140
160
  size.p2 = Math.max(size.p2, game.p2.length)
161
+ noteSize = Math.min(noteSize, leftover(size))
141
162
  }
142
163
 
143
164
  for (const game of list) {
@@ -149,7 +170,8 @@ listCmd.fn = (CLI, ...filters) => {
149
170
  CLI.color('vs '.padStart(size.p1 - game.p1.length + 4)) +
150
171
  CLI.bubble(game.turn === 2 ? 'bh' : 'b', game.p2) +
151
172
  CLI.color(' '.padStart(size.p2 - game.p2.length + 1)) +
152
- CLI.color(game.over ? 'game over' : game.moves),
173
+ CLI.color(game.moves.padEnd(size.moves)) +
174
+ CLI.color.help(' ' + game.note.slice(0, noteSize)),
153
175
  )
154
176
  }
155
177
  }
@@ -0,0 +1,34 @@
1
+ /** Notes CLI command.
2
+ * @copyright 2022
3
+ * @author Chad Transtrum <chad@transtrum.net>
4
+ * @license Apache-2.0
5
+ */
6
+
7
+ export const notesCmd = {
8
+ name: 'notes',
9
+ args: '[#id] <text>',
10
+ desc: 'add a note to a game',
11
+ help: [
12
+ 'Add a short note to a game that will be shown when the game is',
13
+ 'displayed, listed, or exported.',
14
+ ],
15
+ }
16
+
17
+ notesCmd.fn = (CLI, id, ...note) => {
18
+ const current = String(CLI.GAME.id)
19
+ if (!id) id = current
20
+ if (!/^#?\d+$/.test(id)) {
21
+ note.unshift(id)
22
+ id = current
23
+ }
24
+
25
+ if (id.startsWith('#')) id = id.slice(1)
26
+
27
+ const game = CLI.GAMES[id]
28
+ const notes = game.notes || []
29
+ const move = game.moves.length > 0 ? game.moves.split(' ').length : 0
30
+
31
+ notes.push({ move, note: note.join(' ') })
32
+ game.notes = notes
33
+ CLI.save()
34
+ }
@@ -142,7 +142,7 @@ playCmd.fn = (CLI, ...moves) => {
142
142
  }
143
143
 
144
144
  tryCmd.fn = (CLI, move) => {
145
- if (move.length === 0) {
145
+ if (!move || move.length === 0) {
146
146
  CLI.error('You must provide a move.')
147
147
  return CLI.do('help', 'try')
148
148
  }
package/src/cmds/view.js CHANGED
@@ -39,7 +39,10 @@ viewCmd.fn = (CLI, move) => {
39
39
  show = Number(move)
40
40
  }
41
41
 
42
- const players = CLI.GAME.players || ['white', 'black']
42
+ const game = CLI.GAME
43
+ const players = game.players || ['white', 'black']
44
+ const puzzle = game.puzzle || ''
45
+ const { note } = [...(game.notes || [{}])].pop()
43
46
 
44
47
  CLI.out(
45
48
  CLI.color('#' + CLI.ID + ' ') +
@@ -50,4 +53,6 @@ viewCmd.fn = (CLI, move) => {
50
53
  CLI.bubble('b' + (trax.turn === 2 ? 'h' : ''), players[1]),
51
54
  )
52
55
  CLI.display(trax, players, undefined, show)
56
+ if (puzzle) CLI.out(CLI.color('Puzzle ' + puzzle))
57
+ if (note) CLI.out(CLI.color.help(note))
53
58
  }
package/src/engine.js CHANGED
@@ -617,7 +617,7 @@ export const Trax = class {
617
617
  return this.notation
618
618
  .replace(/\n/g, ' ')
619
619
  .split(' ')
620
- .filter((n) => !n.endsWith('.'))
620
+ .filter((n) => Boolean(n) && !n.endsWith('.'))
621
621
  }
622
622
 
623
623
  /** Drop a tile onto the board. This is a lower level call. Use play() if possible instead.
package/src/threats.js CHANGED
@@ -198,4 +198,5 @@ for (const one of Object.keys(corners)) {
198
198
  threats.add(3, 'ralbmrW') // Attack into a wide cave
199
199
  threats.add(3, 'Arlbmwr') // Reversed
200
200
 
201
+ threats.add(4, 'rb¡c?w!br') // Connectable pair on an edge
201
202
  threats.add(4, 'ArlbmrW') // Wide cave ready for an attack
package/src/types.d.ts CHANGED
@@ -62,6 +62,15 @@ type FoundThreats = Record<string, FoundThreat[]>
62
62
  /** All the threats found for both the white and black player. */
63
63
  type FoundThreatsCollection = { b: ColorThreats; w: ColorThreats }
64
64
 
65
+ /** A note with a move number */
66
+ type GameNote = {
67
+ move: number
68
+ note: string
69
+ }
70
+
71
+ /** Notes saved in the game object in the CLI. */
72
+ type GameNotes = GameNote[]
73
+
65
74
  /** A point-like object has numerical x and y properties. */
66
75
  type PointLike = {
67
76
  x: number
package/src/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // Generated by genversion.
2
- export const version = '0.15.0'
2
+ export const version = '0.17.0'