import { createHash } from 'node:crypto'; import { readFile, readdir, stat } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(scriptDirectory, '..'); const pageDirectory = path.join(repoRoot, 'activity', 'game-king'); const aslanDirectory = path.join(pageDirectory, 'aslan'); const layerDirectory = path.join(aslanDirectory, 'assets', 'layers'); const manifestPath = path.join(layerDirectory, 'manifest.json'); function invariant(condition, message) { if (!condition) { throw new Error(`[game-king-aslan-asset-audit] ${message}`); } } function pngDimensions(bytes, filename) { invariant( bytes.length >= 24 && bytes.toString('ascii', 1, 4) === 'PNG', `${filename}: expected PNG signature` ); return { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20), }; } function jpegDimensions(bytes, filename) { invariant( bytes.length >= 4 && bytes[0] === 0xff && bytes[1] === 0xd8, `${filename}: expected JPEG signature` ); for (let offset = 2; offset + 9 < bytes.length; ) { if (bytes[offset] !== 0xff) { offset += 1; continue; } const marker = bytes[offset + 1]; const length = bytes.readUInt16BE(offset + 2); if ( marker >= 0xc0 && marker <= 0xc3 && length >= 7 && offset + length + 2 <= bytes.length ) { return { height: bytes.readUInt16BE(offset + 5), width: bytes.readUInt16BE(offset + 7), }; } invariant(length >= 2, `${filename}: invalid JPEG segment length`); offset += length + 2; } throw new Error(`[game-king-aslan-asset-audit] ${filename}: no JPEG size`); } function bitmapDimensions(bytes, asset) { if (asset.file.format === 'png') return pngDimensions(bytes, asset.filename); if (asset.file.format === 'jpeg') return jpegDimensions(bytes, asset.filename); throw new Error( `[game-king-aslan-asset-audit] ${asset.filename}: unsupported format` ); } const manifest = JSON.parse(await readFile(manifestPath, 'utf8')); invariant(manifest.schemaVersion === 1, 'unsupported manifest schema'); invariant( manifest.figma?.fileKey === 'OTOg5cwbhVNWxkImGmzTJl', 'unexpected Figma file key' ); const forbiddenNodes = new Set(manifest.policy?.forbiddenSourceNodes || []); const approvedCompositeNodes = new Set( manifest.policy?.approvedStandaloneCompositeNodes || [] ); const assets = manifest.assets || []; invariant(assets.length > 0, 'manifest must list the exported layers'); const byFile = new Map(); for (const asset of assets) { const isApprovedStaticComposite = approvedCompositeNodes.has(asset.sourceNode) && /^approved-static-(header-composite|podium-frame)$/.test( asset.kind || '' ); invariant( typeof asset.filename === 'string' && path.basename(asset.filename) === asset.filename, 'layer filename must be a basename' ); invariant( !byFile.has(asset.filename), `${asset.filename}: duplicate asset` ); byFile.set(asset.filename, asset); invariant( /^\d+:\d+$/.test(asset.sourceNode || ''), `${asset.filename}: missing source node` ); invariant( !forbiddenNodes.has(asset.sourceNode), `${asset.filename}: root frame/canvas exports are forbidden` ); invariant( asset.renderedDesignSize?.width > 0 && asset.renderedDesignSize?.height > 0, `${asset.filename}: design size is required` ); invariant( /^[a-f0-9]{64}$/.test(asset.sha256 || ''), `${asset.filename}: SHA-256 is required` ); for (const field of [ 'containsText', 'containsControls', 'containsDynamicBusinessMedia', 'mockOnly', ]) { invariant( typeof asset[field] === 'boolean', `${asset.filename}: ${field} must be boolean` ); } invariant( isApprovedStaticComposite || !asset.containsText, `${asset.filename}: text must be live DOM/i18n content` ); invariant( !asset.containsControls, `${asset.filename}: controls must be DOM elements` ); if (asset.scope === 'runtime') { invariant( !asset.mockOnly, `${asset.filename}: runtime layer cannot be mock-only` ); invariant( !asset.containsDynamicBusinessMedia, `${asset.filename}: runtime layer cannot contain dynamic business media` ); } else { invariant( asset.scope === 'fixture' && asset.mockOnly && asset.containsDynamicBusinessMedia, `${asset.filename}: only an explicit mock fixture may contain business media` ); } const assetPath = path.join(layerDirectory, asset.filename); const [bytes, info] = await Promise.all([ readFile(assetPath), stat(assetPath), ]); const dimensions = bitmapDimensions(bytes, asset); invariant( info.size === asset.file.bytes, `${asset.filename}: byte size mismatch` ); invariant( dimensions.width === asset.file.pixelWidth && dimensions.height === asset.file.pixelHeight, `${asset.filename}: pixel dimensions mismatch` ); if (asset.naturalSize) { invariant( dimensions.width === asset.naturalSize.width && dimensions.height === asset.naturalSize.height, `${asset.filename}: natural source dimensions mismatch` ); } invariant( dimensions.width * dimensions.height <= manifest.policy.maxDecorativeLayerPixels, `${asset.filename}: bitmap is too large for an atomic layer` ); invariant( createHash('sha256').update(bytes).digest('hex') === asset.sha256, `${asset.filename}: SHA-256 mismatch` ); } const diskFiles = new Set( (await readdir(layerDirectory)).filter( (filename) => filename !== 'manifest.json' ) ); invariant( diskFiles.size === byFile.size && [...diskFiles].every((filename) => byFile.has(filename)), 'every layer file must have exactly one traceable manifest record' ); const [html, css, js, common, ...locales] = await Promise.all([ readFile(path.join(pageDirectory, 'aslan.html'), 'utf8'), readFile(path.join(aslanDirectory, 'aslan.css'), 'utf8'), readFile(path.join(aslanDirectory, 'aslan.js'), 'utf8'), readFile(path.join(repoRoot, 'common_aslan', 'aslan-common.js'), 'utf8'), ...['en', 'ar', 'tr', 'es'].map((language) => readFile( path.join(repoRoot, 'common', 'locales', `${language}.json`), 'utf8' ) ), ]); const runtimeSource = `${html}\n${css}\n${js}`; for (const asset of assets) { invariant( runtimeSource.includes(asset.filename), `${asset.filename}: layer is not referenced` ); } for (const term of manifest.policy.forbiddenRuntimeTerms || []) { invariant( !runtimeSource.toLowerCase().includes(String(term).toLowerCase()), `runtime source includes forbidden aggregate-image term: ${term}` ); } for (const match of runtimeSource.matchAll( /(?:\.\/assets\/layers\/)([^"')\s]+)/g )) { invariant( byFile.has(match[1]), `${match[1]}: untraced local bitmap reference` ); } invariant( html.includes('../../common/theme.css') && html.includes('../../common/i18n.js'), 'shared theme and i18n runtime are required' ); invariant( html.includes('data-hy-app-code="aslan"'), 'Aslan app identity is required' ); invariant( /function isMockMode\(\)[\s\S]*query\('mock'\)/.test(js) && /hostname === 'localhost'/.test(js) && /hostname === '127\.0\.0\.1'/.test(js) && /env === 'local'/.test(js) && /env === 'test'/.test(js) && /function buildMockStore\(\)/.test(js), 'mock data must be query-gated and restricted to local/test environments' ); invariant( js.indexOf('var icon = PRIZE_FALLBACK;') > js.indexOf('function buildMockStore()'), 'fixture prize art must be confined to the mock builder' ); for (const endpoint of [ '/activity/game/king/aslan/detail', '/activity/game/king/aslan/me', '/activity/game/king/aslan/draw', '/activity/game/king/aslan/ranking', '/activity/game/king/aslan/draw-records', ]) { invariant( common.includes(endpoint), `missing common_aslan endpoint: ${endpoint}` ); } invariant( common.includes("rankingType: String(rankingType || 'TYCOON')") && common.includes("period: String(period || 'DAILY')"), 'ranking contract must preserve TYCOON/VICTORIOUS and DAILY/OVERALL' ); for (const dtoField of [ "'prizeImage'", "'coinPerDraw'", "'totalConsumed'", "'entries'", "'my'", "'account'", "'cover'", "'sourceUrl'", "'deliveryStatus'", "'failureReason'", ]) { invariant( js.includes(dtoField), `missing Game King backend DTO mapping: ${dtoField}` ); } invariant( /Object\.assign\(\s*\{\},\s*detail \|\| \{\},\s*activity,\s*me \|\| \{\}/.test( common ), 'detail config must be preserved when current-user fields are merged' ); invariant( js.includes("status === 'FAILED'") && js.includes("status === 'PROCESSING'") && js.includes("status === 'UNKNOWN'") && js.includes('function deliveryMessage') && js.includes('source.record || source.drawRecord || source'), 'draw result and record UI must surface delivery success, processing and failure states' ); invariant( common.includes('function nonProductionBaseURL') && common.includes('trustedApiHosts') && common.includes('if (!env) return normalizeBaseURL(PROD_API_BASE)') && !common.includes("query('apiBase')") && !common.includes("query('api_base')") && !common.includes("query('aslan_api_base')") && common.includes("throw new Error('aslan_test_api_not_configured')"), 'production API must be fixed; test/dev needs an explicit trusted build-time host' ); invariant( js.includes("var SETTLEMENT_BOARD_TYPE = 'TYCOON'") && js.includes('rankingRewards[SETTLEMENT_BOARD_TYPE]'), 'Award entry must show the single Tycoon overall settlement board' ); const baseLocale = JSON.parse(locales[0]); const gameKingKeys = Object.keys(baseLocale).filter((key) => key.startsWith('gameKing.') ); invariant( gameKingKeys.length >= 40, 'English game-king i18n keys are incomplete' ); for (const localeText of locales.slice(1)) { const locale = JSON.parse(localeText); for (const key of gameKingKeys) { invariant( typeof locale[key] === 'string' && locale[key], `${key}: locale translation missing` ); } } console.log( `[game-king-aslan-asset-audit] passed: ${assets.length} atomic layers, 4 locales, common_aslan contract` );