@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/CHANGELOG.md +15 -0
- package/README.md +80 -4
- package/docs/engine.md +18 -3
- package/package.json +7 -3
- package/src/cli.js +994 -0
- package/{engine.js → src/engine.js} +20 -13
- package/{point.js → src/point.js} +0 -0
- package/{tty.js → src/tty.js} +81 -15
- package/src/version.js +2 -0
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()
|