feat(scripts): add architecture validation and multi-restart testing

Add validate-architecture.mjs that statically checks game source files for
required patterns (render_game_to_text, GameState.reset, SAFE_ZONE, button
pattern, EventBus events) with magic number warnings. Separate Phaser and
Three.js variants with appropriate checks for each framework.

Add --restart-cycles N and --restart-key flags to iterate-client.js for
automated multi-restart testing. Polls for game_over state, presses restart,
then verifies score reset and mode transition across N cycles.

Both scripts copied to phaser-2d and threejs-3d templates with npm validate
script added to each package.json.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
rshtirmer
2026-02-23 13:10:10 -05:00
parent 25ac88ff0e
commit a4e0bdeec4
9 changed files with 1409 additions and 3 deletions
+161 -1
View File
@@ -19,6 +19,10 @@
// node scripts/iterate-client.js --url http://localhost:3000 \
// --click 480,270 --iterations 2
//
// Multi-restart testing:
// node scripts/iterate-client.js --url http://localhost:3000 \
// --restart-cycles 3 --restart-key Space
//
// Outputs:
// <screenshot-dir>/shot-<i>.png — canvas screenshot per iteration
// <screenshot-dir>/state-<i>.json — render_game_to_text() output per iteration
@@ -46,6 +50,8 @@ function parseArgs(argv) {
clickSelector: null,
waitForGame: true, // Wait for window.__GAME__ to be ready
timeoutMs: 10000, // Max wait for game boot
restartCycles: 0, // Number of restart cycles (0 = disabled)
restartKey: 'Space', // Key to press to restart the game
};
for (let i = 2; i < argv.length; i++) {
@@ -68,6 +74,8 @@ function parseArgs(argv) {
else if (arg === '--click-selector' && next) { args.clickSelector = next; i++; }
else if (arg === '--no-wait') { args.waitForGame = false; }
else if (arg === '--timeout' && next) { args.timeoutMs = parseInt(next, 10); i++; }
else if (arg === '--restart-cycles' && next) { args.restartCycles = parseInt(next, 10); i++; }
else if (arg === '--restart-key' && next) { args.restartKey = next; i++; }
}
if (!args.url) {
@@ -88,7 +96,11 @@ Options:
--screenshot-dir <dir> Output directory (default: output/iterate)
--click-selector <sel> CSS selector to click before starting actions
--no-wait Don't wait for window.__GAME__ to be ready
--timeout <ms> Max wait for game boot (default: 10000)`);
--timeout <ms> Max wait for game boot (default: 10000)
Restart testing:
--restart-cycles <n> Number of restart cycles to test (default: 0 = disabled)
--restart-key <key> Key to press to restart (default: Space)`);
process.exit(1);
}
@@ -387,6 +399,9 @@ async function main() {
console.log(`iterate-client: ${args.url}`);
console.log(` actions: ${steps.length} steps, ${args.iterations} iterations`);
if (args.restartCycles > 0) {
console.log(` restart: ${args.restartCycles} cycles, key: ${args.restartKey}`);
}
console.log(` output: ${args.screenshotDir}/`);
const browser = await chromium.launch({
@@ -495,6 +510,151 @@ async function main() {
}
}
// --- Restart cycle testing ---
if (!hadErrors && args.restartCycles > 0) {
console.log(`\n--- Restart cycle testing (${args.restartCycles} cycles, key: ${args.restartKey}) ---`);
const restartIssues = [];
for (let cycle = 0; cycle < args.restartCycles; cycle++) {
console.log(`\n [cycle ${cycle + 1}/${args.restartCycles}]`);
// Step 1: Run actions to play the game
if (!canvas) canvas = await getCanvasHandle(page);
await runActions(page, canvas, steps);
await sleep(args.pauseMs);
// Step 2: Wait for game_over state (poll render_game_to_text every 500ms, timeout 15s)
let gameOverReached = false;
const pollStart = Date.now();
const pollTimeout = 15000;
while (Date.now() - pollStart < pollTimeout) {
const stateStr = await page.evaluate(() => {
if (typeof window.render_game_to_text === 'function') {
return window.render_game_to_text();
}
if (window.__GAME_STATE__) {
return JSON.stringify(window.__GAME_STATE__);
}
return null;
});
if (stateStr) {
try {
const state = JSON.parse(stateStr);
if (state.mode === 'game_over' || state.gameOver === true) {
gameOverReached = true;
console.log(` game_over reached (${Date.now() - pollStart}ms)`);
break;
}
} catch { /* ignore parse errors */ }
}
// Advance time and wait before next poll
await page.evaluate(async () => {
if (typeof window.advanceTime === 'function') {
await window.advanceTime(500);
}
});
await sleep(500);
}
if (!gameOverReached) {
console.warn(' warn: game_over not reached within 15s, pressing restart anyway');
}
// Capture pre-restart screenshot
const preRestartPath = path.join(args.screenshotDir, `restart-${cycle}-pre.png`);
await captureScreenshot(page, canvas, preRestartPath);
// Step 3: Press the restart key
const restartKeyMapped = KEY_MAP[args.restartKey.toLowerCase()] || args.restartKey;
await page.keyboard.press(restartKeyMapped);
console.log(` pressed ${args.restartKey}`);
// Small delay for game to process restart
await sleep(500);
// Also advance a few frames for the game loop to process
await page.evaluate(async () => {
if (typeof window.advanceTime === 'function') {
await window.advanceTime(500);
}
});
await sleep(250);
// Step 4: Verify state after restart
const postStateStr = await page.evaluate(() => {
if (typeof window.render_game_to_text === 'function') {
return window.render_game_to_text();
}
if (window.__GAME_STATE__) {
return JSON.stringify(window.__GAME_STATE__);
}
return null;
});
// Capture post-restart screenshot
const postRestartPath = path.join(args.screenshotDir, `restart-${cycle}-post.png`);
await captureScreenshot(page, canvas, postRestartPath);
if (postStateStr) {
try {
const postState = JSON.parse(postStateStr);
const issues = [];
// Check score reset to 0
if (typeof postState.score === 'number' && postState.score !== 0) {
issues.push(`score not reset: expected 0, got ${postState.score}`);
}
// Check mode is not game_over (should be playing, menu, or similar)
if (postState.mode === 'game_over') {
issues.push(`mode still game_over after restart`);
}
if (issues.length > 0) {
console.error(` ISSUES: ${issues.join('; ')}`);
restartIssues.push({
cycle: cycle + 1,
issues,
preRestartScreenshot: preRestartPath,
postRestartScreenshot: postRestartPath,
postState: postState,
});
} else {
const mode = postState.mode || 'unknown';
const score = postState.score ?? 'n/a';
console.log(` OK — mode: ${mode}, score: ${score}`);
}
} catch (err) {
console.warn(` warn: could not parse post-restart state: ${err.message}`);
}
} else {
console.warn(' warn: render_game_to_text() returned null after restart');
}
// Check for console errors during this cycle
const cycleErrors = errors.drain();
if (cycleErrors.length) {
const errPath = path.join(args.screenshotDir, `errors-restart-${cycle}.json`);
fs.writeFileSync(errPath, JSON.stringify(cycleErrors, null, 2));
console.error(` ERRORS (${cycleErrors.length}): see ${errPath}`);
hadErrors = true;
break;
}
}
// Write issues file if there were any
if (restartIssues.length > 0) {
const issuesPath = path.join(args.screenshotDir, `restart-${args.restartCycles}-issues.json`);
fs.writeFileSync(issuesPath, JSON.stringify(restartIssues, null, 2));
console.error(`\n Restart issues written to ${issuesPath}`);
} else if (!hadErrors) {
console.log(`\n All ${args.restartCycles} restart cycles passed`);
}
}
await browser.close();
if (hadErrors) {
+223
View File
@@ -0,0 +1,223 @@
#!/usr/bin/env node
// =============================================================================
// validate-architecture.mjs — Static architecture validation for Three.js games
//
// Checks that a game project follows the required architecture patterns:
// - render_game_to_text() in main.js
// - GameState.reset() in core/GameState.js
// - SAFE_ZONE constant in core/Constants.js
// - systems/ directory exists with at least one system file
// - EventBus events defined in core/EventBus.js
// - [WARN] Hardcoded magic numbers outside Constants.js
//
// Usage:
// node scripts/validate-architecture.mjs
//
// Exits 0 if all required checks pass, 1 if any FAIL.
// =============================================================================
import fs from 'node:fs';
import path from 'node:path';
const CWD = process.cwd();
const SRC = path.join(CWD, 'src');
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function readFile(relPath) {
const full = path.join(SRC, relPath);
if (!fs.existsSync(full)) return null;
return fs.readFileSync(full, 'utf-8');
}
function readDir(relPath) {
const full = path.join(SRC, relPath);
if (!fs.existsSync(full) || !fs.statSync(full).isDirectory()) return [];
return fs.readdirSync(full).filter(f => f.endsWith('.js'));
}
function getAllJsFiles(dir, base) {
const results = [];
if (!fs.existsSync(dir)) return results;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
const rel = path.join(base, entry.name);
if (entry.isDirectory()) {
results.push(...getAllJsFiles(full, rel));
} else if (entry.name.endsWith('.js')) {
results.push({ path: full, rel });
}
}
return results;
}
// ---------------------------------------------------------------------------
// Checks
// ---------------------------------------------------------------------------
let passed = 0;
let failed = 0;
let warnings = 0;
function pass(msg) {
console.log(`[PASS] ${msg}`);
passed++;
}
function fail(msg) {
console.log(`[FAIL] ${msg}`);
failed++;
}
function warn(msg) {
console.log(`[WARN] ${msg}`);
warnings++;
}
console.log('=== Architecture Validation (Three.js) ===\n');
// Check 1: render_game_to_text() in main.js
const mainJs = readFile('main.js');
if (mainJs && /render_game_to_text/.test(mainJs)) {
pass('render_game_to_text() found in main.js');
} else if (!mainJs) {
fail('render_game_to_text() — src/main.js not found');
} else {
fail('render_game_to_text() not found in main.js');
}
// Check 2: GameState has reset() method
const gameStateJs = readFile('core/GameState.js');
if (gameStateJs && /reset\s*\(/.test(gameStateJs)) {
pass('GameState.reset() found');
} else if (!gameStateJs) {
fail('GameState.reset() — src/core/GameState.js not found');
} else {
fail('GameState.reset() not found in GameState.js');
}
// Check 3: SAFE_ZONE constant in Constants.js
const constantsJs = readFile('core/Constants.js');
if (constantsJs && /SAFE_ZONE/.test(constantsJs)) {
pass('SAFE_ZONE found in Constants.js');
} else if (!constantsJs) {
fail('SAFE_ZONE — src/core/Constants.js not found');
} else {
fail('SAFE_ZONE not found in Constants.js');
}
// Check 4: systems/ directory with system files (Three.js equivalent of Phaser scenes)
const systemFiles = readDir('systems');
if (systemFiles.length > 0) {
pass(`systems/ directory found (${systemFiles.length} system file${systemFiles.length !== 1 ? 's' : ''}: ${systemFiles.join(', ')})`);
} else {
const systemsDir = path.join(SRC, 'systems');
if (fs.existsSync(systemsDir)) {
fail('systems/ directory exists but contains no .js files');
} else {
fail('systems/ directory not found — Three.js games need src/systems/ for game systems');
}
}
// Check 5: EventBus has events defined
const eventBusJs = readFile('core/EventBus.js');
let eventCount = 0;
if (eventBusJs) {
// Count exported event constants — look for KEY: 'value' patterns in an Events object
const eventMatches = eventBusJs.match(/[A-Z_]+\s*:\s*['"][a-z]+:[a-z_]+['"]/g);
eventCount = eventMatches ? eventMatches.length : 0;
if (eventCount > 0) {
pass(`EventBus events defined (${eventCount} events)`);
} else {
fail('EventBus — no event constants found in EventBus.js');
}
} else {
fail('EventBus — src/core/EventBus.js not found');
}
// Check 6: [WARN] Hardcoded magic numbers
const allJsFiles = getAllJsFiles(SRC, '');
const excludeFiles = new Set(['core/Constants.js', 'core/PixelRenderer.js']);
// Also exclude by basename for flexibility
const excludeBaseNames = new Set(['Constants.js', 'PixelRenderer.js']);
// Common non-magic numbers to allow
const allowedNumbers = new Set([
0, 1, 2, 3, 4, 5, 10, 16, 32, 60, 64, 100, 255, 256, 1000,
0.5, 0.25, 0.75, 1.0, 2.0,
-1, -2,
]);
const magicNumberPattern = /(?<!\w)(-?\d+\.?\d*)\b/g;
const suspiciousLines = [];
for (const { path: filePath, rel } of allJsFiles) {
// Normalize path separators for comparison
const normRel = rel.replace(/\\/g, '/');
const baseName = path.basename(filePath);
if (excludeFiles.has(normRel) || excludeBaseNames.has(baseName)) continue;
const content = fs.readFileSync(filePath, 'utf-8');
const lines = content.split('\n');
for (let lineNum = 0; lineNum < lines.length; lineNum++) {
const line = lines[lineNum];
const trimmed = line.trim();
// Skip comments
if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*')) continue;
// Skip import/export lines
if (trimmed.startsWith('import ') || trimmed.startsWith('export ')) continue;
// Skip lines that are just closing braces, returns of simple values, etc.
if (trimmed.length < 5) continue;
let match;
magicNumberPattern.lastIndex = 0;
while ((match = magicNumberPattern.exec(line)) !== null) {
const num = parseFloat(match[1]);
if (allowedNumbers.has(num)) continue;
if (isNaN(num)) continue;
// Skip hex color literals (0x...)
if (/0x[0-9a-fA-F]+/.test(match[0])) continue;
const before = line.substring(0, match.index);
if (/0x[0-9a-fA-F]*$/.test(before)) continue;
// Skip version-like patterns and string contents
if (/['"`]/.test(before) && /['"`]/.test(line.substring(match.index + match[0].length))) continue;
suspiciousLines.push({
file: `src/${normRel}`,
line: lineNum + 1,
text: trimmed,
number: match[1],
});
}
}
}
if (suspiciousLines.length > 0) {
warn('Possible magic numbers:');
// Show up to 15 suspicious lines
const shown = suspiciousLines.slice(0, 15);
for (const s of shown) {
console.log(` ${s.file}:${s.line} ${s.text}`);
}
if (suspiciousLines.length > 15) {
console.log(` ... and ${suspiciousLines.length - 15} more`);
}
} else {
console.log('[INFO] No suspicious magic numbers detected');
}
// ---------------------------------------------------------------------------
// Summary
// ---------------------------------------------------------------------------
const total = passed + failed;
console.log(`\nResults: ${passed}/${total} passed, ${failed} failed, ${warnings} warning${warnings !== 1 ? 's' : ''}`);
process.exit(failed > 0 ? 1 : 0);
+239
View File
@@ -0,0 +1,239 @@
#!/usr/bin/env node
// =============================================================================
// validate-architecture.mjs — Static architecture validation for Phaser 3 games
//
// Checks that a game project follows the required architecture patterns:
// - render_game_to_text() in main.js
// - GameState.reset() in core/GameState.js
// - SAFE_ZONE constant in core/Constants.js
// - Container+Graphics+Text button pattern in scenes/
// - EventBus events defined in core/EventBus.js
// - [WARN] Hardcoded magic numbers outside Constants.js
//
// Usage:
// node scripts/validate-architecture.mjs
//
// Exits 0 if all required checks pass, 1 if any FAIL.
// =============================================================================
import fs from 'node:fs';
import path from 'node:path';
const CWD = process.cwd();
const SRC = path.join(CWD, 'src');
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function readFile(relPath) {
const full = path.join(SRC, relPath);
if (!fs.existsSync(full)) return null;
return fs.readFileSync(full, 'utf-8');
}
function readDir(relPath) {
const full = path.join(SRC, relPath);
if (!fs.existsSync(full) || !fs.statSync(full).isDirectory()) return [];
return fs.readdirSync(full).filter(f => f.endsWith('.js'));
}
function getAllJsFiles(dir, base) {
const results = [];
if (!fs.existsSync(dir)) return results;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
const rel = path.join(base, entry.name);
if (entry.isDirectory()) {
results.push(...getAllJsFiles(full, rel));
} else if (entry.name.endsWith('.js')) {
results.push({ path: full, rel });
}
}
return results;
}
// ---------------------------------------------------------------------------
// Checks
// ---------------------------------------------------------------------------
let passed = 0;
let failed = 0;
let warnings = 0;
function pass(msg) {
console.log(`[PASS] ${msg}`);
passed++;
}
function fail(msg) {
console.log(`[FAIL] ${msg}`);
failed++;
}
function warn(msg) {
console.log(`[WARN] ${msg}`);
warnings++;
}
console.log('=== Architecture Validation ===\n');
// Check 1: render_game_to_text() in main.js
const mainJs = readFile('main.js');
if (mainJs && /render_game_to_text/.test(mainJs)) {
pass('render_game_to_text() found in main.js');
} else if (!mainJs) {
fail('render_game_to_text() — src/main.js not found');
} else {
fail('render_game_to_text() not found in main.js');
}
// Check 2: GameState has reset() method
const gameStateJs = readFile('core/GameState.js');
if (gameStateJs && /reset\s*\(/.test(gameStateJs)) {
pass('GameState.reset() found');
} else if (!gameStateJs) {
fail('GameState.reset() — src/core/GameState.js not found');
} else {
fail('GameState.reset() not found in GameState.js');
}
// Check 3: SAFE_ZONE constant in Constants.js
const constantsJs = readFile('core/Constants.js');
if (constantsJs && /SAFE_ZONE/.test(constantsJs)) {
pass('SAFE_ZONE found in Constants.js');
} else if (!constantsJs) {
fail('SAFE_ZONE — src/core/Constants.js not found');
} else {
fail('SAFE_ZONE not found in Constants.js');
}
// Check 4: Button pattern (Container+Graphics+Text) in scenes/
const sceneFiles = readDir('scenes');
let buttonFound = false;
let buttonFile = null;
for (const file of sceneFiles) {
const content = readFile(path.join('scenes', file));
if (!content) continue;
// Check for Phaser Container+Graphics+Text button pattern
const hasContainer = /add\.container|Phaser\.GameObjects\.Container|new\s+Container/.test(content);
const hasGraphics = /add\.graphics|Phaser\.GameObjects\.Graphics|new\s+Graphics/.test(content);
const hasText = /add\.text|Phaser\.GameObjects\.Text|new\s+Text/.test(content);
if (hasContainer && hasGraphics && hasText) {
buttonFound = true;
buttonFile = file;
break;
}
}
if (buttonFound) {
pass(`Button pattern (Container+Graphics+Text) found in ${buttonFile}`);
} else if (sceneFiles.length === 0) {
fail('Button pattern — src/scenes/ directory not found or empty');
} else {
fail('Button pattern (Container+Graphics+Text) not found in any scene file');
}
// Check 5: EventBus has events defined
const eventBusJs = readFile('core/EventBus.js');
let eventCount = 0;
if (eventBusJs) {
// Count exported event constants — look for KEY: 'value' patterns in an Events object
const eventMatches = eventBusJs.match(/[A-Z_]+\s*:\s*['"][a-z]+:[a-z_]+['"]/g);
eventCount = eventMatches ? eventMatches.length : 0;
if (eventCount > 0) {
pass(`EventBus events defined (${eventCount} events)`);
} else {
fail('EventBus — no event constants found in EventBus.js');
}
} else {
fail('EventBus — src/core/EventBus.js not found');
}
// Check 6: [WARN] Hardcoded magic numbers
const allJsFiles = getAllJsFiles(SRC, '');
const excludeFiles = new Set(['core/Constants.js', 'core/PixelRenderer.js']);
// Also exclude by basename for flexibility
const excludeBaseNames = new Set(['Constants.js', 'PixelRenderer.js']);
// Common non-magic numbers to allow
const allowedNumbers = new Set([
0, 1, 2, 3, 4, 5, 10, 16, 32, 60, 64, 100, 255, 256, 1000,
0.5, 0.25, 0.75, 1.0, 2.0,
-1, -2,
]);
const magicNumberPattern = /(?<!\w)(-?\d+\.?\d*)\b/g;
const suspiciousLines = [];
for (const { path: filePath, rel } of allJsFiles) {
// Normalize path separators for comparison
const normRel = rel.replace(/\\/g, '/');
const baseName = path.basename(filePath);
if (excludeFiles.has(normRel) || excludeBaseNames.has(baseName)) continue;
const content = fs.readFileSync(filePath, 'utf-8');
const lines = content.split('\n');
for (let lineNum = 0; lineNum < lines.length; lineNum++) {
const line = lines[lineNum];
const trimmed = line.trim();
// Skip comments
if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*')) continue;
// Skip import/export lines
if (trimmed.startsWith('import ') || trimmed.startsWith('export ')) continue;
// Skip lines that are just closing braces, returns of simple values, etc.
if (trimmed.length < 5) continue;
let match;
magicNumberPattern.lastIndex = 0;
while ((match = magicNumberPattern.exec(line)) !== null) {
const num = parseFloat(match[1]);
if (allowedNumbers.has(num)) continue;
if (isNaN(num)) continue;
// Skip hex color literals (0x...)
if (/0x[0-9a-fA-F]+/.test(match[0])) continue;
const before = line.substring(0, match.index);
if (/0x[0-9a-fA-F]*$/.test(before)) continue;
// Skip version-like patterns and string contents
if (/['"`]/.test(before) && /['"`]/.test(line.substring(match.index + match[0].length))) continue;
suspiciousLines.push({
file: `src/${normRel}`,
line: lineNum + 1,
text: trimmed,
number: match[1],
});
}
}
}
if (suspiciousLines.length > 0) {
warn('Possible magic numbers:');
// Show up to 15 suspicious lines
const shown = suspiciousLines.slice(0, 15);
for (const s of shown) {
console.log(` ${s.file}:${s.line} ${s.text}`);
}
if (suspiciousLines.length > 15) {
console.log(` ... and ${suspiciousLines.length - 15} more`);
}
} else {
console.log('[INFO] No suspicious magic numbers detected');
}
// ---------------------------------------------------------------------------
// Summary
// ---------------------------------------------------------------------------
const total = passed + failed;
console.log(`\nResults: ${passed}/${total} passed, ${failed} failed, ${warnings} warning${warnings !== 1 ? 's' : ''}`);
process.exit(failed > 0 ? 1 : 0);