@slugbugblue/trax-cli 0.12.1 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +8 -0
- package/CHANGELOG.md +6 -0
- package/LICENSE +1 -1
- package/README.md +1 -1
- package/eslint.config.js +24 -0
- package/package.json +7 -18
- package/src/cli.js +246 -106
- package/src/cmds/analyze.js +72 -66
- package/src/cmds/delete.js +35 -43
- package/src/cmds/help.js +54 -57
- package/src/cmds/import-export.js +148 -122
- package/src/cmds/index.js +40 -0
- package/src/cmds/list.js +92 -87
- package/src/cmds/new.js +50 -55
- package/src/cmds/notes.js +20 -18
- package/src/cmds/play-try.js +147 -106
- package/src/cmds/puzzles.js +53 -39
- package/src/cmds/select.js +22 -31
- package/src/cmds/suggest.js +28 -37
- package/src/cmds/undo.js +31 -26
- package/src/cmds/view.js +42 -51
- package/src/utils.js +1 -1
- package/src/version.js +1 -1
- package/xo.config.js +10 -0
- package/src/types.d.ts +0 -33
|
@@ -1,19 +1,14 @@
|
|
|
1
|
-
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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.
|
|
1
|
+
/** CLI import/export commands
|
|
2
|
+
* @copyright 2022-2026
|
|
3
|
+
* @author Chad Transtrum <chad@transtrum.net>
|
|
4
|
+
* @license Apache-2.0
|
|
14
5
|
*/
|
|
15
6
|
|
|
16
|
-
|
|
7
|
+
/**
|
|
8
|
+
* @typedef {import('../cli.js').CLIContext} CLIContext
|
|
9
|
+
* @typedef {import('../cli.js').TraxVariant} TraxVariant
|
|
10
|
+
* @typedef {import('../cli.js').GameNotes} GameNotes
|
|
11
|
+
*/
|
|
17
12
|
|
|
18
13
|
import fs from 'node:fs/promises'
|
|
19
14
|
import process from 'node:process'
|
|
@@ -22,14 +17,17 @@ import { Trax } from '@slugbugblue/trax'
|
|
|
22
17
|
import { puzzles, sources } from '@slugbugblue/trax-puzzles'
|
|
23
18
|
|
|
24
19
|
const paths = envPaths('trax', { suffix: '' })
|
|
20
|
+
/** @type {Record<string, string>} */
|
|
25
21
|
const PATHS = {}
|
|
26
22
|
|
|
27
23
|
// Store a copy of the CLI context object locally
|
|
24
|
+
/** @type {Partial<CLIContext>} */
|
|
28
25
|
let cli = {}
|
|
29
26
|
|
|
27
|
+
/** @param {unknown} fsError */
|
|
30
28
|
const fsOuch = (fsError) => {
|
|
31
29
|
if (cli.out) {
|
|
32
|
-
cli.out(fsError
|
|
30
|
+
cli.out(String(fsError))
|
|
33
31
|
} else {
|
|
34
32
|
console.log(fsError)
|
|
35
33
|
}
|
|
@@ -37,18 +35,23 @@ const fsOuch = (fsError) => {
|
|
|
37
35
|
throw fsError
|
|
38
36
|
}
|
|
39
37
|
|
|
38
|
+
/** @param {string} name @returns {string} */
|
|
40
39
|
const expandPath = (name) => {
|
|
41
40
|
// Expand home directory ... anything else?
|
|
42
|
-
if (name.includes('~')) name = name.replace('~', process.env.HOME)
|
|
41
|
+
if (name.includes('~')) name = name.replace('~', process.env.HOME ?? '~')
|
|
43
42
|
return name
|
|
44
43
|
}
|
|
45
44
|
|
|
46
45
|
// Return the actual path on disk, creating it if necessary.
|
|
47
46
|
// Path is one of cache, config, data, log, temp.
|
|
47
|
+
/** @param {string} path @returns {Promise<string | false>} */
|
|
48
48
|
const getPath = async (path) => {
|
|
49
49
|
if (path in PATHS) return PATHS[path]
|
|
50
50
|
if (path in paths) {
|
|
51
|
-
const
|
|
51
|
+
const pathsRecord = /** @type {Record<string, string>} */ (
|
|
52
|
+
/** @type {unknown} */ (paths)
|
|
53
|
+
)
|
|
54
|
+
const realPath = pathsRecord[path]
|
|
52
55
|
await fs.mkdir(realPath, { recursive: true })
|
|
53
56
|
PATHS[path] = realPath
|
|
54
57
|
return realPath
|
|
@@ -57,6 +60,7 @@ const getPath = async (path) => {
|
|
|
57
60
|
return false
|
|
58
61
|
}
|
|
59
62
|
|
|
63
|
+
/** @returns {string} */
|
|
60
64
|
const hexy = (length = 6) => {
|
|
61
65
|
let hex = ''
|
|
62
66
|
while (hex.length < length) {
|
|
@@ -66,6 +70,13 @@ const hexy = (length = 6) => {
|
|
|
66
70
|
return hex
|
|
67
71
|
}
|
|
68
72
|
|
|
73
|
+
/**
|
|
74
|
+
* @param {string} filetype
|
|
75
|
+
* @param {string} filename
|
|
76
|
+
* @param {Record<string, unknown>} variable
|
|
77
|
+
* @param {(s: string) => Record<string, unknown>} decode
|
|
78
|
+
* @returns {Promise<Record<string, unknown>>}
|
|
79
|
+
*/
|
|
69
80
|
export const getFile = async (filetype, filename, variable, decode) => {
|
|
70
81
|
const path = await getPath(filetype)
|
|
71
82
|
if (path) {
|
|
@@ -82,7 +93,9 @@ export const getFile = async (filetype, filename, variable, decode) => {
|
|
|
82
93
|
} else {
|
|
83
94
|
console.log(error)
|
|
84
95
|
}
|
|
85
|
-
} else if (
|
|
96
|
+
} else if (
|
|
97
|
+
/** @type {NodeJS.ErrnoException} */ (fsError).code !== 'ENOENT'
|
|
98
|
+
) {
|
|
86
99
|
fsOuch(fsError)
|
|
87
100
|
}
|
|
88
101
|
}
|
|
@@ -91,6 +104,12 @@ export const getFile = async (filetype, filename, variable, decode) => {
|
|
|
91
104
|
return variable
|
|
92
105
|
}
|
|
93
106
|
|
|
107
|
+
/**
|
|
108
|
+
* @param {string} filetype
|
|
109
|
+
* @param {string} filename
|
|
110
|
+
* @param {string} data
|
|
111
|
+
* @returns {Promise<void>}
|
|
112
|
+
*/
|
|
94
113
|
export const saveFile = async (filetype, filename, data) => {
|
|
95
114
|
const path = await getPath(filetype)
|
|
96
115
|
if (path) {
|
|
@@ -118,6 +137,36 @@ export const importCmd = {
|
|
|
118
137
|
'Load a ".trx" game file into memory. Once imported, it will be assigned',
|
|
119
138
|
'a game id and automatically selected for future commands.',
|
|
120
139
|
],
|
|
140
|
+
/** @param {CLIContext} CLI @param {string} [filename] */
|
|
141
|
+
async fn(CLI, filename) {
|
|
142
|
+
cli = CLI
|
|
143
|
+
if (!filename) {
|
|
144
|
+
CLI.error('Missing filename.')
|
|
145
|
+
return CLI.do('help', 'import')
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
let content
|
|
149
|
+
try {
|
|
150
|
+
content = await fs.readFile(expandPath(filename), 'utf8')
|
|
151
|
+
} catch (fsError) {
|
|
152
|
+
if (
|
|
153
|
+
/** @type {NodeJS.ErrnoException} */ (fsError).code === 'ENOENT' &&
|
|
154
|
+
!filename.endsWith('.trx')
|
|
155
|
+
) {
|
|
156
|
+
try {
|
|
157
|
+
content = await fs.readFile(expandPath(filename + '.trx'), 'utf8')
|
|
158
|
+
} catch {
|
|
159
|
+
content = null
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (!content) return CLI.error(String(fsError))
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (!interpretFile(CLI, content)) {
|
|
167
|
+
CLI.error(filename + ' does not appear to be formatted correctly.')
|
|
168
|
+
}
|
|
169
|
+
},
|
|
121
170
|
}
|
|
122
171
|
|
|
123
172
|
export const exportCmd = {
|
|
@@ -130,29 +179,95 @@ export const exportCmd = {
|
|
|
130
179
|
'Export the current game to an external ".trx" file for safe-keeping or',
|
|
131
180
|
'to share. Specify a game id to export a different game.',
|
|
132
181
|
],
|
|
182
|
+
/** @param {CLIContext} CLI @param {string} [id] @param {string} [filename] */
|
|
183
|
+
async fn(CLI, id, filename) {
|
|
184
|
+
cli = CLI
|
|
185
|
+
let game = CLI.GAME
|
|
186
|
+
if (id) {
|
|
187
|
+
const newid = id.replace(/^#/v, '')
|
|
188
|
+
game = CLI.GAMES[newid]
|
|
189
|
+
if (!game) {
|
|
190
|
+
filename = id
|
|
191
|
+
game = CLI.GAME
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (!game || !game.id) {
|
|
196
|
+
return CLI.error('No active game. Type "new" to start a game.')
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const trax = new Trax(game.rules, game.moves, 'cli')
|
|
200
|
+
|
|
201
|
+
let content = Trax.names[game.rules].replace(' ', '') + '\n'
|
|
202
|
+
content += game.players?.[0] || 'white'
|
|
203
|
+
content += ' vs '
|
|
204
|
+
content += game.players?.[1] || 'black'
|
|
205
|
+
content += '\n; @slugbugblue/trax cli.js v' + CLI.version + '\n'
|
|
206
|
+
if (game.puzzle) {
|
|
207
|
+
const puzzle = puzzles.find((p) => p.id === game.puzzle)
|
|
208
|
+
if (puzzle) {
|
|
209
|
+
const source = sources[puzzle.src]
|
|
210
|
+
content += '; Puzzle ' + game.puzzle
|
|
211
|
+
if (source) {
|
|
212
|
+
content += source.copyright
|
|
213
|
+
? ' ©' + source.copyright + ' by '
|
|
214
|
+
: ' provided courtesy of '
|
|
215
|
+
content += source.name
|
|
216
|
+
if (source.url) content += '\n; ' + source.url
|
|
217
|
+
if (source.license) {
|
|
218
|
+
content += '\n; Licensed under ' + source.license
|
|
219
|
+
content += source.licenseUrl ? ' ' + source.licenseUrl : ''
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
content += '\n'
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
content += interleaveNotation(trax.moves, game.notes)
|
|
228
|
+
|
|
229
|
+
filename &&= filename.replaceAll(/[^ a-z\d.~\/]/gv, '')
|
|
230
|
+
if (filename) {
|
|
231
|
+
const fname = filename.split('/').pop()
|
|
232
|
+
if (!fname) filename += game.rules + game.id
|
|
233
|
+
if (!fname || !fname.includes('.')) filename += '.trx'
|
|
234
|
+
CLI.out(CLI.color('Exporting #' + game.id + ' to ') + filename)
|
|
235
|
+
try {
|
|
236
|
+
fs.writeFile(expandPath(filename), content)
|
|
237
|
+
} catch (error) {
|
|
238
|
+
fsOuch(error)
|
|
239
|
+
}
|
|
240
|
+
} else {
|
|
241
|
+
CLI.out(content)
|
|
242
|
+
}
|
|
243
|
+
},
|
|
133
244
|
}
|
|
134
245
|
|
|
135
246
|
/** Interpret the rules string of a .trx file.
|
|
136
|
-
* @
|
|
247
|
+
* @param {string} r - rules string in lower case
|
|
137
248
|
* @returns {TraxVariant}
|
|
138
249
|
*/
|
|
139
250
|
const getRules = (r) =>
|
|
140
251
|
r.includes('loop') ? 'traxloop' : r.includes('8x8') ? 'trax8' : 'trax'
|
|
141
252
|
|
|
142
253
|
/** Decode a .trx formatted string into a game, preserving players and comments.
|
|
143
|
-
* @
|
|
144
|
-
* @
|
|
254
|
+
* @param {CLIContext} CLI - the CLI object
|
|
255
|
+
* @param {string} content - the contents of the .trx file
|
|
145
256
|
* @returns {boolean} - true if a game was created
|
|
146
257
|
*/
|
|
147
258
|
const interpretFile = (CLI, content) => {
|
|
259
|
+
/** @type {TraxVariant | undefined} */
|
|
148
260
|
let rules
|
|
149
261
|
let trax
|
|
262
|
+
/** @type {string[]} */
|
|
150
263
|
let players = []
|
|
264
|
+
/** @type {string[]} */
|
|
151
265
|
const moves = []
|
|
266
|
+
/** @type {GameNotes} */
|
|
152
267
|
const comments = []
|
|
153
268
|
for (const line of content.split('\n')) {
|
|
154
269
|
if (!line) continue
|
|
155
|
-
const [ln, ...comment] = line.split(/[#;]/)
|
|
270
|
+
const [ln, ...comment] = line.split(/[#;]/v)
|
|
156
271
|
if (ln) {
|
|
157
272
|
const lower = ln.toLowerCase()
|
|
158
273
|
if (!rules && lower.includes('trax')) {
|
|
@@ -183,7 +298,7 @@ const interpretFile = (CLI, content) => {
|
|
|
183
298
|
|
|
184
299
|
if (comment.length > 0) {
|
|
185
300
|
const note = comment.map((c) => c.trim()).join(' ')
|
|
186
|
-
if (!/^@slugbugblue\/trax cli\.js v
|
|
301
|
+
if (!/^@slugbugblue\/trax cli\.js v/v.test(note)) {
|
|
187
302
|
comments.push({
|
|
188
303
|
note,
|
|
189
304
|
move: trax?.move || 0,
|
|
@@ -205,36 +320,9 @@ const interpretFile = (CLI, content) => {
|
|
|
205
320
|
return true
|
|
206
321
|
}
|
|
207
322
|
|
|
208
|
-
importCmd.fn = async (CLI, filename) => {
|
|
209
|
-
cli = CLI
|
|
210
|
-
if (!filename) {
|
|
211
|
-
CLI.error('Missing filename.')
|
|
212
|
-
return CLI.do('help', 'import')
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
let content
|
|
216
|
-
try {
|
|
217
|
-
content = await fs.readFile(expandPath(filename), 'utf8')
|
|
218
|
-
} catch (fsError) {
|
|
219
|
-
if (fsError.code === 'ENOENT' && !filename.endsWith('.trx')) {
|
|
220
|
-
try {
|
|
221
|
-
content = await fs.readFile(expandPath(filename + '.trx'), 'utf8')
|
|
222
|
-
} catch {
|
|
223
|
-
content = null
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
if (!content) return CLI.error(fsError.toString())
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
if (!interpretFile(CLI, content)) {
|
|
231
|
-
CLI.error(filename + ' does not appear to be formatted correctly.')
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
|
|
235
323
|
/** Get all the notes for a certain move.
|
|
236
|
-
* @
|
|
237
|
-
* @
|
|
324
|
+
* @param {number} move - The move number
|
|
325
|
+
* @param {GameNotes} [notes] - The notes for a game
|
|
238
326
|
* @returns {string} - Notes formatted for this move, or empty string if none
|
|
239
327
|
*/
|
|
240
328
|
const gameNotes = (move, notes) => {
|
|
@@ -248,18 +336,18 @@ const gameNotes = (move, notes) => {
|
|
|
248
336
|
)
|
|
249
337
|
}
|
|
250
338
|
|
|
251
|
-
/** Join two strings together with
|
|
252
|
-
*
|
|
253
|
-
* @
|
|
254
|
-
* @
|
|
339
|
+
/** Join two strings together with the appropriate joining character
|
|
340
|
+
* depending on the combined length of the strings.
|
|
341
|
+
* @param {string} a - the first string
|
|
342
|
+
* @param {string} b - the second string
|
|
255
343
|
* @returns {string} - both strings correctly joined
|
|
256
344
|
*/
|
|
257
345
|
const join = (a, b) =>
|
|
258
346
|
a.length === 0 ? b : a.length + b.length > 78 ? a + '\n' + b : a + ' ' + b
|
|
259
347
|
|
|
260
348
|
/** Interleave the game moves with any notes, formatted for the export file.
|
|
261
|
-
* @
|
|
262
|
-
* @
|
|
349
|
+
* @param {string[]} moves - an array of move notations
|
|
350
|
+
* @param {GameNotes} [notes] - The notes for a game
|
|
263
351
|
* @returns {string} - A string that can be placed into an export file
|
|
264
352
|
*/
|
|
265
353
|
const interleaveNotation = (moves, notes) => {
|
|
@@ -294,70 +382,10 @@ const interleaveNotation = (moves, notes) => {
|
|
|
294
382
|
return content
|
|
295
383
|
}
|
|
296
384
|
|
|
297
|
-
|
|
298
|
-
cli = CLI
|
|
299
|
-
let game = CLI.GAME
|
|
300
|
-
if (id) {
|
|
301
|
-
const newid = id.replace(/^#/, '')
|
|
302
|
-
game = CLI.GAMES[newid]
|
|
303
|
-
if (!game) {
|
|
304
|
-
filename = id
|
|
305
|
-
game = CLI.GAME
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
if (!game || !game.id) {
|
|
310
|
-
return CLI.error('No active game. Type "new" to start a game.')
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
const trax = new Trax(game.rules, game.moves, 'cli')
|
|
314
|
-
|
|
315
|
-
let content = Trax.names[trax.rules].replace(' ', '') + '\n'
|
|
316
|
-
content += game.players?.[0] || 'white'
|
|
317
|
-
content += ' vs '
|
|
318
|
-
content += game.players?.[1] || 'black'
|
|
319
|
-
content += '\n; @slugbugblue/trax cli.js v' + CLI.version + '\n'
|
|
320
|
-
if (game.puzzle) {
|
|
321
|
-
const puzzle = puzzles.find((p) => p.id === game.puzzle)
|
|
322
|
-
if (puzzle) {
|
|
323
|
-
const source = sources[puzzle.src]
|
|
324
|
-
content += '; Puzzle ' + game.puzzle
|
|
325
|
-
if (source) {
|
|
326
|
-
content += source.copyright
|
|
327
|
-
? ' ©' + source.copyright + ' by '
|
|
328
|
-
: ' provided courtesy of '
|
|
329
|
-
content += source.name
|
|
330
|
-
if (source.url) content += '\n; ' + source.url
|
|
331
|
-
if (source.license) {
|
|
332
|
-
content += '\n; Licensed under ' + source.license
|
|
333
|
-
content += source.licenseUrl ? ' ' + source.licenseUrl : ''
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
content += '\n'
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
content += interleaveNotation(trax.moves, game.notes)
|
|
342
|
-
|
|
343
|
-
filename &&= filename.replaceAll(/[^ a-z\d.~/]/g, '')
|
|
344
|
-
if (filename) {
|
|
345
|
-
const fname = filename.split('/').pop()
|
|
346
|
-
if (!fname) filename += game.rules + game.id
|
|
347
|
-
if (!fname.includes('.')) filename += '.trx'
|
|
348
|
-
CLI.out(CLI.color('Exporting #' + game.id + ' to ') + filename)
|
|
349
|
-
try {
|
|
350
|
-
fs.writeFile(expandPath(filename), content)
|
|
351
|
-
} catch (error) {
|
|
352
|
-
fsOuch(error)
|
|
353
|
-
}
|
|
354
|
-
} else {
|
|
355
|
-
CLI.out(content)
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
|
|
385
|
+
/** @type {Record<string, string[]>} */
|
|
359
386
|
const FOLDERS = {}
|
|
360
387
|
|
|
388
|
+
/** @param {string} [path] @returns {string[]} */
|
|
361
389
|
export const findFiles = (path) => {
|
|
362
390
|
if (typeof path !== 'string') path = ''
|
|
363
391
|
let folder = '.'
|
|
@@ -372,7 +400,6 @@ export const findFiles = (path) => {
|
|
|
372
400
|
if (!FOLDERS[prefix]) {
|
|
373
401
|
FOLDERS[prefix] = []
|
|
374
402
|
fs.readdir(folder, { withFileTypes: true })
|
|
375
|
-
// eslint-disable-next-line promise/prefer-await-to-then
|
|
376
403
|
.then((dir) => {
|
|
377
404
|
// At this point, everything is happening asynchronously,
|
|
378
405
|
// so we have to call findFiles at least twice before we
|
|
@@ -388,7 +415,6 @@ export const findFiles = (path) => {
|
|
|
388
415
|
|
|
389
416
|
FOLDERS[prefix] = entries
|
|
390
417
|
})
|
|
391
|
-
// eslint-disable-next-line promise/prefer-await-to-then
|
|
392
418
|
.catch(() => {
|
|
393
419
|
// Errors are unimportant and should be silently ignored
|
|
394
420
|
})
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright 2025-2026
|
|
3
|
+
* @author Chad Transtrum <chad@transtrum.net>
|
|
4
|
+
* @license Apache-2.0
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// Load all the commands here and put them in a list.
|
|
8
|
+
// This just keeps the main program a tiny bit cleaner.
|
|
9
|
+
|
|
10
|
+
import { analyzeCmd } from './analyze.js'
|
|
11
|
+
import { deleteCmd } from './delete.js'
|
|
12
|
+
import { helpCmd } from './help.js'
|
|
13
|
+
import { importCmd, exportCmd } from './import-export.js'
|
|
14
|
+
import { listCmd } from './list.js'
|
|
15
|
+
import { newCmd } from './new.js'
|
|
16
|
+
import { notesCmd } from './notes.js'
|
|
17
|
+
import { playCmd, tryCmd } from './play-try.js'
|
|
18
|
+
import { puzzlesCmd } from './puzzles.js'
|
|
19
|
+
import { selectCmd } from './select.js'
|
|
20
|
+
import { suggestCmd } from './suggest.js'
|
|
21
|
+
import { undoCmd } from './undo.js'
|
|
22
|
+
import { viewCmd } from './view.js'
|
|
23
|
+
|
|
24
|
+
export const allCmds = [
|
|
25
|
+
analyzeCmd,
|
|
26
|
+
deleteCmd,
|
|
27
|
+
helpCmd,
|
|
28
|
+
importCmd,
|
|
29
|
+
exportCmd,
|
|
30
|
+
listCmd,
|
|
31
|
+
newCmd,
|
|
32
|
+
notesCmd,
|
|
33
|
+
playCmd,
|
|
34
|
+
tryCmd,
|
|
35
|
+
puzzlesCmd,
|
|
36
|
+
selectCmd,
|
|
37
|
+
suggestCmd,
|
|
38
|
+
undoCmd,
|
|
39
|
+
viewCmd,
|
|
40
|
+
]
|
package/src/cmds/list.js
CHANGED
|
@@ -1,19 +1,10 @@
|
|
|
1
|
-
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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.
|
|
1
|
+
/** CLI list command
|
|
2
|
+
* @copyright 2022-2026
|
|
3
|
+
* @author Chad Transtrum <chad@transtrum.net>
|
|
4
|
+
* @license Apache-2.0
|
|
14
5
|
*/
|
|
15
6
|
|
|
16
|
-
|
|
7
|
+
/** @typedef {import('../cli.js').CLIContext} CLIContext */
|
|
17
8
|
|
|
18
9
|
import process from 'node:process'
|
|
19
10
|
import { Trax } from '@slugbugblue/trax'
|
|
@@ -39,8 +30,75 @@ export const listCmd = {
|
|
|
39
30
|
" <other text>: match a player's name or the latest game note",
|
|
40
31
|
'Add the "reverse" option to reverse the sort order.',
|
|
41
32
|
],
|
|
33
|
+
/** @param {CLIContext} CLI @param {...string} filters */
|
|
34
|
+
fn(CLI, ...filters) {
|
|
35
|
+
if (Object.keys(CLI.GAMES).length === 0) {
|
|
36
|
+
return CLI.error('No games. Type "new" to start a new game.')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const puzzle = filters.find((f) => f.length > 1 && 'puzzles'.startsWith(f))
|
|
40
|
+
if (puzzle) return CLI.do('puzzles', ...filters.filter((f) => f !== puzzle))
|
|
41
|
+
|
|
42
|
+
/** @type {Array<Record<string, string | number | boolean | undefined>>} */
|
|
43
|
+
let list = []
|
|
44
|
+
const size = {
|
|
45
|
+
spacing: 13, // Amount of space taken up by fixed-length and between columns
|
|
46
|
+
id: 1,
|
|
47
|
+
name: 1,
|
|
48
|
+
moves: 1,
|
|
49
|
+
p1: 1,
|
|
50
|
+
p2: 1,
|
|
51
|
+
}
|
|
52
|
+
let noteSize = Number.POSITIVE_INFINITY
|
|
53
|
+
|
|
54
|
+
for (const game of Object.values(CLI.GAMES)) {
|
|
55
|
+
const moves = game.moves.length === 0 ? 0 : game.moves.split(' ').length
|
|
56
|
+
list.push({
|
|
57
|
+
sid: String(game.id).padStart(9, '0'),
|
|
58
|
+
id: (game.id === CLI.GAME.id ? '* #' : '#') + String(game.id),
|
|
59
|
+
name: game.name?.length || 0,
|
|
60
|
+
rules: game.rules,
|
|
61
|
+
moves: game.over ? 'game over' : CLI.plural(moves, 'move'),
|
|
62
|
+
smoves: game.over ? '9999' : String(moves).padStart(9, '0'),
|
|
63
|
+
sturn: String(game.turn),
|
|
64
|
+
p1: game.players?.[0] || 'white',
|
|
65
|
+
p2: game.players?.[1] || 'black',
|
|
66
|
+
turn: game.turn,
|
|
67
|
+
over: game.over,
|
|
68
|
+
note: game.notes?.at(-1)?.note || '',
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
list = listSort(list, filters)
|
|
73
|
+
|
|
74
|
+
for (const game of list) {
|
|
75
|
+
size.id = Math.max(size.id, String(game.id).length)
|
|
76
|
+
size.name = Math.max(size.name, Number(game.name))
|
|
77
|
+
size.moves = Math.max(size.moves, String(game.moves).length)
|
|
78
|
+
size.p1 = Math.max(size.p1, String(game.p1).length)
|
|
79
|
+
size.p2 = Math.max(size.p2, String(game.p2).length)
|
|
80
|
+
noteSize = Math.min(noteSize, leftover(size))
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
for (const game of list) {
|
|
84
|
+
CLI.out(
|
|
85
|
+
CLI.color(String(game.id).padStart(size.id) + ' ') +
|
|
86
|
+
Trax.names[
|
|
87
|
+
/** @type {import('../cli.js').TraxVariant} */ (String(game.rules))
|
|
88
|
+
] +
|
|
89
|
+
' '.repeat(size.name - Number(game.name) + 1) +
|
|
90
|
+
CLI.bubble(game.turn === 1 ? 'wh' : 'w', String(game.p1)) +
|
|
91
|
+
CLI.color('vs '.padStart(size.p1 - String(game.p1).length + 4)) +
|
|
92
|
+
CLI.bubble(game.turn === 2 ? 'bh' : 'b', String(game.p2)) +
|
|
93
|
+
CLI.color(' '.padStart(size.p2 - String(game.p2).length + 1)) +
|
|
94
|
+
CLI.color(String(game.moves).padEnd(size.moves)) +
|
|
95
|
+
CLI.color.help(' ' + String(game.note).slice(0, noteSize)),
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
},
|
|
42
99
|
}
|
|
43
100
|
|
|
101
|
+
/** @param {string} a @param {string} b @returns {number} */
|
|
44
102
|
const alphaSort = (a, b) => {
|
|
45
103
|
a = String(a).toLowerCase()
|
|
46
104
|
b = String(b).toLowerCase()
|
|
@@ -49,6 +107,7 @@ const alphaSort = (a, b) => {
|
|
|
49
107
|
return 0
|
|
50
108
|
}
|
|
51
109
|
|
|
110
|
+
/** @param {string | undefined} sort @returns {string} */
|
|
52
111
|
const sortBy = (sort) => {
|
|
53
112
|
if (!sort) return 'id'
|
|
54
113
|
sort = sort.toLowerCase()
|
|
@@ -67,6 +126,11 @@ const sortBy = (sort) => {
|
|
|
67
126
|
return sort
|
|
68
127
|
}
|
|
69
128
|
|
|
129
|
+
/**
|
|
130
|
+
* @param {Array<Record<string, string | number | boolean | undefined>>} list
|
|
131
|
+
* @param {string[]} filters
|
|
132
|
+
* @returns {Array<Record<string, string | number | boolean | undefined>>}
|
|
133
|
+
*/
|
|
70
134
|
const listSort = (list, filters) => {
|
|
71
135
|
if (list.length === 0) return list
|
|
72
136
|
let reverse = false
|
|
@@ -75,7 +139,7 @@ const listSort = (list, filters) => {
|
|
|
75
139
|
if (by === 'reverse') {
|
|
76
140
|
reverse = true
|
|
77
141
|
} else if (list[0]?.[by]) {
|
|
78
|
-
list.sort((a, b) => alphaSort(a[by], b[by]))
|
|
142
|
+
list.sort((a, b) => alphaSort(String(a[by]), String(b[by])))
|
|
79
143
|
} else if (by === 'trax') {
|
|
80
144
|
list = list.filter((g) => g.rules === by)
|
|
81
145
|
} else if (['over', 'active'].includes(by)) {
|
|
@@ -83,12 +147,16 @@ const listSort = (list, filters) => {
|
|
|
83
147
|
} else {
|
|
84
148
|
list = list.filter((g) => {
|
|
85
149
|
return (
|
|
86
|
-
g.id.endsWith(by) ||
|
|
87
|
-
Trax.names[
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
150
|
+
String(g.id).endsWith(by) ||
|
|
151
|
+
Trax.names[
|
|
152
|
+
/** @type {import('../cli.js').TraxVariant} */ (String(g.rules))
|
|
153
|
+
]
|
|
154
|
+
.toLowerCase()
|
|
155
|
+
.includes(by) ||
|
|
156
|
+
String(g.p1).toLowerCase().includes(by) ||
|
|
157
|
+
String(g.p2).toLowerCase().includes(by) ||
|
|
158
|
+
String(g.moves).startsWith(by + ' ') ||
|
|
159
|
+
String(g.note).includes(by)
|
|
92
160
|
)
|
|
93
161
|
})
|
|
94
162
|
}
|
|
@@ -99,9 +167,9 @@ const listSort = (list, filters) => {
|
|
|
99
167
|
return list
|
|
100
168
|
}
|
|
101
169
|
|
|
102
|
-
/** Find how many columns are left over after all the other columns
|
|
103
|
-
* @
|
|
104
|
-
* @returns {number}
|
|
170
|
+
/** Find how many columns are left over after all the other columns are filled.
|
|
171
|
+
* @param {Record<string, number>} sizes
|
|
172
|
+
* @returns {number}
|
|
105
173
|
*/
|
|
106
174
|
const leftover = (sizes) => {
|
|
107
175
|
let left = process.stdout.columns
|
|
@@ -111,66 +179,3 @@ const leftover = (sizes) => {
|
|
|
111
179
|
|
|
112
180
|
return left
|
|
113
181
|
}
|
|
114
|
-
|
|
115
|
-
listCmd.fn = (CLI, ...filters) => {
|
|
116
|
-
if (Object.keys(CLI.GAMES).length === 0) {
|
|
117
|
-
return CLI.error('No games. Type "new" to start a new game.')
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
const puzzle = filters.find((f) => f.length > 1 && 'puzzles'.startsWith(f))
|
|
121
|
-
if (puzzle) return CLI.do('puzzles', ...filters.filter((f) => f !== puzzle))
|
|
122
|
-
|
|
123
|
-
let list = []
|
|
124
|
-
const size = {
|
|
125
|
-
spacing: 13, // Amount of space taken up by fixed-length and between columns
|
|
126
|
-
id: 1,
|
|
127
|
-
name: 1,
|
|
128
|
-
moves: 1,
|
|
129
|
-
p1: 1,
|
|
130
|
-
p2: 1,
|
|
131
|
-
}
|
|
132
|
-
let noteSize = Number.POSITIVE_INFINITY
|
|
133
|
-
|
|
134
|
-
for (const game of Object.values(CLI.GAMES)) {
|
|
135
|
-
const moves = game.moves.length === 0 ? 0 : game.moves.split(' ').length
|
|
136
|
-
list.push({
|
|
137
|
-
sid: String(game.id).padStart(9, '0'),
|
|
138
|
-
id: (game.id === CLI.GAME.id ? '* #' : '#') + String(game.id),
|
|
139
|
-
name: game.name?.length || 0,
|
|
140
|
-
rules: game.rules,
|
|
141
|
-
moves: game.over ? 'game over' : CLI.plural(moves, 'move'),
|
|
142
|
-
smoves: game.over ? '9999' : String(moves).padStart(9, '0'),
|
|
143
|
-
sturn: String(game.turn),
|
|
144
|
-
p1: game.players?.[0] || 'white',
|
|
145
|
-
p2: game.players?.[1] || 'black',
|
|
146
|
-
turn: game.turn,
|
|
147
|
-
over: game.over,
|
|
148
|
-
note: game.notes?.[game.notes.length - 1]?.note || '',
|
|
149
|
-
})
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
list = listSort(list, filters)
|
|
153
|
-
|
|
154
|
-
for (const game of list) {
|
|
155
|
-
size.id = Math.max(size.id, game.id.length)
|
|
156
|
-
size.name = Math.max(size.name, game.name)
|
|
157
|
-
size.moves = Math.max(size.moves, game.moves.length)
|
|
158
|
-
size.p1 = Math.max(size.p1, game.p1.length)
|
|
159
|
-
size.p2 = Math.max(size.p2, game.p2.length)
|
|
160
|
-
noteSize = Math.min(noteSize, leftover(size))
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
for (const game of list) {
|
|
164
|
-
CLI.out(
|
|
165
|
-
CLI.color(game.id.padStart(size.id) + ' ') +
|
|
166
|
-
Trax.names[game.rules] +
|
|
167
|
-
' '.repeat(size.name - game.name + 1) +
|
|
168
|
-
CLI.bubble(game.turn === 1 ? 'wh' : 'w', game.p1) +
|
|
169
|
-
CLI.color('vs '.padStart(size.p1 - game.p1.length + 4)) +
|
|
170
|
-
CLI.bubble(game.turn === 2 ? 'bh' : 'b', game.p2) +
|
|
171
|
-
CLI.color(' '.padStart(size.p2 - game.p2.length + 1)) +
|
|
172
|
-
CLI.color(game.moves.padEnd(size.moves)) +
|
|
173
|
-
CLI.color.help(' ' + game.note.slice(0, noteSize)),
|
|
174
|
-
)
|
|
175
|
-
}
|
|
176
|
-
}
|