@slugbugblue/trax-cli 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dockerignore +7 -0
- package/.rgignore +2 -0
- package/CHANGELOG.md +80 -0
- package/CONTRIBUTING.md +102 -0
- package/Dockerfile +14 -0
- package/LICENSE +201 -0
- package/README.md +193 -0
- package/package.json +71 -0
- package/src/cli.js +732 -0
- package/src/cmds/analyze.js +112 -0
- package/src/cmds/delete.js +71 -0
- package/src/cmds/help.js +81 -0
- package/src/cmds/import-export.js +394 -0
- package/src/cmds/list.js +177 -0
- package/src/cmds/new.js +136 -0
- package/src/cmds/notes.js +37 -0
- package/src/cmds/play-try.js +175 -0
- package/src/cmds/puzzles.js +188 -0
- package/src/cmds/select.js +47 -0
- package/src/cmds/suggest.js +55 -0
- package/src/cmds/undo.js +40 -0
- package/src/cmds/view.js +65 -0
- package/src/types.d.ts +105 -0
- package/src/utils.js +22 -0
- package/src/version.js +2 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/* Copyright 2022-2023 Chad Transtrum
|
|
2
|
+
*
|
|
3
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
* you may not use this file except in compliance with the License.
|
|
5
|
+
* You may obtain a copy of the License at
|
|
6
|
+
*
|
|
7
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
*
|
|
9
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
* See the License for the specific language governing permissions and
|
|
13
|
+
* limitations under the License.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
// CLI analyze command
|
|
17
|
+
|
|
18
|
+
import { analyze } from '@slugbugblue/trax-analyst'
|
|
19
|
+
import { Trax } from '@slugbugblue/trax'
|
|
20
|
+
|
|
21
|
+
const notationRx = /^[@a-z]+\d+[bps/\\+]$/i
|
|
22
|
+
|
|
23
|
+
export const analyzeCmd = {
|
|
24
|
+
name: 'analyze',
|
|
25
|
+
args: '[move]',
|
|
26
|
+
comp: '<play>',
|
|
27
|
+
desc: 'analyze a position',
|
|
28
|
+
help: [
|
|
29
|
+
'Analyze the current position of the currently selected game, or pass in a',
|
|
30
|
+
'move to analyze the result of that move.',
|
|
31
|
+
],
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const threatNames = {
|
|
35
|
+
0: 'corners',
|
|
36
|
+
1: 'attacks',
|
|
37
|
+
2: 'Ls',
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const listThreats = (threats, detailed) => {
|
|
41
|
+
const summary = []
|
|
42
|
+
for (const level of Object.keys(threats || {})) {
|
|
43
|
+
const name = (threatNames[level] || `${level}-stage threats`) + ': '
|
|
44
|
+
const items = []
|
|
45
|
+
if (detailed) {
|
|
46
|
+
for (const item of threats[level]) {
|
|
47
|
+
items.push('[' + item.at + ':' + item.threat + ':' + item.match + ']')
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
summary.push(name + threats[level].length + ' ' + items.join(' '))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return summary.join(' ')
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const listFaulty = (faulty) => {
|
|
58
|
+
const summary = []
|
|
59
|
+
for (const threat of faulty) {
|
|
60
|
+
summary.push(
|
|
61
|
+
`[L${threat.level} ${threat.at}:${threat.threat}:${threat.match}]`,
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return 'Faulty: ' + summary.join(' ')
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
analyzeCmd.fn = (CLI, move) => {
|
|
69
|
+
if (!String(CLI.GAME?.id)) {
|
|
70
|
+
return CLI.error('No active game. Type "new" to start a game.')
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let trax = CLI.TRAX
|
|
74
|
+
|
|
75
|
+
const detailed = 'detail'.startsWith(move) || 'debug'.startsWith(move)
|
|
76
|
+
|
|
77
|
+
if (notationRx.test(move)) {
|
|
78
|
+
move = CLI.fixNotation(move)
|
|
79
|
+
CLI.do('try', move)
|
|
80
|
+
trax = new Trax(trax.rules, trax.moves, 'cli')
|
|
81
|
+
const play = trax.dropTile(move)
|
|
82
|
+
if (!play.valid || trax.over) return
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (trax.over) {
|
|
86
|
+
return CLI.error('Game has ended.')
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const { edge, threats, scores, faulty } = analyze(trax)
|
|
90
|
+
|
|
91
|
+
if (detailed) CLI.out(CLI.color.white(edge.w))
|
|
92
|
+
CLI.out(
|
|
93
|
+
CLI.bubble('w', scores.w) +
|
|
94
|
+
CLI.color.white(' ') +
|
|
95
|
+
listThreats(threats.w, detailed),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
if (detailed) {
|
|
99
|
+
if (faulty.w.length > 0) CLI.out(CLI.color.white('') + listFaulty(faulty.w))
|
|
100
|
+
CLI.out(CLI.color.black(edge.b))
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
CLI.out(
|
|
104
|
+
CLI.bubble('b', scores.b) +
|
|
105
|
+
CLI.color.black(' ') +
|
|
106
|
+
listThreats(threats.b, detailed),
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
if (detailed && faulty.b.length > 0) {
|
|
110
|
+
CLI.out(CLI.color.black('') + listFaulty(faulty.b))
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/* Copyright 2022 Chad Transtrum
|
|
2
|
+
*
|
|
3
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
* you may not use this file except in compliance with the License.
|
|
5
|
+
* You may obtain a copy of the License at
|
|
6
|
+
*
|
|
7
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
*
|
|
9
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
* See the License for the specific language governing permissions and
|
|
13
|
+
* limitations under the License.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
// CLI delete command
|
|
17
|
+
|
|
18
|
+
export const deleteCmd = {
|
|
19
|
+
name: 'delete',
|
|
20
|
+
alt: ['remove', 'rm'],
|
|
21
|
+
args: '[#id] ["force"]',
|
|
22
|
+
comp: '<id> force',
|
|
23
|
+
desc: 'delete a game',
|
|
24
|
+
help: [
|
|
25
|
+
'Delete the current game. Or specify a game number to delete that game.',
|
|
26
|
+
'If the game is in progress, you must type "force" to delete it.',
|
|
27
|
+
],
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const selectAnyGame = (CLI) => {
|
|
31
|
+
if (CLI.GAMES) {
|
|
32
|
+
const games = Object.values(CLI.GAMES).sort((a, b) => {
|
|
33
|
+
// Active games first
|
|
34
|
+
if (a.over && !b.over) return 1
|
|
35
|
+
if (b.over && !a.over) return -1
|
|
36
|
+
// Games with more moves first
|
|
37
|
+
return (b.moves?.length || 0) - (a.moves?.length || 0)
|
|
38
|
+
})
|
|
39
|
+
if (games.length > 0) {
|
|
40
|
+
CLI.do('select', String(games[0].id))
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
deleteCmd.fn = (CLI, id, force = 'x') => {
|
|
46
|
+
const current = String(CLI.GAME.id)
|
|
47
|
+
if (!id) id = current
|
|
48
|
+
if ('force'.startsWith(id)) {
|
|
49
|
+
id = current
|
|
50
|
+
force = 'force'
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (id.startsWith('#')) id = id.slice(1)
|
|
54
|
+
|
|
55
|
+
const game = CLI.GAMES[id]
|
|
56
|
+
|
|
57
|
+
if (!game || !String(game.id)) {
|
|
58
|
+
return CLI.error('Invalid id. Type "list" to see available games.')
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (game.over || game.moves === '' || 'force'.startsWith(force)) {
|
|
62
|
+
CLI.delete(id)
|
|
63
|
+
CLI.out(CLI.color('Deleted game #' + id + '.'))
|
|
64
|
+
|
|
65
|
+
if (id === String(current)) {
|
|
66
|
+
selectAnyGame(CLI)
|
|
67
|
+
}
|
|
68
|
+
} else {
|
|
69
|
+
CLI.error('Game #' + id + ' is still active. Use "force" to delete it.')
|
|
70
|
+
}
|
|
71
|
+
}
|
package/src/cmds/help.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/* Copyright 2022 Chad Transtrum
|
|
2
|
+
*
|
|
3
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
* you may not use this file except in compliance with the License.
|
|
5
|
+
* You may obtain a copy of the License at
|
|
6
|
+
*
|
|
7
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
*
|
|
9
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
* See the License for the specific language governing permissions and
|
|
13
|
+
* limitations under the License.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
// CLI help command
|
|
17
|
+
|
|
18
|
+
export const helpCmd = {
|
|
19
|
+
name: 'help',
|
|
20
|
+
opts: ['--help', '-h', '-?'],
|
|
21
|
+
alt: '?',
|
|
22
|
+
args: '[topic]',
|
|
23
|
+
comp: '<cmd>',
|
|
24
|
+
desc: 'display information on commands',
|
|
25
|
+
help: [
|
|
26
|
+
'Type "help <command>" or "<command> help" to see additional information',
|
|
27
|
+
'for a specific command.',
|
|
28
|
+
],
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Find the length of the printable characters of the string
|
|
32
|
+
const length = (text) => text.replace(/[<">]/g, '').length
|
|
33
|
+
|
|
34
|
+
helpCmd.fn = (CLI, word) => {
|
|
35
|
+
// If we are looking for help on a particular commands, get that command
|
|
36
|
+
word = CLI.resolveCommand(word) || (word ? String(word).toLowerCase() : '')
|
|
37
|
+
let cmds = CLI.commands.filter((c) => !word || c.startsWith(word))
|
|
38
|
+
if (cmds.length === 0) {
|
|
39
|
+
cmds = CLI.commands.filter((c) => c.includes(word))
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// If we don't have a few commands, select all commands
|
|
43
|
+
if (cmds.length === 0) cmds = CLI.commands
|
|
44
|
+
|
|
45
|
+
// Load all the commands we need to print into a local variable
|
|
46
|
+
// and also use the loop to get the length of the longest args
|
|
47
|
+
let max = 1
|
|
48
|
+
const commands = {}
|
|
49
|
+
for (const key of cmds) {
|
|
50
|
+
commands[key] = CLI.cmd(key)
|
|
51
|
+
max = Math.max(max, key.length + length(commands[key].args))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Print a summary of all matching commands
|
|
55
|
+
for (const key of cmds.sort()) {
|
|
56
|
+
const topic = commands[key]
|
|
57
|
+
if (topic.hide && !CLI.repl) continue // Hidden commands only in repl
|
|
58
|
+
const space = ' '.repeat(5 + max - length(topic.args) - key.length)
|
|
59
|
+
CLI.out(
|
|
60
|
+
CLI.color.command(` ${key} ${topic.args}`) +
|
|
61
|
+
CLI.color.short(space + topic.desc),
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// And if we have just one command, print details on that command
|
|
66
|
+
if (cmds.length === 1) {
|
|
67
|
+
const topic = commands[cmds[0]]
|
|
68
|
+
for (const alias of topic.alt) {
|
|
69
|
+
CLI.out(CLI.color.command(` ${alias}`))
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
CLI.out('')
|
|
73
|
+
if (CLI.is(topic.help, 'str')) {
|
|
74
|
+
CLI.out(' ' + CLI.color.help(topic.help))
|
|
75
|
+
} else {
|
|
76
|
+
for (const line of topic.help) {
|
|
77
|
+
CLI.out(' ' + CLI.color.help(line))
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
/* Copyright 2022-2023 Chad Transtrum
|
|
2
|
+
*
|
|
3
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
* you may not use this file except in compliance with the License.
|
|
5
|
+
* You may obtain a copy of the License at
|
|
6
|
+
*
|
|
7
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
*
|
|
9
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
* See the License for the specific language governing permissions and
|
|
13
|
+
* limitations under the License.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
// CLI import/export commands
|
|
17
|
+
|
|
18
|
+
import fs from 'node:fs/promises'
|
|
19
|
+
import process from 'node:process'
|
|
20
|
+
|
|
21
|
+
import envPaths from 'env-paths'
|
|
22
|
+
import makeDir from 'make-dir'
|
|
23
|
+
|
|
24
|
+
import { Trax } from '@slugbugblue/trax'
|
|
25
|
+
import { puzzles, sources } from '@slugbugblue/trax-puzzles'
|
|
26
|
+
|
|
27
|
+
const paths = envPaths('trax', { suffix: '' })
|
|
28
|
+
const PATHS = {}
|
|
29
|
+
|
|
30
|
+
// Store a copy of the CLI context object locally
|
|
31
|
+
let cli = {}
|
|
32
|
+
|
|
33
|
+
const fsOuch = (fsError) => {
|
|
34
|
+
if (cli.out) {
|
|
35
|
+
cli.out(fsError, cli.COLORS.fatal)
|
|
36
|
+
} else {
|
|
37
|
+
console.log(fsError)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
throw fsError
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const expandPath = (name) => {
|
|
44
|
+
// Expand home directory ... anything else?
|
|
45
|
+
if (name.includes('~')) name = name.replace('~', process.env.HOME)
|
|
46
|
+
return name
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Return the actual path on disk, creating it if necessary.
|
|
50
|
+
// Path is one of cache, config, data, log, temp.
|
|
51
|
+
const getPath = async (path) => {
|
|
52
|
+
if (path in PATHS) return PATHS[path]
|
|
53
|
+
if (path in paths) {
|
|
54
|
+
const realPath = await makeDir(paths[path])
|
|
55
|
+
PATHS[path] = realPath
|
|
56
|
+
return realPath
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return false
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const hexy = (length = 6) => {
|
|
63
|
+
let hex = ''
|
|
64
|
+
while (hex.length < length) {
|
|
65
|
+
hex += '0123456789abcdef'[Math.floor(Math.random() * 16)]
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return hex
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export const getFile = async (filetype, filename, variable, decode) => {
|
|
72
|
+
const path = await getPath(filetype)
|
|
73
|
+
if (path) {
|
|
74
|
+
try {
|
|
75
|
+
const contents = await fs.readFile(`${path}/${filename}`, 'utf8')
|
|
76
|
+
for (const [key, value] of Object.entries(decode(contents))) {
|
|
77
|
+
variable[key] = value
|
|
78
|
+
}
|
|
79
|
+
} catch (fsError) {
|
|
80
|
+
if (fsError instanceof TypeError || fsError instanceof SyntaxError) {
|
|
81
|
+
const error = `Unable to read ${filetype} file ${path}/${filename}`
|
|
82
|
+
if (cli.error) {
|
|
83
|
+
cli.error(error)
|
|
84
|
+
} else {
|
|
85
|
+
console.log(error)
|
|
86
|
+
}
|
|
87
|
+
} else if (fsError.code !== 'ENOENT') {
|
|
88
|
+
fsOuch(fsError)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return variable
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export const saveFile = (filetype, filename, data) => {
|
|
97
|
+
getPath(filetype).then((path) => {
|
|
98
|
+
if (path) {
|
|
99
|
+
const parts = filename.split('.')
|
|
100
|
+
parts.splice(1, 0, hexy())
|
|
101
|
+
const tmpfile = path + '/' + parts.join('.')
|
|
102
|
+
filename = path + '/' + filename
|
|
103
|
+
fs.writeFile(tmpfile, data)
|
|
104
|
+
.then(() => {
|
|
105
|
+
fs.rm(filename, { force: true, maxRetries: 5 })
|
|
106
|
+
.then(() => {
|
|
107
|
+
fs.rename(tmpfile, filename).catch(fsOuch)
|
|
108
|
+
})
|
|
109
|
+
.catch(fsOuch)
|
|
110
|
+
})
|
|
111
|
+
.catch(fsOuch)
|
|
112
|
+
}
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export const importCmd = {
|
|
117
|
+
name: 'import',
|
|
118
|
+
alt: ['load', 'open'],
|
|
119
|
+
args: '<filename>',
|
|
120
|
+
comp: '<file>',
|
|
121
|
+
desc: 'open a game file',
|
|
122
|
+
help: [
|
|
123
|
+
'Load a ".trx" game file into memory. Once imported, it will be assigned',
|
|
124
|
+
'a game id and automatically selected for future commands.',
|
|
125
|
+
],
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export const exportCmd = {
|
|
129
|
+
name: 'export',
|
|
130
|
+
alt: ['save', 'keep', 'share'],
|
|
131
|
+
args: '[#id] [filename]',
|
|
132
|
+
comp: '<id> <file>',
|
|
133
|
+
desc: 'save a game to a file',
|
|
134
|
+
help: [
|
|
135
|
+
'Export the current game to an external ".trx" file for safe-keeping or',
|
|
136
|
+
'to share. Specify a game id to export a different game.',
|
|
137
|
+
],
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Interpret the rules string of a .trx file.
|
|
141
|
+
* @arg {string} r - rules string in lower case
|
|
142
|
+
* @returns {TraxVariant}
|
|
143
|
+
*/
|
|
144
|
+
const getRules = (r) =>
|
|
145
|
+
r.includes('loop') ? 'traxloop' : r.includes('8x8') ? 'trax8' : 'trax'
|
|
146
|
+
|
|
147
|
+
/** Decode a .trx formatted string into a game, preserving players and comments.
|
|
148
|
+
* @arg {any} CLI - the CLI object
|
|
149
|
+
* @arg {string} content - the contents of the .trx file
|
|
150
|
+
* @returns {boolean} - true if a game was created
|
|
151
|
+
*/
|
|
152
|
+
const interpretFile = (CLI, content) => {
|
|
153
|
+
let rules
|
|
154
|
+
let trax
|
|
155
|
+
let players = []
|
|
156
|
+
const moves = []
|
|
157
|
+
const comments = []
|
|
158
|
+
for (const line of content.split('\n')) {
|
|
159
|
+
if (!line) continue
|
|
160
|
+
const [ln, ...comment] = line.split(/[#;]/)
|
|
161
|
+
if (ln) {
|
|
162
|
+
const lower = ln.toLowerCase()
|
|
163
|
+
if (!rules && lower.includes('trax')) {
|
|
164
|
+
rules = getRules(lower)
|
|
165
|
+
} else if (players.length === 0 && lower.includes(' vs ')) {
|
|
166
|
+
players = ln.split(' ').filter(Boolean)
|
|
167
|
+
} else {
|
|
168
|
+
moves.push(...ln.split(' '))
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (!trax && rules && (players.length > 0 || moves.length > 0)) {
|
|
173
|
+
trax = new Trax(rules)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (moves.length > 0) {
|
|
177
|
+
if (!trax) return false
|
|
178
|
+
if (trax.gameOver) {
|
|
179
|
+
comments.push({
|
|
180
|
+
note: moves.map((c) => c.trim()).join(' '),
|
|
181
|
+
move: trax.move,
|
|
182
|
+
})
|
|
183
|
+
} else {
|
|
184
|
+
trax.playMoves(moves.filter(Boolean))
|
|
185
|
+
moves.splice(0, moves.length)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (comment.length > 0) {
|
|
190
|
+
const note = comment.map((c) => c.trim()).join(' ')
|
|
191
|
+
if (!/^@slugbugblue\/trax cli\.js v/.test(note)) {
|
|
192
|
+
comments.push({
|
|
193
|
+
note,
|
|
194
|
+
move: trax?.move || 0,
|
|
195
|
+
})
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (!trax) return false
|
|
201
|
+
|
|
202
|
+
CLI.do('new', rules, ...players)
|
|
203
|
+
CLI.GAMES[String(CLI.GAME.id)].notes = comments
|
|
204
|
+
if (trax.moves.length > 0) {
|
|
205
|
+
CLI.do('play', ...trax.moves)
|
|
206
|
+
} else {
|
|
207
|
+
CLI.save()
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return true
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
importCmd.fn = async (CLI, filename) => {
|
|
214
|
+
cli = CLI
|
|
215
|
+
if (!filename) {
|
|
216
|
+
CLI.error('Missing filename.')
|
|
217
|
+
return CLI.do('help', 'import')
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
let content
|
|
221
|
+
try {
|
|
222
|
+
content = await fs.readFile(expandPath(filename), 'utf8')
|
|
223
|
+
} catch (fsError) {
|
|
224
|
+
if (fsError.code === 'ENOENT' && !filename.endsWith('.trx')) {
|
|
225
|
+
try {
|
|
226
|
+
content = await fs.readFile(expandPath(filename + '.trx'), 'utf8')
|
|
227
|
+
} catch {
|
|
228
|
+
content = null
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (!content) return CLI.error(fsError.toString())
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (!interpretFile(CLI, content)) {
|
|
236
|
+
CLI.error(filename + ' does not appear to be formatted correctly.')
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Get all the notes for a certain move.
|
|
241
|
+
* @arg {number} move - The move number
|
|
242
|
+
* @arg {GameNotes} notes? - The notes for a game
|
|
243
|
+
* @returns {string} - Notes formatted for this move, or empty string if none
|
|
244
|
+
*/
|
|
245
|
+
const gameNotes = (move, notes) => {
|
|
246
|
+
notes = notes || []
|
|
247
|
+
const moveNotes = notes.filter((n) => n.move === move)
|
|
248
|
+
if (moveNotes.length === 0) return ''
|
|
249
|
+
return (
|
|
250
|
+
'; ' +
|
|
251
|
+
moveNotes.map((n) => n.note.replace(/\n/g, '\n; ')).join('\n; ') +
|
|
252
|
+
'\n'
|
|
253
|
+
)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Join two strings together with a character with the appropriate joining
|
|
257
|
+
* character depending on the length of the strings.
|
|
258
|
+
* @arg {string} a - the first string
|
|
259
|
+
* @arg {string} b - the second string
|
|
260
|
+
* @returns {string} - both strings correctly joined
|
|
261
|
+
*/
|
|
262
|
+
const join = (a, b) =>
|
|
263
|
+
a.length === 0 ? b : a.length + b.length > 78 ? a + '\n' + b : a + ' ' + b
|
|
264
|
+
|
|
265
|
+
/** Interleave the game moves with any notes, formatted for the export file.
|
|
266
|
+
* @arg {string[]} moves - an array of move notations
|
|
267
|
+
* @arg {GameNotes} notes? - The notes for a game
|
|
268
|
+
* @returns {string} - A string that can be placed into an export file
|
|
269
|
+
*/
|
|
270
|
+
const interleaveNotation = (moves, notes) => {
|
|
271
|
+
let content = ''
|
|
272
|
+
let moveNumber = 0
|
|
273
|
+
let annotation = ''
|
|
274
|
+
for (const move of moves) {
|
|
275
|
+
const comments = gameNotes(moveNumber, notes)
|
|
276
|
+
if (comments) {
|
|
277
|
+
content += join(annotation, comments)
|
|
278
|
+
annotation = ''
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
moveNumber += 1
|
|
282
|
+
const notation = String(moveNumber) + '. ' + move
|
|
283
|
+
if (annotation.length + notation.length < 79) {
|
|
284
|
+
annotation += (annotation.length > 0 ? ' ' : '') + notation
|
|
285
|
+
} else {
|
|
286
|
+
content += '\n' + annotation
|
|
287
|
+
annotation = notation
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const comments = gameNotes(moveNumber, notes)
|
|
292
|
+
if (comments) {
|
|
293
|
+
content += join(annotation, comments)
|
|
294
|
+
annotation = ''
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
content += annotation
|
|
298
|
+
if (!content.endsWith('\n')) content += '\n'
|
|
299
|
+
return content
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
exportCmd.fn = (CLI, id, filename) => {
|
|
303
|
+
cli = CLI
|
|
304
|
+
let game = CLI.GAME
|
|
305
|
+
if (id) {
|
|
306
|
+
const newid = id.replace(/^#/, '')
|
|
307
|
+
game = CLI.GAMES[newid]
|
|
308
|
+
if (!game) {
|
|
309
|
+
filename = id
|
|
310
|
+
game = CLI.GAME
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (!game || !game.id) {
|
|
315
|
+
return CLI.error('No active game. Type "new" to start a game.')
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const trax = new Trax(game.rules, game.moves, 'cli')
|
|
319
|
+
|
|
320
|
+
let content = Trax.names[trax.rules].replace(' ', '') + '\n'
|
|
321
|
+
content += game.players?.[0] || 'white'
|
|
322
|
+
content += ' vs '
|
|
323
|
+
content += game.players?.[1] || 'black'
|
|
324
|
+
content += '\n; @slugbugblue/trax cli.js v' + CLI.version + '\n'
|
|
325
|
+
if (game.puzzle) {
|
|
326
|
+
const puzzle = puzzles.find((p) => p.id === game.puzzle)
|
|
327
|
+
if (puzzle) {
|
|
328
|
+
const source = sources[puzzle.src]
|
|
329
|
+
content += '; Puzzle ' + game.puzzle
|
|
330
|
+
if (source) {
|
|
331
|
+
content += source.copyright
|
|
332
|
+
? ' ©' + source.copyright + ' by '
|
|
333
|
+
: ' provided courtesy of '
|
|
334
|
+
content += source.name
|
|
335
|
+
if (source.url) content += '\n; ' + source.url
|
|
336
|
+
if (source.license) {
|
|
337
|
+
content += '\n; Licensed under ' + source.license
|
|
338
|
+
content += source.licenseUrl ? ' ' + source.licenseUrl : ''
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
content += '\n'
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
content += interleaveNotation(trax.moves, game.notes)
|
|
347
|
+
|
|
348
|
+
if (filename) filename = filename.replace(/[^ a-z\d.~/]/g, '')
|
|
349
|
+
if (filename) {
|
|
350
|
+
const fname = filename.split('/').pop()
|
|
351
|
+
if (!fname) filename += game.rules + game.id
|
|
352
|
+
if (!fname.includes('.')) filename += '.trx'
|
|
353
|
+
CLI.out(CLI.color('Exporting #' + game.id + ' to ') + filename)
|
|
354
|
+
fs.writeFile(expandPath(filename), content).catch(fsOuch)
|
|
355
|
+
} else {
|
|
356
|
+
CLI.out(content)
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const FOLDERS = {}
|
|
361
|
+
|
|
362
|
+
export const findFiles = (path) => {
|
|
363
|
+
if (typeof path !== 'string') path = ''
|
|
364
|
+
let folder = '.'
|
|
365
|
+
if (path.includes('/')) {
|
|
366
|
+
folder = path.slice(0, path.lastIndexOf('/'))
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const prefix = folder + '/'
|
|
370
|
+
|
|
371
|
+
folder = expandPath(folder)
|
|
372
|
+
|
|
373
|
+
if (!FOLDERS[prefix]) {
|
|
374
|
+
FOLDERS[prefix] = []
|
|
375
|
+
fs.readdir(folder, { withFileTypes: true })
|
|
376
|
+
.then((dir) => {
|
|
377
|
+
// This is all happening asynchronously, so we have to call findFiles
|
|
378
|
+
// at least twice before we get any useful information ...
|
|
379
|
+
const entries = []
|
|
380
|
+
for (const entry of dir) {
|
|
381
|
+
if (entry.isDirectory() && !entry.name.startsWith('.')) {
|
|
382
|
+
entries.push(prefix + entry.name + '/')
|
|
383
|
+
} else if (entry.isFile() && entry.name.endsWith('.trx')) {
|
|
384
|
+
entries.push(prefix + entry.name)
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
FOLDERS[prefix] = entries
|
|
389
|
+
})
|
|
390
|
+
.catch(() => {})
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
return FOLDERS[prefix]
|
|
394
|
+
}
|