terminal-quest 1.1.2 → 1.2.1

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.
@@ -20,7 +20,7 @@ export type ObjectiveCheck = {
20
20
  path: string;
21
21
  };
22
22
  export interface Hint {
23
- level: 1 | 2 | 3;
23
+ level: 0 | 1 | 2 | 3;
24
24
  text: string;
25
25
  }
26
26
  export interface Objective {
@@ -28,10 +28,17 @@ export function suggestCommand(input) {
28
28
  * Returns a feedback message if the input matches a pattern, otherwise null.
29
29
  */
30
30
  export function checkMissionFeedback(input, feedbacks) {
31
- const cmd = input.split(/\s+/)[0];
32
31
  for (const fb of feedbacks) {
33
- if (cmd === fb.pattern || input.startsWith(fb.pattern)) {
34
- return fb.message;
32
+ try {
33
+ if (new RegExp(fb.pattern).test(input)) {
34
+ return fb.message;
35
+ }
36
+ }
37
+ catch {
38
+ // Fallback to simple matching if invalid regex
39
+ if (input === fb.pattern || input.startsWith(fb.pattern)) {
40
+ return fb.message;
41
+ }
35
42
  }
36
43
  }
37
44
  return null;
@@ -29,6 +29,11 @@ export class CommandHandler {
29
29
  return result;
30
30
  }
31
31
  previousOutput = result.output;
32
+ // Real Unix commands output trailing newline; ensure intermediate pipe
33
+ // output ends with \n so wc -l and other line-based tools count correctly
34
+ if (previousOutput && !previousOutput.endsWith('\n') && i < segments.length - 1) {
35
+ previousOutput += '\n';
36
+ }
32
37
  }
33
38
  return { output: previousOutput };
34
39
  }
@@ -1,7 +1,18 @@
1
1
  export function cat(fs, args) {
2
- if (args.length === 0) {
2
+ // Extract stdin
3
+ let stdin;
4
+ const stdinIdx = args.findIndex(a => a.startsWith('__stdin__:'));
5
+ if (stdinIdx !== -1) {
6
+ stdin = args[stdinIdx].slice('__stdin__:'.length);
7
+ args = [...args.slice(0, stdinIdx), ...args.slice(stdinIdx + 1)];
8
+ }
9
+ if (args.length === 0 && stdin == null) {
3
10
  return { output: '', error: 'cat: missing file operand' };
4
11
  }
12
+ // If stdin and no file args, pass through stdin (pipe usage)
13
+ if (args.length === 0 && stdin != null) {
14
+ return { output: stdin };
15
+ }
5
16
  const outputs = [];
6
17
  for (const filePath of args) {
7
18
  try {
@@ -67,6 +67,9 @@ export function cut(fs, args) {
67
67
  return { output: '', error: 'cut: missing file operand' };
68
68
  }
69
69
  const lines = content.split('\n');
70
+ // Remove trailing empty element from trailing newline (pipe or file)
71
+ if (lines.length > 0 && lines[lines.length - 1] === '')
72
+ lines.pop();
70
73
  const result = lines.map(line => {
71
74
  const parts = line.split(delimiter);
72
75
  return fields.map(f => parts[f - 1] ?? '').join(delimiter);
@@ -49,6 +49,9 @@ export function grep(fs, args) {
49
49
  return { output: '', error: `grep: invalid regular expression '${pattern}'` };
50
50
  }
51
51
  const lines = stdin.split('\n');
52
+ // Remove trailing empty element from trailing newline (pipe)
53
+ if (lines.length > 0 && lines[lines.length - 1] === '')
54
+ lines.pop();
52
55
  const results = [];
53
56
  let matchCount = 0;
54
57
  for (let i = 0; i < lines.length; i++) {
@@ -84,6 +87,9 @@ export function grep(fs, args) {
84
87
  try {
85
88
  const content = fs.readFile(filePath);
86
89
  const lines = content.split('\n');
90
+ // Remove trailing empty element from trailing newline
91
+ if (lines.length > 0 && lines[lines.length - 1] === '')
92
+ lines.pop();
87
93
  let fileMatchCount = 0;
88
94
  for (let i = 0; i < lines.length; i++) {
89
95
  if (regex.test(lines[i])) {
@@ -53,6 +53,9 @@ export function head(fs, args) {
53
53
  if (content === '')
54
54
  return { output: '' };
55
55
  const lines = content.split('\n');
56
+ // Remove trailing empty element from trailing newline (pipe or file)
57
+ if (lines.length > 0 && lines[lines.length - 1] === '')
58
+ lines.pop();
56
59
  const selected = lines.slice(0, lineCount);
57
60
  return { output: selected.join('\n') };
58
61
  }
@@ -13,18 +13,22 @@ const HELP_TEXT = `使用可能なコマンド:
13
13
  head [-n N] file 先頭N行を表示
14
14
  tail [-n N] file 末尾N行を表示
15
15
  wc [-l] [-w] [-c] file 行数/単語数/バイト数
16
- sort [-r] [-n] file ソート
16
+ sort [-r] [-n] [-t delim] [-k N] file ソート
17
17
  uniq [-c] file 重複除去
18
18
  cut -d delim -f N file フィールド切り出し
19
19
  chmod mode file 権限変更
20
20
  echo text [> file] テキスト出力/ファイル書き込み
21
21
  git <subcmd> Gitコマンド (status/log/diff/branch/checkout/merge/stash)
22
+ man [command] コマンドの詳しい使い方を表示
22
23
  clear 画面クリア
23
24
  help このヘルプを表示
24
- hint ヒントを表示
25
+
26
+ ゲーム内コマンド:
27
+ hint ヒントを表示 (Ctrl+H でも可)
25
28
  objectives / obj 現在の目標を表示
29
+ cmds このミッションの新しいコマンドを表示
26
30
 
27
- ヒント: コマンドは | (パイプ) で繋げられます
31
+ ヒント: コマンドは | (パイプ) で繋げられます
28
32
  例: cat file.txt | grep ERROR | wc -l`;
29
33
  export function help(_fs, _args) {
30
34
  return { output: HELP_TEXT };
@@ -69,6 +69,9 @@ export function sort(fs, args) {
69
69
  return { output: '', error: 'sort: missing file operand' };
70
70
  }
71
71
  const lines = content.split('\n');
72
+ // Remove trailing empty element from trailing newline (pipe or file)
73
+ if (lines.length > 0 && lines[lines.length - 1] === '')
74
+ lines.pop();
72
75
  const getKey = (line) => {
73
76
  if (delimiter !== undefined && keyField !== undefined) {
74
77
  const fields = line.split(delimiter);
@@ -53,6 +53,9 @@ export function tail(fs, args) {
53
53
  if (content === '')
54
54
  return { output: '' };
55
55
  const lines = content.split('\n');
56
+ // Remove trailing empty element from trailing newline (pipe or file)
57
+ if (lines.length > 0 && lines[lines.length - 1] === '')
58
+ lines.pop();
56
59
  const selected = lines.slice(-lineCount);
57
60
  return { output: selected.join('\n') };
58
61
  }
@@ -37,6 +37,9 @@ export function uniq(fs, args) {
37
37
  return { output: '', error: 'uniq: missing file operand' };
38
38
  }
39
39
  const lines = content.split('\n');
40
+ // Remove trailing empty element from trailing newline (pipe or file)
41
+ if (lines.length > 0 && lines[lines.length - 1] === '')
42
+ lines.pop();
40
43
  const result = [];
41
44
  if (showCount) {
42
45
  let i = 0;
@@ -2,7 +2,8 @@ import type { GameProgress, Screen } from '../data/types.js';
2
2
  interface StorySelectScreenProps {
3
3
  progress: GameProgress;
4
4
  onNavigate: (screen: Screen) => void;
5
+ onResetStory: (storyId: string) => void;
5
6
  }
6
- export declare function StorySelectScreen({ progress, onNavigate }: StorySelectScreenProps): import("react/jsx-runtime").JSX.Element;
7
+ export declare function StorySelectScreen({ progress, onNavigate, onResetStory }: StorySelectScreenProps): import("react/jsx-runtime").JSX.Element;
7
8
  export {};
8
9
  //# sourceMappingURL=StorySelectScreen.d.ts.map
@@ -6,13 +6,16 @@ import { MenuItem } from '../components/MenuItem.js';
6
6
  import { ProgressBar } from '../components/ProgressBar.js';
7
7
  import { stories } from '../data/stories/index.js';
8
8
  import { isStoryUnlocked } from '../state/ProgressStore.js';
9
+ const SUB_MENU_OPTIONS = 3; // つづきから, はじめから, もどる
9
10
  const courseConfig = [
10
11
  { key: 'kids', label: '✨ 小学生向けコース', emoji: '✨' },
11
12
  { key: 'beginner', label: '💻 はじめてコース', emoji: '💻' },
12
13
  { key: 'engineer', label: '🖥️ エンジニアコース', emoji: '🖥️' },
13
14
  ];
14
- export function StorySelectScreen({ progress, onNavigate }) {
15
+ export function StorySelectScreen({ progress, onNavigate, onResetStory }) {
15
16
  const [selectedIndex, setSelectedIndex] = useState(0);
17
+ const [subMenu, setSubMenu] = useState(null);
18
+ const [lockedMessage, setLockedMessage] = useState(null);
16
19
  const groupedStories = useMemo(() => {
17
20
  const groups = [];
18
21
  let flatIndex = 0;
@@ -39,6 +42,39 @@ export function StorySelectScreen({ progress, onNavigate }) {
39
42
  }, []);
40
43
  const { groups, totalStories } = groupedStories;
41
44
  useInput((_input, key) => {
45
+ if (subMenu) {
46
+ if (key.upArrow) {
47
+ setSubMenu(prev => prev ? { ...prev, selectedOption: Math.max(0, prev.selectedOption - 1) } : null);
48
+ }
49
+ if (key.downArrow) {
50
+ setSubMenu(prev => prev ? { ...prev, selectedOption: Math.min(SUB_MENU_OPTIONS - 1, prev.selectedOption + 1) } : null);
51
+ }
52
+ if (key.return) {
53
+ const story = stories.find(s => s.id === subMenu.storyId);
54
+ if (!story)
55
+ return;
56
+ if (subMenu.selectedOption === 0) {
57
+ // つづきから
58
+ const storyProg = progress.storyProgress[subMenu.storyId];
59
+ const missionIndex = storyProg ? storyProg.currentMissionIndex : 0;
60
+ const clampedIndex = Math.min(missionIndex, story.missions.length - 1);
61
+ onNavigate({ type: 'missionBrief', storyId: subMenu.storyId, missionIndex: clampedIndex });
62
+ }
63
+ else if (subMenu.selectedOption === 1) {
64
+ // はじめから
65
+ onResetStory(subMenu.storyId);
66
+ onNavigate({ type: 'missionBrief', storyId: subMenu.storyId, missionIndex: 0 });
67
+ }
68
+ else {
69
+ // もどる
70
+ setSubMenu(null);
71
+ }
72
+ }
73
+ if (key.escape) {
74
+ setSubMenu(null);
75
+ }
76
+ return;
77
+ }
42
78
  if (key.upArrow) {
43
79
  setSelectedIndex(prev => (prev > 0 ? prev - 1 : totalStories));
44
80
  }
@@ -55,12 +91,24 @@ export function StorySelectScreen({ progress, onNavigate }) {
55
91
  return;
56
92
  const story = storyItem.story;
57
93
  const unlocked = isStoryUnlocked(progress, story.id, stories);
58
- if (!unlocked)
94
+ if (!unlocked) {
95
+ const reqNames = story.unlockRequires
96
+ .map(reqId => stories.find(s => s.id === reqId))
97
+ .filter(Boolean)
98
+ .map(s => `「${s.title}」`);
99
+ setLockedMessage(`🔒 ${reqNames.join(' と ')} をクリアすると解放されます`);
59
100
  return;
101
+ }
102
+ setLockedMessage(null);
60
103
  const storyProg = progress.storyProgress[story.id];
61
- const missionIndex = storyProg ? storyProg.currentMissionIndex : 0;
62
- const clampedIndex = Math.min(missionIndex, story.missions.length - 1);
63
- onNavigate({ type: 'missionBrief', storyId: story.id, missionIndex: clampedIndex });
104
+ if (storyProg && storyProg.completedMissions.length > 0) {
105
+ // 進捗あり サブメニュー表示
106
+ setSubMenu({ storyId: story.id, selectedOption: 0 });
107
+ }
108
+ else {
109
+ // 進捗なし → 直接開始
110
+ onNavigate({ type: 'missionBrief', storyId: story.id, missionIndex: 0 });
111
+ }
64
112
  }
65
113
  if (key.escape) {
66
114
  onNavigate({ type: 'title' });
@@ -75,7 +123,7 @@ export function StorySelectScreen({ progress, onNavigate }) {
75
123
  const storyProg = progress.storyProgress[story.id];
76
124
  const completed = storyProg?.completedMissions.length ?? 0;
77
125
  const total = story.missions.length;
78
- return (_jsxs(Box, { flexDirection: "column", marginBottom: 0, children: [_jsx(MenuItem, { label: `${story.emoji} ${story.title}`, isSelected: flatIndex === selectedIndex, isLocked: !unlocked, description: unlocked ? story.description : undefined }), unlocked && (_jsx(Box, { marginLeft: 4, children: _jsx(ProgressBar, { current: completed, total: total, width: 15 }) }))] }, story.id));
79
- }), _jsx(Box, { marginTop: 1, children: _jsx(MenuItem, { label: "\u2190 \u30BF\u30A4\u30C8\u30EB\u306B\u623B\u308B", isSelected: selectedIndex === totalStories }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: colors.muted, children: "\u2191\u2193\u3067\u9078\u629E\u3001Enter\u3067\u6C7A\u5B9A\u3001Esc\u3067\u623B\u308B" }) })] }));
126
+ return (_jsxs(Box, { flexDirection: "column", marginBottom: 0, children: [_jsx(MenuItem, { label: `${story.emoji} ${story.title}`, isSelected: !subMenu && flatIndex === selectedIndex, isLocked: !unlocked, description: unlocked ? story.description : undefined }), unlocked && (_jsx(Box, { marginLeft: 4, children: _jsx(ProgressBar, { current: completed, total: total, width: 15 }) })), subMenu && subMenu.storyId === story.id && (_jsxs(Box, { marginLeft: 4, flexDirection: "column", children: [_jsx(MenuItem, { label: `▶ つづきから(ミッション ${Math.min((storyProg?.currentMissionIndex ?? 0) + 1, total)})`, isSelected: subMenu.selectedOption === 0 }), _jsx(MenuItem, { label: "\uD83D\uDD04 \u306F\u3058\u3081\u304B\u3089", isSelected: subMenu.selectedOption === 1 }), _jsx(MenuItem, { label: "\u2190 \u3082\u3069\u308B", isSelected: subMenu.selectedOption === 2 })] }))] }, story.id));
127
+ }), _jsx(Box, { marginTop: 1, children: _jsx(MenuItem, { label: "\u2190 \u30BF\u30A4\u30C8\u30EB\u306B\u623B\u308B", isSelected: !subMenu && selectedIndex === totalStories }) }), lockedMessage && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: colors.warning, children: lockedMessage }) })), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: colors.muted, children: "\u2191\u2193\u3067\u9078\u629E\u3001Enter\u3067\u6C7A\u5B9A\u3001Esc\u3067\u623B\u308B" }) })] }));
80
128
  }
81
129
  //# sourceMappingURL=StorySelectScreen.js.map
@@ -1,5 +1,5 @@
1
1
  import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
- import { useState, useCallback } from 'react';
2
+ import { useState, useCallback, useRef } from 'react';
3
3
  import { Box, Text, useInput } from 'ink';
4
4
  import { colors } from '../utils/colors.js';
5
5
  import { stories } from '../data/stories/index.js';
@@ -12,6 +12,7 @@ import { TerminalOutput } from '../components/TerminalOutput.js';
12
12
  import { ObjectivePanel } from '../components/ObjectivePanel.js';
13
13
  import { HintBar } from '../components/HintBar.js';
14
14
  import { suggestCommand, checkMissionFeedback } from '../engine/CommandFeedback.js';
15
+ import { getCommandMeta } from '../data/commands-meta.js';
15
16
  export function TerminalScreen({ storyId, missionIndex, onNavigate, onMissionComplete, onStoryComplete, onCommandExecuted, }) {
16
17
  const story = stories.find(s => s.id === storyId);
17
18
  const mission = story?.missions[missionIndex];
@@ -45,7 +46,8 @@ export function TerminalScreen({ storyId, missionIndex, onNavigate, onMissionCom
45
46
  const [completedObjectives, setCompletedObjectives] = useState([]);
46
47
  const [currentHint, setCurrentHint] = useState(null);
47
48
  const [hintLevel, setHintLevel] = useState(0);
48
- const [commandCount, setCommandCount] = useState(0);
49
+ const commandCountRef = useRef(0);
50
+ const [cmdsHintShown, setCmdsHintShown] = useState(new Set());
49
51
  useInput((input, key) => {
50
52
  if (key.escape) {
51
53
  onNavigate({ type: 'storySelect' });
@@ -61,18 +63,28 @@ export function TerminalScreen({ storyId, missionIndex, onNavigate, onMissionCom
61
63
  if (currentObjIndex >= mission.objectives.length)
62
64
  return;
63
65
  const obj = mission.objectives[currentObjIndex];
66
+ // newCommandsがあり、まだこの目標でcmdsプレヒントを出していない場合
67
+ if (mission.newCommands && mission.newCommands.length > 0 && !cmdsHintShown.has(obj.id)) {
68
+ setCmdsHintShown(prev => new Set(prev).add(obj.id));
69
+ const cmdsMsg = course === 'kids'
70
+ ? 'まずは cmds とにゅうりょくして、つかえるコマンドをかくにんしてみよう!'
71
+ : 'まず cmds と入力して、このミッションのコマンドを確認してみましょう。';
72
+ setCurrentHint({ level: 0, text: cmdsMsg });
73
+ setHintLevel(0);
74
+ return;
75
+ }
64
76
  const hint = hintEngine.getNextHint(obj.id, obj.hints);
65
77
  if (hint) {
66
78
  setCurrentHint(hint);
67
79
  setHintLevel(hintEngine.getCurrentLevel(obj.id));
68
80
  }
69
- }, [mission, missionEngine, hintEngine]);
81
+ }, [mission, missionEngine, hintEngine, cmdsHintShown, course]);
70
82
  const handleCommand = useCallback((input) => {
71
83
  const trimmed = input.trim();
72
84
  if (!trimmed || !commandHandler || !missionEngine || !mission)
73
85
  return;
74
86
  setCommandHistory(prev => [...prev, trimmed]);
75
- setCommandCount(prev => prev + 1);
87
+ commandCountRef.current += 1;
76
88
  onCommandExecuted();
77
89
  const cwd = missionEngine.getFS().getCwd();
78
90
  setOutputLines(prev => [
@@ -96,6 +108,38 @@ export function TerminalScreen({ storyId, missionIndex, onNavigate, onMissionCom
96
108
  });
97
109
  return;
98
110
  }
111
+ if (trimmed === 'cmds') {
112
+ if (mission.newCommands && mission.newCommands.length > 0) {
113
+ setOutputLines(prev => [
114
+ ...prev,
115
+ { text: '📖 このミッションの新しいコマンド:', type: 'system' },
116
+ ]);
117
+ for (const cmdName of mission.newCommands) {
118
+ const meta = getCommandMeta(cmdName);
119
+ if (!meta)
120
+ continue;
121
+ setOutputLines(prev => [
122
+ ...prev,
123
+ { text: ` ${meta.name} - ${meta.description}`, type: 'output' },
124
+ ...meta.examples.map(ex => ({
125
+ text: ` $ ${ex.cmd.padEnd(28)} ${ex.desc}`,
126
+ type: 'output',
127
+ })),
128
+ ]);
129
+ }
130
+ }
131
+ else {
132
+ setOutputLines(prev => [
133
+ ...prev,
134
+ { text: 'このミッションに新しいコマンドはありません。', type: 'system' },
135
+ ]);
136
+ }
137
+ setOutputLines(prev => [
138
+ ...prev,
139
+ { text: '💡 全コマンド一覧は man、詳細は man <コマンド名> で確認できます。', type: 'system' },
140
+ ]);
141
+ return;
142
+ }
99
143
  const result = commandHandler.execute(trimmed);
100
144
  if (result.output === 'CLEAR_SCREEN') {
101
145
  setOutputLines([]);
@@ -134,13 +178,19 @@ export function TerminalScreen({ storyId, missionIndex, onNavigate, onMissionCom
134
178
  }
135
179
  }
136
180
  }
137
- const parts = trimmed.split(/\s+/);
138
- const cmd = parts[0];
139
- const args = parts.slice(1);
140
- const newlyCompleted = missionEngine.checkObjectives(cmd, args, result.output);
141
- if (newlyCompleted.length > 0) {
142
- setCompletedObjectives(prev => [...prev, ...newlyCompleted]);
143
- for (const objId of newlyCompleted) {
181
+ // Parse all commands in pipe chain for objective checking
182
+ const pipeSegments = trimmed.split('|').map(s => s.trim()).filter(s => s);
183
+ let allNewlyCompleted = [];
184
+ for (const segment of pipeSegments) {
185
+ const parts = segment.split(/\s+/);
186
+ const cmd = parts[0];
187
+ const args = parts.slice(1);
188
+ const newlyCompleted = missionEngine.checkObjectives(cmd, args, result.output);
189
+ allNewlyCompleted.push(...newlyCompleted);
190
+ }
191
+ if (allNewlyCompleted.length > 0) {
192
+ setCompletedObjectives(prev => [...prev, ...allNewlyCompleted]);
193
+ for (const objId of allNewlyCompleted) {
144
194
  const obj = mission.objectives.find(o => o.id === objId);
145
195
  if (obj) {
146
196
  setOutputLines(prev => [
@@ -151,13 +201,14 @@ export function TerminalScreen({ storyId, missionIndex, onNavigate, onMissionCom
151
201
  }
152
202
  setCurrentHint(null);
153
203
  if (missionEngine.isAllComplete()) {
204
+ const finalCount = commandCountRef.current;
154
205
  setTimeout(() => {
155
- onMissionComplete(storyId, mission.id, hintEngine.getTotalHintsUsed(), commandCount + 1);
206
+ onMissionComplete(storyId, mission.id, hintEngine.getTotalHintsUsed(), finalCount);
156
207
  const isLast = story ? missionIndex >= story.missions.length - 1 : false;
157
208
  if (isLast) {
158
209
  onStoryComplete(storyId);
159
210
  }
160
- onNavigate({ type: 'missionComplete', storyId, missionIndex, commandCount: commandCount + 1 });
211
+ onNavigate({ type: 'missionComplete', storyId, missionIndex, commandCount: finalCount });
161
212
  }, 500);
162
213
  }
163
214
  }
@@ -169,7 +220,6 @@ export function TerminalScreen({ storyId, missionIndex, onNavigate, onMissionCom
169
220
  missionIndex,
170
221
  story,
171
222
  completedObjectives,
172
- commandCount,
173
223
  hintEngine,
174
224
  onCommandExecuted,
175
225
  onMissionComplete,
@@ -182,9 +232,9 @@ export function TerminalScreen({ storyId, missionIndex, onNavigate, onMissionCom
182
232
  }
183
233
  const currentObj = mission.objectives[missionEngine.getCurrentObjectiveIndex()];
184
234
  return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsxs(Box, { justifyContent: "space-between", marginBottom: 1, children: [_jsxs(Text, { bold: true, color: colors.secondary, children: [story.emoji, " ", mission.title] }), _jsx(Text, { color: colors.muted, children: course === 'kids'
185
- ? 'Esc: もどる | Tab: ほかん | hint: ヒント | obj: もくひょう'
235
+ ? 'Esc: もどる | Tab: ほかん | hint: ヒント | obj: もくひょう | cmds: コマンド'
186
236
  : course === 'beginner'
187
- ? 'Esc: 戻る | Tab: 補完 | Ctrl+H: ヒント | hint: ヒント | obj: 目標一覧'
188
- : 'Esc: 戻る | Tab: 補完 | Ctrl+H: ヒント | hint: ヒント | obj: 目標' })] }), _jsx(ObjectivePanel, { objectives: mission.objectives, completedIds: completedObjectives }), currentHint && currentObj && (_jsx(HintBar, { hint: currentHint, currentLevel: hintLevel, maxLevel: currentObj.hints.length })), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(TerminalOutput, { lines: outputLines, maxLines: 30 }), _jsx(TerminalPrompt, { cwd: missionEngine.getFS().getCwd(), onSubmit: handleCommand, history: commandHistory, tabCompletion: tabCompletion ?? undefined })] })] }));
237
+ ? 'Esc: 戻る | Tab: 補完 | Ctrl+H: ヒント | hint: ヒント | obj: 目標 | cmds: コマンド'
238
+ : 'Esc: 戻る | Tab: 補完 | Ctrl+H: ヒント | hint: ヒント | obj: 目標 | cmds: コマンド' })] }), _jsx(ObjectivePanel, { objectives: mission.objectives, completedIds: completedObjectives }), currentHint && currentObj && (_jsx(HintBar, { hint: currentHint, currentLevel: hintLevel, maxLevel: currentObj.hints.length })), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(TerminalOutput, { lines: outputLines, maxLines: 30 }), _jsx(TerminalPrompt, { cwd: missionEngine.getFS().getCwd(), onSubmit: handleCommand, history: commandHistory, tabCompletion: tabCompletion ?? undefined })] })] }));
189
239
  }
190
240
  //# sourceMappingURL=TerminalScreen.js.map
@@ -6,6 +6,7 @@ export declare function useGameState(): {
6
6
  completeMission: (storyId: string, missionId: string, hintsUsed: number, commandCount?: number) => void;
7
7
  completeStory: (storyId: string) => void;
8
8
  incrementCommands: () => void;
9
+ resetStory: (storyId: string) => void;
9
10
  resetAll: () => void;
10
11
  };
11
12
  //# sourceMappingURL=useGameState.d.ts.map
@@ -79,9 +79,19 @@ export function useGameState() {
79
79
  };
80
80
  });
81
81
  }
82
- }, [progress]);
82
+ }, [progress.totalCommandsExecuted, progress.completedStories, progress.storyProgress]);
83
+ const resetStory = useCallback((storyId) => {
84
+ setProgress(prev => {
85
+ const { [storyId]: _, ...restStoryProgress } = prev.storyProgress;
86
+ return {
87
+ ...prev,
88
+ storyProgress: restStoryProgress,
89
+ completedStories: prev.completedStories.filter(id => id !== storyId),
90
+ };
91
+ });
92
+ }, []);
83
93
  const resetAll = useCallback(() => {
84
- setProgress(initialGameState.progress);
94
+ setProgress({ ...initialGameState.progress });
85
95
  }, []);
86
96
  return {
87
97
  screen,
@@ -90,6 +100,7 @@ export function useGameState() {
90
100
  completeMission,
91
101
  completeStory,
92
102
  incrementCommands,
103
+ resetStory,
93
104
  resetAll,
94
105
  };
95
106
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "terminal-quest",
3
- "version": "1.1.2",
3
+ "version": "1.2.1",
4
4
  "description": "ストーリー駆動型ターミナルコマンド学習CLI - Learn terminal commands through interactive stories",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",