@slugbugblue/trax-cli 0.12.1 → 0.14.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.
@@ -1,25 +1,17 @@
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.
1
+ /** CLI play/try commands
2
+ * @copyright 2022-2026
3
+ * @author Chad Transtrum <chad@transtrum.net>
4
+ * @license Apache-2.0
14
5
  */
15
6
 
16
- // CLI play/try commands
7
+ /** @typedef {import('../cli.js').CLIContext} CLIContext */
17
8
 
18
9
  import { suggest } from '@slugbugblue/trax-analyst'
10
+ import { puzzles } from '@slugbugblue/trax-puzzles'
19
11
  import { timeString } from '@slugbugblue/trax-cli/utils'
20
12
 
21
- const moveRx = /^\d+\.?$/
22
- const notationRx = /^[@a-z]+\d+[bps/\\+]$/i
13
+ const moveRx = /^\d+\.?$/v
14
+ const notationRx = /^[@a-z]+\d+[bps\/\\+]$/iv
23
15
 
24
16
  export const playCmd = {
25
17
  name: 'play',
@@ -35,6 +27,65 @@ export const playCmd = {
35
27
  'Multiple moves can be submitted at once. Move numbers are optional,',
36
28
  'but if included, they will be checked for accuracy.',
37
29
  ],
30
+ /** @param {CLIContext} CLI @param {...string} moves */
31
+ fn(CLI, ...moves) {
32
+ if (moves.length === 0 || !moves[0]) {
33
+ CLI.error('You must provide a move.')
34
+ return CLI.do('help', 'play')
35
+ }
36
+
37
+ if (!CLI.GAME?.id) {
38
+ CLI.do('new')
39
+ }
40
+
41
+ const game = CLI.GAME
42
+ const trax = CLI.TRAX
43
+
44
+ if (trax.over) {
45
+ return CLI.error('The game is over.')
46
+ }
47
+
48
+ const start = trax.move
49
+ let checkMove
50
+
51
+ for (let move of moves) {
52
+ if (notationRx.test(move)) {
53
+ const moveNumber = trax.move
54
+ move = CLI.fixNotation(move)
55
+ if (checkMove) {
56
+ trax.play(checkMove, move)
57
+ } else {
58
+ trax.play(move)
59
+ }
60
+
61
+ if (trax.move === moveNumber) {
62
+ CLI.error(
63
+ 'Move "' +
64
+ (checkMove ? checkMove + '. ' : '') +
65
+ move +
66
+ '" is invalid.',
67
+ )
68
+ }
69
+ } else if (moveRx.test(move)) {
70
+ checkMove = Number(move)
71
+ } else if (move) {
72
+ CLI.error('Move "' + move + '" is not in the correct notation.')
73
+ }
74
+ }
75
+
76
+ if (trax.move !== start) {
77
+ const result = bot(CLI)
78
+ CLI.updateGameData()
79
+ CLI.do('view')
80
+ if (result === 'lose') {
81
+ CLI.error('Failed to complete puzzle ' + game.puzzle)
82
+ } else if (result === 'win') {
83
+ CLI.out(CLI.color.success('You completed puzzle ' + game.puzzle))
84
+ } else if (result) {
85
+ CLI.out(result)
86
+ }
87
+ }
88
+ },
38
89
  }
39
90
 
40
91
  export const tryCmd = {
@@ -47,15 +98,86 @@ export const tryCmd = {
47
98
  'View a move without committing to it. This is useful for seeing the',
48
99
  'effects of forced tiles.',
49
100
  ],
101
+ /** @param {CLIContext} CLI @param {string} [move] */
102
+ fn(CLI, move) {
103
+ if (!move || move.length === 0) {
104
+ CLI.error('You must provide a move.')
105
+ return CLI.do('help', 'try')
106
+ }
107
+
108
+ if (!CLI.TRAX) {
109
+ CLI.do('new')
110
+ }
111
+
112
+ if (CLI.TRAX.over) {
113
+ return CLI.error('The game is over.')
114
+ }
115
+
116
+ if (notationRx.test(move)) {
117
+ move = CLI.fixNotation(move)
118
+ const play = CLI.TRAX.dropTile(move, undefined, 'tentative')
119
+ if (play.valid || play.dropped.length > 0) {
120
+ CLI.display(CLI.TRAX, CLI.GAME.players, move)
121
+ } else {
122
+ CLI.error('Move is invalid.')
123
+ }
124
+ } else {
125
+ CLI.error('Move is invalid.')
126
+ }
127
+ },
50
128
  }
51
129
 
130
+ /**
131
+ * Check if a concrete move matches a defense key token.
132
+ * Handles the catch-all wildcard (*), column wildcards (@**, A**, etc.),
133
+ * and row wildcards (*0*, *5*, etc.).
134
+ * @param {string} move
135
+ * @param {string} token
136
+ * @returns {boolean}
137
+ */
138
+ const matchesToken = (move, token) => {
139
+ if (token === '*') return true
140
+ if (!token.includes('*')) return token === move
141
+ const pattern =
142
+ '^' +
143
+ token
144
+ .replace('*', token.startsWith('*') ? '[@A-Za-z]+' : '[0-9]+')
145
+ .replace('*', String.raw`[+/\\]`) +
146
+ '$'
147
+ return new RegExp(pattern, 'v').test(move)
148
+ }
149
+
150
+ /**
151
+ * Find the defense move for the current puzzle state, if one exists.
152
+ * First checks position-code keys (exact board-state match, O(1)), then
153
+ * falls back to wildcard notation keys for paths containing '*'.
154
+ * @param {import('@slugbugblue/trax-puzzles').Defense} defense
155
+ * @param {string} positionCode
156
+ * @param {string[]} moves
157
+ * @returns {string | undefined}
158
+ */
159
+ const getDefenseMove = (defense, positionCode, moves) => {
160
+ if (Object.hasOwn(defense, positionCode)) return defense[positionCode]
161
+ const key = Object.keys(defense)
162
+ .filter((k) => k.includes('*'))
163
+ .find((k) => {
164
+ const tokens = k.split(' ')
165
+ return (
166
+ tokens.length === moves.length &&
167
+ tokens.every((token, i) => matchesToken(moves[i], token))
168
+ )
169
+ })
170
+ return key ? defense[key] : undefined
171
+ }
172
+
173
+ /** @param {CLIContext} CLI @returns {string} */
52
174
  const bot = (CLI) => {
53
175
  const game = CLI.GAME
54
176
  const trax = CLI.TRAX
55
177
  const players = game.players || ['a', 'b']
56
178
  const name = players[trax.turn - 1]
57
179
  if (trax.over && game.puzzle) {
58
- if (trax.move <= game.max && name !== 'puzzlebot') {
180
+ if (trax.move <= (game.max ?? 0) && name !== 'puzzlebot') {
59
181
  CLI.puzzleSolved(game.puzzle)
60
182
  return 'win'
61
183
  }
@@ -63,8 +185,8 @@ const bot = (CLI) => {
63
185
  return 'lose'
64
186
  }
65
187
 
66
- if (!trax.over && /bot\b/i.test(name)) {
67
- if (game.puzzle && trax.move >= game.max) {
188
+ if (!trax.over && /bot\b/iv.test(name)) {
189
+ if (game.puzzle && game.max !== undefined && trax.move >= game.max) {
68
190
  trax.play('puzzled')
69
191
  return 'lose'
70
192
  }
@@ -72,7 +194,22 @@ const bot = (CLI) => {
72
194
  CLI.do('view')
73
195
  CLI.out(CLI.bubble(trax.color + 'd', `${name} thinking...`))
74
196
  const start = Date.now()
75
- const move = suggest(trax)?.pick?.move
197
+
198
+ let move
199
+ if (game.puzzle) {
200
+ const puzzle = puzzles.find((p) => p.id === game.puzzle)
201
+ if (puzzle?.defense) {
202
+ const offset = puzzle.notation.split(' ').length
203
+ move = getDefenseMove(
204
+ puzzle.defense,
205
+ trax.positionCode(),
206
+ trax.moves.slice(offset),
207
+ )
208
+ }
209
+ }
210
+
211
+ move ??= suggest(trax)?.pick?.move
212
+
76
213
  const ms = Date.now() - start
77
214
  if (move) {
78
215
  CLI.out(
@@ -87,89 +224,3 @@ const bot = (CLI) => {
87
224
 
88
225
  return ''
89
226
  }
90
-
91
- playCmd.fn = (CLI, ...moves) => {
92
- if (moves.length === 0 || !moves[0]) {
93
- CLI.error('You must provide a move.')
94
- return CLI.do('help', 'play')
95
- }
96
-
97
- if (!CLI.GAME?.id) {
98
- CLI.do('new')
99
- }
100
-
101
- const game = CLI.GAME
102
- const trax = CLI.TRAX
103
-
104
- if (trax.over) {
105
- return CLI.error('The game is over.')
106
- }
107
-
108
- const start = trax.move
109
- let checkMove
110
-
111
- for (let move of moves) {
112
- if (notationRx.test(move)) {
113
- const moveNumber = trax.move
114
- move = CLI.fixNotation(move)
115
- if (checkMove) {
116
- trax.play(checkMove, move)
117
- } else {
118
- trax.play(move)
119
- }
120
-
121
- if (trax.move === moveNumber) {
122
- CLI.error(
123
- 'Move "' +
124
- (checkMove ? checkMove + '. ' : '') +
125
- move +
126
- '" is invalid.',
127
- )
128
- }
129
- } else if (moveRx.test(move)) {
130
- checkMove = Number(move)
131
- } else if (move) {
132
- CLI.error('Move "' + move + '" is not in the correct notation.')
133
- }
134
- }
135
-
136
- if (trax.move !== start) {
137
- const result = bot(CLI)
138
- CLI.updateGameData()
139
- CLI.do('view')
140
- if (result === 'lose') {
141
- CLI.error('Failed to complete puzzle ' + game.puzzle)
142
- } else if (result === 'win') {
143
- CLI.out(CLI.color.success('You completed puzzle ' + game.puzzle))
144
- } else if (result) {
145
- CLI.out(result)
146
- }
147
- }
148
- }
149
-
150
- tryCmd.fn = (CLI, move) => {
151
- if (!move || move.length === 0) {
152
- CLI.error('You must provide a move.')
153
- return CLI.do('help', 'try')
154
- }
155
-
156
- if (!CLI.TRAX) {
157
- CLI.do('new')
158
- }
159
-
160
- if (CLI.TRAX.over) {
161
- return CLI.error('The game is over.')
162
- }
163
-
164
- if (notationRx.test(move)) {
165
- move = CLI.fixNotation(move)
166
- const play = CLI.TRAX.dropTile(move, undefined, 'tentative')
167
- if (play.valid || play.dropped.length > 0) {
168
- CLI.display(CLI.TRAX, CLI.GAME.players, move)
169
- } else {
170
- CLI.error('Move is invalid.')
171
- }
172
- } else {
173
- CLI.error('Move is invalid.')
174
- }
175
- }
@@ -1,9 +1,11 @@
1
1
  /** @file Puzzles CLI
2
- * @copyright 2022-2023
2
+ * @copyright 2022-2026
3
3
  * @author Chad Transtrum <chad@transtrum.net>
4
4
  * @license Apache-2.0
5
5
  */
6
6
 
7
+ /** @typedef {import('../cli.js').CLIContext} CLIContext */
8
+
7
9
  import { Trax } from '@slugbugblue/trax'
8
10
  import { levels, puzzles, sources } from '@slugbugblue/trax-puzzles'
9
11
 
@@ -29,13 +31,50 @@ export const puzzlesCmd = {
29
31
  'Practice your skills with puzzles. Use "list" to see the puzzle',
30
32
  'categories, or "start" to play a puzzle.',
31
33
  ],
34
+ /** @param {CLIContext} CLI @param {string} [action] @param {string} [id] */
35
+ fn(CLI, action, id) {
36
+ action = action?.toLowerCase() ?? 'list'
37
+ if (startAliases.some((alias) => alias.startsWith(action))) {
38
+ if (!id) return startNextPuzzle(CLI)
39
+
40
+ const puzzle = puzzles.find((p) => p.id === id)
41
+ if (puzzle) {
42
+ return startPuzzle(CLI, puzzle)
43
+ }
44
+
45
+ if (validLevels.includes(id)) {
46
+ return startNextPuzzle(CLI, levels.indexOf(id))
47
+ }
48
+
49
+ const possibles = puzzles.filter((p) => p.id.includes(id))
50
+ if (possibles.length === 1) {
51
+ return startPuzzle(CLI, possibles[0])
52
+ }
53
+
54
+ return CLI.error('Could not find puzzle ' + id)
55
+ }
56
+
57
+ if (action) {
58
+ const x = id || action
59
+ if (validLevels.includes(x)) return listLevel(CLI, x)
60
+ if (listAliases.some((alias) => alias.startsWith(x))) {
61
+ return listPuzzles(CLI)
62
+ }
63
+
64
+ return showPuzzle(CLI, x)
65
+ }
66
+
67
+ listPuzzles(CLI)
68
+ },
32
69
  }
33
70
 
71
+ /** @param {string} [word] @returns {string} */
34
72
  const an = (word = '') => {
35
- const a = /^[aeiou]/.test(word) ? 'an' : 'a'
73
+ const a = /^[aeiou]/v.test(word) ? 'an' : 'a'
36
74
  return a + ' ' + word
37
75
  }
38
76
 
77
+ /** @param {CLIContext} CLI @param {string} id @param {boolean} [started] */
39
78
  const showPuzzle = (CLI, id, started = false) => {
40
79
  let puzzle = puzzles.find((p) => p.id === id)
41
80
  if (!puzzle) {
@@ -80,6 +119,7 @@ const showPuzzle = (CLI, id, started = false) => {
80
119
  )
81
120
  }
82
121
 
122
+ /** @param {CLIContext} CLI */
83
123
  const listPuzzles = (CLI) => {
84
124
  let size = 1
85
125
  const data = []
@@ -112,6 +152,7 @@ const listPuzzles = (CLI) => {
112
152
  }
113
153
  }
114
154
 
155
+ /** @param {CLIContext} CLI @param {string} levelName */
115
156
  const listLevel = (CLI, levelName) => {
116
157
  let size = 1
117
158
  const level = levels.indexOf(levelName)
@@ -134,55 +175,28 @@ const listLevel = (CLI, levelName) => {
134
175
  }
135
176
  }
136
177
 
178
+ /** @param {CLIContext} CLI @param {import('@slugbugblue/trax-puzzles').Puzzle} puzzle */
137
179
  const startPuzzle = (CLI, puzzle) => {
138
180
  const id = CLI.newPuzzle(puzzle)
139
181
  CLI.out(CLI.color(`Started puzzle ${puzzle.id} as game #${id}`))
140
182
  showPuzzle(CLI, puzzle.id, true)
141
183
  }
142
184
 
185
+ /** @param {CLIContext} CLI @param {number} [level] */
143
186
  const startNextPuzzle = (CLI, level) => {
144
187
  if (level === undefined) level = CLI.puzzleLevel
145
- const unsolved = puzzles.filter((p) => !CLI.puzzle(p.id).solved)
188
+ const unsolved = puzzles
189
+ .filter((p) => !CLI.puzzle(p.id).solved)
190
+ .toSorted(
191
+ (a, b) =>
192
+ a.level - b.level ||
193
+ a.notation.split(' ').length - b.notation.split(' ').length,
194
+ )
146
195
  if (unsolved.length === 0) {
147
- CLI.out(CLI.success('You have already completed all the puzzles.'))
196
+ CLI.out(CLI.color.success('You have already completed all the puzzles.'))
148
197
  return CLI.error('Specify a specific puzzle to try one again.')
149
198
  }
150
199
 
151
200
  const next = unsolved.find((p) => p.level >= level) || unsolved[0]
152
201
  startPuzzle(CLI, next)
153
202
  }
154
-
155
- puzzlesCmd.fn = (CLI, action, id) => {
156
- action = action?.toLowerCase() ?? 'list'
157
- if (startAliases.some((alias) => alias.startsWith(action))) {
158
- if (!id) return startNextPuzzle(CLI)
159
-
160
- const puzzle = puzzles.find((p) => p.id === id)
161
- if (puzzle) {
162
- return startPuzzle(CLI, puzzle)
163
- }
164
-
165
- if (validLevels.includes(id)) {
166
- return startNextPuzzle(CLI, levels.indexOf(id))
167
- }
168
-
169
- const possibles = puzzles.filter((p) => p.id.includes(id))
170
- if (possibles.length === 1) {
171
- return startPuzzle(CLI, possibles[0])
172
- }
173
-
174
- return CLI.error('Could not find puzzle ' + id)
175
- }
176
-
177
- if (action) {
178
- const x = id || action
179
- if (validLevels.includes(x)) return listLevel(CLI, x)
180
- if (listAliases.some((alias) => alias.startsWith(x))) {
181
- return listPuzzles(CLI)
182
- }
183
-
184
- return showPuzzle(CLI, x)
185
- }
186
-
187
- listPuzzles(CLI)
188
- }
@@ -1,19 +1,10 @@
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.
1
+ /** CLI select command
2
+ * @copyright 2022-2026
3
+ * @author Chad Transtrum <chad@transtrum.net>
4
+ * @license Apache-2.0
14
5
  */
15
6
 
16
- // CLI select command
7
+ /** @typedef {import('../cli.js').CLIContext} CLIContext */
17
8
 
18
9
  export const selectCmd = {
19
10
  name: 'select',
@@ -21,27 +12,27 @@ export const selectCmd = {
21
12
  args: '#id [command]',
22
13
  comp: '<id> <cmd>',
23
14
  desc: 'select a different game',
24
- rx: /^#\d+$/,
15
+ rx: /^#\d+$/v,
25
16
  help: [
26
17
  'Make another game the default game of future commands. If another',
27
18
  'command is included, that command will be run immediately.',
28
19
  ],
29
- }
30
-
31
- selectCmd.fn = (CLI, id, ...cmds) => {
32
- if (!id) {
33
- CLI.error('Missing id.')
34
- return CLI.do('help', 'select')
35
- }
20
+ /** @param {CLIContext} CLI @param {string} [id] @param {...string} cmds */
21
+ fn(CLI, id, ...cmds) {
22
+ if (!id) {
23
+ CLI.error('Missing id.')
24
+ return CLI.do('help', 'select')
25
+ }
36
26
 
37
- if (id.startsWith('#')) id = id.slice(1)
38
- CLI.load(id)
39
- if (String(CLI.GAME?.id) === id) {
40
- CLI.out(CLI.color(`Game #${id} selected.`))
41
- if (cmds.length > 0) {
42
- CLI.doNext(cmds.join(' '))
27
+ if (id.startsWith('#')) id = id.slice(1)
28
+ CLI.load(id)
29
+ if (String(CLI.GAME?.id) === id) {
30
+ CLI.out(CLI.color(`Game #${id} selected.`))
31
+ if (cmds.length > 0) {
32
+ CLI.doNext(cmds.join(' '))
33
+ }
34
+ } else {
35
+ CLI.error('Invalid id. Type "list" to see available games.')
43
36
  }
44
- } else {
45
- CLI.error('Invalid id. Type "list" to see available games.')
46
- }
37
+ },
47
38
  }
@@ -1,19 +1,10 @@
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.
1
+ /** CLI suggest command
2
+ * @copyright 2022-2026
3
+ * @author Chad Transtrum <chad@transtrum.net>
4
+ * @license Apache-2.0
14
5
  */
15
6
 
16
- // CLI suggest command
7
+ /** @typedef {import('../cli.js').CLIContext} CLIContext */
17
8
 
18
9
  import { suggest } from '@slugbugblue/trax-analyst'
19
10
  import { timeString } from '@slugbugblue/trax-cli/utils'
@@ -23,33 +14,33 @@ export const suggestCmd = {
23
14
  alt: ['bot'],
24
15
  desc: 'suggest a move',
25
16
  help: ['Suggest a move in the current game.'],
26
- }
17
+ /** @param {CLIContext} CLI @param {string} [debug] */
18
+ fn(CLI, debug) {
19
+ if (!String(CLI.GAME?.id)) {
20
+ return CLI.error('No active game. Type "new" to start a game.')
21
+ }
27
22
 
28
- suggestCmd.fn = (CLI, debug) => {
29
- if (!String(CLI.GAME?.id)) {
30
- return CLI.error('No active game. Type "new" to start a game.')
31
- }
23
+ const game = CLI.TRAX
24
+ const suggestion = suggest(game)
32
25
 
33
- const game = CLI.TRAX
34
- const suggestion = suggest(game)
26
+ if (!suggestion.best) return
35
27
 
36
- if (!suggestion.best) return
28
+ CLI.out(
29
+ CLI.bubble(
30
+ game.turn === 1 ? 'w' : 'b',
31
+ String(game.move + 1) + '. ' + suggestion.pick.move,
32
+ ),
33
+ )
37
34
 
38
- CLI.out(
39
- CLI.bubble(
40
- game.turn === 1 ? 'w' : 'b',
41
- String(game.move + 1) + '. ' + suggestion.pick.move,
42
- ),
43
- )
35
+ if (debug && 'debug'.startsWith(debug)) {
36
+ const m = []
37
+ for (const move of suggestion.options) {
38
+ m.push(move.move + ' ' + move.score)
39
+ }
44
40
 
45
- if ('debug'.startsWith(debug)) {
46
- const m = []
47
- for (const move of suggestion.options) {
48
- m.push(move.move + ' ' + move.score)
41
+ CLI.out(
42
+ '[' + m.join(', ') + '] ' + CLI.color.black(timeString(suggestion.ms)),
43
+ )
49
44
  }
50
-
51
- CLI.out(
52
- '[' + m.join(', ') + '] ' + CLI.color.black(timeString(suggestion.ms)),
53
- )
54
- }
45
+ },
55
46
  }
package/src/cmds/undo.js CHANGED
@@ -1,19 +1,12 @@
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.
1
+ /** CLI undo command
2
+ * @copyright 2022-2026
3
+ * @author Chad Transtrum <chad@transtrum.net>
4
+ * @license Apache-2.0
14
5
  */
15
6
 
16
- // CLI undo command
7
+ /** @typedef {import('../cli.js').CLIContext} CLIContext */
8
+
9
+ import { puzzles } from '@slugbugblue/trax-puzzles'
17
10
 
18
11
  export const undoCmd = {
19
12
  name: 'undo',
@@ -24,17 +17,29 @@ export const undoCmd = {
24
17
  'Backtrack one or more moves in a game. Type "undo" to take back the last',
25
18
  'move, or "undo 2" to undo the last two moves.',
26
19
  ],
27
- }
28
-
29
- undoCmd.fn = (CLI, n) => {
30
- n = Math.max(1, Math.floor(Number(n) || 1)) // Get an integer 1 or higher
20
+ /** @param {CLIContext} CLI @param {string} [n] */
21
+ fn(CLI, n) {
22
+ const count = Math.max(1, Math.floor(Number(n) || 1))
23
+ const { moves } = CLI.TRAX
31
24
 
32
- const { moves } = CLI.TRAX
33
- const undo = moves.slice(0, 0 - n)
34
- if (moves.length === undo.length) {
35
- CLI.error('Unable to undo.')
36
- } else {
37
- CLI.setGame(undo)
38
- CLI.do('view')
39
- }
25
+ if (CLI.GAME?.puzzle) {
26
+ const puzzle = puzzles.find((p) => p.id === CLI.GAME.puzzle)
27
+ const offset = puzzle ? puzzle.notation.split(' ').length : 0
28
+ const undoTo = Math.max(offset, moves.length - count * 2)
29
+ if (undoTo === moves.length) {
30
+ CLI.error('Cannot undo before the start of the puzzle.')
31
+ } else {
32
+ CLI.setGame(moves.slice(0, undoTo))
33
+ CLI.do('view')
34
+ }
35
+ } else {
36
+ const undo = moves.slice(0, 0 - count)
37
+ if (moves.length === undo.length) {
38
+ CLI.error('Unable to undo.')
39
+ } else {
40
+ CLI.setGame(undo)
41
+ CLI.do('view')
42
+ }
43
+ }
44
+ },
40
45
  }