termi-kids 0.1.2 → 0.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.
- package/README.md +17 -21
- package/SAFETY.md +2 -2
- package/dist/cli.js +9 -29
- package/dist/learn/runner.js +1 -1
- package/dist/projects/blankGame.js +100 -0
- package/dist/projects/gameIdeas.js +216 -0
- package/dist/setup/wizard.js +14 -88
- package/dist/surfaces/buildGame.js +335 -0
- package/dist/surfaces/buildLoop.js +114 -0
- package/dist/surfaces/commands.js +3 -3
- package/dist/surfaces/home.js +65 -25
- package/dist/ui/text.js +20 -16
- package/package.json +2 -2
package/dist/setup/wizard.js
CHANGED
|
@@ -16,7 +16,6 @@ import { appendAudit } from '../safety/audit.js';
|
|
|
16
16
|
import { ensureGuardFetch, guardProgressBar } from '../safety/guarddownload.js';
|
|
17
17
|
import { guardModelReady } from '../safety/modelstore.js';
|
|
18
18
|
import { nameIsOkay } from '../safety/prefilter.js';
|
|
19
|
-
import { scaffoldById } from '../projects/scaffolds/index.js';
|
|
20
19
|
import { renderBanner } from '../ui/banner.js';
|
|
21
20
|
import { mascot } from '../ui/mascot.js';
|
|
22
21
|
import { style } from '../ui/theme.js';
|
|
@@ -262,14 +261,7 @@ async function createPinStep() {
|
|
|
262
261
|
}
|
|
263
262
|
}
|
|
264
263
|
async function consentStep(settings) {
|
|
265
|
-
|
|
266
|
-
message: 'How old is your kid?',
|
|
267
|
-
options: [
|
|
268
|
-
{ value: 'under13', label: 'Under 13' },
|
|
269
|
-
{ value: 'teen', label: '13 or older' },
|
|
270
|
-
],
|
|
271
|
-
initialValue: 'under13',
|
|
272
|
-
}));
|
|
264
|
+
// One safety bar for every age. No under-13 / over-13 split.
|
|
273
265
|
const agreed = ensure(await p.confirm({ message: `${T.wizard.consentIntro} Do you agree?`, initialValue: true }));
|
|
274
266
|
if (!agreed) {
|
|
275
267
|
bail();
|
|
@@ -280,13 +272,13 @@ async function consentStep(settings) {
|
|
|
280
272
|
ts: attestedAt,
|
|
281
273
|
layer: 'system',
|
|
282
274
|
event: 'consent',
|
|
283
|
-
excerpt:
|
|
275
|
+
excerpt: 'parent consent, one safety bar for all ages',
|
|
284
276
|
});
|
|
285
277
|
}
|
|
286
278
|
catch {
|
|
287
279
|
// Consent still counts; the audit line is best effort.
|
|
288
280
|
}
|
|
289
|
-
return { ...settings, ageBand, consentAttestedAt: attestedAt };
|
|
281
|
+
return { ...settings, ageBand: 'under13', consentAttestedAt: attestedAt };
|
|
290
282
|
}
|
|
291
283
|
async function providerLoop(settings) {
|
|
292
284
|
let current = settings;
|
|
@@ -318,41 +310,27 @@ async function providerLoop(settings) {
|
|
|
318
310
|
return { ...current, activeProvider: null };
|
|
319
311
|
}
|
|
320
312
|
/**
|
|
321
|
-
*
|
|
322
|
-
*
|
|
323
|
-
*
|
|
324
|
-
* local filter plus the online checks) is already on and that the checker
|
|
325
|
-
* strengthens it when the download lands, then chooses to start building
|
|
326
|
-
* now or wait and watch the bar. Either way the pipeline hot-attaches the
|
|
327
|
-
* checker the moment the verified file is in place. Declining turns the
|
|
328
|
-
* setting off; a failed or interrupted download resumes on the next start.
|
|
313
|
+
* Installs the on-device safety checker as part of setup. Always on: the
|
|
314
|
+
* model download starts here (and resumes on later boots). Parent can wait
|
|
315
|
+
* for the bar or keep going; declining is not offered.
|
|
329
316
|
*/
|
|
330
317
|
async function localGuardStep(settings) {
|
|
331
318
|
if (guardModelReady()) {
|
|
332
|
-
|
|
333
|
-
// wizard re-run must not silently flip a deliberate off back to on.
|
|
334
|
-
return settings;
|
|
335
|
-
}
|
|
336
|
-
const wants = ensure(await p.confirm({ message: T.wizard.guardOffer, initialValue: true }));
|
|
337
|
-
if (!wants) {
|
|
338
|
-
p.log.info(T.wizard.guardDeclined);
|
|
339
|
-
audit('settings_change', 'local classifier off (declined in setup)');
|
|
340
|
-
return { ...settings, localClassifier: false };
|
|
319
|
+
return { ...settings, localClassifier: true };
|
|
341
320
|
}
|
|
321
|
+
p.log.info(T.wizard.guardOffer);
|
|
342
322
|
const fetchDone = ensureGuardFetch();
|
|
343
323
|
p.log.info(T.wizard.guardBackground);
|
|
324
|
+
audit('settings_change', 'local classifier install started in setup');
|
|
344
325
|
const wait = ensure(await p.select({
|
|
345
326
|
message: T.wizard.guardWaitPick,
|
|
346
327
|
options: [
|
|
347
|
-
{ value: 'now', label: T.wizard.guardStartNow, hint: T.wizard.guardStartNowHint },
|
|
348
328
|
{ value: 'wait', label: T.wizard.guardWaitHere, hint: T.wizard.guardWaitHereHint },
|
|
329
|
+
{ value: 'now', label: T.wizard.guardStartNow, hint: T.wizard.guardStartNowHint },
|
|
349
330
|
],
|
|
350
|
-
initialValue: '
|
|
331
|
+
initialValue: 'wait',
|
|
351
332
|
}));
|
|
352
333
|
if (wait === 'wait') {
|
|
353
|
-
// The wait shows a live bar with periodic escape hatches (after one
|
|
354
|
-
// minute, then every ten): a slow connection must not trap the parent
|
|
355
|
-
// in setup when the download finishes fine in the background anyway.
|
|
356
334
|
const escapeAfterMs = [60_000, 600_000];
|
|
357
335
|
let escapeIndex = 0;
|
|
358
336
|
for (;;) {
|
|
@@ -406,64 +384,12 @@ async function firstGameStep(settings) {
|
|
|
406
384
|
if (p.isCancel(wants) || !wants) {
|
|
407
385
|
return;
|
|
408
386
|
}
|
|
409
|
-
const games = scaffoldById('games');
|
|
410
|
-
if (games === undefined) {
|
|
411
|
-
return;
|
|
412
|
-
}
|
|
413
|
-
const themeId = await p.select({
|
|
414
|
-
message: 'Pick a style for your game.',
|
|
415
|
-
options: games.themes.map((t) => ({ value: t.id, label: `${t.emoji} ${t.label}` })),
|
|
416
|
-
});
|
|
417
|
-
if (p.isCancel(themeId)) {
|
|
418
|
-
return;
|
|
419
|
-
}
|
|
420
|
-
const theme = games.themes.find((t) => t.id === themeId);
|
|
421
|
-
if (theme === undefined) {
|
|
422
|
-
return;
|
|
423
|
-
}
|
|
424
|
-
const CUSTOM = '__custom__';
|
|
425
|
-
const suggestions = suggestProjectNames(theme.label);
|
|
426
|
-
const namePick = await p.select({
|
|
427
|
-
message: 'Pick a name for it.',
|
|
428
|
-
options: [
|
|
429
|
-
...suggestions.map((n) => ({ value: n, label: n })),
|
|
430
|
-
{ value: CUSTOM, label: 'Type my own name' },
|
|
431
|
-
],
|
|
432
|
-
});
|
|
433
|
-
if (p.isCancel(namePick)) {
|
|
434
|
-
return;
|
|
435
|
-
}
|
|
436
|
-
let prettyName = namePick;
|
|
437
|
-
if (namePick === CUSTOM) {
|
|
438
|
-
const typed = await p.text({
|
|
439
|
-
message: 'What is its name?',
|
|
440
|
-
validate: (value) => {
|
|
441
|
-
const trimmed = (value ?? '').trim();
|
|
442
|
-
if (trimmed.length === 0)
|
|
443
|
-
return 'It needs a name.';
|
|
444
|
-
if (!nameIsOkay(trimmed))
|
|
445
|
-
return 'That name will not work. Try another one.';
|
|
446
|
-
return undefined;
|
|
447
|
-
},
|
|
448
|
-
});
|
|
449
|
-
if (p.isCancel(typed)) {
|
|
450
|
-
return;
|
|
451
|
-
}
|
|
452
|
-
prettyName = typed.trim();
|
|
453
|
-
}
|
|
454
387
|
try {
|
|
455
|
-
const
|
|
456
|
-
|
|
457
|
-
const made = create.createProject('games', theme.id, prettyName);
|
|
458
|
-
await home.awardBadge('first-project');
|
|
459
|
-
const starters = made.starterPrompts.slice(0, 2);
|
|
460
|
-
if (starters.length > 0) {
|
|
461
|
-
p.note(starters.map((line) => `- ${line}`).join('\n'), 'Try saying');
|
|
462
|
-
}
|
|
463
|
-
await home.openChatLoop(made.project, settings);
|
|
388
|
+
const build = await import('../surfaces/buildGame.js');
|
|
389
|
+
await build.runBuildGame(settings);
|
|
464
390
|
}
|
|
465
391
|
catch {
|
|
466
|
-
p.log.warn('I could not start the game yet. Try
|
|
392
|
+
p.log.warn('I could not start the game yet. Try Build a game next time.');
|
|
467
393
|
}
|
|
468
394
|
}
|
|
469
395
|
/** Runs the full setup wizard, parent flow then kid flow. */
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build a game: pick an idea, prompt loop, live preview, done/improve,
|
|
3
|
+
* final polish. Projects save in the local library under TERMI_PROJECTS_DIR.
|
|
4
|
+
*/
|
|
5
|
+
import * as p from '@clack/prompts';
|
|
6
|
+
import { getSecret } from '../auth/keychain.js';
|
|
7
|
+
import { hasTokens } from '../auth/tokens.js';
|
|
8
|
+
import { startPreview } from '../preview/server.js';
|
|
9
|
+
import { createProviderClient, pickClassifierBackend, } from '../providers/index.js';
|
|
10
|
+
import { classifyProviderError } from '../providers/errors.js';
|
|
11
|
+
import { appendAudit } from '../safety/audit.js';
|
|
12
|
+
import { createSafetyPipeline } from '../safety/classifier.js';
|
|
13
|
+
import { ensureGuardFetch } from '../safety/guarddownload.js';
|
|
14
|
+
import { lazyGuardAccessor } from '../safety/guardrunner.js';
|
|
15
|
+
import { guardModelReady } from '../safety/modelstore.js';
|
|
16
|
+
import { createSessionState } from '../safety/session.js';
|
|
17
|
+
import { nameIsOkay } from '../safety/prefilter.js';
|
|
18
|
+
import { createBlankGameProject } from '../projects/blankGame.js';
|
|
19
|
+
import { GAME_IDEAS, gameIdeaById, gameIdeaMenuOptions, isOwnIdea, } from '../projects/gameIdeas.js';
|
|
20
|
+
import { providerLabel } from '../setup/wizard.js';
|
|
21
|
+
import { renderProviderError } from '../ui/errors.js';
|
|
22
|
+
import { style } from '../ui/theme.js';
|
|
23
|
+
import { T } from '../ui/text.js';
|
|
24
|
+
import { completenessHint, defaultNameForIdea, helpQuestions, parseDoneChoice, polishPrompt, seedPromptForIdea, suggestPromptFromAnswers, summarizeProjectFiles, } from './buildLoop.js';
|
|
25
|
+
import { awardBadge } from './home.js';
|
|
26
|
+
function audit(event) {
|
|
27
|
+
try {
|
|
28
|
+
appendAudit(event);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
// Never break the kid flow for the log.
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function providerAvailability(settings) {
|
|
35
|
+
return {
|
|
36
|
+
'openai-chatgpt': hasTokens(),
|
|
37
|
+
'openai-api': (getSecret('api-key-openai-api') ?? '').length > 0,
|
|
38
|
+
anthropic: (getSecret('api-key-anthropic') ?? '').length > 0,
|
|
39
|
+
xai: (getSecret('api-key-xai') ?? '').length > 0 && settings.xaiParentAck,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
async function pickIdea() {
|
|
43
|
+
const pick = await p.select({
|
|
44
|
+
message: 'Build a game. What do you want to make?',
|
|
45
|
+
options: gameIdeaMenuOptions(),
|
|
46
|
+
initialValue: GAME_IDEAS[0].id,
|
|
47
|
+
});
|
|
48
|
+
if (p.isCancel(pick)) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
return gameIdeaById(pick) ?? null;
|
|
52
|
+
}
|
|
53
|
+
async function pickProjectName(idea) {
|
|
54
|
+
const suggested = defaultNameForIdea(idea);
|
|
55
|
+
const typed = await p.text({
|
|
56
|
+
message: 'Name your game.',
|
|
57
|
+
placeholder: suggested,
|
|
58
|
+
defaultValue: suggested,
|
|
59
|
+
validate: (value) => {
|
|
60
|
+
const trimmed = (value ?? '').trim() || suggested;
|
|
61
|
+
if (trimmed.length === 0)
|
|
62
|
+
return 'It needs a name.';
|
|
63
|
+
if (!nameIsOkay(trimmed))
|
|
64
|
+
return 'That name will not work. Try another one.';
|
|
65
|
+
return undefined;
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
if (p.isCancel(typed)) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
const name = typed.trim() || suggested;
|
|
72
|
+
return name;
|
|
73
|
+
}
|
|
74
|
+
async function gatherHelpAnswers(idea) {
|
|
75
|
+
const questions = helpQuestions(idea);
|
|
76
|
+
const answers = [];
|
|
77
|
+
for (const question of questions) {
|
|
78
|
+
const ans = await p.text({
|
|
79
|
+
message: question,
|
|
80
|
+
validate: (value) => (value ?? '').trim().length > 0 ? undefined : 'Say a little so I can help.',
|
|
81
|
+
});
|
|
82
|
+
if (p.isCancel(ans)) {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
answers.push(ans.trim());
|
|
86
|
+
}
|
|
87
|
+
return answers;
|
|
88
|
+
}
|
|
89
|
+
async function obtainPrompt(idea) {
|
|
90
|
+
const path = await p.select({
|
|
91
|
+
message: 'How do you want to tell Termi what to build?',
|
|
92
|
+
options: [
|
|
93
|
+
{ value: 'write', label: 'I will write my own prompt' },
|
|
94
|
+
{ value: 'help', label: 'Help me with a prompt for my idea' },
|
|
95
|
+
...(seedPromptForIdea(idea).length > 0
|
|
96
|
+
? [{ value: 'seed', label: 'Use the ready idea prompt' }]
|
|
97
|
+
: []),
|
|
98
|
+
],
|
|
99
|
+
});
|
|
100
|
+
if (p.isCancel(path)) {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
let draft = '';
|
|
104
|
+
if (path === 'seed') {
|
|
105
|
+
draft = seedPromptForIdea(idea);
|
|
106
|
+
}
|
|
107
|
+
else if (path === 'help') {
|
|
108
|
+
const answers = await gatherHelpAnswers(idea);
|
|
109
|
+
if (answers === null) {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
draft = suggestPromptFromAnswers(idea, answers);
|
|
113
|
+
p.note(draft, 'Suggested prompt');
|
|
114
|
+
}
|
|
115
|
+
const text = await p.text({
|
|
116
|
+
message: path === 'write' ? 'Type your prompt.' : 'Edit the prompt, or press Enter to run it.',
|
|
117
|
+
defaultValue: draft,
|
|
118
|
+
placeholder: draft || 'Make a fun browser game where...',
|
|
119
|
+
validate: (value) => (value ?? '').trim().length > 0 ? undefined : 'Need a prompt to build.',
|
|
120
|
+
});
|
|
121
|
+
if (p.isCancel(text)) {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
return text.trim();
|
|
125
|
+
}
|
|
126
|
+
function listKidFileSummaries(project) {
|
|
127
|
+
const out = [];
|
|
128
|
+
for (const file of project.listKidFiles()) {
|
|
129
|
+
const content = project.readFile(file.relPath);
|
|
130
|
+
if (content !== null) {
|
|
131
|
+
out.push({ relPath: file.relPath, content });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
async function runOneTurn(kidMessage, deps) {
|
|
137
|
+
const loop = await import('../agent/loop.js');
|
|
138
|
+
const spin = p.spinner();
|
|
139
|
+
spin.start(T.chat.thinking);
|
|
140
|
+
let result;
|
|
141
|
+
try {
|
|
142
|
+
result = await loop.runTurn(kidMessage, {
|
|
143
|
+
provider: deps.provider,
|
|
144
|
+
modelAlias: deps.settings.modelAlias,
|
|
145
|
+
safety: deps.safety,
|
|
146
|
+
session: deps.session,
|
|
147
|
+
project: deps.project,
|
|
148
|
+
snapshots: deps.snapshots,
|
|
149
|
+
preview: deps.preview,
|
|
150
|
+
audit,
|
|
151
|
+
ui: {
|
|
152
|
+
onActivity(line) {
|
|
153
|
+
spin.message(line);
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
catch (err) {
|
|
159
|
+
spin.stop();
|
|
160
|
+
const label = deps.settings.activeProvider !== null
|
|
161
|
+
? providerLabel(deps.settings.activeProvider)
|
|
162
|
+
: 'AI helper';
|
|
163
|
+
console.log(renderProviderError(classifyProviderError(err), label));
|
|
164
|
+
return 'error';
|
|
165
|
+
}
|
|
166
|
+
spin.stop(T.chat.working);
|
|
167
|
+
if (result.status === 'ok') {
|
|
168
|
+
if (result.replyText) {
|
|
169
|
+
console.log(result.replyText);
|
|
170
|
+
}
|
|
171
|
+
try {
|
|
172
|
+
deps.preview.notifyChange();
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
// Preview may already be closed.
|
|
176
|
+
}
|
|
177
|
+
return 'ok';
|
|
178
|
+
}
|
|
179
|
+
if (result.status === 'blocked') {
|
|
180
|
+
if (result.screen !== null) {
|
|
181
|
+
console.log(result.screen.body);
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
console.log(T.blocks.generic);
|
|
185
|
+
}
|
|
186
|
+
return 'blocked';
|
|
187
|
+
}
|
|
188
|
+
if (result.screen !== null) {
|
|
189
|
+
console.log(result.screen.body);
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
console.log(T.errors.oops);
|
|
193
|
+
}
|
|
194
|
+
return 'error';
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Full Build a game session for one project from idea pick to library save.
|
|
198
|
+
* Returns the project when something was saved, null on cancel at the start.
|
|
199
|
+
*/
|
|
200
|
+
export async function runBuildGame(settings) {
|
|
201
|
+
const idea = await pickIdea();
|
|
202
|
+
if (idea === null) {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
const prettyName = await pickProjectName(idea);
|
|
206
|
+
if (prettyName === null) {
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
let project;
|
|
210
|
+
try {
|
|
211
|
+
project = createBlankGameProject(prettyName, idea.label);
|
|
212
|
+
}
|
|
213
|
+
catch (err) {
|
|
214
|
+
p.log.error(err instanceof Error ? err.message : 'Could not create the game folder.');
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
await awardBadge('first-project');
|
|
218
|
+
let provider = null;
|
|
219
|
+
if (settings.activeProvider !== null) {
|
|
220
|
+
try {
|
|
221
|
+
provider = createProviderClient(settings.activeProvider);
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
provider = null;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
if (provider === null) {
|
|
228
|
+
p.log.warn(`${T.offline.noProvider} ${T.offline.stillWorks}`);
|
|
229
|
+
p.log.info(`Your game folder is ready: ${project.meta.prettyName}`);
|
|
230
|
+
return project;
|
|
231
|
+
}
|
|
232
|
+
if (settings.localClassifier && !guardModelReady()) {
|
|
233
|
+
void ensureGuardFetch();
|
|
234
|
+
}
|
|
235
|
+
const backend = pickClassifierBackend(settings, providerAvailability(settings));
|
|
236
|
+
const safety = createSafetyPipeline({
|
|
237
|
+
classifierModel: () => backend.classifierClient !== null
|
|
238
|
+
? backend.classifierClient.languageModel('classifier', settings.modelAlias)
|
|
239
|
+
: null,
|
|
240
|
+
moderationKey: () => backend.moderationKey,
|
|
241
|
+
localGuard: lazyGuardAccessor(settings.localClassifier),
|
|
242
|
+
audit,
|
|
243
|
+
});
|
|
244
|
+
const session = createSessionState();
|
|
245
|
+
let snapshots = {
|
|
246
|
+
beginTurn() { },
|
|
247
|
+
undo() {
|
|
248
|
+
return false;
|
|
249
|
+
},
|
|
250
|
+
redo() {
|
|
251
|
+
return false;
|
|
252
|
+
},
|
|
253
|
+
};
|
|
254
|
+
try {
|
|
255
|
+
const snapMod = await import('../projects/snapshots.js');
|
|
256
|
+
snapshots = snapMod.createSnapshotStore(project);
|
|
257
|
+
}
|
|
258
|
+
catch {
|
|
259
|
+
// undo optional
|
|
260
|
+
}
|
|
261
|
+
const preview = await startPreview(project.dir, { openBrowser: true });
|
|
262
|
+
console.log(`${T.chat.previewOpened} ${style.dim(preview.url)}`);
|
|
263
|
+
console.log(style.dim('The browser will update after each build.'));
|
|
264
|
+
const turnDeps = { provider, settings, project, preview, safety, session, snapshots };
|
|
265
|
+
// First build
|
|
266
|
+
for (;;) {
|
|
267
|
+
const prompt = await obtainPrompt(idea);
|
|
268
|
+
if (prompt === null) {
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
const status = await runOneTurn(prompt, turnDeps);
|
|
272
|
+
if (status === 'ok') {
|
|
273
|
+
try {
|
|
274
|
+
preview.notifyChange();
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
//
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
const next = await p.select({
|
|
281
|
+
message: 'Do you think you are done, or want to improve the idea?',
|
|
282
|
+
options: [
|
|
283
|
+
{ value: 'improve', label: 'Improve' },
|
|
284
|
+
{ value: 'done', label: 'I am done' },
|
|
285
|
+
],
|
|
286
|
+
initialValue: 'improve',
|
|
287
|
+
});
|
|
288
|
+
if (p.isCancel(next)) {
|
|
289
|
+
break;
|
|
290
|
+
}
|
|
291
|
+
const choice = parseDoneChoice(next);
|
|
292
|
+
if (choice === 'improve') {
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
if (choice === 'done') {
|
|
296
|
+
console.log(style.title('Testing your game and making final fixes...'));
|
|
297
|
+
const summary = summarizeProjectFiles(listKidFileSummaries(project));
|
|
298
|
+
const hint = completenessHint(summary);
|
|
299
|
+
console.log(style.dim(`Suggestion: ${hint}`));
|
|
300
|
+
const approve = await p.confirm({
|
|
301
|
+
message: 'Apply this final improvement?',
|
|
302
|
+
initialValue: true,
|
|
303
|
+
});
|
|
304
|
+
if (!p.isCancel(approve) && approve) {
|
|
305
|
+
const finalPrompt = polishPrompt(`${hint}. Project: ${summary}`);
|
|
306
|
+
await runOneTurn(finalPrompt, turnDeps);
|
|
307
|
+
try {
|
|
308
|
+
preview.notifyChange();
|
|
309
|
+
}
|
|
310
|
+
catch {
|
|
311
|
+
//
|
|
312
|
+
}
|
|
313
|
+
console.log(style.good('Final fixes applied. Check the browser!'));
|
|
314
|
+
}
|
|
315
|
+
else {
|
|
316
|
+
console.log('Okay. Your game is saved as is.');
|
|
317
|
+
}
|
|
318
|
+
break;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
p.log.success(`Saved in your game library: ${project.meta.prettyName}`);
|
|
322
|
+
// Keep preview open so the kid can keep playing; they Ctrl+C later or return home.
|
|
323
|
+
return project;
|
|
324
|
+
}
|
|
325
|
+
/** Exported for tests that assert the catalog is wired. */
|
|
326
|
+
export function buildGameIdeaCount() {
|
|
327
|
+
return GAME_IDEAS.length;
|
|
328
|
+
}
|
|
329
|
+
export function buildGameFirstLabel() {
|
|
330
|
+
return GAME_IDEAS[0]?.label ?? '';
|
|
331
|
+
}
|
|
332
|
+
export function buildGameIsOwnFirst() {
|
|
333
|
+
const first = GAME_IDEAS[0];
|
|
334
|
+
return first !== undefined && isOwnIdea(first);
|
|
335
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers for the Build a game prompt loop.
|
|
3
|
+
* UI and network live in buildGame.ts; tests cover this module fully.
|
|
4
|
+
*/
|
|
5
|
+
import { isOwnIdea } from '../projects/gameIdeas.js';
|
|
6
|
+
/** Clarifying questions when the kid picks Help me with a prompt. */
|
|
7
|
+
export function helpQuestions(idea) {
|
|
8
|
+
if (isOwnIdea(idea)) {
|
|
9
|
+
return [
|
|
10
|
+
'What is your game about in one short line?',
|
|
11
|
+
'How does the player win or finish?',
|
|
12
|
+
'What keys or clicks should control it?',
|
|
13
|
+
];
|
|
14
|
+
}
|
|
15
|
+
return [
|
|
16
|
+
`What should feel special about "${idea.label}"?`,
|
|
17
|
+
'Do you want easy mode, normal, or hard?',
|
|
18
|
+
];
|
|
19
|
+
}
|
|
20
|
+
/** Builds a starter prompt from a catalog idea (not own-idea). */
|
|
21
|
+
export function seedPromptForIdea(idea) {
|
|
22
|
+
if (isOwnIdea(idea) || idea.seedPrompt.trim().length === 0) {
|
|
23
|
+
return '';
|
|
24
|
+
}
|
|
25
|
+
return idea.seedPrompt.trim();
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Turns help-question answers into a suggested build prompt the kid can
|
|
29
|
+
* edit. Works offline without a model call.
|
|
30
|
+
*/
|
|
31
|
+
export function suggestPromptFromAnswers(idea, answers) {
|
|
32
|
+
const cleaned = answers.map((a) => a.trim()).filter((a) => a.length > 0);
|
|
33
|
+
if (isOwnIdea(idea)) {
|
|
34
|
+
const about = cleaned[0] ?? 'a fun browser game';
|
|
35
|
+
const win = cleaned[1] ?? 'reach a high score';
|
|
36
|
+
const controls = cleaned[2] ?? 'arrow keys or click';
|
|
37
|
+
return (`Make a complete local browser game about ${about}. ` +
|
|
38
|
+
`The player wins when they ${win}. ` +
|
|
39
|
+
`Controls: ${controls}. ` +
|
|
40
|
+
`Use only HTML, CSS, and JavaScript in index.html, style.css, and game.js. ` +
|
|
41
|
+
`No images from the internet. Keep it kid friendly and playable after one load.`);
|
|
42
|
+
}
|
|
43
|
+
const special = cleaned[0] ?? idea.blurb;
|
|
44
|
+
const difficulty = cleaned[1] ?? 'normal';
|
|
45
|
+
const base = seedPromptForIdea(idea) || `Make a ${idea.label} browser game.`;
|
|
46
|
+
return (`${base} ` +
|
|
47
|
+
`Make it feel special by: ${special}. ` +
|
|
48
|
+
`Difficulty: ${difficulty}. ` +
|
|
49
|
+
`Use only HTML, CSS, and JavaScript files. No outside images.`);
|
|
50
|
+
}
|
|
51
|
+
/** Final polish instruction after the kid says they are done. */
|
|
52
|
+
export function polishPrompt(projectSummary) {
|
|
53
|
+
const summary = projectSummary.trim().length > 0
|
|
54
|
+
? projectSummary.trim().slice(0, 800)
|
|
55
|
+
: 'the current game files';
|
|
56
|
+
return (`You are doing final testing and fixes for this kid game. ` +
|
|
57
|
+
`Read the project files. Based on ${summary}, make ONE clear improvement ` +
|
|
58
|
+
`so the game feels more complete (for example a start screen, score, ` +
|
|
59
|
+
`restart key, win or lose message, or clearer controls). ` +
|
|
60
|
+
`Apply the change in the files. Keep it simple and kid friendly. ` +
|
|
61
|
+
`No internet images. Then briefly say what you fixed.`);
|
|
62
|
+
}
|
|
63
|
+
/** Summarize kid files for the polish prompt (pure). */
|
|
64
|
+
export function summarizeProjectFiles(files) {
|
|
65
|
+
if (files.length === 0) {
|
|
66
|
+
return 'no files yet';
|
|
67
|
+
}
|
|
68
|
+
return files
|
|
69
|
+
.map((f) => {
|
|
70
|
+
const lines = f.content.split('\n').length;
|
|
71
|
+
const hasCanvas = /canvas/i.test(f.content);
|
|
72
|
+
const hasScore = /score/i.test(f.content);
|
|
73
|
+
const flags = [
|
|
74
|
+
hasCanvas ? 'canvas' : null,
|
|
75
|
+
hasScore ? 'score' : null,
|
|
76
|
+
lines < 20 ? 'short' : null,
|
|
77
|
+
]
|
|
78
|
+
.filter(Boolean)
|
|
79
|
+
.join(',');
|
|
80
|
+
return `${f.relPath}(${lines} lines${flags ? `; ${flags}` : ''})`;
|
|
81
|
+
})
|
|
82
|
+
.join('; ');
|
|
83
|
+
}
|
|
84
|
+
/** Maps select values to done/improve. */
|
|
85
|
+
export function parseDoneChoice(value) {
|
|
86
|
+
if (value === 'done' || value === 'improve') {
|
|
87
|
+
return value;
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
/** Default project name from an idea label. */
|
|
92
|
+
export function defaultNameForIdea(idea) {
|
|
93
|
+
if (isOwnIdea(idea)) {
|
|
94
|
+
return 'My Game';
|
|
95
|
+
}
|
|
96
|
+
return idea.label;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* One completeness suggestion line for the terminal (before the kid
|
|
100
|
+
* approves a polish apply). Pure heuristic from file summary text.
|
|
101
|
+
*/
|
|
102
|
+
export function completenessHint(summary) {
|
|
103
|
+
const s = summary.toLowerCase();
|
|
104
|
+
if (!s.includes('score')) {
|
|
105
|
+
return 'Add a clear score the player can see.';
|
|
106
|
+
}
|
|
107
|
+
if (!s.includes('canvas') && !s.includes('button')) {
|
|
108
|
+
return 'Add a start screen with a Play button.';
|
|
109
|
+
}
|
|
110
|
+
if (s.includes('short')) {
|
|
111
|
+
return 'Add a win or lose message and a restart key.';
|
|
112
|
+
}
|
|
113
|
+
return 'Add clearer control hints on the start screen.';
|
|
114
|
+
}
|
|
@@ -110,10 +110,10 @@ export function helpText() {
|
|
|
110
110
|
['/preview', 'watch your project run'],
|
|
111
111
|
['/undo', 'take back the last change'],
|
|
112
112
|
['/redo', 'bring a change back'],
|
|
113
|
-
['/new', '
|
|
114
|
-
['/ideas', 'get
|
|
113
|
+
['/new', 'build a new game'],
|
|
114
|
+
['/ideas', 'get game ideas'],
|
|
115
115
|
['/badges', 'see your badges'],
|
|
116
|
-
['/learn', '
|
|
116
|
+
['/learn', 'Learn AI lessons'],
|
|
117
117
|
['/quest', 'build with me, step by step'],
|
|
118
118
|
['/help', 'show this list'],
|
|
119
119
|
['/done', 'finish and celebrate'],
|