From 13565d1ea3dcd4a16a295257ff6b9b1f2ef9a943 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Sun, 5 Jul 2026 07:42:54 +0900 Subject: [PATCH] Verify visual asset references --- package.json | 1 + scripts/verify-release-candidate.mjs | 1 + scripts/verify-visual-asset-data.mjs | 146 +++++++++++++++++++++++++++ src/game/scenes/BootScene.ts | 22 ++++ 4 files changed, 170 insertions(+) create mode 100644 scripts/verify-visual-asset-data.mjs diff --git a/package.json b/package.json index 7c858c5..f2f92ab 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "verify:battle-data": "node scripts/verify-battle-scenario-data.mjs", "verify:campaign-data": "node scripts/verify-campaign-flow-data.mjs", "verify:story-assets": "node scripts/verify-story-asset-data.mjs", + "verify:visual-assets": "node scripts/verify-visual-asset-data.mjs", "verify:flow": "node scripts/verify-flow.mjs", "verify:save-flow": "node scripts/verify-save-retry-flow.mjs", "verify:release": "node scripts/verify-release-candidate.mjs", diff --git a/scripts/verify-release-candidate.mjs b/scripts/verify-release-candidate.mjs index d8e8833..b66935c 100644 --- a/scripts/verify-release-candidate.mjs +++ b/scripts/verify-release-candidate.mjs @@ -12,6 +12,7 @@ try { runCommand('node', ['scripts/verify-campaign-flow-data.mjs']); runCommand('node', ['scripts/verify-battle-scenario-data.mjs']); runCommand('node', ['scripts/verify-story-asset-data.mjs']); + runCommand('node', ['scripts/verify-visual-asset-data.mjs']); mkdirSync(screenshotDir, { recursive: true }); serverProcess = await ensurePreviewServer(targetUrl); diff --git a/scripts/verify-visual-asset-data.mjs b/scripts/verify-visual-asset-data.mjs new file mode 100644 index 0000000..c8a5615 --- /dev/null +++ b/scripts/verify-visual-asset-data.mjs @@ -0,0 +1,146 @@ +import { readFileSync, statSync } from 'node:fs'; +import { createServer } from 'vite'; + +const server = await createServer({ + logLevel: 'error', + server: { middlewareMode: true }, + appType: 'custom' +}); + +try { + const { battleUiIconManifest } = await server.ssrLoadModule('/src/game/data/battleUiIcons.ts'); + const { itemCatalog, equipmentSlots } = await server.ssrLoadModule('/src/game/data/battleItems.ts'); + const errors = []; + + validateBattleUiIconAtlas(errors, battleUiIconManifest); + validateItemCatalog(errors, itemCatalog, equipmentSlots); + validateGeneratedEquipmentIcons(errors, itemCatalog); + + if (errors.length) { + console.error(`Visual asset data verification failed with ${errors.length} issue(s):`); + errors.slice(0, 100).forEach((error) => console.error(`- ${error}`)); + if (errors.length > 100) { + console.error(`- ...and ${errors.length - 100} more`); + } + process.exitCode = 1; + } else { + console.log( + `Verified battle UI atlas ${battleUiIconManifest.columns}x${battleUiIconManifest.rows}, ` + + `${Object.keys(battleUiIconManifest.frames).length} icon keys, and ${Object.keys(itemCatalog).length} generated equipment icons.` + ); + } +} finally { + await server.close(); +} + +function validateBattleUiIconAtlas(errors, manifest) { + assertNonEmptyString(errors, manifest.source, 'battleUiIconManifest.source'); + assertNonEmptyString(errors, manifest.textureKey, 'battleUiIconManifest.textureKey'); + assertPositiveInteger(errors, manifest.frameWidth, 'battleUiIconManifest.frameWidth'); + assertPositiveInteger(errors, manifest.frameHeight, 'battleUiIconManifest.frameHeight'); + assertPositiveInteger(errors, manifest.columns, 'battleUiIconManifest.columns'); + assertPositiveInteger(errors, manifest.rows, 'battleUiIconManifest.rows'); + + let dimensions; + try { + dimensions = readPngDimensions(manifest.source); + } catch (error) { + errors.push(`battle UI icon atlas cannot be read at "${manifest.source}": ${error.message}`); + return; + } + + const expectedWidth = manifest.columns * manifest.frameWidth; + const expectedHeight = manifest.rows * manifest.frameHeight; + if (dimensions.width !== expectedWidth || dimensions.height !== expectedHeight) { + errors.push(`battle UI icon atlas is ${dimensions.width}x${dimensions.height}, expected ${expectedWidth}x${expectedHeight}`); + } + + const frameLimit = manifest.columns * manifest.rows; + Object.entries(manifest.frames).forEach(([key, frame]) => { + assertNonEmptyString(errors, key, 'battle UI icon key'); + if (!Number.isInteger(frame) || frame < 0 || frame >= frameLimit) { + errors.push(`battle UI icon "${key}" frame ${frame} is outside atlas frame range 0..${frameLimit - 1}`); + } + }); +} + +function validateItemCatalog(errors, itemCatalog, equipmentSlots) { + const validSlots = new Set(equipmentSlots); + const validRanks = new Set(['common', 'treasure']); + + Object.entries(itemCatalog).forEach(([id, item]) => { + const context = `itemCatalog.${id}`; + if (item.id !== id) { + errors.push(`${context}: item id does not match catalog key`); + } + assertNonEmptyString(errors, item.name, `${context}.name`); + assertNonEmptyString(errors, item.description, `${context}.description`); + if (!validSlots.has(item.slot)) { + errors.push(`${context}: unknown equipment slot "${item.slot}"`); + } + if (!validRanks.has(item.rank)) { + errors.push(`${context}: unknown equipment rank "${item.rank}"`); + } + if (!Array.isArray(item.effects) || item.effects.length === 0) { + errors.push(`${context}: effects must not be empty`); + } else { + item.effects.forEach((effect, index) => assertNonEmptyString(errors, effect, `${context}.effects[${index}]`)); + } + ['attackBonus', 'defenseBonus', 'strategyBonus'].forEach((field) => { + if (item[field] !== undefined && (!Number.isInteger(item[field]) || item[field] < 0)) { + errors.push(`${context}.${field} must be a non-negative integer`); + } + }); + }); +} + +function validateGeneratedEquipmentIcons(errors, itemCatalog) { + const bootScene = readFileSync('src/game/scenes/BootScene.ts', 'utf8'); + const generatedItemIds = new Set([...bootScene.matchAll(/createItemIcon\('item-([^']+)'/g)].map((match) => match[1])); + const catalogItemIds = new Set(Object.keys(itemCatalog)); + + Array.from(catalogItemIds) + .sort() + .forEach((itemId) => { + if (!generatedItemIds.has(itemId)) { + errors.push(`BootScene does not generate an equipment icon texture for item-${itemId}`); + } + }); + Array.from(generatedItemIds) + .sort() + .forEach((itemId) => { + if (!catalogItemIds.has(itemId)) { + errors.push(`BootScene generates item-${itemId}, but itemCatalog has no matching item`); + } + }); +} + +function readPngDimensions(path) { + const stats = statSync(path); + if (stats.size < 24) { + throw new Error('file is too small to be a PNG'); + } + + const bytes = readFileSync(path); + const signature = '89504e470d0a1a0a'; + if (bytes.subarray(0, 8).toString('hex') !== signature) { + throw new Error('file does not have a PNG signature'); + } + + return { + width: bytes.readUInt32BE(16), + height: bytes.readUInt32BE(20) + }; +} + +function assertPositiveInteger(errors, value, context) { + if (!Number.isInteger(value) || value <= 0) { + errors.push(`${context} must be a positive integer`); + } +} + +function assertNonEmptyString(errors, value, context) { + if (typeof value !== 'string' || value.trim().length === 0) { + errors.push(`${context} must be a non-empty string`); + } +} diff --git a/src/game/scenes/BootScene.ts b/src/game/scenes/BootScene.ts index 6327ca2..be602a2 100644 --- a/src/game/scenes/BootScene.ts +++ b/src/game/scenes/BootScene.ts @@ -34,7 +34,9 @@ export class BootScene extends Phaser.Scene { }); this.createItemIcon('item-green-dragon-glaive', (graphics) => this.drawPolearmIcon(graphics, 0x5fbf8f, 0xd8b15f)); this.createItemIcon('item-serpent-spear', (graphics) => this.drawSpearIcon(graphics, 0xe0e8ef, 0xb86b55)); + this.createItemIcon('item-iron-spear', (graphics) => this.drawSpearIcon(graphics, 0xd8dfe6, 0x6cc5ff)); this.createItemIcon('item-sky-piercer-halberd', (graphics) => this.drawPolearmIcon(graphics, 0xd8dfe6, 0xd95f4f)); + this.createItemIcon('item-western-cavalry-spear', (graphics) => this.drawSpearIcon(graphics, 0xe6d3a6, 0xb86b55)); this.createItemIcon('item-white-feather-fan', (graphics) => this.drawFanIcon(graphics, 0xf3f0df, 0x6e8eb8)); this.createItemIcon('item-yellow-turban-saber', (graphics) => this.drawSaberIcon(graphics, 0xd8b15f, 0x9c6b2f)); this.createItemIcon('item-short-bow', (graphics) => this.drawBowIcon(graphics, 0xc58a4c, 0xe8dfca)); @@ -48,6 +50,7 @@ export class BootScene extends Phaser.Scene { this.createItemIcon('item-peach-charm', (graphics) => this.drawCharmIcon(graphics, 0xf0a6a4, 0xd8b15f)); this.createItemIcon('item-war-manual', (graphics) => this.drawBookIcon(graphics, 0x5b78a0, 0xe8dfca)); + this.createItemIcon('item-mountain-guide-scroll', (graphics) => this.drawMapScrollIcon(graphics, 0xcfae72, 0x5f8f65)); this.createItemIcon('item-bravery-token', (graphics) => this.drawTokenIcon(graphics, 0xd8b15f, 0x9b4c3a)); this.createItemIcon('item-grain-pouch', (graphics) => this.drawPouchIcon(graphics, 0xa7794a, 0xe8dfca)); this.createItemIcon('item-yellow-scarf-charm', (graphics) => this.drawCharmIcon(graphics, 0xd8b15f, 0x8b6f2a)); @@ -244,6 +247,25 @@ export class BootScene extends Phaser.Scene { graphics.strokePath(); } + private drawMapScrollIcon(graphics: Phaser.GameObjects.Graphics, paperColor: number, routeColor: number) { + graphics.fillStyle(0x8d5a3a, 1); + graphics.fillRoundedRect(5, 5, 5, 18, 2); + graphics.fillRoundedRect(18, 5, 5, 18, 2); + graphics.fillStyle(paperColor, 1); + graphics.fillRoundedRect(8, 6, 12, 16, 2); + graphics.lineStyle(1, 0x4e3522, 0.7); + graphics.strokeRoundedRect(8, 6, 12, 16, 2); + graphics.lineStyle(2, routeColor, 1); + graphics.beginPath(); + graphics.moveTo(10, 18); + graphics.lineTo(13, 14); + graphics.lineTo(15, 16); + graphics.lineTo(18, 10); + graphics.strokePath(); + graphics.fillStyle(0xd8b15f, 1); + graphics.fillCircle(18, 10, 2); + } + private drawTokenIcon(graphics: Phaser.GameObjects.Graphics, baseColor: number, accentColor: number) { graphics.fillStyle(baseColor, 1); graphics.fillCircle(14, 14, 9);