mirror of
https://github.com/PlayableIntelligence/game-creator.git
synced 2026-09-19 07:34:10 +08:00
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:
+161
-1
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user