@slugbugblue/trax 0.8.0 → 0.9.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/src/cli.js CHANGED
@@ -16,47 +16,31 @@
16
16
 
17
17
  // CLI for Trax, with a fallback REPL
18
18
 
19
- import fs from 'node:fs/promises'
20
19
  import process from 'node:process'
21
20
  import repl from 'node:repl'
22
21
 
23
- import envPaths from 'env-paths'
24
- import makeDir from 'make-dir'
25
22
  import YAML from 'yaml'
26
23
 
27
24
  import { Trax } from './engine.js'
28
25
  import { version } from './version.js'
29
26
  import * as tty from './tty.js'
30
27
 
31
- const notationRx = /^([@a-z]+)(\d+)([bps/\\+])$/i
32
- const moveRx = /^(\d+)\.?$/
33
-
34
- const CONFIG = {}
35
- const DATA = {}
36
-
37
- const paths = envPaths('trax', { suffix: '' })
38
- const PATHS = {}
39
-
40
- const state = {
41
- game: {},
42
- trax: new Trax(),
43
- }
44
-
45
- const COLORS = {
46
- black: 240,
47
- command: 33,
48
- default: 39,
49
- error: 205,
50
- fatal: 127,
51
- help: 74,
52
- id: 195,
53
- variable: 123,
54
- optional: 110,
55
- short: 153,
56
- white: 250,
57
- }
58
-
59
- let startRepl = false
28
+ // Load all the commands as individual plugins
29
+ import { deleteCmd } from './cmds/delete.js'
30
+ import { helpCmd } from './cmds/help.js'
31
+ import {
32
+ importCmd,
33
+ exportCmd,
34
+ getFile,
35
+ saveFile,
36
+ findFiles,
37
+ } from './cmds/import-export.js'
38
+ import { listCmd } from './cmds/list.js'
39
+ import { newCmd } from './cmds/new.js'
40
+ import { playCmd, tryCmd } from './cmds/play-try.js'
41
+ import { selectCmd } from './cmds/select.js'
42
+ import { undoCmd } from './cmds/undo.js'
43
+ import { viewCmd } from './cmds/view.js'
60
44
 
61
45
  // Because typeof [] and null are both 'object'. is([], 'arr') => true
62
46
  const is = (variable, type) =>
@@ -66,128 +50,80 @@ const is = (variable, type) =>
66
50
  .toLowerCase()
67
51
  .startsWith(type)
68
52
 
69
- // Return the actual path on disk, creating it if necessary.
70
- // Path is one of cache, config, data, log, temp.
71
- const getPath = async (path) => {
72
- if (path in PATHS) return PATHS[path]
73
- if (path in paths) {
74
- const realPath = await makeDir(paths[path])
75
- PATHS[path] = realPath
76
- return realPath
77
- }
78
-
79
- return false
80
- }
81
-
82
- const hexy = (length = 6) => {
83
- let hex = ''
84
- while (hex.length < length) {
85
- hex += '0123456789abcdef'[Math.floor(Math.random() * 16)]
86
- }
87
-
88
- return hex
89
- }
90
-
91
- const fsOuch = (fsError) => {
92
- tty.outty(fsError, COLORS.fatal)
93
- throw fsError
94
- }
95
-
96
- // Hope this works
97
- const getFile = async (filetype, filename, variable, decode) => {
98
- const path = await getPath(filetype)
99
- if (path) {
100
- try {
101
- const contents = await fs.readFile(`${path}/${filename}`, 'utf8')
102
- for (const [key, value] of Object.entries(decode(contents))) {
103
- variable[key] = value
104
- }
105
- } catch (fsError) {
106
- if (fsError instanceof TypeError || fsError instanceof SyntaxError) {
107
- error(`Unable to read ${filetype} file ${path}/${filename}`)
108
- } else if (fsError.code !== 'ENOENT') {
109
- fsOuch(fsError)
53
+ // Sample command:
54
+ const versionCmd = {
55
+ name: 'version',
56
+ opts: ['--version', '-v'],
57
+ desc: 'display version number',
58
+ help: 'Print out the current version of this program.',
59
+ fn: (CLI) => CLI.out('Trax CLI v' + version),
60
+ }
61
+
62
+ const quitCmd = {
63
+ name: 'quit',
64
+ alt: 'exit',
65
+ desc: 'press <Ctrl-D> to exit',
66
+ help: 'To exit, press Ctrl-D on a blank line or type "quit".',
67
+ hide: true,
68
+ fn() {
69
+ // I actually have no idea why a broken promise is required here,
70
+ // but I tried a lot of different options, and this is what worked
71
+ return new Promise(() => {
72
+ if (CLI.repl) {
73
+ CLI.repl.close()
110
74
  }
111
- }
112
- }
113
-
114
- return variable
115
- }
116
-
117
- const getConfig = async () => {
118
- await getFile('config', 'trax.yaml', CONFIG, YAML.parse)
119
- return CONFIG
120
- }
121
-
122
- const getData = async () => {
123
- await getFile('data', 'trax.json', DATA, JSON.parse)
124
- return DATA
125
- }
126
-
127
- const saveFile = (filetype, filename, data) => {
128
- getPath(filetype).then((path) => {
129
- if (path) {
130
- const parts = filename.split('.')
131
- parts.splice(1, 0, hexy())
132
- const tmpfile = path + '/' + parts.join('.')
133
- filename = path + '/' + filename
134
- fs.writeFile(tmpfile, data)
135
- .then(() => {
136
- fs.rm(filename, { force: true, maxRetries: 5 })
137
- .then(() => {
138
- fs.rename(tmpfile, filename).catch(fsOuch)
139
- })
140
- .catch(fsOuch)
141
- })
142
- .catch(fsOuch)
143
- }
144
- })
145
- }
146
-
147
- const saveConfig = () => {
148
- saveFile('config', 'trax.yaml', YAML.stringify(CONFIG))
149
- }
150
-
151
- const saveData = () => {
152
- saveFile('data', 'trax.json', JSON.stringify(DATA, null, 2) + '\n')
75
+ })
76
+ },
153
77
  }
154
78
 
155
- const parseCommandLine = () => {
156
- let cmd = ''
157
- const args = process.argv.slice(2)
158
- let dash = true
159
- let action = false
160
- for (const arg of args) {
161
- if (dash && arg.startsWith('-')) {
162
- if (arg === '--') dash = false
163
- if (arg === '-i' || arg === '--interactive') startRepl = true
164
- if (arg === '-h' || arg === '--help') cmd = 'help ' + cmd
165
- if (arg === '-v' || arg === '--version') {
166
- console.info('trax v' + version)
167
- action = true
168
- }
169
- } else {
170
- cmd += arg + ' '
79
+ const plugins = [
80
+ deleteCmd,
81
+ exportCmd,
82
+ helpCmd,
83
+ importCmd,
84
+ listCmd,
85
+ newCmd,
86
+ playCmd,
87
+ quitCmd,
88
+ selectCmd,
89
+ tryCmd,
90
+ undoCmd,
91
+ versionCmd,
92
+ viewCmd,
93
+ ]
94
+
95
+ const newId = (DATA) => {
96
+ let max = 0
97
+ let min = Number.POSITIVE_INFINITY
98
+ if (DATA.games) {
99
+ for (const id of Object.keys(DATA.games)) {
100
+ max = Math.max(max, Number(id) || 0)
101
+ min = Math.min(min, Number(id) || 0)
171
102
  }
103
+ } else {
104
+ DATA.games = {}
172
105
  }
173
106
 
174
- return action && cmd.length === 0 ? undefined : cmd.slice(0, -1)
107
+ if (max === 0 || min <= 1) return String(max + 1)
108
+
109
+ return String(min - 1)
175
110
  }
176
111
 
112
+ // Colorize text according to simplistic and not very complete patterns
177
113
  const color = (text, def) => {
178
114
  let out = ''
179
115
  let quote = false
180
116
  let id = false
181
117
  const colors = [def]
182
- const c = (t) => tty.color(t, colors[colors.length - 1] || COLORS.default)
183
- const short = (t) => tty.color(t, COLORS.short) + c()
118
+ const c = (t) => tty.color(t, colors[colors.length - 1] || CLI.COLORS.default)
119
+ const short = (t) => tty.color(t, CLI.COLORS.short) + c()
184
120
  for (const char of text) {
185
121
  switch (char) {
186
122
  case '"':
187
123
  if (quote) {
188
124
  colors.pop()
189
125
  } else {
190
- colors.push(COLORS.command)
126
+ colors.push(CLI.COLORS.command)
191
127
  }
192
128
 
193
129
  out += c('')
@@ -195,14 +131,14 @@ const color = (text, def) => {
195
131
  break
196
132
  case '#':
197
133
  id = true
198
- colors.push(COLORS.id)
134
+ colors.push(CLI.COLORS.id)
199
135
  out += c('#')
200
136
  break
201
137
  case '|':
202
138
  out += short('|')
203
139
  break
204
140
  case '[':
205
- colors.push(COLORS.optional)
141
+ colors.push(CLI.COLORS.optional)
206
142
  out += short('[')
207
143
  break
208
144
  case ']':
@@ -210,7 +146,7 @@ const color = (text, def) => {
210
146
  out += short(']')
211
147
  break
212
148
  case '<':
213
- colors.push(COLORS.variable)
149
+ colors.push(CLI.COLORS.variable)
214
150
  out += c('')
215
151
  break
216
152
  case '>':
@@ -228,726 +164,423 @@ const color = (text, def) => {
228
164
  }
229
165
  }
230
166
 
231
- return tty.color(out, def || COLORS.default)
167
+ return tty.color(out, def || CLI.COLORS.default)
232
168
  }
233
169
 
234
- const error = (text) => {
235
- tty.outty(color(text, COLORS.error))
236
- }
170
+ // Internal representations of files
171
+ const CONFIG = { id: 0 }
172
+ const DATA = { games: {} }
237
173
 
238
- const commands = {
239
- delete: {
240
- alt: ['remove', 'rm'],
241
- args: '[#id] ["force"]',
242
- desc: 'delete a game',
243
- help: [
244
- 'Delete the current game. Or specify a game number to delete that game.',
245
- 'If the game is in progress, you must type "force" to delete it.',
246
- ],
247
- },
248
- export: {
249
- alt: ['save', 'keep', 'share'],
250
- args: '[#id] [filename]',
251
- desc: 'save a game to a file',
252
- help: [
253
- 'Export the current game to an external ".trx" file for safe-keeping or',
254
- 'to share. Specify a game id to export a different game.',
255
- ],
256
- },
257
- help: {
258
- alt: '?',
259
- args: '[topic]',
260
- desc: 'display help for a command',
261
- help: [
262
- 'Type "help <command>" or "<command> help" to see additional information',
263
- 'for a specific command.',
264
- ],
265
- },
266
- import: {
267
- alt: ['load', 'open'],
268
- args: '<filename>',
269
- desc: 'open a game file',
270
- help: [
271
- 'Load a ".trx" game file into memory. Once imported, it will be assigned',
272
- 'a game id and automatically selected for future commands.',
273
- ],
274
- },
275
- list: {
276
- alt: 'ls',
277
- args: '[sort|filter...] ["reverse"]',
278
- desc: 'list all games',
279
- help: [
280
- 'Valid sort options are:',
281
- ' <id>: sort by game id',
282
- ' <game>: sort by game type',
283
- ' <player1> or <player2>: sort by player name',
284
- ' <moves>: sort by the number of moves',
285
- 'Available filters:',
286
- ' #id: match a game or a certain number of moves',
287
- ' <active> or <over>: whether a game is in progress or not',
288
- ' <loop> or <8x8> or <trax>: filter by game type',
289
- " other text: match a player's name",
290
- 'Add the "reverse" option to reverse the sort order.',
291
- ],
292
- },
293
- new: {
294
- alt: 'start',
295
- args: '[variant] [[p1 name] "vs" [p2 name]]',
296
- desc: 'start a new game',
297
- help: [
298
- 'Valid variants are <trax>, <loop>, and <8x8>. Defaults to "trax" if not',
299
- 'specified. Use the "vs" keyword to use specific names for the players.',
300
- ],
301
- },
302
- play: {
303
- alt: ['move', 'mv'],
304
- args: '<move> [...]',
305
- desc: 'play a move',
306
- help: [
307
- 'Enter the move in standard Trax notation. The final character of the',
308
- 'notation can be replaced with a letter for ease of entering at the',
309
- 'command line. Use <s> for "/", <b> for "\\", and <p> for "+".',
310
- 'Multiple moves can be submitted at once. Move numbers are optional,',
311
- 'but if included, they will be checked for accuracy.',
312
- ],
313
- },
314
- select: {
315
- alt: ['choose', '#'],
316
- args: '#id',
317
- desc: 'select a different game',
318
- help: 'Make another game the default game of future commands.',
174
+ // CLI context object for plugins
175
+ const CLI = {
176
+ // Quick access to all stored games
177
+ get GAMES() {
178
+ return DATA.games || {}
319
179
  },
320
- try: {
321
- alt: 'see',
322
- args: '<move>',
323
- desc: 'see the effects of a move',
324
- help: [
325
- 'View a move without committing to it. This is useful for seeing the',
326
- 'effects of forced tiles.',
327
- ],
180
+ // And information about the current game
181
+ get GAME() {
182
+ return (CONFIG.id && DATA.games?.[CONFIG.id]) || {}
328
183
  },
329
- undo: {
330
- args: '[n]',
331
- desc: 'undo the last move in a game',
332
- help: [
333
- 'Backtrack one or more moves in a game. Type "undo" to take back the last',
334
- 'move, or "undo 2" to undo the last two moves.',
335
- ],
184
+ // And the current game ID
185
+ get ID() {
186
+ return CONFIG.id
336
187
  },
337
- view: {
338
- alt: ['show', 'display'],
339
- args: '[#id]',
340
- desc: 'show the game board',
341
- help: [
342
- 'Show the current game. Or specify a game number to switch to and view',
343
- 'that game. Future commands will default to that game.',
344
- ],
345
- },
346
- quit: {
347
- alt: 'exit',
348
- desc: 'press <Ctrl-D> to exit',
349
- help: 'To exit, press Ctrl-D on a blank line or type "quit".',
188
+
189
+ // Provide access to the current Trax object
190
+ TRAX: new Trax(),
191
+
192
+ // Color codes
193
+ COLORS: {
194
+ black: 240,
195
+ command: 33,
196
+ default: 39,
197
+ error: 205,
198
+ fatal: 127,
199
+ help: 74,
200
+ id: 195,
201
+ variable: 123,
202
+ optional: 110,
203
+ short: 153,
204
+ white: 250,
350
205
  },
351
- }
352
206
 
353
- const length = (text) => (text ? text.replace(/[<">]/g, '').length : 0)
207
+ // All of the above COLORS will be populated automatically into "color", where
208
+ // they can be called as functions, to apply the given color, eg:
209
+ // CLI.color('hi') and CLI.color.default('hi') are equivalent
210
+ color,
354
211
 
355
- commands.help.fn = (word) => {
356
- word = (word && abbreviations[word.toLowerCase()]) || word
357
- let cmds = Object.keys(commands).filter((c) => !word || c.startsWith(word))
358
- if (cmds.length === 0) {
359
- cmds = Object.keys(commands).filter((c) => c.includes(word))
360
- }
212
+ cmd(command) {
213
+ // Return a command structure object or an empty object
214
+ if (is(command, 'str') && command in commands) {
215
+ return commands[command]
216
+ }
361
217
 
362
- if (cmds.length === 0) cmds = Object.keys(commands)
363
- let max = 1
364
- for (const key of cmds) {
365
- max = Math.max(max, key.length + length(commands[key].args))
366
- }
218
+ return {}
219
+ },
367
220
 
368
- for (const key of cmds.sort()) {
369
- if (key === 'quit' && !state.repl) continue // Hide quit unless in repl
370
- const topic = commands[key]
371
- const space = ' '.repeat(5 + max - length(topic.args) - key.length)
372
- tty.outty(
373
- color(` ${key} ${topic.args || ''}`, COLORS.command) +
374
- color(space + topic.desc, COLORS.short),
375
- )
376
- }
221
+ get commands() {
222
+ return Object.keys(commands)
223
+ },
377
224
 
378
- if (cmds.length === 1) {
379
- const topic = commands[cmds[0]]
380
- const { alt } = topic
381
- if (alt) {
382
- const alts = is(alt, 'arr') ? alt : [alt]
383
- for (const alias of alts) {
384
- tty.outty(color(` ${alias}`, COLORS.command))
385
- }
386
- }
225
+ delete(id) {
226
+ delete DATA.games[id]
227
+ saveData()
228
+ },
387
229
 
388
- tty.outty()
389
- if (is(topic.help, 'str')) {
390
- tty.outty(' ' + color(topic.help, COLORS.help))
230
+ do(cmd, ...args) {
231
+ // Call another plugin
232
+ if (cmd in commands) {
233
+ commands[cmd].fn(CLI, ...args)
391
234
  } else {
392
- for (const line of topic.help || []) {
393
- tty.outty(' ' + color(line, COLORS.help))
394
- }
235
+ throw new SyntaxError(`${cmd} is not a valid command`)
395
236
  }
396
- }
397
- }
398
-
399
- const newId = () => {
400
- let max = 0
401
- let min = Number.POSITIVE_INFINITY
402
- if (DATA.games) {
403
- for (const id of Object.keys(DATA.games)) {
404
- max = Math.max(max, Number(id) || 0)
405
- min = Math.min(min, Number(id) || 0)
406
- }
407
- } else {
408
- DATA.games = {}
409
- }
410
-
411
- if (max === 0 || min <= 1) return String(max + 1)
237
+ },
412
238
 
413
- return String(min - 1)
414
- }
239
+ async doNext(...cmd) {
240
+ // Execute another command line
241
+ await evaluate(cmd.join(' '))
242
+ },
415
243
 
416
- commands.import.fn = async (filename) => {
417
- if (!filename) {
418
- error('Missing filename.')
419
- return commands.help.fn('import')
420
- }
244
+ error(text) {
245
+ // Prints an error to stdout
246
+ CLI.out(CLI.color.error(text))
247
+ },
421
248
 
422
- let content
423
- try {
424
- content = await fs.readFile(filename, 'utf8')
425
- } catch (fsError) {
426
- if (fsError.code === 'ENOENT' && !filename.endsWith('.trx')) {
427
- try {
428
- content = await fs.readFile(filename + '.trx', 'utf8')
429
- } catch {
430
- content = null
431
- }
249
+ newGame(rules, players, moves) {
250
+ // Creates a new game
251
+ const id = newId(DATA)
252
+ const trax = new Trax(rules, moves, 'cli')
253
+ DATA.games[id] = {
254
+ id,
255
+ rules: trax.rules,
256
+ name: trax.name,
257
+ turn: trax.turn,
258
+ players,
259
+ moves: trax.moves.join(' '),
432
260
  }
433
261
 
434
- if (!content) error(fsError.toString())
435
- }
262
+ CONFIG.id = id
263
+ CLI.TRAX = trax
436
264
 
437
- if (content) {
438
- let game
439
- let players = []
440
- const moves = []
441
- for (const line of content.split('\n')) {
442
- if (!line) continue
443
- const ln = line.split(';')[0]
444
- if (!ln) continue
445
- const lower = ln.toLowerCase()
446
- if (!game && lower.includes('trax')) {
447
- game = 'trax'
448
- if (lower.includes('loop')) game = 'traxloop'
449
- if (lower.includes('8x8')) game = 'trax8'
450
- } else if (players.length === 0 && lower.includes(' vs ')) {
451
- players = ln.split(' ').filter((p) => p)
452
- } else {
453
- moves.push(...ln.split(' '))
454
- }
455
- }
265
+ saveConfig()
266
+ saveData()
456
267
 
457
- if (game) {
458
- commands.new.fn(game, ...players)
459
- commands.play.fn(...moves.filter((m) => m))
460
- } else {
461
- error(filename + ' does not appear to be formatted correctly.')
462
- }
463
- }
464
- }
268
+ return id
269
+ },
465
270
 
466
- commands.export.fn = (id, filename) => {
467
- let { game } = state
468
- if (id) {
469
- const newid = id.replace(/^#/, '')
470
- game = DATA.games?.[newid]
471
- if (!game) {
472
- filename = id
473
- game = state.game
271
+ out(text) {
272
+ // Prints to stdout
273
+ console.info(text + tty.color(''))
274
+ },
275
+
276
+ resolveCommand(word) {
277
+ // Determine if an entered word is a valid command.
278
+ // Returns the command if a single match is found, an array if multiple
279
+ // commands match, or undefined if nothing matches.
280
+ if (is(word, 'str') && word.length > 0) {
281
+ return abbreviations[word.toLowerCase()]
474
282
  }
475
- }
476
283
 
477
- if (!game || !game.id) {
478
- return error('No active game. Type "new" to start a game.')
479
- }
284
+ return undefined
285
+ },
480
286
 
481
- const trax = new Trax(game.rules, game.moves, 'cli')
482
-
483
- let content = Trax.names[trax.rules].replace(' ', '') + '\n'
484
- content += game?.players?.[0] || 'white'
485
- content += ' vs '
486
- content += game?.players?.[1] || 'black'
487
- content += '\n; @slugbugblue/trax cli.js v' + version + '\n'
488
- content += trax.notation + '\n'
489
- if (filename) filename = filename.replace(/[^ a-z\d./]/g, '')
490
- if (filename) {
491
- const fname = filename.split('/').pop()
492
- if (!fname.includes('.')) filename += '.trx'
493
- tty.outty(color('Exporting #' + game.id + ' to ') + filename)
494
- fs.writeFile(filename, content).catch(fsOuch)
495
- } else {
496
- tty.outty(content)
497
- }
498
- }
287
+ setGame(moves) {
288
+ // Updates the moves for the current game
289
+ const game = CLI.GAME
290
+ const trax = new Trax(game.rules, moves, 'cli')
291
+ if (game.over || trax.over) game.over = trax.over
292
+ game.moves = trax.moves.join(' ')
293
+ game.turn = trax.turn
294
+ CLI.TRAX = trax
499
295
 
500
- const updateGameData = () => {
501
- const data = DATA.games[state.game.id]
502
- const moves = state.trax.moves.join(' ')
503
- if (data.moves !== moves) {
504
- data.moves = moves
505
- data.turn = state.trax.turn
506
- if (state.trax.over) data.over = true
507
296
  saveData()
508
- }
509
- }
297
+ },
510
298
 
511
- commands.new.fn = (variant, ...names) => {
512
- let rules = ''
513
- if (variant) {
514
- if (['8x8', '8', 'trax8x8'].includes(variant)) rules = 'trax8'
515
- if (['loop', 'l'].includes(variant)) rules = 'traxloop'
516
- if (variant === 't') rules = 'trax'
517
- if (['trax', 'traxloop', 'trax8'].includes(variant)) rules = variant
518
- if (!rules) {
519
- if (names.includes('vs') || names.includes('VS')) {
520
- // We don't have a variant, we have player names
521
- names.unshift(variant)
522
- } else {
523
- error('Unknown Trax variant.')
524
- commands.help.fn('new')
525
- return
526
- }
299
+ updateGameData() {
300
+ const data = DATA.games[CLI.GAME.id]
301
+ const moves = CLI.TRAX.moves.join(' ')
302
+ if (data.moves !== moves) {
303
+ data.moves = moves
304
+ data.turn = CLI.TRAX.turn
305
+ if (CLI.TRAX.over) data.over = true
306
+ saveData()
527
307
  }
528
- }
308
+ },
529
309
 
530
- const players = ['white', 'black']
531
- if (names.length > 0) {
532
- let vs = names.indexOf('vs')
533
- if (vs === -1) vs = names.indexOf('VS')
534
- if (vs >= 0) {
535
- players[0] = names.slice(0, vs).join(' ') || players[0]
536
- players[1] = names.slice(vs + 1).join(' ') || players[1]
537
- }
538
- }
310
+ load(id) {
311
+ if (!id) id = Object.keys(DATA.games || {})[0]
312
+ if (id && DATA.games?.[id] && DATA.games[id].rules) {
313
+ if (id !== String(CONFIG.id)) {
314
+ CONFIG.id = id
315
+ saveConfig()
316
+ }
539
317
 
540
- CONFIG.id = newId()
541
- state.trax = new Trax(rules, undefined, 'cli')
542
- DATA.games[CONFIG.id] = {
543
- id: CONFIG.id,
544
- rules: state.trax.rules,
545
- name: state.trax.name,
546
- turn: 1,
547
- players,
548
- moves: state.trax.moves.join(' '),
549
- }
550
- state.game = DATA.games[CONFIG.id]
318
+ const game = CLI.GAME
319
+ if (!game.players) game.players = ['white', 'black']
320
+ CLI.TRAX = new Trax(game.rules, game.moves, 'cli')
321
+ }
322
+ },
551
323
 
552
- saveConfig()
553
- saveData()
324
+ // Pass through functions
325
+ bubble: tty.bubble,
326
+ display: tty.display,
327
+ name: tty.name,
554
328
 
555
- tty.outty(color('Started new game #' + CONFIG.id))
329
+ is,
330
+ version,
556
331
  }
557
332
 
558
- const fixNotation = (move) => {
559
- if (move.endsWith('b')) return move.slice(0, -1) + '\\'
560
- if (move.endsWith('s')) return move.slice(0, -1) + '/'
561
- if (move.endsWith('p')) return move.slice(0, -1) + '+'
562
- return move
563
- }
333
+ let startRepl = false
564
334
 
565
- commands.play.fn = (...moves) => {
566
- if (moves.length === 0 || !moves[0]) {
567
- error('You must provide a move.')
568
- return commands.help.fn('play')
569
- }
335
+ const getConfig = async () => {
336
+ await getFile('config', 'trax.yaml', CONFIG, YAML.parse)
337
+ return CONFIG
338
+ }
570
339
 
571
- if (!state.trax || !state.game.id) {
572
- commands.new.fn()
573
- }
340
+ const getData = async () => {
341
+ await getFile('data', 'trax.json', DATA, JSON.parse)
342
+ return DATA
343
+ }
574
344
 
575
- if (state.trax.over) {
576
- error('The game is over.')
577
- return
578
- }
345
+ const saveConfig = () => {
346
+ saveFile('config', 'trax.yaml', YAML.stringify(CONFIG))
347
+ }
579
348
 
580
- const start = state.trax.move
581
- let checkMove
349
+ const saveData = () => {
350
+ saveFile('data', 'trax.json', JSON.stringify(DATA, null, 2) + '\n')
351
+ }
582
352
 
583
- for (let move of moves) {
584
- if (notationRx.test(move)) {
585
- const moveNumber = state.trax.move
586
- move = fixNotation(move)
587
- if (checkMove) {
588
- state.trax.playMove(checkMove, move)
589
- } else {
590
- state.trax.play(move)
591
- }
353
+ const OPTS = {}
592
354
 
593
- if (state.trax.move === moveNumber) {
594
- error(
595
- 'Move "' +
596
- (checkMove ? checkMove + '. ' : '') +
597
- move +
598
- '" is invalid.',
599
- )
355
+ const parseCommandLine = () => {
356
+ let cmd = ''
357
+ const args = process.argv.slice(2)
358
+ let dash = true
359
+ for (const arg of args) {
360
+ if (dash && arg.startsWith('-')) {
361
+ if (arg === '--') dash = false
362
+ if (arg === '-i' || arg === '--interactive') startRepl = true
363
+ if (arg in OPTS) {
364
+ cmd = OPTS[arg] + ' ' + cmd
600
365
  }
601
- } else if (moveRx.test(move)) {
602
- checkMove = Number(move)
603
- } else if (move) {
604
- error('Move "' + move + '" is not in the correct notation.')
366
+ } else {
367
+ cmd += arg + ' '
605
368
  }
606
369
  }
607
370
 
608
- if (state.trax.move !== start) {
609
- updateGameData()
610
- commands.view.fn()
611
- }
371
+ return cmd.slice(0, -1)
612
372
  }
613
373
 
614
- commands.try.fn = (move) => {
615
- if (move.length === 0) {
616
- error('You must provide a move.')
617
- return commands.help.fn('try')
618
- }
374
+ // Auto-populate the color functions
375
+ for (const [name, value] of Object.entries(CLI.COLORS)) {
376
+ CLI.color[name] = (text) => color(text, value)
377
+ }
619
378
 
620
- if (!state.trax) {
621
- commands.new.fn()
622
- }
379
+ const forceArray = (item) =>
380
+ is(item, 'arr') ? item : is(item, 'undef') ? [] : [item]
623
381
 
624
- if (state.trax.over) {
625
- error('The game is over.')
626
- return
627
- }
382
+ const abbreviations = {}
383
+ const commands = {}
384
+ const aliases = []
385
+ const regexes = []
628
386
 
629
- if (notationRx.test(move)) {
630
- move = fixNotation(move)
631
- const play = state.trax.dropTile(move, undefined, 'tentative')
632
- if (play.valid || play.dropped.length > 0) {
633
- tty.display(state.trax, state.game.players, move)
634
- } else {
635
- error('Move is invalid.')
387
+ const addAbbrev = (abbr, full) => {
388
+ for (let abbrev of abbr) {
389
+ while (abbrev.length > 0) {
390
+ const abbrSet = abbreviations[abbrev] || new Set()
391
+ abbrSet.add(full)
392
+ abbreviations[abbrev] = abbrSet
393
+ abbrev = abbrev.slice(0, -1)
636
394
  }
637
- } else {
638
- error('Move is invalid.')
639
395
  }
640
396
  }
641
397
 
642
- commands.view.fn = (id) => {
643
- if (id) {
644
- // Hand off the hard work to the hard worker
645
- commands.select.fn(id)
646
- }
647
-
648
- if (!state.game?.id) {
649
- return error('No active game. Type "new" to start a game.')
650
- }
651
-
652
- tty.outty(
653
- color('#' + CONFIG.id + ' ') +
654
- tty.name(state.trax.rules) +
655
- ' ' +
656
- tty.bubble('w', state.game.players[0]) +
657
- color(' vs ') +
658
- tty.bubble('b', state.game.players[1]),
659
- )
660
- tty.display(state.trax, state.game.players)
398
+ const makeComp = (comp) => {
399
+ if (is(comp, 'arr') || is(comp, 'func')) return comp
400
+ if (is(comp, 'str')) return comp.split(' ')
401
+ return []
661
402
  }
662
403
 
663
- const plural = (n, noun, nouns) =>
664
- n + ' ' + (Math.abs(n) === 1 ? noun : nouns ? nouns : noun + 's')
404
+ const processPlugins = () => {
405
+ // Process all the plugin command entries
406
+ for (const cmd of plugins) {
407
+ if (cmd.opts) {
408
+ for (const opt of forceArray(cmd.opts)) {
409
+ OPTS[opt] = cmd.name
410
+ }
411
+ }
665
412
 
666
- const alphaSort = (a, b) => {
667
- a = String(a).toLowerCase()
668
- b = String(b).toLowerCase()
669
- if (a < b) return -1
670
- if (b < a) return 1
671
- return 0
672
- }
413
+ if (cmd.rx) {
414
+ for (const rx of forceArray(cmd.rx)) {
415
+ regexes.push({ rx, name: cmd.name })
416
+ }
417
+ }
673
418
 
674
- const sortBy = (sort) => {
675
- if (!sort) return 'id'
676
- sort = sort.toLowerCase()
677
- if (sort === '#') return 'sid'
678
- if ('ids'.startsWith(sort)) return 'sid'
679
- if ('games'.startsWith(sort)) return 'rules'
680
- if ('rules'.startsWith(sort)) return 'rules'
681
- if ('names'.startsWith(sort)) return 'rules'
682
- if ('numbers'.startsWith(sort)) return 'sid'
683
- if ('moves'.startsWith(sort)) return 'smoves'
684
- if ('status'.startsWith(sort)) return 'smoves'
685
- if ('turn'.startsWith(sort)) return 'sturn'
686
- if ('player'.startsWith(sort) && sort.endsWith('1')) return 'p1'
687
- if ('player'.startsWith(sort) && sort.endsWith('2')) return 'p2'
688
- if ('reverse'.startsWith(sort)) return 'reverse'
689
- return sort
690
- }
419
+ const entry = {
420
+ alt: forceArray(cmd.alt),
421
+ args: cmd.args || '',
422
+ comp: makeComp(cmd.comp),
423
+ desc: cmd.desc,
424
+ help: forceArray(cmd.help),
425
+ hide: cmd.hide,
426
+ fn: cmd.fn,
427
+ }
428
+ addAbbrev([cmd.name, ...entry.alt], cmd.name)
429
+ commands[cmd.name] = entry
691
430
 
692
- const listSort = (list, filters) => {
693
- if (list.length === 0) return list
694
- let reverse = false
695
- for (const sort of filters) {
696
- const by = sortBy(sort)
697
- if (by === 'reverse') {
698
- reverse = true
699
- } else if (list[0]?.[by]) {
700
- list.sort((a, b) => alphaSort(a[by], b[by]))
701
- } else if (by === 'trax') {
702
- list = list.filter((g) => g.rules === by)
703
- } else if (['over', 'active'].includes(by)) {
704
- list = list.filter((g) => (by === 'over' ? g.over : !g.over))
705
- } else {
706
- list = list.filter(
707
- (g) =>
708
- g.id.endsWith(by) ||
709
- Trax.names[g.rules].toLowerCase().includes(by) ||
710
- g.p1.toLowerCase().includes(by) ||
711
- g.p2.toLowerCase().includes(by) ||
712
- g.moves.startsWith(by + ' '),
713
- )
431
+ aliases.push(cmd.name)
432
+ for (const alias of entry.alt) {
433
+ aliases.push(alias)
714
434
  }
715
435
  }
716
436
 
717
- if (reverse) list.reverse()
718
-
719
- return list
437
+ for (const [abbr, value] of Object.entries(abbreviations)) {
438
+ const newValue = [...value]
439
+ abbreviations[abbr] = value.size > 1 ? newValue.sort() : newValue[0]
440
+ }
720
441
  }
721
442
 
722
- commands.list.fn = (...filters) => {
723
- if (!DATA.games || Object.keys(DATA.games).length === 0) {
724
- return error('No games. Type "new" to start a new game.')
443
+ const expand = (completions, match) => {
444
+ completions = [...completions] // Do not modify in-place
445
+ if (completions.includes('<cmd>')) {
446
+ // All commands
447
+ completions[completions.indexOf('<cmd>')] = aliases.join('|')
725
448
  }
726
449
 
727
- let list = []
728
- const size = {
729
- id: 1,
730
- name: 1,
731
- moves: 1,
732
- p1: 1,
733
- p2: 1,
734
- }
450
+ if (completions.includes('<id>')) {
451
+ // Game numbers
452
+ const ids = []
453
+ for (const game of Object.values(DATA.games || {})) {
454
+ if (game.id) ids.push('#' + game.id)
455
+ }
735
456
 
736
- for (const game of Object.values(DATA.games)) {
737
- const moves = game.moves.length === 0 ? 0 : game.moves.split(' ').length
738
- list.push({
739
- sid: String(game.id).padStart(9, '0'),
740
- id: (game.id === state.game.id ? '* #' : '#') + String(game.id),
741
- name: game.name?.length || 0,
742
- rules: game.rules,
743
- moves: plural(moves, 'move'),
744
- smoves: game.over ? '9999' : String(moves).padStart(9, '0'),
745
- sturn: String(game.turn),
746
- p1: game.players?.[0] || 'white',
747
- p2: game.players?.[1] || 'black',
748
- turn: game.turn,
749
- over: game.over,
750
- })
457
+ completions[completions.indexOf('<id>')] = ids.join('|')
751
458
  }
752
459
 
753
- list = listSort(list, filters)
460
+ if (completions.includes('<move>')) {
461
+ // Move numbers
462
+ const moves = []
463
+ for (let move = 1; move <= CLI.TRAX.moves.length; move++) {
464
+ moves.push(String(move))
465
+ }
754
466
 
755
- for (const game of list) {
756
- size.id = Math.max(size.id, game.id.length)
757
- size.name = Math.max(size.name, game.name)
758
- size.moves = Math.max(size.moves, game.moves.length)
759
- size.p1 = Math.max(size.p1, game.p1.length)
760
- size.p2 = Math.max(size.p2, game.p2.length)
467
+ completions[completions.indexOf('<move>')] = moves.join('|')
761
468
  }
762
469
 
763
- for (const game of list) {
764
- tty.outty(
765
- color(game.id.padStart(size.id) + ' ') +
766
- tty.name(game.rules) +
767
- ' '.repeat(size.name - game.name + 1) +
768
- tty.bubble(game.turn === 1 ? 'wh' : 'w', game.p1) +
769
- color('vs '.padStart(size.p1 - game.p1.length + 4)) +
770
- tty.bubble(game.turn === 2 ? 'bh' : 'b', game.p2) +
771
- color(' '.padStart(size.p2 - game.p2.length + 1)) +
772
- color(game.over ? 'game over' : game.moves),
773
- )
470
+ if (completions.includes('<play>')) {
471
+ // Moves
472
+ const moves = CLI.TRAX.possibleMoves()
473
+ completions[completions.indexOf('<play>')] = moves.join('|')
774
474
  }
775
- }
776
475
 
777
- const load = (id) => {
778
- if (!id) id = Object.keys(DATA.games || {})[0]
779
- if (id && DATA.games?.[id] && DATA.games[id].rules) {
780
- state.game = DATA.games[id]
781
- if (!state.game.players) state.game.players = ['white', 'black']
782
- state.trax = new Trax(state.game.rules, state.game.moves, 'cli')
783
- if (id !== String(CONFIG.id)) {
784
- CONFIG.id = id
785
- saveConfig()
786
- }
476
+ if (completions.includes('<file>')) {
477
+ // Files ... a bit tricky since we don't want to async in the middle of
478
+ // a completion, but we do want to list all the .trx files in the folder
479
+ // we are looking for ... so we cheat and initiate a search on the first
480
+ // call, and maybe have completions by the second or third call ...
481
+ completions[completions.indexOf('<file>')] = findFiles(match).join('|')
787
482
  }
788
- }
789
483
 
790
- commands.select.fn = (id) => {
791
- if (!id) {
792
- error('Missing id.')
793
- return commands.help.fn('select')
794
- }
795
-
796
- if (id.startsWith('#')) id = id.slice(1)
797
- load(id)
798
- if (String(state.game.id) === id) {
799
- tty.outty(color('Game #' + state.game.id + ' selected.'))
800
- } else {
801
- error('Invalid id. Type "list" to see available games.')
802
- }
484
+ return completions
803
485
  }
804
486
 
805
- const selectAnyGame = () => {
806
- if (DATA.games) {
807
- const games = Object.values(DATA.games).sort((a, b) => {
808
- // Active games first
809
- if (a.over && !b.over) return 1
810
- if (b.over && !a.over) return -1
811
- // Games with more moves first
812
- return (b.moves?.length || 0) - (a.moves?.length || 0)
813
- })
814
- if (games.length > 0) {
815
- commands.select.fn(String(games[0].id))
816
- }
817
- }
818
- }
487
+ const completer = (line) => {
488
+ const words = line.split(/\s+/).filter((w) => w)
819
489
 
820
- commands.delete.fn = (id, force = 'x') => {
821
- if (!id) id = String(state.game.id)
822
- if ('force'.startsWith(id)) {
823
- id = String(state.game.id)
824
- force = 'force'
490
+ let match = words.slice(-1)[0] || ''
491
+ const previous = new Set(words.slice(1, -1).map((s) => s.toLowerCase()))
492
+ if (line.endsWith(' ')) {
493
+ previous.add(match.toLowerCase())
494
+ match = ''
825
495
  }
826
496
 
827
- if (id.startsWith('#')) id = id.slice(1)
828
-
829
- const game = DATA.games?.[id]
830
-
831
- if (!game || !String(game.id)) {
832
- return error('Invalid id. Type "list" to see available games.')
833
- }
497
+ let completions = []
498
+ const cmd = abbreviations[String(words[0]).toLowerCase()]
834
499
 
835
- if (game.over || game.moves === '' || 'force'.startsWith(force)) {
836
- delete DATA.games[id]
837
- tty.outty(color('Deleted game #' + id + '.'))
838
- saveData()
500
+ if (is(cmd, 'str') && (line.endsWith(' ') || words.length > 1)) {
501
+ completions = commands[cmd].comp
502
+ if (is(completions, 'func')) {
503
+ completions = completions(CLI, words.slice(1), aliases)
504
+ }
839
505
 
840
- if (id === String(state.game.id)) {
841
- CONFIG.id = null
842
- state.game = null
843
- state.trax = new Trax()
844
- selectAnyGame()
506
+ completions = expand(completions, match)
507
+ if (words.length === 1 || (words.length === 2 && !line.endsWith(' '))) {
508
+ completions.push('help', '?')
845
509
  }
846
510
  } else {
847
- error('Game #' + id + ' is still active. Use "force" to delete it.')
511
+ completions = aliases
848
512
  }
849
- }
850
513
 
851
- commands.undo.fn = (id, n) => {
852
- if (Number(n) || (id && id.startsWith('#'))) {
853
- // Id has been supplied
854
- commands.select.fn(id)
855
- } else {
856
- n = id
514
+ for (let i = 0; i < completions.length; i++) {
515
+ const word = completions[i]
516
+ if (word.includes('|')) {
517
+ const comps = word.split('|')
518
+ if (comps.some((c) => previous.has(c.toLowerCase()))) {
519
+ completions[i] = ''
520
+ } else {
521
+ completions[i] = comps
522
+ }
523
+ }
857
524
  }
858
525
 
859
- n = Math.max(1, Math.floor(Number(n) || 1)) // Get an integer 1 or higher
526
+ completions = completions
527
+ .flat()
528
+ .filter((c) => c && !previous.has(c.toLowerCase()))
860
529
 
861
- const moves = state.game.moves.split(' ')
862
- const undo = moves.slice(0, 0 - n)
863
- if (moves.length === undo.length) {
864
- error('Unable to undo.')
865
- } else {
866
- if (state.game.over) state.game.over = false
867
- state.game.moves = undo.join(' ')
868
- state.trax = new Trax(state.game.rules, undo, 'cli')
869
- state.game.turn = state.trax.turn
870
- saveData()
871
- commands.view.fn()
530
+ if (match && completions.length > 0) {
531
+ completions = completions.filter((c) => {
532
+ return c.toLowerCase().startsWith(match.toLowerCase())
533
+ })
872
534
  }
873
- }
874
535
 
875
- commands.quit.fn = () => {
876
- // I actually have no idea why a broken promise is required here,
877
- // but I tried a lot of different options, and this is what worked
878
- return new Promise(() => {
879
- if (state.repl) {
880
- state.repl.close()
881
- }
882
- })
883
- }
884
-
885
- const abbreviations = {}
886
-
887
- const addAbbrev = (abbr, full) => {
888
- const abbrArray = is(abbr, 'str') ? [abbr] : abbr
889
- for (let abbrev of abbrArray) {
890
- while (abbrev.length > 0) {
891
- const abbrSet = abbreviations[abbrev] || new Set()
892
- abbrSet.add(full)
893
- abbreviations[abbrev] = abbrSet
894
- abbrev = abbrev.slice(0, -1)
895
- }
896
- }
897
- }
536
+ if (!match && completions.length === 1) completions.push(' ')
898
537
 
899
- const addAbbreviations = () => {
900
- for (const key of Object.keys(commands)) {
901
- addAbbrev(key, key)
902
- if (commands[key].alt) addAbbrev(commands[key].alt, key)
903
- }
538
+ completions = completions.map((c) => c + ' ').sort()
904
539
 
905
- for (const [abbr, value] of Object.entries(abbreviations)) {
906
- const newValue = [...value]
907
- abbreviations[abbr] = value.size > 1 ? newValue.sort() : newValue[0]
908
- }
540
+ return [completions, match]
909
541
  }
910
542
 
911
543
  const evaluate = async (command) => {
912
- const [CMD, ...words] = command.split(/\s+/)
544
+ const [CMD, ...words] = command.split(/\s+/).filter((c) => c)
913
545
  const cmdLower = CMD.toLowerCase()
546
+ CLI.CMD = cmdLower
914
547
  const cmd = abbreviations[cmdLower]
915
548
  if (is(cmd, 'str')) {
916
549
  if (words.length > 0 && abbreviations[words[0].toLowerCase()] === 'help') {
917
- commands.help.fn(cmd)
550
+ commands.help.fn(CLI, cmd)
918
551
  } else {
919
- await commands[cmd].fn(...words)
552
+ await commands[cmd].fn(CLI, ...words)
920
553
  }
921
554
  } else if (is(cmd, 'arr')) {
922
- error('Ambiguous command. Perhaps you meant one of the following:')
555
+ CLI.error('Ambiguous command. Perhaps you meant one of the following:')
923
556
  const maybe = new Set()
924
557
 
925
558
  for (const alias of cmd) {
926
559
  if (alias.startsWith(cmdLower)) maybe.add(alias)
927
560
 
928
- let aliases = commands[alias].alt
929
- aliases = is(aliases, 'arr') ? aliases : [aliases || '']
561
+ let alts = commands[alias].alt
562
+ alts = is(alts, 'arr') ? alts : [alts || '']
930
563
 
931
- for (const alt of aliases) {
564
+ for (const alt of alts) {
932
565
  if (alt.startsWith(cmdLower)) maybe.add(alt)
933
566
  }
934
567
  }
935
568
 
936
569
  if (maybe.size > 0) {
937
570
  for (const alias of [...maybe].sort()) {
938
- tty.outty(color(' ' + alias, COLORS.command))
571
+ tty.outty(CLI.color.command(' ' + alias))
939
572
  }
940
573
  }
941
574
  } else if (CMD) {
942
- if (CMD.startsWith('#') && CMD.length > 1) {
943
- commands.select.fn(CMD.slice(1))
944
- } else if (
945
- notationRx.test(CMD) ||
946
- (moveRx.test(CMD) && notationRx.test(words[0]))
947
- ) {
948
- commands.play.fn(CMD, ...words)
575
+ let rxCmd
576
+ for (const regex of regexes) {
577
+ if (regex.rx.test(CMD)) rxCmd = regex.name
578
+ }
579
+
580
+ if (rxCmd) {
581
+ await commands[rxCmd].fn(CLI, CMD, ...words)
949
582
  } else {
950
- error('Unknown command. Type "help" for a list of valid commands.')
583
+ CLI.error('Unknown command. Type "help" for a list of valid commands.')
951
584
  }
952
585
  }
953
586
 
@@ -955,11 +588,11 @@ const evaluate = async (command) => {
955
588
  }
956
589
 
957
590
  const main = async () => {
591
+ processPlugins()
958
592
  const command = parseCommandLine()
959
593
  if (is(command, 'str')) {
960
594
  await Promise.all([getConfig(), getData()])
961
- load(CONFIG.id)
962
- addAbbreviations()
595
+ CLI.load(CONFIG.id)
963
596
  if (command) await evaluate(command)
964
597
  if (startRepl || !command) {
965
598
  if (!command) {
@@ -967,25 +600,21 @@ const main = async () => {
967
600
  tty.outty(color('Type "help" for more information.'))
968
601
  }
969
602
 
970
- state.repl = repl.start({
603
+ CLI.repl = repl.start({
971
604
  prompt: 'trax❯ ',
972
605
  async eval(cmd, ctx, fn, cb) {
973
606
  try {
974
607
  await evaluate(cmd)
975
608
  } catch (error) {
976
- tty.outty(error, COLORS.fatal)
609
+ tty.outty(error, CLI.COLORS.fatal)
977
610
  throw error
978
611
  }
979
612
 
980
613
  cb(null, undefined)
981
614
  },
982
- writer(value) {
983
- if (value) {
984
- return value
985
- }
986
-
987
- return ''
988
- },
615
+ ignoreUndefined: true,
616
+ completer,
617
+ preview: true,
989
618
  })
990
619
  }
991
620
  }