@shaykec/claude-teach 0.6.5 → 0.6.7

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shaykec/claude-teach",
3
- "version": "0.6.5",
3
+ "version": "0.6.7",
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.6",
18
+ "@shaykec/bridge": "0.4.8",
19
19
  "@shaykec/shared": "0.1.3",
20
- "@shaykec/plugin": "0.2.11",
20
+ "@shaykec/plugin": "0.2.12",
21
21
  "@shaykec/extension": "0.1.0"
22
22
  },
23
23
  "publishConfig": {
@@ -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
  */
@@ -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 {
@@ -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');