hyapp-h5/scripts/check-activity-game-king-aslan-assets.mjs
2026-07-18 11:13:12 +08:00

339 lines
11 KiB
JavaScript

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`
);