@slugbugblue/trax 0.7.0 → 0.8.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 ADDED
@@ -0,0 +1,994 @@
1
+ #!/usr/bin/env node
2
+ /* Copyright 2022 Chad Transtrum
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ // CLI for Trax, with a fallback REPL
18
+
19
+ import fs from 'node:fs/promises'
20
+ import process from 'node:process'
21
+ import repl from 'node:repl'
22
+
23
+ import envPaths from 'env-paths'
24
+ import makeDir from 'make-dir'
25
+ import YAML from 'yaml'
26
+
27
+ import { Trax } from './engine.js'
28
+ import { version } from './version.js'
29
+ import * as tty from './tty.js'
30
+
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
60
+
61
+ // Because typeof [] and null are both 'object'. is([], 'arr') => true
62
+ const is = (variable, type) =>
63
+ Object.prototype.toString
64
+ .call(variable)
65
+ .slice(8, -1)
66
+ .toLowerCase()
67
+ .startsWith(type)
68
+
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)
110
+ }
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')
153
+ }
154
+
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 + ' '
171
+ }
172
+ }
173
+
174
+ return action && cmd.length === 0 ? undefined : cmd.slice(0, -1)
175
+ }
176
+
177
+ const color = (text, def) => {
178
+ let out = ''
179
+ let quote = false
180
+ let id = false
181
+ 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()
184
+ for (const char of text) {
185
+ switch (char) {
186
+ case '"':
187
+ if (quote) {
188
+ colors.pop()
189
+ } else {
190
+ colors.push(COLORS.command)
191
+ }
192
+
193
+ out += c('')
194
+ quote = !quote
195
+ break
196
+ case '#':
197
+ id = true
198
+ colors.push(COLORS.id)
199
+ out += c('#')
200
+ break
201
+ case '|':
202
+ out += short('|')
203
+ break
204
+ case '[':
205
+ colors.push(COLORS.optional)
206
+ out += short('[')
207
+ break
208
+ case ']':
209
+ colors.pop()
210
+ out += short(']')
211
+ break
212
+ case '<':
213
+ colors.push(COLORS.variable)
214
+ out += c('')
215
+ break
216
+ case '>':
217
+ colors.pop()
218
+ out += c('')
219
+ break
220
+ default:
221
+ if (id && !/[\da-z]/.test(char)) {
222
+ id = false
223
+ colors.pop()
224
+ out += c(char)
225
+ } else {
226
+ out += char
227
+ }
228
+ }
229
+ }
230
+
231
+ return tty.color(out, def || COLORS.default)
232
+ }
233
+
234
+ const error = (text) => {
235
+ tty.outty(color(text, COLORS.error))
236
+ }
237
+
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.',
319
+ },
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
+ ],
328
+ },
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
+ ],
336
+ },
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".',
350
+ },
351
+ }
352
+
353
+ const length = (text) => (text ? text.replace(/[<">]/g, '').length : 0)
354
+
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
+ }
361
+
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
+ }
367
+
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
+ }
377
+
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
+ }
387
+
388
+ tty.outty()
389
+ if (is(topic.help, 'str')) {
390
+ tty.outty(' ' + color(topic.help, COLORS.help))
391
+ } else {
392
+ for (const line of topic.help || []) {
393
+ tty.outty(' ' + color(line, COLORS.help))
394
+ }
395
+ }
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)
412
+
413
+ return String(min - 1)
414
+ }
415
+
416
+ commands.import.fn = async (filename) => {
417
+ if (!filename) {
418
+ error('Missing filename.')
419
+ return commands.help.fn('import')
420
+ }
421
+
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
+ }
432
+ }
433
+
434
+ if (!content) error(fsError.toString())
435
+ }
436
+
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
+ }
456
+
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
+ }
465
+
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
474
+ }
475
+ }
476
+
477
+ if (!game || !game.id) {
478
+ return error('No active game. Type "new" to start a game.')
479
+ }
480
+
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
+ }
499
+
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
+ saveData()
508
+ }
509
+ }
510
+
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
+ }
527
+ }
528
+ }
529
+
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
+ }
539
+
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]
551
+
552
+ saveConfig()
553
+ saveData()
554
+
555
+ tty.outty(color('Started new game #' + CONFIG.id))
556
+ }
557
+
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
+ }
564
+
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
+ }
570
+
571
+ if (!state.trax || !state.game.id) {
572
+ commands.new.fn()
573
+ }
574
+
575
+ if (state.trax.over) {
576
+ error('The game is over.')
577
+ return
578
+ }
579
+
580
+ const start = state.trax.move
581
+ let checkMove
582
+
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
+ }
592
+
593
+ if (state.trax.move === moveNumber) {
594
+ error(
595
+ 'Move "' +
596
+ (checkMove ? checkMove + '. ' : '') +
597
+ move +
598
+ '" is invalid.',
599
+ )
600
+ }
601
+ } else if (moveRx.test(move)) {
602
+ checkMove = Number(move)
603
+ } else if (move) {
604
+ error('Move "' + move + '" is not in the correct notation.')
605
+ }
606
+ }
607
+
608
+ if (state.trax.move !== start) {
609
+ updateGameData()
610
+ commands.view.fn()
611
+ }
612
+ }
613
+
614
+ commands.try.fn = (move) => {
615
+ if (move.length === 0) {
616
+ error('You must provide a move.')
617
+ return commands.help.fn('try')
618
+ }
619
+
620
+ if (!state.trax) {
621
+ commands.new.fn()
622
+ }
623
+
624
+ if (state.trax.over) {
625
+ error('The game is over.')
626
+ return
627
+ }
628
+
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.')
636
+ }
637
+ } else {
638
+ error('Move is invalid.')
639
+ }
640
+ }
641
+
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)
661
+ }
662
+
663
+ const plural = (n, noun, nouns) =>
664
+ n + ' ' + (Math.abs(n) === 1 ? noun : nouns ? nouns : noun + 's')
665
+
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
+ }
673
+
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
+ }
691
+
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
+ )
714
+ }
715
+ }
716
+
717
+ if (reverse) list.reverse()
718
+
719
+ return list
720
+ }
721
+
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.')
725
+ }
726
+
727
+ let list = []
728
+ const size = {
729
+ id: 1,
730
+ name: 1,
731
+ moves: 1,
732
+ p1: 1,
733
+ p2: 1,
734
+ }
735
+
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
+ })
751
+ }
752
+
753
+ list = listSort(list, filters)
754
+
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)
761
+ }
762
+
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
+ )
774
+ }
775
+ }
776
+
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
+ }
787
+ }
788
+ }
789
+
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
+ }
803
+ }
804
+
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
+ }
819
+
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'
825
+ }
826
+
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
+ }
834
+
835
+ if (game.over || game.moves === '' || 'force'.startsWith(force)) {
836
+ delete DATA.games[id]
837
+ tty.outty(color('Deleted game #' + id + '.'))
838
+ saveData()
839
+
840
+ if (id === String(state.game.id)) {
841
+ CONFIG.id = null
842
+ state.game = null
843
+ state.trax = new Trax()
844
+ selectAnyGame()
845
+ }
846
+ } else {
847
+ error('Game #' + id + ' is still active. Use "force" to delete it.')
848
+ }
849
+ }
850
+
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
857
+ }
858
+
859
+ n = Math.max(1, Math.floor(Number(n) || 1)) // Get an integer 1 or higher
860
+
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()
872
+ }
873
+ }
874
+
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
+ }
898
+
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
+ }
904
+
905
+ for (const [abbr, value] of Object.entries(abbreviations)) {
906
+ const newValue = [...value]
907
+ abbreviations[abbr] = value.size > 1 ? newValue.sort() : newValue[0]
908
+ }
909
+ }
910
+
911
+ const evaluate = async (command) => {
912
+ const [CMD, ...words] = command.split(/\s+/)
913
+ const cmdLower = CMD.toLowerCase()
914
+ const cmd = abbreviations[cmdLower]
915
+ if (is(cmd, 'str')) {
916
+ if (words.length > 0 && abbreviations[words[0].toLowerCase()] === 'help') {
917
+ commands.help.fn(cmd)
918
+ } else {
919
+ await commands[cmd].fn(...words)
920
+ }
921
+ } else if (is(cmd, 'arr')) {
922
+ error('Ambiguous command. Perhaps you meant one of the following:')
923
+ const maybe = new Set()
924
+
925
+ for (const alias of cmd) {
926
+ if (alias.startsWith(cmdLower)) maybe.add(alias)
927
+
928
+ let aliases = commands[alias].alt
929
+ aliases = is(aliases, 'arr') ? aliases : [aliases || '']
930
+
931
+ for (const alt of aliases) {
932
+ if (alt.startsWith(cmdLower)) maybe.add(alt)
933
+ }
934
+ }
935
+
936
+ if (maybe.size > 0) {
937
+ for (const alias of [...maybe].sort()) {
938
+ tty.outty(color(' ' + alias, COLORS.command))
939
+ }
940
+ }
941
+ } 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)
949
+ } else {
950
+ error('Unknown command. Type "help" for a list of valid commands.')
951
+ }
952
+ }
953
+
954
+ return undefined
955
+ }
956
+
957
+ const main = async () => {
958
+ const command = parseCommandLine()
959
+ if (is(command, 'str')) {
960
+ await Promise.all([getConfig(), getData()])
961
+ load(CONFIG.id)
962
+ addAbbreviations()
963
+ if (command) await evaluate(command)
964
+ if (startRepl || !command) {
965
+ if (!command) {
966
+ tty.outty(color('Welcome to the Trax CLI v' + version + '.'))
967
+ tty.outty(color('Type "help" for more information.'))
968
+ }
969
+
970
+ state.repl = repl.start({
971
+ prompt: 'trax❯ ',
972
+ async eval(cmd, ctx, fn, cb) {
973
+ try {
974
+ await evaluate(cmd)
975
+ } catch (error) {
976
+ tty.outty(error, COLORS.fatal)
977
+ throw error
978
+ }
979
+
980
+ cb(null, undefined)
981
+ },
982
+ writer(value) {
983
+ if (value) {
984
+ return value
985
+ }
986
+
987
+ return ''
988
+ },
989
+ })
990
+ }
991
+ }
992
+ }
993
+
994
+ main()