@shaykec/claude-teach 0.6.6 → 0.6.8
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/package.json +3 -3
- package/src/cli.e2e.test.js +3 -3
- package/src/cli.js +42 -28
- package/src/game-yaml.test.js +209 -0
- package/src/progress.js +27 -0
- package/src/progress.test.js +54 -1
- package/src/registry.js +1 -0
- package/src/registry.test.js +21 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shaykec/claude-teach",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.8",
|
|
4
4
|
"description": "Socratic AI teaching platform — learn anything through guided dialogue, visual canvas, and gamification",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/cli.js",
|
|
@@ -15,9 +15,9 @@
|
|
|
15
15
|
"commander": "^12.0.0",
|
|
16
16
|
"js-yaml": "^4.1.0",
|
|
17
17
|
"chalk": "^5.3.0",
|
|
18
|
-
"@shaykec/bridge": "0.4.
|
|
18
|
+
"@shaykec/bridge": "0.4.9",
|
|
19
19
|
"@shaykec/shared": "0.1.3",
|
|
20
|
-
"@shaykec/plugin": "0.2.
|
|
20
|
+
"@shaykec/plugin": "0.2.12",
|
|
21
21
|
"@shaykec/extension": "0.1.0"
|
|
22
22
|
},
|
|
23
23
|
"publishConfig": {
|
package/src/cli.e2e.test.js
CHANGED
|
@@ -674,7 +674,7 @@ describe('CLI E2E: Setup command', () => {
|
|
|
674
674
|
const { stdout, exitCode } = runCli(['start', '--help']);
|
|
675
675
|
expect(exitCode).toBe(0);
|
|
676
676
|
const clean = stripAnsi(stdout);
|
|
677
|
-
expect(clean).toContain('Launch
|
|
677
|
+
expect(clean).toContain('Launch ClaudeTeach');
|
|
678
678
|
expect(clean).toContain('--no-server');
|
|
679
679
|
expect(clean).toContain('--port');
|
|
680
680
|
});
|
|
@@ -881,7 +881,7 @@ describe('CLI E2E: Subcommand help texts', () => {
|
|
|
881
881
|
{ args: ['level-up', '--help'], contains: 'belt roadmap' },
|
|
882
882
|
{ args: ['serve', '--help'], contains: 'bridge server' },
|
|
883
883
|
{ args: ['inbox', '--help'], contains: 'captured from the browser' },
|
|
884
|
-
{ args: ['start', '--help'], contains: 'Launch
|
|
884
|
+
{ args: ['start', '--help'], contains: 'Launch ClaudeTeach' },
|
|
885
885
|
{ args: ['setup', '--help'], contains: 'Check environment' },
|
|
886
886
|
{ args: ['registry:build', '--help'], contains: 'Rebuild the module registry' },
|
|
887
887
|
{ args: ['install', '--help'], contains: 'Install a module pack' },
|
|
@@ -916,7 +916,7 @@ describe('CLI E2E: npx comprehensive', () => {
|
|
|
916
916
|
it('npx start --help shows launch description', () => {
|
|
917
917
|
const { stdout, exitCode } = runNpx(['start', '--help'], { timeout: 60000 });
|
|
918
918
|
expect(exitCode).toBe(0);
|
|
919
|
-
expect(stripAnsi(stdout)).toContain('Launch
|
|
919
|
+
expect(stripAnsi(stdout)).toContain('Launch ClaudeTeach');
|
|
920
920
|
});
|
|
921
921
|
|
|
922
922
|
it('npx setup --help shows setup options', () => {
|
package/src/cli.js
CHANGED
|
@@ -435,7 +435,8 @@ program
|
|
|
435
435
|
// --- start ---
|
|
436
436
|
program
|
|
437
437
|
.command('start')
|
|
438
|
-
.description('Launch
|
|
438
|
+
.description('Launch ClaudeTeach — browser chat by default, or --terminal for CLI mode')
|
|
439
|
+
.option('--terminal', 'Use terminal CLI mode instead of browser chat')
|
|
439
440
|
.option('--no-server', 'Skip starting the bridge server')
|
|
440
441
|
.option('--no-browser', 'Skip opening the canvas in the browser')
|
|
441
442
|
.option('--port <port>', 'Bridge server port', '3456')
|
|
@@ -470,10 +471,10 @@ program
|
|
|
470
471
|
server = await new Promise((resolve, reject) => {
|
|
471
472
|
const srv = startServer({
|
|
472
473
|
port,
|
|
474
|
+
pluginDir,
|
|
473
475
|
progressProvider: { getProgress: () => loadProgress(ROOT) },
|
|
474
476
|
onReady: () => resolve(srv),
|
|
475
477
|
});
|
|
476
|
-
// Handle port-in-use and other startup errors
|
|
477
478
|
srv.server.on('error', (err) => {
|
|
478
479
|
if (err.code === 'EADDRINUSE') {
|
|
479
480
|
reject(new Error(`Port ${port} is already in use. Kill the old process or use --port <other>`));
|
|
@@ -492,38 +493,51 @@ program
|
|
|
492
493
|
// 4. Open canvas in browser (unless --no-browser or --no-server)
|
|
493
494
|
if (server && opts.browser !== false) {
|
|
494
495
|
const canvasUrl = `http://localhost:${opts.port}`;
|
|
495
|
-
|
|
496
|
-
if (platform === 'darwin') {
|
|
497
|
-
exec(`open "${canvasUrl}"`);
|
|
498
|
-
} else if (platform === 'linux') {
|
|
499
|
-
exec(`xdg-open "${canvasUrl}"`);
|
|
500
|
-
} else if (platform === 'win32') {
|
|
501
|
-
exec(`start "" "${canvasUrl}"`);
|
|
502
|
-
}
|
|
496
|
+
openUrl(canvasUrl);
|
|
503
497
|
console.log(chalk.dim(` Canvas: ${canvasUrl}`));
|
|
504
498
|
}
|
|
505
499
|
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
500
|
+
if (opts.terminal) {
|
|
501
|
+
// --- Terminal mode: spawn Claude Code with stdio: 'inherit' (legacy) ---
|
|
502
|
+
console.log(chalk.green('Launching Claude Code in terminal mode...'));
|
|
503
|
+
console.log(chalk.dim(` Plugin: ${pluginDir}`));
|
|
509
504
|
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
505
|
+
const claudeArgs = [
|
|
506
|
+
'--plugin-dir', pluginDir,
|
|
507
|
+
'--allowedTools', 'Bash(*),Read,Write,Edit,Glob,Grep',
|
|
508
|
+
...cmd.args,
|
|
509
|
+
];
|
|
510
|
+
const child = spawn('claude', claudeArgs, { stdio: 'inherit' });
|
|
516
511
|
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
512
|
+
child.on('error', (err) => {
|
|
513
|
+
console.error(chalk.red(`Failed to start Claude Code: ${err.message}`));
|
|
514
|
+
if (server) server.close();
|
|
515
|
+
process.exit(1);
|
|
516
|
+
});
|
|
522
517
|
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
518
|
+
child.on('close', (code) => {
|
|
519
|
+
if (server) server.close();
|
|
520
|
+
process.exit(code ?? 0);
|
|
521
|
+
});
|
|
522
|
+
} else {
|
|
523
|
+
// --- Browser chat mode (default): Claude runs via SDK in the bridge ---
|
|
524
|
+
console.log(chalk.green('ClaudeTeach is ready — chat in your browser!'));
|
|
525
|
+
console.log(chalk.dim(` Plugin: ${pluginDir}`));
|
|
526
|
+
console.log(chalk.dim(` Claude Code runs via the Agent SDK (no terminal TTY)`));
|
|
527
|
+
console.log(chalk.dim(` Use --terminal flag for classic CLI mode`));
|
|
528
|
+
|
|
529
|
+
// Keep the process alive — the bridge server handles everything.
|
|
530
|
+
// On SIGINT, clean up and exit.
|
|
531
|
+
process.on('SIGINT', () => {
|
|
532
|
+
console.log(chalk.dim('\nShutting down...'));
|
|
533
|
+
if (server) server.close();
|
|
534
|
+
process.exit(0);
|
|
535
|
+
});
|
|
536
|
+
process.on('SIGTERM', () => {
|
|
537
|
+
if (server) server.close();
|
|
538
|
+
process.exit(0);
|
|
539
|
+
});
|
|
540
|
+
}
|
|
527
541
|
});
|
|
528
542
|
|
|
529
543
|
// --- setup ---
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validates game.yaml structure for all modules that include one.
|
|
3
|
+
* Ensures YAML is well-formed, every game has a valid type, and
|
|
4
|
+
* game-type-specific required fields are present.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, it, expect } from 'vitest';
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import yaml from 'js-yaml';
|
|
11
|
+
import { GAME_TYPES } from '../../shared/src/constants.js';
|
|
12
|
+
|
|
13
|
+
const MODULES_DIR = path.resolve(import.meta.dirname, '../../modules');
|
|
14
|
+
|
|
15
|
+
const VALID_TYPES = new Set(GAME_TYPES);
|
|
16
|
+
|
|
17
|
+
const REQUIRED_FIELDS = {
|
|
18
|
+
'speed-round': ['rounds'],
|
|
19
|
+
'bug-hunt': ['snippets'],
|
|
20
|
+
'classify': ['categories', 'items'],
|
|
21
|
+
'scenario': ['steps'],
|
|
22
|
+
'command-sprint': ['challenges'],
|
|
23
|
+
'memory-match': ['pairs'],
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function getModulesWithGames() {
|
|
27
|
+
const modules = fs.readdirSync(MODULES_DIR, { withFileTypes: true })
|
|
28
|
+
.filter(d => d.isDirectory());
|
|
29
|
+
|
|
30
|
+
const result = [];
|
|
31
|
+
for (const mod of modules) {
|
|
32
|
+
const gamePath = path.join(MODULES_DIR, mod.name, 'game.yaml');
|
|
33
|
+
if (fs.existsSync(gamePath)) {
|
|
34
|
+
result.push({ slug: mod.name, gamePath });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return result;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const modulesWithGames = getModulesWithGames();
|
|
41
|
+
|
|
42
|
+
describe('game.yaml validation', () => {
|
|
43
|
+
it('finds at least 5 modules with game.yaml', () => {
|
|
44
|
+
expect(modulesWithGames.length).toBeGreaterThanOrEqual(5);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe.each(modulesWithGames)('$slug', ({ slug, gamePath }) => {
|
|
48
|
+
let doc;
|
|
49
|
+
|
|
50
|
+
it('parses as valid YAML', () => {
|
|
51
|
+
const raw = fs.readFileSync(gamePath, 'utf-8');
|
|
52
|
+
doc = yaml.load(raw);
|
|
53
|
+
expect(doc).toBeTruthy();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('has a top-level "games" array with at least one entry', () => {
|
|
57
|
+
const raw = fs.readFileSync(gamePath, 'utf-8');
|
|
58
|
+
doc = yaml.load(raw);
|
|
59
|
+
expect(Array.isArray(doc.games)).toBe(true);
|
|
60
|
+
expect(doc.games.length).toBeGreaterThanOrEqual(1);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('every game has a valid type', () => {
|
|
64
|
+
const raw = fs.readFileSync(gamePath, 'utf-8');
|
|
65
|
+
doc = yaml.load(raw);
|
|
66
|
+
for (const game of doc.games) {
|
|
67
|
+
expect(VALID_TYPES.has(game.type), `invalid type "${game.type}" in ${slug}`).toBe(true);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('every game has a non-empty title', () => {
|
|
72
|
+
const raw = fs.readFileSync(gamePath, 'utf-8');
|
|
73
|
+
doc = yaml.load(raw);
|
|
74
|
+
for (const game of doc.games) {
|
|
75
|
+
expect(typeof game.title).toBe('string');
|
|
76
|
+
expect(game.title.length).toBeGreaterThan(0);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('every game has its type-specific required fields', () => {
|
|
81
|
+
const raw = fs.readFileSync(gamePath, 'utf-8');
|
|
82
|
+
doc = yaml.load(raw);
|
|
83
|
+
for (const game of doc.games) {
|
|
84
|
+
const required = REQUIRED_FIELDS[game.type] || [];
|
|
85
|
+
for (const field of required) {
|
|
86
|
+
expect(game[field], `missing "${field}" in ${slug}/${game.type}/${game.title}`).toBeDefined();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('speed-round games have valid rounds', () => {
|
|
92
|
+
const raw = fs.readFileSync(gamePath, 'utf-8');
|
|
93
|
+
doc = yaml.load(raw);
|
|
94
|
+
for (const game of doc.games) {
|
|
95
|
+
if (game.type !== 'speed-round') continue;
|
|
96
|
+
expect(Array.isArray(game.rounds)).toBe(true);
|
|
97
|
+
expect(game.rounds.length).toBeGreaterThanOrEqual(3);
|
|
98
|
+
for (const round of game.rounds) {
|
|
99
|
+
expect(typeof round.question).toBe('string');
|
|
100
|
+
expect(Array.isArray(round.options)).toBe(true);
|
|
101
|
+
expect(round.options.length).toBeGreaterThanOrEqual(2);
|
|
102
|
+
expect(typeof round.answer).toBe('number');
|
|
103
|
+
expect(round.answer).toBeGreaterThanOrEqual(0);
|
|
104
|
+
expect(round.answer).toBeLessThan(round.options.length);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('bug-hunt games have valid snippets', () => {
|
|
110
|
+
const raw = fs.readFileSync(gamePath, 'utf-8');
|
|
111
|
+
doc = yaml.load(raw);
|
|
112
|
+
for (const game of doc.games) {
|
|
113
|
+
if (game.type !== 'bug-hunt') continue;
|
|
114
|
+
expect(Array.isArray(game.snippets)).toBe(true);
|
|
115
|
+
expect(game.snippets.length).toBeGreaterThanOrEqual(3);
|
|
116
|
+
for (const snippet of game.snippets) {
|
|
117
|
+
expect(typeof snippet.code).toBe('string');
|
|
118
|
+
expect(snippet.code.trim().length).toBeGreaterThan(0);
|
|
119
|
+
expect(typeof snippet.bugLine).toBe('number');
|
|
120
|
+
expect(snippet.bugLine).toBeGreaterThanOrEqual(1);
|
|
121
|
+
expect(typeof snippet.explanation).toBe('string');
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('classify games have valid categories and items', () => {
|
|
127
|
+
const raw = fs.readFileSync(gamePath, 'utf-8');
|
|
128
|
+
doc = yaml.load(raw);
|
|
129
|
+
for (const game of doc.games) {
|
|
130
|
+
if (game.type !== 'classify') continue;
|
|
131
|
+
expect(Array.isArray(game.categories)).toBe(true);
|
|
132
|
+
expect(game.categories.length).toBeGreaterThanOrEqual(2);
|
|
133
|
+
const catNames = new Set(game.categories.map(c => c.name));
|
|
134
|
+
for (const cat of game.categories) {
|
|
135
|
+
expect(typeof cat.name).toBe('string');
|
|
136
|
+
expect(typeof cat.color).toBe('string');
|
|
137
|
+
expect(cat.color).toMatch(/^#[0-9a-fA-F]{6}$/);
|
|
138
|
+
}
|
|
139
|
+
expect(Array.isArray(game.items)).toBe(true);
|
|
140
|
+
expect(game.items.length).toBeGreaterThanOrEqual(4);
|
|
141
|
+
for (const item of game.items) {
|
|
142
|
+
expect(typeof item.text).toBe('string');
|
|
143
|
+
expect(catNames.has(item.category),
|
|
144
|
+
`item "${item.text}" references unknown category "${item.category}"`).toBe(true);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('scenario games have valid steps', () => {
|
|
150
|
+
const raw = fs.readFileSync(gamePath, 'utf-8');
|
|
151
|
+
doc = yaml.load(raw);
|
|
152
|
+
for (const game of doc.games) {
|
|
153
|
+
if (game.type !== 'scenario') continue;
|
|
154
|
+
expect(Array.isArray(game.steps)).toBe(true);
|
|
155
|
+
expect(game.steps.length).toBeGreaterThanOrEqual(3);
|
|
156
|
+
expect(typeof game.startHealth).toBe('number');
|
|
157
|
+
expect(game.startHealth).toBeGreaterThan(0);
|
|
158
|
+
const stepIds = new Set(game.steps.map(s => s.id));
|
|
159
|
+
stepIds.add('end');
|
|
160
|
+
expect(stepIds.has('start')).toBe(true);
|
|
161
|
+
for (const step of game.steps) {
|
|
162
|
+
expect(typeof step.id).toBe('string');
|
|
163
|
+
expect(typeof step.situation).toBe('string');
|
|
164
|
+
expect(Array.isArray(step.choices)).toBe(true);
|
|
165
|
+
expect(step.choices.length).toBeGreaterThanOrEqual(1);
|
|
166
|
+
for (const choice of step.choices) {
|
|
167
|
+
expect(typeof choice.text).toBe('string');
|
|
168
|
+
expect(typeof choice.consequence).toBe('string');
|
|
169
|
+
expect(typeof choice.health).toBe('number');
|
|
170
|
+
expect(stepIds.has(choice.next),
|
|
171
|
+
`choice in step "${step.id}" points to unknown next "${choice.next}"`).toBe(true);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('command-sprint games have valid challenges', () => {
|
|
178
|
+
const raw = fs.readFileSync(gamePath, 'utf-8');
|
|
179
|
+
doc = yaml.load(raw);
|
|
180
|
+
for (const game of doc.games) {
|
|
181
|
+
if (game.type !== 'command-sprint') continue;
|
|
182
|
+
expect(Array.isArray(game.challenges)).toBe(true);
|
|
183
|
+
expect(game.challenges.length).toBeGreaterThanOrEqual(3);
|
|
184
|
+
for (const ch of game.challenges) {
|
|
185
|
+
expect(typeof ch.prompt).toBe('string');
|
|
186
|
+
expect(typeof ch.answer).toBe('string');
|
|
187
|
+
expect(ch.answer.trim().length).toBeGreaterThan(0);
|
|
188
|
+
if (ch.alternates !== undefined) {
|
|
189
|
+
expect(Array.isArray(ch.alternates)).toBe(true);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('memory-match games have valid pairs', () => {
|
|
196
|
+
const raw = fs.readFileSync(gamePath, 'utf-8');
|
|
197
|
+
doc = yaml.load(raw);
|
|
198
|
+
for (const game of doc.games) {
|
|
199
|
+
if (game.type !== 'memory-match') continue;
|
|
200
|
+
expect(Array.isArray(game.pairs)).toBe(true);
|
|
201
|
+
expect(game.pairs.length).toBeGreaterThanOrEqual(3);
|
|
202
|
+
for (const pair of game.pairs) {
|
|
203
|
+
expect(typeof pair.term).toBe('string');
|
|
204
|
+
expect(typeof pair.definition).toBe('string');
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
});
|
package/src/progress.js
CHANGED
|
@@ -132,6 +132,33 @@ export function recordQuizScore(rootDir, slug, score) {
|
|
|
132
132
|
return progress;
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
+
/**
|
|
136
|
+
* Record a game score for a module. Tracks latest and best scores.
|
|
137
|
+
*/
|
|
138
|
+
export function recordGameScore(rootDir, slug, score, maxScore) {
|
|
139
|
+
const progress = loadProgress(rootDir);
|
|
140
|
+
|
|
141
|
+
if (!progress.modules[slug]) {
|
|
142
|
+
progress.modules[slug] = {
|
|
143
|
+
status: 'in-progress',
|
|
144
|
+
started: new Date().toISOString().split('T')[0],
|
|
145
|
+
xp_earned: 0,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const mod = progress.modules[slug];
|
|
150
|
+
mod.game_score = `${score}/${maxScore}`;
|
|
151
|
+
mod.last_session = new Date().toISOString().split('T')[0];
|
|
152
|
+
|
|
153
|
+
const currentBest = mod.game_best ? parseInt(mod.game_best.split('/')[0], 10) : 0;
|
|
154
|
+
if (score > currentBest) {
|
|
155
|
+
mod.game_best = `${score}/${maxScore}`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
saveProgress(rootDir, progress);
|
|
159
|
+
return progress;
|
|
160
|
+
}
|
|
161
|
+
|
|
135
162
|
/**
|
|
136
163
|
* Get summary stats from progress.
|
|
137
164
|
*/
|
package/src/progress.test.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
-
import { loadProgress, saveProgress, recordXP, completeModule, updateWalkthroughStep, recordQuizScore, getStats } from './progress.js';
|
|
2
|
+
import { loadProgress, saveProgress, recordXP, completeModule, updateWalkthroughStep, recordQuizScore, recordGameScore, getStats } from './progress.js';
|
|
3
3
|
import fs from 'fs';
|
|
4
4
|
import path from 'path';
|
|
5
5
|
import os from 'os';
|
|
@@ -293,6 +293,59 @@ describe('recordQuizScore', () => {
|
|
|
293
293
|
});
|
|
294
294
|
});
|
|
295
295
|
|
|
296
|
+
/* ================================================================== */
|
|
297
|
+
/* recordGameScore */
|
|
298
|
+
/* ================================================================== */
|
|
299
|
+
|
|
300
|
+
describe('recordGameScore', () => {
|
|
301
|
+
it('should record the game score as a fraction string', () => {
|
|
302
|
+
const result = recordGameScore(tmpDir, 'git', 8, 10);
|
|
303
|
+
expect(result.modules.git.game_score).toBe('8/10');
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('should create module entry if it did not exist', () => {
|
|
307
|
+
const result = recordGameScore(tmpDir, 'new-mod', 5, 10);
|
|
308
|
+
expect(result.modules['new-mod']).toBeDefined();
|
|
309
|
+
expect(result.modules['new-mod'].status).toBe('in-progress');
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it('should update last_session date', () => {
|
|
313
|
+
const result = recordGameScore(tmpDir, 'git', 7, 10);
|
|
314
|
+
const today = new Date().toISOString().split('T')[0];
|
|
315
|
+
expect(result.modules.git.last_session).toBe(today);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('should track best score', () => {
|
|
319
|
+
recordGameScore(tmpDir, 'git', 5, 10);
|
|
320
|
+
const result = recordGameScore(tmpDir, 'git', 8, 10);
|
|
321
|
+
expect(result.modules.git.game_best).toBe('8/10');
|
|
322
|
+
expect(result.modules.git.game_score).toBe('8/10');
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it('should not downgrade best score', () => {
|
|
326
|
+
recordGameScore(tmpDir, 'git', 9, 10);
|
|
327
|
+
const result = recordGameScore(tmpDir, 'git', 3, 10);
|
|
328
|
+
expect(result.modules.git.game_best).toBe('9/10');
|
|
329
|
+
expect(result.modules.git.game_score).toBe('3/10');
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it('should persist to disk', () => {
|
|
333
|
+
recordGameScore(tmpDir, 'git', 6, 10);
|
|
334
|
+
const loaded = loadProgress(tmpDir);
|
|
335
|
+
expect(loaded.modules.git.game_score).toBe('6/10');
|
|
336
|
+
expect(loaded.modules.git.game_best).toBe('6/10');
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it('should preserve existing module data', () => {
|
|
340
|
+
recordXP(tmpDir, 'git', 'read', 15);
|
|
341
|
+
recordQuizScore(tmpDir, 'git', '4/5');
|
|
342
|
+
const result = recordGameScore(tmpDir, 'git', 7, 10);
|
|
343
|
+
expect(result.modules.git.xp_earned).toBe(15);
|
|
344
|
+
expect(result.modules.git.quiz_score).toBe('4/5');
|
|
345
|
+
expect(result.modules.git.game_score).toBe('7/10');
|
|
346
|
+
});
|
|
347
|
+
});
|
|
348
|
+
|
|
296
349
|
/* ================================================================== */
|
|
297
350
|
/* getStats */
|
|
298
351
|
/* ================================================================== */
|
package/src/registry.js
CHANGED
|
@@ -217,6 +217,7 @@ function detectCapabilities(modulePath) {
|
|
|
217
217
|
if (existsSync(join(modulePath, 'quiz.md'))) caps.push('quiz');
|
|
218
218
|
if (existsSync(join(modulePath, 'quick-ref.md'))) caps.push('quick-ref');
|
|
219
219
|
if (existsSync(join(modulePath, 'resources.md'))) caps.push('resources');
|
|
220
|
+
if (existsSync(join(modulePath, 'game.yaml'))) caps.push('game');
|
|
220
221
|
|
|
221
222
|
// Check for media entries in module.yaml
|
|
222
223
|
try {
|
package/src/registry.test.js
CHANGED
|
@@ -345,6 +345,27 @@ describe('buildRegistry (filesystem)', () => {
|
|
|
345
345
|
expect(mod.capabilities).not.toContain('media');
|
|
346
346
|
});
|
|
347
347
|
|
|
348
|
+
it('should detect game capability when game.yaml exists', async () => {
|
|
349
|
+
const moduleDir = createModule('game-test');
|
|
350
|
+
fs.writeFileSync(path.join(moduleDir, 'game.yaml'), 'games:\n - type: speed-round\n title: Test\n', 'utf-8');
|
|
351
|
+
|
|
352
|
+
const { buildRegistry } = await import('./registry.js');
|
|
353
|
+
const registry = await buildRegistry(tmpDir);
|
|
354
|
+
|
|
355
|
+
const mod = registry.modules.find(m => m.slug === 'game-test');
|
|
356
|
+
expect(mod.capabilities).toContain('game');
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it('should not detect game capability when game.yaml is absent', async () => {
|
|
360
|
+
createModule('no-game-test');
|
|
361
|
+
|
|
362
|
+
const { buildRegistry } = await import('./registry.js');
|
|
363
|
+
const registry = await buildRegistry(tmpDir);
|
|
364
|
+
|
|
365
|
+
const mod = registry.modules.find(m => m.slug === 'no-game-test');
|
|
366
|
+
expect(mod.capabilities).not.toContain('game');
|
|
367
|
+
});
|
|
368
|
+
|
|
348
369
|
it('should skip directories without module.yaml', async () => {
|
|
349
370
|
// Create a directory without module.yaml
|
|
350
371
|
const emptyDir = path.join(tmpDir, 'packages', 'modules', 'no-yaml');
|