diff --git a/docs/battle-ui-icons-v2-action-item-contact.png b/docs/battle-ui-icons-v2-action-item-contact.png index c5bd702..63735e8 100644 Binary files a/docs/battle-ui-icons-v2-action-item-contact.png and b/docs/battle-ui-icons-v2-action-item-contact.png differ diff --git a/docs/equipment-icon-quality-audit.md b/docs/equipment-icon-quality-audit.md index daaf773..1ef1004 100644 --- a/docs/equipment-icon-quality-audit.md +++ b/docs/equipment-icon-quality-audit.md @@ -1,86 +1,86 @@ # Equipment Icon Quality Audit -Generated by `scripts/audit-equipment-icon-quality.mjs`. +Generated by `scripts/audit-equipment-icon-quality.mjs` from the current catalog, PNG headers, and runtime source. -## Summary +## Result -- Catalog items: 23 -- Generated procedural item textures: 23 -- Slots: accessory 7, armor 5, weapon 11 -- Ranks: common 11, treasure 12 -- Texture size: 64px -- Drawing grid: 28 units scaled 2.29x into the texture -- Runtime item icon scene: `src/game/scenes/CampScene.ts` -- Observed runtime display sizes: `14px` -- Direct item texture render sites found: 5 +- Status: **PASS** +- Catalog contract: 23/23 items +- Complete 128/32 pairs: 23/23 +- Fully valid catalog rows: 23/23 +- 128px payload: 393,307 B (384.1 KiB) across 23 PNG files +- 32px payload: 40,270 B (39.3 KiB) across 23 PNG files - Browser visual QA baseline: `1920x1080` CSS viewport at 100% zoom -## Main Findings +## Asset Coverage -- Equipment item icons are generated at runtime in `BootScene`, not stored as individual raster images. -- The 64px textures are drawn from a compact 28-unit source grid, so they are crisp but intentionally simple. -- Battle combat preview mostly uses the high-resolution `battle-ui-icons` slot frames and item names; per-item textures appear primarily in camp/sortie equipment UI. -- Treasure/common distinction often relies on UI frame color plus a small palette change, not a strong unique silhouette. -- Reused helper silhouettes are the strongest quality risk because several different items collapse to the same shape at 14-22px. +- 128px missing: none +- 32px missing: none +- 128px uncataloged/extra: none +- 32px uncataloged/extra: none +- Required PNG format: exact square dimensions, 8-bit RGBA (PNG color type 6), non-empty file. -## Completed Improvement Passes +## Boot Loading Contract -- weapon spear/polearm family: `green-dragon-glaive`, `serpent-spear`, `sky-piercer-halberd`, `iron-spear`, `western-cavalry-spear` - - The previous shared spear/polearm silhouettes now use item-specific helper shapes with enough primitive detail for 20px and 30px equipment UI reads. -- armor silhouettes: `oath-robe`, `reinforced-lamellar`, `cloth-armor`, `lamellar-armor`, `rebel-vest` - - The previous shared armor and robe silhouettes now use item-specific helper shapes with stronger outer contours and material cues. -- accessory charms and tokens: `bravery-token`, `mountain-guide-scroll`, `peach-charm`, `war-manual`, `wind-quiver`, `grain-pouch`, `yellow-scarf-charm` - - Accessory icons now use distinct badge, scroll, charm, pouch, manual, and quiver silhouettes at small camp UI sizes. -- remaining low-detail weapon icons: `leader-axe`, `training-sword`, `yellow-turban-saber` - - The final low-detail common weapons now use dedicated axe, training sword, and Yellow Turban saber silhouettes. +| check | result | contract | +| --- | --- | --- | +| 128px source glob | PASS | `equipment-icons/128/*.png` is imported eagerly | +| 32px source glob | PASS | `equipment-icons/32/*.png` is imported eagerly | +| full texture preload | PASS | 128px files load as `item-${id}` | +| micro texture preload | PASS | 32px files load as `item-${id}-micro` | +| catalog-derived texture key | PASS | file stem becomes the stable item texture id | +| procedural fallback guard | PASS | procedural full icon is created only when the loaded texture key is absent | +| procedural fallback coverage | PASS | 23/23 catalog ids have a base-key fallback | -## Next Improvement Target +The authored 128px files are the normal `item-${id}` textures and the authored 32px files are the `item-${id}-micro` textures. The existing procedural 64px drawing path is retained only as a **loaded-base-texture-missing fallback**: its guard leaves a successfully loaded authored base texture untouched. Micro textures are static assets, so their completeness is enforced by this audit rather than by the procedural fallback. -- Target: no flagged equipment icon batch -- Item ids: none currently flagged -- Reason: No generated equipment icon currently has a low-detail or shared-helper audit concern; choose the next visual asset pass from in-game QA. +## Camp Render Usage -## Highest-Risk Shared Silhouette Groups +- Micro render sites: 4 +- Full render sites: 2 -| score | signature | slots | ranks | items | -| ---: | --- | --- | --- | --- | -| 5 | `drawFanIcon` | weapon | treasure | `white-feather-fan` | -| 5 | `drawGreenDragonGlaiveIcon` | weapon | treasure | `green-dragon-glaive` | -| 5 | `drawSerpentSpearIcon` | weapon | treasure | `serpent-spear` | -| 5 | `drawSkyPiercerHalberdIcon` | weapon | treasure | `sky-piercer-halberd` | -| 5 | `drawSwordIcon+drawSwordIcon` | weapon | treasure | `twin-oath-blades` | +| tier | source location | method | key expression | requested display size | +| --- | --- | --- | --- | --- | +| micro | `src/game/scenes/CampScene.ts:16367` | `renderSortieEquipmentIcons` | `item-${item.id}-micro` | `iconSize` | +| micro | `src/game/scenes/CampScene.ts:21970` | `renderEquipmentCompareRow` | `item-${entry.item.id}-micro` | `22px` | +| micro | `src/game/scenes/CampScene.ts:22012` | `renderEquipmentEffectSummary` | `item-${entry.item.id}-micro` | `20px` | +| micro | `src/game/scenes/CampScene.ts:22158` | `renderEquipmentInventoryBox` | `item-${entry.item.id}-micro` | `21px` | +| full | `src/game/scenes/CampScene.ts:22341` | `renderEquipmentCard` | `item-${item.id}` | `30px` | +| full | `src/game/scenes/CampScene.ts:23078` | `renderEquipmentSwapComparisonCard` | `item-${item.id}` | `40px` | -## Full Item Inventory +Micro textures are used for compact sortie, inventory, and equipped-item rows. Full textures are used for larger equipment cards and the compare/confirm panel. -| item id | slot | rank | draw helpers | calls | primitives | colors | concerns | -| --- | --- | --- | --- | ---: | ---: | ---: | --- | -| `green-dragon-glaive` | weapon | treasure | `drawGreenDragonGlaiveIcon` | 1 | 23 | 6 | none | -| `serpent-spear` | weapon | treasure | `drawSerpentSpearIcon` | 1 | 29 | 4 | none | -| `sky-piercer-halberd` | weapon | treasure | `drawSkyPiercerHalberdIcon` | 1 | 24 | 7 | none | -| `twin-oath-blades` | weapon | treasure | `drawSwordIcon`
`drawSwordIcon` | 2 | 32 | 4 | none | -| `white-feather-fan` | weapon | treasure | `drawFanIcon` | 1 | 19 | 3 | none | -| `iron-spear` | weapon | common | `drawIronSpearIcon` | 1 | 26 | 7 | none | -| `leader-axe` | weapon | common | `drawLeaderAxeIcon` | 1 | 26 | 8 | none | -| `short-bow` | weapon | common | `drawBowIcon` | 1 | 18 | 3 | none | -| `training-sword` | weapon | common | `drawTrainingSwordIcon` | 1 | 31 | 7 | none | -| `western-cavalry-spear` | weapon | common | `drawWesternCavalrySpearIcon` | 1 | 27 | 7 | none | -| `yellow-turban-saber` | weapon | common | `drawYellowTurbanSaberIcon` | 1 | 35 | 4 | none | -| `oath-robe` | armor | treasure | `drawOathRobeIcon` | 1 | 25 | 6 | none | -| `reinforced-lamellar` | armor | treasure | `drawReinforcedLamellarIcon` | 1 | 24 | 5 | none | -| `cloth-armor` | armor | common | `drawClothArmorIcon` | 1 | 24 | 6 | none | -| `lamellar-armor` | armor | common | `drawLamellarArmorIcon` | 1 | 23 | 4 | none | -| `rebel-vest` | armor | common | `drawRebelVestIcon` | 1 | 24 | 7 | none | -| `bravery-token` | accessory | treasure | `drawBraveryTokenIcon` | 1 | 19 | 5 | none | -| `mountain-guide-scroll` | accessory | treasure | `drawMountainGuideScrollIcon` | 1 | 23 | 7 | none | -| `peach-charm` | accessory | treasure | `drawPeachCharmIcon` | 1 | 22 | 5 | none | -| `war-manual` | accessory | treasure | `drawWarManualIcon` | 1 | 20 | 7 | none | -| `wind-quiver` | accessory | treasure | `drawWindQuiverIcon` | 1 | 24 | 6 | none | -| `grain-pouch` | accessory | common | `drawGrainPouchIcon` | 1 | 22 | 5 | none | -| `yellow-scarf-charm` | accessory | common | `drawYellowScarfCharmIcon` | 1 | 25 | 5 | none | +## Full Catalog Inventory -## Recommended Next Batch Definition +| item id | slot | rank | 128px PNG | bytes | 32px PNG | bytes | pair | concerns | +| --- | --- | --- | --- | ---: | --- | ---: | --- | --- | +| `green-dragon-glaive` | weapon | treasure | 128x128 RGBA8 | 9,893 | 32x32 RGBA8 | 1,252 | yes | none | +| `iron-spear` | weapon | common | 128x128 RGBA8 | 6,364 | 32x32 RGBA8 | 830 | yes | none | +| `leader-axe` | weapon | common | 128x128 RGBA8 | 12,684 | 32x32 RGBA8 | 1,487 | yes | none | +| `serpent-spear` | weapon | treasure | 128x128 RGBA8 | 6,832 | 32x32 RGBA8 | 952 | yes | none | +| `short-bow` | weapon | common | 128x128 RGBA8 | 12,987 | 32x32 RGBA8 | 1,543 | yes | none | +| `sky-piercer-halberd` | weapon | treasure | 128x128 RGBA8 | 11,490 | 32x32 RGBA8 | 1,473 | yes | none | +| `training-sword` | weapon | common | 128x128 RGBA8 | 8,933 | 32x32 RGBA8 | 1,260 | yes | none | +| `twin-oath-blades` | weapon | treasure | 128x128 RGBA8 | 15,345 | 32x32 RGBA8 | 1,852 | yes | none | +| `western-cavalry-spear` | weapon | common | 128x128 RGBA8 | 7,505 | 32x32 RGBA8 | 883 | yes | none | +| `white-feather-fan` | weapon | treasure | 128x128 RGBA8 | 24,987 | 32x32 RGBA8 | 2,422 | yes | none | +| `yellow-turban-saber` | weapon | common | 128x128 RGBA8 | 12,895 | 32x32 RGBA8 | 1,568 | yes | none | +| `cloth-armor` | armor | common | 128x128 RGBA8 | 23,329 | 32x32 RGBA8 | 2,185 | yes | none | +| `lamellar-armor` | armor | common | 128x128 RGBA8 | 21,179 | 32x32 RGBA8 | 1,970 | yes | none | +| `oath-robe` | armor | treasure | 128x128 RGBA8 | 23,579 | 32x32 RGBA8 | 2,207 | yes | none | +| `rebel-vest` | armor | common | 128x128 RGBA8 | 20,584 | 32x32 RGBA8 | 1,768 | yes | none | +| `reinforced-lamellar` | armor | treasure | 128x128 RGBA8 | 20,788 | 32x32 RGBA8 | 1,920 | yes | none | +| `bravery-token` | accessory | treasure | 128x128 RGBA8 | 26,569 | 32x32 RGBA8 | 2,418 | yes | none | +| `grain-pouch` | accessory | common | 128x128 RGBA8 | 25,980 | 32x32 RGBA8 | 2,336 | yes | none | +| `mountain-guide-scroll` | accessory | treasure | 128x128 RGBA8 | 24,057 | 32x32 RGBA8 | 2,290 | yes | none | +| `peach-charm` | accessory | treasure | 128x128 RGBA8 | 15,997 | 32x32 RGBA8 | 1,592 | yes | none | +| `war-manual` | accessory | treasure | 128x128 RGBA8 | 21,080 | 32x32 RGBA8 | 1,978 | yes | none | +| `wind-quiver` | accessory | treasure | 128x128 RGBA8 | 20,471 | 32x32 RGBA8 | 2,084 | yes | none | +| `yellow-scarf-charm` | accessory | common | 128x128 RGBA8 | 19,779 | 32x32 RGBA8 | 2,000 | yes | none | -- Scope: no generated equipment icon is currently below the audit threshold. -- Item ids: none currently flagged -- Success criteria: Keep the generated equipment icon audit green while selecting the next art target from runtime screenshots. -- Suggested next area: map/background or unit sprite assets that still look low-resolution in gameplay. +## Findings + +- All 23 catalog items have valid, paired 128px and 32px RGBA8 PNG assets. +- File stems map to stable Boot texture keys for both full and micro tiers. +- Camp compact rows use micro textures while larger cards and comparison panels use full textures. +- Procedural icons remain a load-failure fallback for base item keys, not the normal rendered asset path. diff --git a/scripts/audit-equipment-icon-quality.mjs b/scripts/audit-equipment-icon-quality.mjs index 5be2115..1a9398e 100644 --- a/scripts/audit-equipment-icon-quality.mjs +++ b/scripts/audit-equipment-icon-quality.mjs @@ -1,9 +1,13 @@ -import { readFileSync, writeFileSync } from 'node:fs'; +import { readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; import { createServer } from 'vite'; import { desktopBrowserViewportLabel } from './desktop-browser-viewport.mjs'; +const expectedCatalogCount = 23; const bootScenePath = 'src/game/scenes/BootScene.ts'; const campScenePath = 'src/game/scenes/CampScene.ts'; +const icon128Directory = 'src/assets/images/ui/equipment-icons/128'; +const icon32Directory = 'src/assets/images/ui/equipment-icons/32'; const reportPath = 'docs/equipment-icon-quality-audit.md'; const server = await createServer({ @@ -12,543 +16,393 @@ const server = await createServer({ appType: 'custom' }); +let issueCount = 0; + try { const { itemCatalog, equipmentSlots } = await server.ssrLoadModule('/src/game/data/battleItems.ts'); const bootSceneSource = readFileSync(bootScenePath, 'utf8'); const campSceneSource = readFileSync(campScenePath, 'utf8'); + const catalogRows = collectCatalogRows(itemCatalog, equipmentSlots); + const directory128 = inspectDirectory(icon128Directory, 128); + const directory32 = inspectDirectory(icon32Directory, 32); + const assetRows = collectAssetRows(catalogRows, directory128, directory32); + const bootAudit = inspectBootContract(bootSceneSource, catalogRows.map((row) => row.itemId)); + const campAudit = inspectCampUsage(campSceneSource); + const catalogIssues = collectCatalogIssues(catalogRows); + const issues = [ + ...catalogIssues, + ...directory128.directoryIssues, + ...directory32.directoryIssues, + ...assetRows.flatMap((row) => row.issues.map((issue) => `${row.itemId}: ${issue}`)), + ...directory128.extraFiles.map((file) => `128px directory has an uncataloged file: ${file}`), + ...directory32.extraFiles.map((file) => `32px directory has an uncataloged file: ${file}`), + ...bootAudit.issues, + ...campAudit.issues + ]; - const iconSourceSize = numberConstant(bootSceneSource, 'itemIconSourceSize'); - const iconTextureSize = numberConstant(bootSceneSource, 'itemIconTextureSize'); - const drawFunctions = parseDrawFunctions(bootSceneSource); - const iconDefinitions = parseIconDefinitions(bootSceneSource, drawFunctions); - const sceneUsage = parseSceneUsage(campSceneSource); - const itemRows = collectItemRows(itemCatalog, equipmentSlots, iconDefinitions); - const groupedRows = groupBySignature(itemRows); - const recommendations = buildRecommendations(itemRows, groupedRows); - const report = renderReport({ - iconSourceSize, - iconTextureSize, - itemRows, - groupedRows, - recommendations, - sceneUsage - }); - - writeFileSync(reportPath, report, 'utf8'); - console.log(`Wrote ${reportPath}`); + issueCount = issues.length; + writeFileSync( + reportPath, + renderReport({ + catalogRows, + assetRows, + directory128, + directory32, + bootAudit, + campAudit, + issues + }), + 'utf8' + ); + console.log(`Wrote ${reportPath}: ${issues.length === 0 ? 'PASS' : `FAIL (${issues.length} issues)`}`); } finally { await server.close(); } -function numberConstant(source, name) { - const match = source.match(new RegExp(`const\\s+${name}\\s*=\\s*(\\d+)`)); - if (!match) { - throw new Error(`Could not find ${name}`); - } - return Number(match[1]); +if (issueCount > 0) { + process.exitCode = 1; } -function parseIconDefinitions(source, drawFunctions) { - const definitions = new Map(); - const matcher = /this\.createItemIcon\('item-([^']+)'/g; - let match; - - while ((match = matcher.exec(source))) { - const itemId = match[1]; - const callText = expressionSpan(source, match.index); - const drawCalls = [...callText.matchAll(/this\.(draw[A-Za-z]+Icon)\(([^)]*)\)/g)].map((drawMatch) => ({ - functionName: drawMatch[1], - colors: [...drawMatch[2].matchAll(/0x[0-9a-fA-F]{6}/g)].map((colorMatch) => colorMatch[0].toLowerCase()) - })); - const colors = [ - ...new Set( - drawCalls.flatMap((call) => [...call.colors, ...(drawFunctions.get(call.functionName)?.colors ?? [])]) - ) - ].sort(); - const primitiveCount = drawCalls.reduce((sum, call) => sum + (drawFunctions.get(call.functionName)?.primitiveCount ?? 0), 0); - const lineNumber = source.slice(0, match.index).split(/\r?\n/).length; - - definitions.set(itemId, { - itemId, - lineNumber, - drawCalls, - drawFunctionNames: drawCalls.map((call) => call.functionName), - colors, - primitiveCount, - signature: drawCalls.map((call) => call.functionName).sort().join('+') - }); - } - - return definitions; -} - -function expressionSpan(source, startIndex) { - const openIndex = source.indexOf('(', startIndex); - if (openIndex < 0) { - throw new Error(`Could not find call expression at ${startIndex}`); - } - - let depth = 0; - let quote = ''; - let escaped = false; - - for (let index = openIndex; index < source.length; index += 1) { - const char = source[index]; - - if (quote) { - if (escaped) { - escaped = false; - } else if (char === '\\') { - escaped = true; - } else if (char === quote) { - quote = ''; - } - continue; - } - - if (char === "'" || char === '"' || char === '`') { - quote = char; - continue; - } - - if (char === '(') { - depth += 1; - } else if (char === ')') { - depth -= 1; - if (depth === 0) { - return source.slice(startIndex, index + 1); - } - } - } - - throw new Error(`Unclosed call expression at ${startIndex}`); -} - -function parseDrawFunctions(source) { - const functions = new Map(); - const matcher = /private\s+(draw[A-Za-z]+Icon)\([^)]*\)\s*\{/g; - let match; - - while ((match = matcher.exec(source))) { - const functionName = match[1]; - const body = blockSpan(source, source.indexOf('{', match.index)); - functions.set(functionName, { - functionName, - primitiveCount: countGraphicsPrimitives(body), - colors: [...new Set([...body.matchAll(/0x[0-9a-fA-F]{6}/g)].map((colorMatch) => colorMatch[0].toLowerCase()))].sort() - }); - } - - return functions; -} - -function blockSpan(source, openIndex) { - let depth = 0; - let quote = ''; - let escaped = false; - - for (let index = openIndex; index < source.length; index += 1) { - const char = source[index]; - - if (quote) { - if (escaped) { - escaped = false; - } else if (char === '\\') { - escaped = true; - } else if (char === quote) { - quote = ''; - } - continue; - } - - if (char === "'" || char === '"' || char === '`') { - quote = char; - continue; - } - - if (char === '{') { - depth += 1; - } else if (char === '}') { - depth -= 1; - if (depth === 0) { - return source.slice(openIndex + 1, index); - } - } - } - - throw new Error(`Unclosed block at ${openIndex}`); -} - -function countGraphicsPrimitives(body) { - return [ - ...body.matchAll( - /graphics\.(fillTriangle|fillRect|fillRoundedRect|fillEllipse|fillCircle|lineStyle|beginPath|moveTo|lineTo|strokePath|strokeRect|strokeRoundedRect|strokeCircle)/g - ) - ].length; -} - -function parseSceneUsage(source) { - const sizes = new Set(); - const usageBlocks = [...source.matchAll(/add\.image\([^`]+`item-\$\{[^`]+`[\s\S]{0,240}?setDisplaySize\((\d+),\s*(\d+)\)/g)]; - const sortieSignature = source.match(/renderSortieEquipmentIcons\([^)]*iconSize\s*=\s*(\d+)/); - - usageBlocks.forEach((match) => { - if (match[1] === match[2]) { - sizes.add(Number(match[1])); - } else { - sizes.add(`${match[1]}x${match[2]}`); - } - }); - - if (sortieSignature) { - sizes.add(Number(sortieSignature[1])); - } - - const directItemImageCount = [...source.matchAll(/add\.image\([^`]+`item-\$\{/g)].length; - return { - scene: campScenePath, - displaySizes: [...sizes].sort((left, right) => Number(left) - Number(right)), - directItemImageCount - }; -} - -function collectItemRows(itemCatalog, equipmentSlots, iconDefinitions) { +function collectCatalogRows(itemCatalog, equipmentSlots) { const slotOrder = new Map(equipmentSlots.map((slot, index) => [slot, index])); - - const rows = Object.entries(itemCatalog) - .map(([itemId, item]) => { - const icon = iconDefinitions.get(itemId); - const concerns = []; - if (!icon) { - concerns.push('missing generated icon'); - } else { - if (icon.primitiveCount < 18) { - concerns.push('low primitive detail'); - } - } - - return { - itemId, - slot: item.slot, - rank: item.rank, - attackBonus: item.attackBonus ?? 0, - defenseBonus: item.defenseBonus ?? 0, - strategyBonus: item.strategyBonus ?? 0, - icon, - signature: icon?.signature ?? 'missing', - drawFunctions: icon?.drawFunctionNames ?? [], - drawCalls: icon?.drawCalls.length ?? 0, - primitiveCount: icon?.primitiveCount ?? 0, - colorCount: icon?.colors.length ?? 0, - concerns - }; - }); - const signatureCounts = countBy(rows.filter((row) => row.icon), (row) => row.signature); - rows.forEach((row) => { - if (row.icon && row.drawCalls <= 1 && (signatureCounts.get(row.signature) ?? 0) > 1) { - row.concerns.unshift('shared helper silhouette'); - } - }); - - return rows.sort((left, right) => { - const slotDiff = (slotOrder.get(left.slot) ?? 99) - (slotOrder.get(right.slot) ?? 99); - if (slotDiff !== 0) { - return slotDiff; - } - if (left.rank !== right.rank) { - return left.rank === 'treasure' ? -1 : 1; - } - return left.itemId.localeCompare(right.itemId); - }); -} - -function groupBySignature(itemRows) { - const groups = new Map(); - itemRows.forEach((row) => { - const group = groups.get(row.signature) ?? []; - group.push(row); - groups.set(row.signature, group); - }); - - return [...groups.entries()] - .map(([signature, rows]) => ({ - signature, - rows, - slots: [...new Set(rows.map((row) => row.slot))].sort(), - ranks: [...new Set(rows.map((row) => row.rank))].sort(), - itemIds: rows.map((row) => row.itemId), - score: groupRiskScore(rows) + return Object.entries(itemCatalog) + .map(([catalogKey, item]) => ({ + catalogKey, + itemId: item.id, + slot: item.slot, + rank: item.rank })) - .sort((left, right) => right.score - left.score || right.rows.length - left.rows.length || left.signature.localeCompare(right.signature)); + .sort((left, right) => { + const slotDifference = (slotOrder.get(left.slot) ?? 99) - (slotOrder.get(right.slot) ?? 99); + return slotDifference || left.itemId.localeCompare(right.itemId); + }); } -function groupRiskScore(rows) { - const slots = new Set(rows.map((row) => row.slot)); - const ranks = new Set(rows.map((row) => row.rank)); - let score = rows.length; - - if (rows.length >= 3) { - score += 3; - } - if (slots.has('weapon')) { - score += 3; - } - if (ranks.has('treasure') && ranks.has('common')) { - score += 3; - } - if (rows.some((row) => row.primitiveCount < 18)) { - score += 2; - } - if (rows.some((row) => row.rank === 'treasure')) { - score += 1; +function collectCatalogIssues(catalogRows) { + const issues = []; + if (catalogRows.length !== expectedCatalogCount) { + issues.push(`itemCatalog count is ${catalogRows.length}; expected ${expectedCatalogCount}`); } - return score; -} - -function buildRecommendations(itemRows, groupedRows) { - const weaponPolearmTargetIds = [ - 'green-dragon-glaive', - 'serpent-spear', - 'iron-spear', - 'sky-piercer-halberd', - 'western-cavalry-spear' - ]; - const armorTargetIds = ['oath-robe', 'reinforced-lamellar', 'cloth-armor', 'lamellar-armor', 'rebel-vest']; - const accessoryIds = itemRows.filter((row) => row.slot === 'accessory').map((row) => row.itemId); - const lowDetailWeaponTargetIds = ['leader-axe', 'training-sword', 'yellow-turban-saber']; - const targets = [ - { - title: 'weapon spear/polearm family', - itemIds: weaponPolearmTargetIds, - completeReason: - 'The previous shared spear/polearm silhouettes now use item-specific helper shapes with enough primitive detail for 20px and 30px equipment UI reads.', - openReason: - 'These are high-frequency equipment surfaces and several treasure/common weapons share a thin diagonal shaft silhouette; upgrading this group gives the clearest identity gain in sortie prep and equipment swap screens.', - successCriteria: - 'Each item should read as a distinct weapon silhouette at 20px and 30px, with treasure weapons visibly richer than common weapons.' - }, - { - title: 'armor silhouettes', - itemIds: armorTargetIds, - completeReason: - 'The previous shared armor and robe silhouettes now use item-specific helper shapes with stronger outer contours and material cues.', - openReason: - 'Armor and robes are readable but still depend on simple triangular torsos and small color accents; this is now the largest remaining shared-equipment silhouette group.', - successCriteria: - 'Each armor item should keep its slot identity while gaining a distinct outer silhouette and material cue at 20px and 30px.' - }, - { - title: 'accessory charms and tokens', - itemIds: accessoryIds, - completeReason: - 'Accessory icons now use distinct badge, scroll, charm, pouch, manual, and quiver silhouettes at small camp UI sizes.', - openReason: 'Accessories are the smallest UI read and several use compact badge-like shapes.', - successCriteria: - 'Each accessory should keep a compact footprint while gaining a distinct readable prop shape at 20px and 30px.' - }, - { - title: 'remaining low-detail weapon icons', - itemIds: lowDetailWeaponTargetIds, - completeReason: - 'The final low-detail common weapons now use dedicated axe, training sword, and Yellow Turban saber silhouettes.', - openReason: - 'These are the last generated equipment icons still below the primitive-detail threshold after the larger weapon, armor, and accessory passes.', - successCriteria: 'Clear the low primitive detail concerns without changing stable item texture keys.' + const seenIds = new Set(); + catalogRows.forEach((row) => { + if (row.catalogKey !== row.itemId) { + issues.push(`itemCatalog key/id mismatch: ${row.catalogKey} != ${row.itemId}`); } - ].map((target) => ({ - ...target, - rows: itemRows.filter((row) => target.itemIds.includes(row.itemId)) - })); - const completedTargets = targets - .filter((target) => improvementTargetComplete(target)) - .map((target) => ({ - title: target.title, - itemIds: target.rows.map((row) => row.itemId), - reason: target.completeReason - })); - const remainingConcernRows = itemRows.filter((row) => row.concerns.length > 0); - const nextTarget = - targets.find((target) => !improvementTargetComplete(target)) ?? { - title: remainingConcernRows.length ? 'remaining low-detail equipment icons' : 'no flagged equipment icon batch', - rows: remainingConcernRows, - openReason: remainingConcernRows.length - ? 'All named batches are complete; remaining work should be selected from the low-detail inventory rows.' - : 'No generated equipment icon currently has a low-detail or shared-helper audit concern; choose the next visual asset pass from in-game QA.', - successCriteria: remainingConcernRows.length - ? 'Clear remaining low-detail concerns without changing stable texture keys.' - : 'Keep the generated equipment icon audit green while selecting the next art target from runtime screenshots.' - }; - const nextTargetIndex = targets.findIndex((target) => target.title === nextTarget.title); - const followUps = targets - .filter((target, index) => index > nextTargetIndex && !improvementTargetComplete(target)) - .map((target) => ({ - title: target.title, - itemIds: target.rows.map((row) => row.itemId), - reason: target.openReason - })); + if (seenIds.has(row.itemId)) { + issues.push(`itemCatalog has duplicate id: ${row.itemId}`); + } + seenIds.add(row.itemId); + }); + return issues; +} + +function inspectDirectory(directory, expectedSize) { + const entries = readdirSync(directory, { withFileTypes: true }); + const files = entries.filter((entry) => entry.isFile()).map((entry) => entry.name).sort(); + const pngFiles = files.filter((name) => name.toLowerCase().endsWith('.png')); + const nonPngFiles = files.filter((name) => !name.toLowerCase().endsWith('.png')); + const assets = new Map( + pngFiles.map((name) => { + const itemId = name.slice(0, -4); + return [itemId, inspectPng(join(directory, name), expectedSize)]; + }) + ); return { - completedTargets, - firstTargetTitle: nextTarget.title, - firstTargetIds: nextTarget.rows.map((row) => row.itemId), - firstTargetReason: nextTarget.openReason, - firstTargetSuccessCriteria: nextTarget.successCriteria, - followUps, - topRiskGroups: groupedRows.slice(0, 5) + directory, + expectedSize, + files, + assets, + nonPngFiles, + extraFiles: [], + directoryIssues: nonPngFiles.map((name) => `${directory} contains a non-PNG file: ${name}`) }; } -function improvementTargetComplete(target) { - return ( - target.rows.length === target.itemIds.length && - target.rows.every((row) => row.icon && row.primitiveCount >= 18) && - new Set(target.rows.map((row) => row.signature)).size === target.rows.length - ); +function inspectPng(path, expectedSize) { + const bytes = statSync(path).size; + const buffer = readFileSync(path); + const signature = buffer.subarray(0, 8).toString('hex'); + const validSignature = signature === '89504e470d0a1a0a'; + const enoughHeader = buffer.length >= 26; + const width = enoughHeader ? buffer.readUInt32BE(16) : 0; + const height = enoughHeader ? buffer.readUInt32BE(20) : 0; + const bitDepth = enoughHeader ? buffer[24] : 0; + const colorType = enoughHeader ? buffer[25] : -1; + const issues = []; + + if (!validSignature || !enoughHeader) { + issues.push('invalid PNG header'); + } else { + if (width !== expectedSize || height !== expectedSize) { + issues.push(`expected ${expectedSize}x${expectedSize}, got ${width}x${height}`); + } + if (bitDepth !== 8 || colorType !== 6) { + issues.push(`expected RGBA8, got ${pngColorLabel(colorType, bitDepth)}`); + } + } + if (bytes <= 0) { + issues.push('empty file'); + } + + return { path, bytes, width, height, bitDepth, colorType, issues }; } -function renderReport({ iconSourceSize, iconTextureSize, itemRows, groupedRows, recommendations, sceneUsage }) { - const slotCounts = countBy(itemRows, (row) => row.slot); - const rankCounts = countBy(itemRows, (row) => row.rank); - const generatedCount = itemRows.filter((row) => row.icon).length; - const scale = (iconTextureSize / iconSourceSize).toFixed(2); +function collectAssetRows(catalogRows, directory128, directory32) { + const catalogIds = new Set(catalogRows.map((row) => row.itemId)); + directory128.extraFiles = directory128.files.filter((name) => { + const itemId = name.toLowerCase().endsWith('.png') ? name.slice(0, -4) : name; + return !catalogIds.has(itemId); + }); + directory32.extraFiles = directory32.files.filter((name) => { + const itemId = name.toLowerCase().endsWith('.png') ? name.slice(0, -4) : name; + return !catalogIds.has(itemId); + }); + + return catalogRows.map((catalogRow) => { + const icon128 = directory128.assets.get(catalogRow.itemId); + const icon32 = directory32.assets.get(catalogRow.itemId); + const issues = []; + if (!icon128) { + issues.push('missing 128px PNG'); + } else { + issues.push(...icon128.issues.map((issue) => `128px ${issue}`)); + } + if (!icon32) { + issues.push('missing 32px PNG'); + } else { + issues.push(...icon32.issues.map((issue) => `32px ${issue}`)); + } + if (!icon128 || !icon32) { + issues.push('incomplete 128/32 pair'); + } + return { ...catalogRow, icon128, icon32, issues }; + }); +} + +function inspectBootContract(source, catalogIds) { + const fallbackIds = [...source.matchAll(/this\.createItemIcon\('item-([^']+)'/g)].map((match) => match[1]); + const missingFallbackIds = catalogIds.filter((itemId) => !fallbackIds.includes(itemId)); + const extraFallbackIds = fallbackIds.filter((itemId) => !catalogIds.includes(itemId)); + const checks = [ + { + name: '128px source glob', + pass: /import\.meta\.glob\('\.\.\/\.\.\/assets\/images\/ui\/equipment-icons\/128\/\*\.png'/.test(source), + detail: '`equipment-icons/128/*.png` is imported eagerly' + }, + { + name: '32px source glob', + pass: /import\.meta\.glob\('\.\.\/\.\.\/assets\/images\/ui\/equipment-icons\/32\/\*\.png'/.test(source), + detail: '`equipment-icons/32/*.png` is imported eagerly' + }, + { + name: 'full texture preload', + pass: /preloadEquipmentIcons\(equipmentIcon128Modules,\s*''\)/.test(source), + detail: '128px files load as `item-${id}`' + }, + { + name: 'micro texture preload', + pass: /preloadEquipmentIcons\(equipmentIcon32Modules,\s*'-micro'\)/.test(source), + detail: '32px files load as `item-${id}-micro`' + }, + { + name: 'catalog-derived texture key', + pass: /this\.load\.image\(`item-\$\{itemId\}\$\{suffix\}`,\s*url\)/.test(source), + detail: 'file stem becomes the stable item texture id' + }, + { + name: 'procedural fallback guard', + pass: /private\s+createItemIcon\([\s\S]{0,260}?if\s*\(this\.textures\.exists\(key\)\)\s*\{\s*return;\s*\}/.test(source), + detail: 'procedural full icon is created only when the loaded texture key is absent' + }, + { + name: 'procedural fallback coverage', + pass: missingFallbackIds.length === 0 && extraFallbackIds.length === 0, + detail: `${fallbackIds.length}/${catalogIds.length} catalog ids have a base-key fallback` + } + ]; + + return { + fallbackIds, + missingFallbackIds, + extraFallbackIds, + checks, + issues: [ + ...checks.filter((check) => !check.pass).map((check) => `Boot preload contract failed: ${check.name}`), + ...missingFallbackIds.map((itemId) => `Boot procedural fallback missing: ${itemId}`), + ...extraFallbackIds.map((itemId) => `Boot procedural fallback is uncataloged: ${itemId}`) + ] + }; +} + +function inspectCampUsage(source) { + const lines = source.split(/\r?\n/); + const usages = []; + let currentMethod = '(scene body)'; + + lines.forEach((line, index) => { + const methodMatch = line.match(/^\s*(?:private|public|protected)\s+([A-Za-z][A-Za-z0-9_]*)\s*\(/); + if (methodMatch) { + currentMethod = methodMatch[1]; + } + const imageMatch = line.match(/this\.add\.image\(.+?`item-\$\{([^}]+)\}(-micro)?`\)/); + if (!imageMatch) { + return; + } + + const nearbySource = lines.slice(index, index + 7).join(' '); + const displayMatch = nearbySource.match( + /setDisplaySize\(this\.campUiLength\(([^)]+)\),\s*this\.campUiLength\(([^)]+)\)\)/ + ); + usages.push({ + line: index + 1, + method: currentMethod, + itemExpression: imageMatch[1], + tier: imageMatch[2] ? 'micro' : 'full', + displaySize: displayMatch + ? displayMatch[1].trim() === displayMatch[2].trim() + ? displayMatch[1].trim() + : `${displayMatch[1].trim()} x ${displayMatch[2].trim()}` + : 'not detected' + }); + }); + + const microUsages = usages.filter((usage) => usage.tier === 'micro'); + const fullUsages = usages.filter((usage) => usage.tier === 'full'); + const issues = []; + if (microUsages.length === 0) { + issues.push('CampScene has no `item-${id}-micro` render usage'); + } + if (fullUsages.length === 0) { + issues.push('CampScene has no `item-${id}` full render usage'); + } + usages.forEach((usage) => { + const numericSize = /^\d+$/.test(usage.displaySize) ? Number(usage.displaySize) : undefined; + if (usage.tier === 'micro' && numericSize !== undefined && numericSize > 32) { + issues.push(`CampScene line ${usage.line} uses a micro texture at ${numericSize}px`); + } + if (usage.displaySize === 'not detected') { + issues.push(`CampScene line ${usage.line} has no detected square display size`); + } + }); + + return { usages, microUsages, fullUsages, issues }; +} + +function renderReport({ catalogRows, assetRows, directory128, directory32, bootAudit, campAudit, issues }) { + const pairedCount = assetRows.filter((row) => row.icon128 && row.icon32).length; + const validCount = assetRows.filter((row) => row.issues.length === 0).length; + const total128Bytes = sumBytes(assetRows.map((row) => row.icon128)); + const total32Bytes = sumBytes(assetRows.map((row) => row.icon32)); + const missing128 = assetRows.filter((row) => !row.icon128).map((row) => row.itemId); + const missing32 = assetRows.filter((row) => !row.icon32).map((row) => row.itemId); return `${[ '# Equipment Icon Quality Audit', '', - 'Generated by `scripts/audit-equipment-icon-quality.mjs`.', + 'Generated by `scripts/audit-equipment-icon-quality.mjs` from the current catalog, PNG headers, and runtime source.', '', - '## Summary', + '## Result', '', - `- Catalog items: ${itemRows.length}`, - `- Generated procedural item textures: ${generatedCount}`, - `- Slots: ${formatCounts(slotCounts)}`, - `- Ranks: ${formatCounts(rankCounts)}`, - `- Texture size: ${iconTextureSize}px`, - `- Drawing grid: ${iconSourceSize} units scaled ${scale}x into the texture`, - `- Runtime item icon scene: \`${sceneUsage.scene}\``, - `- Observed runtime display sizes: ${sceneUsage.displaySizes.map((size) => `\`${size}px\``).join(', ') || 'none found'}`, - `- Direct item texture render sites found: ${sceneUsage.directItemImageCount}`, + `- Status: **${issues.length === 0 ? 'PASS' : 'FAIL'}**`, + `- Catalog contract: ${catalogRows.length}/${expectedCatalogCount} items`, + `- Complete 128/32 pairs: ${pairedCount}/${catalogRows.length}`, + `- Fully valid catalog rows: ${validCount}/${catalogRows.length}`, + `- 128px payload: ${formatBytes(total128Bytes)} across ${directory128.assets.size} PNG files`, + `- 32px payload: ${formatBytes(total32Bytes)} across ${directory32.assets.size} PNG files`, `- Browser visual QA baseline: \`${desktopBrowserViewportLabel}\` CSS viewport at 100% zoom`, '', - '## Main Findings', + '## Asset Coverage', '', - '- Equipment item icons are generated at runtime in `BootScene`, not stored as individual raster images.', - '- The 64px textures are drawn from a compact 28-unit source grid, so they are crisp but intentionally simple.', - '- Battle combat preview mostly uses the high-resolution `battle-ui-icons` slot frames and item names; per-item textures appear primarily in camp/sortie equipment UI.', - '- Treasure/common distinction often relies on UI frame color plus a small palette change, not a strong unique silhouette.', - '- Reused helper silhouettes are the strongest quality risk because several different items collapse to the same shape at 14-22px.', + `- 128px missing: ${formatIds(missing128)}`, + `- 32px missing: ${formatIds(missing32)}`, + `- 128px uncataloged/extra: ${formatFileNames(directory128.extraFiles)}`, + `- 32px uncataloged/extra: ${formatFileNames(directory32.extraFiles)}`, + '- Required PNG format: exact square dimensions, 8-bit RGBA (PNG color type 6), non-empty file.', '', - ...renderCompletedTargets(recommendations.completedTargets), - '## Next Improvement Target', + '## Boot Loading Contract', '', - `- Target: ${recommendations.firstTargetTitle}`, - `- Item ids: ${formatItemIds(recommendations.firstTargetIds)}`, - `- Reason: ${recommendations.firstTargetReason}`, - '', - ...renderFollowUpTargets(recommendations.followUps), - '## Highest-Risk Shared Silhouette Groups', - '', - '| score | signature | slots | ranks | items |', - '| ---: | --- | --- | --- | --- |', - ...recommendations.topRiskGroups.map( - (group) => - `| ${group.score} | \`${group.signature}\` | ${group.slots.join(', ')} | ${group.ranks.join(', ')} | ${group.itemIds.map((itemId) => `\`${itemId}\``).join(', ')} |` + '| check | result | contract |', + '| --- | --- | --- |', + ...bootAudit.checks.map( + (check) => `| ${check.name} | ${check.pass ? 'PASS' : 'FAIL'} | ${check.detail} |` ), '', - '## Full Item Inventory', + 'The authored 128px files are the normal `item-${id}` textures and the authored 32px files are the `item-${id}-micro` textures. The existing procedural 64px drawing path is retained only as a **loaded-base-texture-missing fallback**: its guard leaves a successfully loaded authored base texture untouched. Micro textures are static assets, so their completeness is enforced by this audit rather than by the procedural fallback.', '', - '| item id | slot | rank | draw helpers | calls | primitives | colors | concerns |', - '| --- | --- | --- | --- | ---: | ---: | ---: | --- |', - ...itemRows.map((row) => - [ - `\`${row.itemId}\``, - row.slot, - row.rank, - row.drawFunctions.map((name) => `\`${name}\``).join('
') || 'missing', - row.drawCalls, - row.primitiveCount, - row.colorCount, - row.concerns.join('
') || 'none' - ].join(' | ') - ).map((line) => `| ${line} |`), + '## Camp Render Usage', '', - ...renderRecommendedNextBatch(recommendations) + `- Micro render sites: ${campAudit.microUsages.length}`, + `- Full render sites: ${campAudit.fullUsages.length}`, + '', + '| tier | source location | method | key expression | requested display size |', + '| --- | --- | --- | --- | --- |', + ...campAudit.usages.map( + (usage) => + `| ${usage.tier} | \`${campScenePath}:${usage.line}\` | \`${usage.method}\` | \`item-\${${usage.itemExpression}}${usage.tier === 'micro' ? '-micro' : ''}\` | ${formatDisplaySize(usage.displaySize)} |` + ), + '', + 'Micro textures are used for compact sortie, inventory, and equipped-item rows. Full textures are used for larger equipment cards and the compare/confirm panel.', + '', + '## Full Catalog Inventory', + '', + '| item id | slot | rank | 128px PNG | bytes | 32px PNG | bytes | pair | concerns |', + '| --- | --- | --- | --- | ---: | --- | ---: | --- | --- |', + ...assetRows.map( + (row) => + `| \`${row.itemId}\` | ${row.slot} | ${row.rank} | ${formatPng(row.icon128)} | ${formatAssetBytes(row.icon128)} | ${formatPng(row.icon32)} | ${formatAssetBytes(row.icon32)} | ${row.icon128 && row.icon32 ? 'yes' : 'no'} | ${row.issues.join('
') || 'none'} |` + ), + '', + '## Findings', + '', + ...(issues.length === 0 + ? [ + '- All 23 catalog items have valid, paired 128px and 32px RGBA8 PNG assets.', + '- File stems map to stable Boot texture keys for both full and micro tiers.', + '- Camp compact rows use micro textures while larger cards and comparison panels use full textures.', + '- Procedural icons remain a load-failure fallback for base item keys, not the normal rendered asset path.' + ] + : issues.map((issue) => `- FAIL: ${issue}`)), + '' ].join('\n')}`; } -function formatItemIds(itemIds) { - return itemIds.length ? itemIds.map((itemId) => `\`${itemId}\``).join(', ') : 'none currently flagged'; +function pngColorLabel(colorType, bitDepth) { + const labels = new Map([ + [0, 'grayscale'], + [2, 'RGB'], + [3, 'indexed'], + [4, 'grayscale+alpha'], + [6, 'RGBA'] + ]); + return `${labels.get(colorType) ?? `color-type-${colorType}`}${bitDepth}`; } -function renderRecommendedNextBatch(recommendations) { - if (!recommendations.firstTargetIds.length) { - return [ - '## Recommended Next Batch Definition', - '', - '- Scope: no generated equipment icon is currently below the audit threshold.', - '- Item ids: none currently flagged', - `- Success criteria: ${recommendations.firstTargetSuccessCriteria}`, - '- Suggested next area: map/background or unit sprite assets that still look low-resolution in gameplay.', - '' - ]; - } - - return [ - '## Recommended Next Batch Definition', - '', - `- Scope: improve ${recommendations.firstTargetTitle} while keeping \`item-\${itemId}\` texture keys stable.`, - `- Item ids: ${formatItemIds(recommendations.firstTargetIds)}`, - `- Success criteria: ${recommendations.firstTargetSuccessCriteria}`, - '- Keep the existing 64px texture size unless a UI-wide equipment icon pass is planned.', - `- Add before/after contact sheets and verify the sortie equipment list and equipment swap panel in a desktop browser at the ${desktopBrowserViewportLabel} CSS viewport and 100% zoom.`, - '' - ]; +function formatPng(asset) { + return asset ? `${asset.width}x${asset.height} ${pngColorLabel(asset.colorType, asset.bitDepth)}` : 'missing'; } -function renderCompletedTargets(completedTargets) { - if (!completedTargets.length) { - return []; - } - - return [ - '## Completed Improvement Passes', - '', - ...completedTargets.flatMap((target) => [ - `- ${target.title}: ${target.itemIds.map((itemId) => `\`${itemId}\``).join(', ')}`, - ` - ${target.reason}` - ]), - '' - ]; +function formatAssetBytes(asset) { + return asset ? asset.bytes.toLocaleString('en-US') : '-'; } -function renderFollowUpTargets(followUps) { - if (!followUps.length) { - return []; - } - - return [ - '## Follow-Up Targets', - '', - ...followUps.flatMap((target) => [ - `- ${target.title}: ${target.itemIds.map((itemId) => `\`${itemId}\``).join(', ')}`, - ` - ${target.reason}` - ]), - '' - ]; +function formatDisplaySize(size) { + return /^\d+$/.test(size) ? `\`${size}px\`` : `\`${size}\``; } -function countBy(rows, keySelector) { - return rows.reduce((counts, row) => { - const key = keySelector(row); - counts.set(key, (counts.get(key) ?? 0) + 1); - return counts; - }, new Map()); +function sumBytes(assets) { + return assets.reduce((total, asset) => total + (asset?.bytes ?? 0), 0); } -function formatCounts(counts) { - return [...counts.entries()] - .sort(([left], [right]) => String(left).localeCompare(String(right))) - .map(([key, count]) => `${key} ${count}`) - .join(', '); +function formatBytes(bytes) { + return `${bytes.toLocaleString('en-US')} B (${(bytes / 1024).toFixed(1)} KiB)`; +} + +function formatIds(ids) { + return ids.length ? ids.map((id) => `\`${id}\``).join(', ') : 'none'; +} + +function formatFileNames(names) { + return names.length ? names.map((name) => `\`${name}\``).join(', ') : 'none'; } diff --git a/scripts/build-battle-ui-icons.py b/scripts/build-battle-ui-icons.py index d5696f4..7a93581 100644 --- a/scripts/build-battle-ui-icons.py +++ b/scripts/build-battle-ui-icons.py @@ -19,7 +19,8 @@ FRAME = 128 MICRO_FRAME = 32 SCALE = 4 COLS = 5 -ROWS = 6 +SEED_ROWS = 6 +ROWS = 8 SOURCE_FRAMES = { 4: ("attack", "attack-v2.png"), 16: ("hit", "hit-v2.png"), @@ -29,6 +30,16 @@ SOURCE_FRAMES = { 27: ("bean", "bean-v2.png"), 28: ("salve", "salve-v2.png"), 29: ("wine", "wine-v2.png"), + 30: ("confusion", "confusion-v2.png"), + 31: ("benevolent-command", "benevolent-command-v2.png"), + 32: ("azure-dragon-strike", "azure-dragon-strike-v2.png"), + 33: ("changban-roar", "changban-roar-v2.png"), + 34: ("single-rider-rescue", "single-rider-rescue-v2.png"), + 35: ("east-wind-fire", "east-wind-fire-v2.png"), + 36: ("hundred-pace-pierce", "hundred-pace-pierce-v2.png"), + 37: ("western-cavalry-charge", "western-cavalry-charge-v2.png"), + 38: ("qilin-stratagem", "qilin-stratagem-v2.png"), + 39: ("burn", "burn-v2.png"), } REPLACEMENT_FRAMES: dict[int, tuple[str, Callable[[Image.Image], Image.Image]]] = { 10: ("axe", lambda atlas: compose_axe_icon(atlas)), @@ -44,11 +55,12 @@ def main() -> None: raise FileNotFoundError(f"missing seed atlas: {seed_path}") seed_atlas = Image.open(seed_path).convert("RGBA") - expected_size = (COLS * FRAME, ROWS * FRAME) - if seed_atlas.size != expected_size: - raise ValueError(f"seed atlas must be {expected_size}, got {seed_atlas.size}: {seed_path}") + expected_seed_size = (COLS * FRAME, SEED_ROWS * FRAME) + if seed_atlas.size != expected_seed_size: + raise ValueError(f"seed atlas must be {expected_seed_size}, got {seed_atlas.size}: {seed_path}") - atlas = seed_atlas.copy() + atlas = Image.new("RGBA", (COLS * FRAME, ROWS * FRAME), (0, 0, 0, 0)) + atlas.alpha_composite(seed_atlas, (0, 0)) for index, (name, filename) in SOURCE_FRAMES.items(): tile = source_icon(SOURCE_DIR / filename) @@ -149,10 +161,11 @@ def micro_atlas_frame(atlas: Image.Image, index: int) -> Image.Image: def write_contact_sheet(seed_atlas: Image.Image, atlas: Image.Image, micro_atlas: Image.Image) -> None: card_width = 500 card_height = 220 - sheet = Image.new("RGB", (card_width * 4, 40 + card_height * 2), (8, 14, 20)) - draw = ImageDraw.Draw(sheet) - draw.text((14, 13), "Battle UI V2 action/item icons - before, HQ, runtime sizes", fill=(232, 220, 183)) labels = list(SOURCE_FRAMES.items()) + sheet_rows = math.ceil(len(labels) / 4) + sheet = Image.new("RGB", (card_width * 4, 40 + card_height * sheet_rows), (8, 14, 20)) + draw = ImageDraw.Draw(sheet) + draw.text((14, 13), "Battle UI V2 action/item/signature/status icons - before, HQ, runtime sizes", fill=(232, 220, 183)) runtime_sizes = (14, 20, 30, 34, 52, 70) for position, (index, (name, _filename)) in enumerate(labels): diff --git a/scripts/build-equipment-icons.py b/scripts/build-equipment-icons.py new file mode 100644 index 0000000..106a7a0 --- /dev/null +++ b/scripts/build-equipment-icons.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import argparse +from pathlib import Path + +from PIL import Image + + +ROOT = Path(__file__).resolve().parents[1] +DEFAULT_SOURCE_DIR = ROOT / "tmp" / "imagegen" +OUTPUT_ROOT = ROOT / "src" / "assets" / "images" / "ui" / "equipment-icons" + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Build centered 128px and 32px equipment icons from transparent source art." + ) + parser.add_argument("ids", nargs="+", help="Equipment ids whose -alpha.png source should be built.") + parser.add_argument("--source-dir", type=Path, default=DEFAULT_SOURCE_DIR) + return parser.parse_args() + + +def fit_icon(source: Image.Image, size: int) -> Image.Image: + rgba = source.convert("RGBA") + bounds = rgba.getchannel("A").getbbox() + if bounds is None: + raise ValueError("source image has no visible pixels") + + cropped = rgba.crop(bounds) + inner_size = max(1, round(size * 0.88)) + scale = min(inner_size / cropped.width, inner_size / cropped.height) + resized_size = ( + max(1, round(cropped.width * scale)), + max(1, round(cropped.height * scale)), + ) + resized = cropped.resize(resized_size, Image.Resampling.LANCZOS) + canvas = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + offset = ((size - resized.width) // 2, (size - resized.height) // 2) + canvas.alpha_composite(resized, offset) + return canvas + + +def main() -> None: + args = parse_args() + for size in (128, 32): + (OUTPUT_ROOT / str(size)).mkdir(parents=True, exist_ok=True) + + for item_id in args.ids: + source_path = args.source_dir / f"{item_id}-alpha.png" + if not source_path.exists(): + raise FileNotFoundError(f"missing transparent source: {source_path}") + + with Image.open(source_path) as source: + for size in (128, 32): + output_path = OUTPUT_ROOT / str(size) / f"{item_id}.png" + fit_icon(source, size).save(output_path, optimize=True) + print(f"Wrote {output_path.relative_to(ROOT)}") + + +if __name__ == "__main__": + main() diff --git a/scripts/measure-performance.mjs b/scripts/measure-performance.mjs index e1c75a7..0c88665 100644 --- a/scripts/measure-performance.mjs +++ b/scripts/measure-performance.mjs @@ -16,13 +16,15 @@ const legacyUiScale = 1.5; const budgets = { titleReadyMs: readBudget('PERF_BUDGET_TITLE_READY_MS', 30000), storyReadyFromNewGameMs: readBudget('PERF_BUDGET_STORY_READY_MS', 30000), - firstBattleReadyFromStoryMs: readBudget('PERF_BUDGET_BATTLE_READY_MS', 60000), - firstBattlePlayableFromStoryMs: readBudget('PERF_BUDGET_BATTLE_PLAYABLE_MS', 70000), + firstBattleReadyFromStoryMs: readBudget('PERF_BUDGET_BATTLE_READY_MS', 6000), + firstBattlePlayableFromStoryMs: readBudget('PERF_BUDGET_BATTLE_PLAYABLE_MS', 25000), firstLoadEncodedKB: readBudget('PERF_BUDGET_FIRST_LOAD_KB', 10000), storyTransitionEncodedKB: readBudget('PERF_BUDGET_STORY_TRANSITION_KB', 15000), + battleEntryTransitionEncodedKB: readBudget('PERF_BUDGET_BATTLE_ENTRY_KB', 45000), battleTransitionEncodedKB: readBudget('PERF_BUDGET_BATTLE_TRANSITION_KB', 110000), storyBattleTransitionEncodedKB: readBudget('PERF_BUDGET_STORY_BATTLE_TRANSITION_KB', 125000), storyActionSheetRequests: 0, + battleEntryActionSheetRequests: 0, largestJsKB: readBudget('PERF_BUDGET_LARGEST_JS_KB', 2500) }; @@ -97,26 +99,52 @@ try { const storyMs = Date.now() - storyStartedAt; const storyEntries = await resourceEntries(page); - const battleStartedAt = Date.now(); - await advanceStoryUntilBattle(page, 'first-battle-zhuo-commandery'); + const battleTransitionStart = await advanceStoryUntilBattle(page, 'first-battle-zhuo-commandery'); + const battleStartedAt = battleTransitionStart.startedAt; const battleReadyState = await waitForBattleReady(page, 'first-battle-zhuo-commandery'); - await page.waitForLoadState('networkidle', { timeout: 120000 }); - const battleMs = Date.now() - battleStartedAt; - const battleEntries = await resourceEntries(page); + const battleReadyMs = Date.now() - battleStartedAt; + const battleReadyEntries = await resourceEntries(page); - let firstBattlePlayableMs = battleMs; + let firstBattlePlayableMs = battleReadyMs; + let battlePlayableState = battleReadyState; if (battleReadyState.phase === 'deployment') { - const playableStartedAt = Date.now(); await clickBattleDeploymentStart(page); - await waitForBattlePlayable(page, 'first-battle-zhuo-commandery'); - firstBattlePlayableMs += Date.now() - playableStartedAt; + battlePlayableState = await waitForBattlePlayable(page, 'first-battle-zhuo-commandery'); + firstBattlePlayableMs = Date.now() - battleStartedAt; } + assert( + battleReadyState.combatAssets?.baseReady === true && battleReadyState.combatAssets?.actionReady === false, + `Expected deployment to open from base sheets before action sheets: ${JSON.stringify(battleReadyState)}` + ); + assert( + battlePlayableState.combatAssets?.status === 'ready' || battlePlayableState.combatAssets?.status === 'degraded', + `Expected combat assets to settle before battle play: ${JSON.stringify(battlePlayableState)}` + ); + if (battlePlayableState.combatAssets?.status === 'ready') { + assert( + battlePlayableState.combatAssets.actionReady === true && battlePlayableState.combatAssets.portraitReady === true, + `Expected all deferred combat assets before battle play: ${JSON.stringify(battlePlayableState)}` + ); + } + await page.waitForLoadState('networkidle', { timeout: 120000 }); + const battleEntries = await resourceEntries(page); + const combatAssetStallFallback = measureRenderer === 'webgl' + ? { + action: await verifyCombatAssetStallFallback(browser, targetUrl, 'action'), + portrait: await verifyCombatAssetStallFallback(browser, targetUrl, 'portrait') + } + : null; const titleNames = new Set(titleEntries.map((entry) => entry.name)); - const storyNames = new Set(storyEntries.map((entry) => entry.name)); + const preBattleNames = new Set(battleTransitionStart.resourceEntries.map((entry) => entry.name)); + const battleReadyNames = new Set(battleReadyEntries.map((entry) => entry.name)); const storyOnly = storyEntries.filter((entry) => !titleNames.has(entry.name)); - const battleOnly = battleEntries.filter((entry) => !storyNames.has(entry.name)); + const battleEntryOnly = battleReadyEntries.filter((entry) => !preBattleNames.has(entry.name)); + const battleOnly = battleEntries.filter((entry) => !preBattleNames.has(entry.name)); + const battleDeferredOnly = battleOnly.filter((entry) => !battleReadyNames.has(entry.name)); const storyTransition = segmentStats(storyOnly); + const battleEntryTransition = segmentStats(battleEntryOnly); + const battleDeferredTransition = segmentStats(battleDeferredOnly); const battleTransition = segmentStats(battleOnly); const report = { targetUrl, @@ -127,17 +155,26 @@ try { timingsMs: { titleReady: titleMs, storyReadyFromNewGame: storyMs, - firstBattleReadyFromStory: battleMs, + firstBattleReadyFromStory: battleReadyMs, firstBattlePlayableFromStory: firstBattlePlayableMs }, build: buildStats(), firstLoad: segmentStats(titleEntries), storyTransition, + battleEntryTransition, + battleDeferredTransition, battleTransition, assetPolicy: { storyActionSheetRequests: actionSheetFiles(storyOnly), + battleEntryActionSheetRequests: actionSheetFiles(battleEntryOnly), + battleDeferredActionSheetRequests: actionSheetFiles(battleDeferredOnly), battleActionSheetRequests: actionSheetFiles(battleOnly) - } + }, + battleAssetStreaming: { + entry: battleReadyState.combatAssets, + playable: battlePlayableState.combatAssets + }, + combatAssetStallFallback }; report.budget = evaluateBudgets(report, budgets); @@ -147,6 +184,8 @@ try { timingsMs: report.timingsMs, firstLoad: report.firstLoad.summary, storyTransition: report.storyTransition.summary, + battleEntryTransition: report.battleEntryTransition.summary, + battleDeferredTransition: report.battleDeferredTransition.summary, battleTransition: report.battleTransition.summary, largestJs: report.build.javascript[0] ?? null, budget: report.budget.summary @@ -283,21 +322,36 @@ async function waitForStoryReady(page) { async function advanceStoryUntilBattle(page, battleId) { for (let i = 0; i < 48; i += 1) { - const ready = await page.evaluate((expectedBattleId) => { + const state = await page.evaluate((expectedBattleId) => { try { - const state = window.__HEROS_DEBUG__?.battle(); - return state?.scene === 'BattleScene' && state?.battleId === expectedBattleId; + const battle = window.__HEROS_DEBUG__?.battle(); + const story = window.__HEROS_DEBUG__?.scene('StoryScene')?.getDebugState?.(); + return { + battleReady: battle?.scene === 'BattleScene' && battle?.battleId === expectedBattleId, + storyReady: story?.ready === true, + storyLastPage: story?.isLastPage === true, + storyTargetsBattle: story?.nextScene === 'BattleScene', + currentPageReady: story?.assetStreaming?.currentPageReady === true + }; } catch { - return false; + return {}; } }, battleId); - if (ready) { - return; + if (state.battleReady) { + throw new Error('Battle became ready before the transition start could be measured.'); + } + if (state.storyReady && state.storyLastPage && state.storyTargetsBattle && state.currentPageReady) { + await page.waitForTimeout(600); + const entries = await resourceEntries(page); + const startedAt = Date.now(); + await page.keyboard.press('Space'); + return { startedAt, resourceEntries: entries }; } await page.keyboard.press('Space'); await page.waitForTimeout(220); } + throw new Error(`Story did not reach the battle transition for ${battleId}.`); } async function waitForBattleReady(page, battleId) { @@ -309,9 +363,10 @@ async function waitForBattleReady(page, battleId) { state?.battleId === expectedBattleId && (state?.phase === 'deployment' || state?.phase === 'idle') && state?.mapBackgroundReady === true && + state?.combatAssets?.baseReady === true && state?.resultVisible === false ) - ? { phase: state.phase } + ? { phase: state.phase, combatAssets: state.combatAssets } : false; } catch { return false; @@ -320,7 +375,7 @@ async function waitForBattleReady(page, battleId) { } async function waitForBattlePlayable(page, battleId) { - await page.waitForFunction((expectedBattleId) => { + return page.waitForFunction((expectedBattleId) => { try { const state = window.__HEROS_DEBUG__?.battle(); return ( @@ -328,13 +383,130 @@ async function waitForBattlePlayable(page, battleId) { state?.battleId === expectedBattleId && state?.phase === 'idle' && state?.mapBackgroundReady === true && + (state?.combatAssets?.status === 'ready' || state?.combatAssets?.status === 'degraded') && state?.triggeredBattleEvents?.includes('opening') && state?.resultVisible === false - ); + ) + ? { phase: state.phase, combatAssets: state.combatAssets } + : false; } catch { return false; } - }, battleId, { timeout: 30000 }); + }, battleId, { timeout: 30000 }).then((handle) => handle.jsonValue()); +} + +async function verifyCombatAssetStallFallback(browserInstance, baseUrl, kind) { + const context = await browserInstance.newContext(desktopBrowserContextOptions); + const pageErrors = []; + let stalledUrl = null; + const stallReleaseMs = 1800; + const watchdogMs = 450; + + try { + await context.addInitScript(() => window.localStorage.clear()); + const page = await context.newPage(); + page.on('pageerror', (error) => pageErrors.push(error.message)); + await page.route('**/*', async (route) => { + const url = route.request().url(); + const fileName = decodeURIComponent(url.split('/').pop()?.split('?')[0] ?? ''); + const shouldStall = kind === 'action' + ? /^unit-.*-actions-[^.]+\.png$/i.test(fileName) + : /^(?:liu-bei|guan-yu|zhang-fei)-[^.]+\.webp$/i.test(fileName); + const isSelectedStall = shouldStall && (!stalledUrl || stalledUrl === url); + if (isSelectedStall) { + stalledUrl ??= url; + await delay(stallReleaseMs); + await route.abort('timedout'); + return; + } + await route.continue(); + }); + + const battleUrl = new URL(baseUrl); + battleUrl.searchParams.set('debug', '1'); + battleUrl.searchParams.set('renderer', 'webgl'); + battleUrl.searchParams.set('debugBattle', 'first-battle-zhuo-commandery'); + battleUrl.searchParams.set('debugCombatAssetWatchdogMs', String(watchdogMs)); + battleUrl.searchParams.set('assetStallQa', kind); + await page.goto(battleUrl.toString(), { waitUntil: 'domcontentloaded' }); + const entry = await waitForBattleReady(page, 'first-battle-zhuo-commandery'); + assert( + entry.phase === 'deployment' && entry.combatAssets?.baseReady === true, + `Expected ${kind} stall QA to reach deployment from base sheets: ${JSON.stringify(entry)}` + ); + + await clickBattleDeploymentStart(page); + const playable = await waitForBattlePlayable(page, 'first-battle-zhuo-commandery'); + const settled = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.combatAssets ?? null); + assert(stalledUrl, `Expected ${kind} stall QA to intercept one deferred asset request.`); + assert( + playable.combatAssets?.status === 'degraded' && + settled?.pendingDeploymentConfirmation === false && + settled?.settlementCount === 1, + `Expected ${kind} stall watchdog to settle once and resume pending combat: ${JSON.stringify({ playable, settled, stalledUrl })}` + ); + + const fallback = await page.evaluate((assetKind) => { + const battle = window.__HEROS_DEBUG__?.battle(); + if (!battle) { + return null; + } + if (assetKind === 'action') { + const missing = new Set(battle.combatAssets?.missingActionKeys ?? []); + const unitTextures = (battle.units ?? []).map((unit) => ({ + id: unit.id, + textureBase: unit.textureBase, + actionTexture: unit.actionTexture + })); + return { + missingKeys: [...missing], + fallbackUnitIds: unitTextures + .filter((unit) => missing.has(unit.textureBase) && unit.actionTexture === unit.textureBase) + .map((unit) => unit.id), + unitTextures + }; + } + return { + missingKeys: [...(battle.combatAssets?.missingPortraitKeys ?? [])], + fallbackUnitIds: (battle.units ?? []) + .filter((unit) => unit.combatPortraitKey && !unit.combatPortraitReady) + .map((unit) => unit.id) + }; + }, kind); + assert( + fallback?.missingKeys?.length > 0 && fallback?.fallbackUnitIds?.length > 0, + `Expected ${kind} stall QA to expose a concrete fallback: ${JSON.stringify(fallback)}` + ); + + await page.waitForTimeout(stallReleaseMs + 350); + const afterLateFailure = await page.evaluate(() => { + const battle = window.__HEROS_DEBUG__?.battle(); + return { + phase: battle?.phase ?? null, + status: battle?.combatAssets?.status ?? null, + pending: battle?.combatAssets?.pendingDeploymentConfirmation ?? null, + settlementCount: battle?.combatAssets?.settlementCount ?? null + }; + }); + assert( + afterLateFailure.phase === 'idle' && + afterLateFailure.status === 'degraded' && + afterLateFailure.pending === false && + afterLateFailure.settlementCount === 1, + `Expected late ${kind} completion/error callbacks to remain inert: ${JSON.stringify(afterLateFailure)}` + ); + assert(pageErrors.length === 0, `Expected no page errors during ${kind} stall fallback: ${JSON.stringify(pageErrors)}`); + + return { + status: afterLateFailure.status, + settlementCount: afterLateFailure.settlementCount, + missingKeyCount: fallback.missingKeys.length, + fallbackUnitIds: fallback.fallbackUnitIds, + stalledFile: decodeURIComponent(stalledUrl.split('/').pop()?.split('?')[0] ?? '') + }; + } finally { + await context.close(); + } } async function resourceEntries(page) { @@ -430,9 +602,11 @@ function evaluateBudgets(report, budgetValues) { ['firstBattlePlayableFromStoryMs', report.timingsMs.firstBattlePlayableFromStory, budgetValues.firstBattlePlayableFromStoryMs], ['firstLoadEncodedKB', report.firstLoad.summary.encodedKB, budgetValues.firstLoadEncodedKB], ['storyTransitionEncodedKB', report.storyTransition.summary.encodedKB, budgetValues.storyTransitionEncodedKB], + ['battleEntryTransitionEncodedKB', report.battleEntryTransition.summary.encodedKB, budgetValues.battleEntryTransitionEncodedKB], ['battleTransitionEncodedKB', report.battleTransition.summary.encodedKB, budgetValues.battleTransitionEncodedKB], ['storyBattleTransitionEncodedKB', storyBattleTransitionEncodedKB, budgetValues.storyBattleTransitionEncodedKB], ['storyActionSheetRequests', report.assetPolicy.storyActionSheetRequests.length, budgetValues.storyActionSheetRequests], + ['battleEntryActionSheetRequests', report.assetPolicy.battleEntryActionSheetRequests.length, budgetValues.battleEntryActionSheetRequests], ['largestJsKB', largestJs, budgetValues.largestJsKB] ]; const failures = checks diff --git a/scripts/verify-campaign-recruit-data.mjs b/scripts/verify-campaign-recruit-data.mjs index 3558dc1..a19c6f6 100644 --- a/scripts/verify-campaign-recruit-data.mjs +++ b/scripts/verify-campaign-recruit-data.mjs @@ -44,6 +44,7 @@ try { validateReachability(errors, recruitTemplates, recruitRewardRefs); validateUniqueRecruitRewards(errors, recruitRewardRefs); validateSortieRecruitAvailability(errors, scenarioEntries); + validateEarlyJianYongChoice(errors, battleScenarios); if (errors.length) { console.error(`Campaign recruit data verification failed with ${errors.length} issue(s):`); @@ -214,6 +215,58 @@ function validateSortieRecruitAvailability(errors, scenarioEntries) { }); } +function validateEarlyJianYongChoice(errors, battleScenarios) { + const firstBattleId = 'first-battle-zhuo-commandery'; + const earlyChoiceBattleIds = [ + 'second-battle-yellow-turban-pursuit', + 'third-battle-guangzong-road', + 'fourth-battle-guangzong-camp', + 'fifth-battle-sishui-vanguard', + 'sixth-battle-jieqiao-relief', + 'seventh-battle-xuzhou-rescue' + ]; + const foundingTrio = ['liu-bei', 'guan-yu', 'zhang-fei']; + const earlyRoster = [...foundingTrio, 'jian-yong']; + + const firstScenario = battleScenarios[firstBattleId]; + if (!firstScenario?.campaignReward?.recruits?.includes('jian-yong')) { + errors.push(`${firstBattleId}: first victory must recruit jian-yong`); + } + const firstVictoryCopy = firstScenario?.victoryPages?.map((page) => `${page.text ?? ''} ${page.cutscene?.title ?? ''} ${page.cutscene?.subtitle ?? ''}`).join(' ') ?? ''; + if (!firstVictoryCopy.includes('간옹') || !firstVictoryCopy.includes('최대 3명')) { + errors.push(`${firstBattleId}: victory story must explain Jian Yong's arrival and the four-officer/three-slot choice`); + } + + earlyChoiceBattleIds.forEach((battleId) => { + const scenario = battleScenarios[battleId]; + if (!scenario) { + errors.push(`${battleId}: missing early-choice battle scenario`); + return; + } + const allyIds = new Set(scenario.units.filter((unit) => unit.faction === 'ally').map((unit) => unit.id)); + earlyRoster.forEach((unitId) => { + if (!allyIds.has(unitId)) { + errors.push(`${battleId}: early 4-of-3 roster is missing ${unitId}`); + } + }); + if (scenario.sortie?.sortieLimit !== 3) { + errors.push(`${battleId}: early formation must keep sortieLimit 3, got ${scenario.sortie?.sortieLimit}`); + } + const recommendedIds = new Set((scenario.sortie?.recommendedUnits ?? []).map((entry) => entry.unitId)); + if (recommendedIds.size !== foundingTrio.length || foundingTrio.some((unitId) => !recommendedIds.has(unitId))) { + errors.push(`${battleId}: recommended formation must remain the founding trio`); + } + if (!scenario.bonds.some((bond) => bond.id === 'liu-bei__jian-yong')) { + errors.push(`${battleId}: missing liu-bei__jian-yong resonance`); + } + }); + + const seventhRecruits = battleScenarios['seventh-battle-xuzhou-rescue']?.campaignReward?.recruits ?? []; + if (seventhRecruits.includes('jian-yong') || !seventhRecruits.includes('mi-zhu')) { + errors.push('seventh-battle-xuzhou-rescue: must recruit only the new Xuzhou officer Mi Zhu, not Jian Yong again'); + } +} + 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/scripts/verify-campaign-save-normalization.mjs b/scripts/verify-campaign-save-normalization.mjs index ecc4630..c1b8a00 100644 --- a/scripts/verify-campaign-save-normalization.mjs +++ b/scripts/verify-campaign-save-normalization.mjs @@ -644,7 +644,7 @@ try { const crossBattleResonance = Object.entries(battleScenarios) .filter(([battleId]) => battleId !== 'first-battle-zhuo-commandery') .flatMap(([battleId, scenario]) => scenario.bonds.map((bond) => ({ battleId, bond }))) - .find(({ bond }) => bond.level >= 30 && !firstBattleBondIds.has(bond.id)); + .find(({ bond }) => bond.level >= 30 && bond.id !== 'liu-bei__jian-yong' && !firstBattleBondIds.has(bond.id)); assert(crossBattleResonance, 'Expected campaign scenarios to expose a later-battle-only eligible bond fixture.'); const crossBattleResonanceFixture = { ...coreResonanceFixture, @@ -693,10 +693,10 @@ try { ); const reloadedScenarioOnlyResonance = loadCampaignState(); assert( - normalizedScenarioOnlyResonance.bonds.length === 0 && + !normalizedScenarioOnlyResonance.bonds.some((bond) => bond.id === crossBattleResonance.bond.id) && normalizedScenarioOnlyResonance.sortieResonanceSelection?.bondId === crossBattleResonance.bond.id && selectedScenarioOnlyResonance.sortieResonanceSelection?.battleId === crossBattleResonance.battleId && - reloadedScenarioOnlyResonance.bonds.length === 0 && + !reloadedScenarioOnlyResonance.bonds.some((bond) => bond.id === crossBattleResonance.bond.id) && reloadedScenarioOnlyResonance.sortieResonanceSelection?.bondId === crossBattleResonance.bond.id, `Expected a scenario-owned eligible bond to remain selectable and reload safely before campaign progress contains it: ${JSON.stringify({ normalizedScenarioOnlyResonance, selectedScenarioOnlyResonance, reloadedScenarioOnlyResonance })}` ); @@ -848,6 +848,87 @@ try { completedCampVisits: [], createdAt: '2026-07-03T09:00:00.000Z' }; + setFirstBattleReport(firstBattleReport); + const preJianYongSave = JSON.parse(storage.get(campaignStorageKey)); + preJianYongSave.roster = preJianYongSave.roster.filter((unit) => unit.id !== 'jian-yong'); + preJianYongSave.bonds = preJianYongSave.bonds.filter((bond) => bond.id !== 'liu-bei__jian-yong'); + preJianYongSave.firstBattleReport.bonds = preJianYongSave.firstBattleReport.bonds.filter((bond) => bond.id !== 'liu-bei__jian-yong'); + preJianYongSave.firstBattleReport.campaignRewards.recruits = preJianYongSave.firstBattleReport.campaignRewards.recruits.filter((recruit) => recruit.unitId !== 'jian-yong'); + preJianYongSave.battleHistory[firstScenario.id].bonds = preJianYongSave.battleHistory[firstScenario.id].bonds.filter((bond) => bond.id !== 'liu-bei__jian-yong'); + preJianYongSave.battleHistory[firstScenario.id].campaignRewards.recruits = preJianYongSave.battleHistory[firstScenario.id].campaignRewards.recruits.filter((recruit) => recruit.unitId !== 'jian-yong'); + storage.set(campaignStorageKey, JSON.stringify(preJianYongSave)); + const repairedEarlyChoiceSave = loadCampaignState(); + const persistedEarlyChoiceSave = saveCampaignState(repairedEarlyChoiceSave); + assert( + persistedEarlyChoiceSave.roster.filter((unit) => unit.id === 'jian-yong').length === 1 && + persistedEarlyChoiceSave.bonds.filter((bond) => bond.id === 'liu-bei__jian-yong').length === 1 && + persistedEarlyChoiceSave.firstBattleReport?.bonds.filter((bond) => bond.id === 'liu-bei__jian-yong').length === 1 && + persistedEarlyChoiceSave.firstBattleReport?.campaignRewards?.recruits.filter((recruit) => recruit.unitId === 'jian-yong').length === 1 && + persistedEarlyChoiceSave.battleHistory[firstScenario.id]?.bonds.filter((bond) => bond.id === 'liu-bei__jian-yong').length === 1 && + persistedEarlyChoiceSave.battleHistory[firstScenario.id]?.campaignRewards?.recruits.filter((recruit) => recruit.unitId === 'jian-yong').length === 1, + `Expected pre-Jian-Yong first-victory saves to backfill one roster member, reward, and resonance everywhere: ${JSON.stringify(persistedEarlyChoiceSave)}` + ); + + resetCampaignState(); + const preservedGlobalBondCount = 157; + const preservedGlobalBonds = Array.from({ length: preservedGlobalBondCount }, (_, index) => ({ + id: `legacy-global-bond-${index}`, + unitIds: ['liu-bei', 'guan-yu'], + title: `Legacy global bond ${index}`, + level: 1, + exp: index, + description: `Late-campaign global bond fixture ${index}`, + battleExp: 0 + })); + const rewardRecruitLimit = 128; + const preservedRewardRecruits = Array.from({ length: rewardRecruitLimit }, (_, index) => ({ + unitId: `legacy-reward-recruit-${index}`, + name: `Legacy reward recruit ${index}` + })); + const largePreJianYongSave = JSON.parse(JSON.stringify(preJianYongSave)); + largePreJianYongSave.bonds = preservedGlobalBonds; + largePreJianYongSave.firstBattleReport.bonds = preservedGlobalBonds.slice(0, 96); + largePreJianYongSave.battleHistory[firstScenario.id].bonds = preservedGlobalBonds.slice(0, 96); + largePreJianYongSave.firstBattleReport.campaignRewards.recruits = preservedRewardRecruits; + largePreJianYongSave.battleHistory[firstScenario.id].campaignRewards.recruits = preservedRewardRecruits; + const originalFirstScenarioUnitCount = firstScenario.units.length; + firstScenario.units.push(...preservedRewardRecruits.map((recruit) => ({ id: recruit.unitId, faction: 'ally' }))); + let repairedLargeEarlyChoiceSave; + try { + storage.set(campaignStorageKey, JSON.stringify(largePreJianYongSave)); + repairedLargeEarlyChoiceSave = saveCampaignState(loadCampaignState()); + } finally { + firstScenario.units.splice(originalFirstScenarioUnitCount); + } + const repairedLargeGlobalBondIds = new Set(repairedLargeEarlyChoiceSave.bonds.map((bond) => bond.id)); + assert( + repairedLargeEarlyChoiceSave.bonds.length === preservedGlobalBondCount + 1 && + preservedGlobalBonds.every((bond) => repairedLargeGlobalBondIds.has(bond.id)) && + repairedLargeEarlyChoiceSave.bonds.filter((bond) => bond.id === 'liu-bei__jian-yong').length === 1, + `Expected all ${preservedGlobalBondCount} late-campaign global bonds to survive Jian Yong backfill: ${JSON.stringify(repairedLargeEarlyChoiceSave.bonds)}` + ); + assert( + repairedLargeEarlyChoiceSave.firstBattleReport?.bonds.length === 96 && + repairedLargeEarlyChoiceSave.firstBattleReport.bonds.some((bond) => bond.id === 'liu-bei__jian-yong') && + repairedLargeEarlyChoiceSave.battleHistory[firstScenario.id]?.bonds.length === 96 && + repairedLargeEarlyChoiceSave.battleHistory[firstScenario.id].bonds.some((bond) => bond.id === 'liu-bei__jian-yong'), + `Expected report and settlement bond snapshots to retain their 96-entry cap while adding Jian Yong: ${JSON.stringify({ report: repairedLargeEarlyChoiceSave.firstBattleReport?.bonds, settlement: repairedLargeEarlyChoiceSave.battleHistory[firstScenario.id]?.bonds })}` + ); + const repairedReportRecruits = repairedLargeEarlyChoiceSave.firstBattleReport?.campaignRewards?.recruits ?? []; + const repairedSettlementRecruits = repairedLargeEarlyChoiceSave.battleHistory[firstScenario.id]?.campaignRewards?.recruits ?? []; + const preservedRewardRecruitIds = new Set(preservedRewardRecruits.slice(0, rewardRecruitLimit - 1).map((recruit) => recruit.unitId)); + assert( + repairedReportRecruits.length === rewardRecruitLimit && + repairedSettlementRecruits.length === rewardRecruitLimit && + repairedReportRecruits.filter((recruit) => recruit.unitId === 'jian-yong').length === 1 && + repairedSettlementRecruits.filter((recruit) => recruit.unitId === 'jian-yong').length === 1 && + [...preservedRewardRecruitIds].every((unitId) => repairedReportRecruits.some((recruit) => recruit.unitId === unitId)) && + [...preservedRewardRecruitIds].every((unitId) => repairedSettlementRecruits.some((recruit) => recruit.unitId === unitId)) && + !repairedReportRecruits.some((recruit) => recruit.unitId === preservedRewardRecruits[rewardRecruitLimit - 1].unitId) && + !repairedSettlementRecruits.some((recruit) => recruit.unitId === preservedRewardRecruits[rewardRecruitLimit - 1].unitId), + `Expected 128-entry reward recruit snapshots to preserve 127 valid entries and append required Jian Yong without exceeding the schema cap: ${JSON.stringify({ report: repairedReportRecruits, settlement: repairedSettlementRecruits })}` + ); + resetCampaignState(); const performanceUnit = alliedFirstBattleUnits.find((unit) => unit.id === 'liu-bei') ?? alliedFirstBattleUnits[0]; const recommendedButNotDeployedUnit = alliedFirstBattleUnits.find((unit) => unit.id !== performanceUnit?.id); assert(performanceUnit, 'Expected an allied first-battle unit for sortie performance clone checks'); @@ -971,10 +1052,10 @@ try { ); const individualizedReserveBond = individualizedReserveTraining.bonds.find((bond) => bond.id === 'reserve-drill-bond'); assert( - individualizedReserveAward?.focusId === 'bond-practice' && + individualizedReserveAward?.focusId === 'bond-practice' && individualizedReserveAward.expGained === 5 && individualizedReserveAward.equipmentExpGained === 1 && - individualizedReserveAward.bondExpGained === 4 && + individualizedReserveAward.bondExpGained === 8 && individualizedReserveBond?.exp === 4, `Expected per-officer reserve drill assignments to override the global focus and apply bond growth during battle settlement: ${JSON.stringify(individualizedReserveTraining)}` ); @@ -1754,8 +1835,10 @@ try { ); const staleReportRefs = loadCampaignState(); assert( - staleReportRefs.firstBattleReport?.bonds.length === 1 && - staleReportRefs.firstBattleReport.bonds[0].id === 'liu-bei__guan-yu' && + staleReportRefs.firstBattleReport?.bonds.length === 2 && + staleReportRefs.firstBattleReport.bonds.some((bond) => bond.id === 'liu-bei__guan-yu') && + staleReportRefs.firstBattleReport.bonds.some((bond) => bond.id === 'liu-bei__jian-yong') && + !staleReportRefs.firstBattleReport.bonds.some((bond) => bond.id === 'liu-bei__ghost') && staleReportRefs.firstBattleReport.mvp === undefined && JSON.stringify(staleReportRefs.firstBattleReport.sortiePerformance?.selectedUnitIds) === JSON.stringify(['liu-bei']) && JSON.stringify(staleReportRefs.firstBattleReport.sortiePerformance?.formationAssignments) === JSON.stringify({ 'liu-bei': 'front' }) && @@ -2015,10 +2098,11 @@ try { malformedReport.firstBattleReport.campaignRewards?.supplies[0] === 'Bean' && malformedReport.firstBattleReport.campaignRewards?.equipment.length === 1 && malformedReport.firstBattleReport.campaignRewards?.reputation.length === 1 && - malformedReport.firstBattleReport.campaignRewards?.recruits.length === 1 && + malformedReport.firstBattleReport.campaignRewards?.recruits.length === 2 && malformedReport.firstBattleReport.campaignRewards?.recruits[0].unitId === 'guan-yu' && malformedReport.firstBattleReport.campaignRewards?.recruits[0].name === 'Guan Yu' && malformedReport.firstBattleReport.campaignRewards?.recruits.filter((recruit) => recruit.unitId === 'guan-yu').length === 1 && + malformedReport.firstBattleReport.campaignRewards?.recruits.filter((recruit) => recruit.unitId === 'jian-yong').length === 1 && malformedReport.firstBattleReport.campaignRewards?.unlocks.length === 1 && malformedReport.firstBattleReport.campaignRewards?.unlocks[0].battleId === 'second-battle-yellow-turban-pursuit' && malformedReport.firstBattleReport.campaignRewards?.unlocks[0].title === 'Next' && @@ -2259,8 +2343,9 @@ try { assert( malformedHistory.battleHistory['first-battle-zhuo-commandery']?.itemRewards.length === 1 && malformedHistory.battleHistory['first-battle-zhuo-commandery']?.campaignRewards?.equipment[0] === 'Iron Sword' && - malformedHistory.battleHistory['first-battle-zhuo-commandery']?.campaignRewards?.recruits.length === 1 && + malformedHistory.battleHistory['first-battle-zhuo-commandery']?.campaignRewards?.recruits.length === 2 && malformedHistory.battleHistory['first-battle-zhuo-commandery']?.campaignRewards?.recruits[0].unitId === 'guan-yu' && + malformedHistory.battleHistory['first-battle-zhuo-commandery']?.campaignRewards?.recruits.some((recruit) => recruit.unitId === 'jian-yong') && malformedHistory.battleHistory['first-battle-zhuo-commandery']?.campaignRewards?.unlocks.length === 1 && malformedHistory.battleHistory['first-battle-zhuo-commandery']?.campaignRewards?.unlocks[0].battleId === 'second-battle-yellow-turban-pursuit' && malformedHistory.battleHistory['first-battle-zhuo-commandery']?.campaignRewards?.note === undefined, @@ -2283,10 +2368,11 @@ try { malformedHistory.battleHistory['first-battle-zhuo-commandery']?.units[0].equipment.weapon.itemId === 'training-sword' && malformedHistory.battleHistory['first-battle-zhuo-commandery']?.units[0].equipment.armor.itemId === 'cloth-armor' && malformedHistory.battleHistory['first-battle-zhuo-commandery']?.units[0].equipment.accessory.itemId === 'grain-pouch' && - malformedHistory.battleHistory['first-battle-zhuo-commandery']?.bonds.length === 1 && + malformedHistory.battleHistory['first-battle-zhuo-commandery']?.bonds.length === 2 && malformedHistory.battleHistory['first-battle-zhuo-commandery']?.bonds[0].level === 1 && malformedHistory.battleHistory['first-battle-zhuo-commandery']?.bonds[0].exp === 99 && malformedHistory.battleHistory['first-battle-zhuo-commandery']?.bonds[0].battleExp === 4 && + malformedHistory.battleHistory['first-battle-zhuo-commandery']?.bonds.some((bond) => bond.id === 'liu-bei__jian-yong') && malformedHistory.battleHistory['first-battle-zhuo-commandery']?.reserveTraining?.length === 1 && malformedHistory.battleHistory['first-battle-zhuo-commandery']?.reserveTraining?.[0].focusId === 'balanced', `Expected nested battle history progress arrays to filter invalid entries and normalize numbers: ${JSON.stringify(malformedHistory)}` diff --git a/scripts/verify-flow.mjs b/scripts/verify-flow.mjs index 8952c78..9ea17e6 100644 --- a/scripts/verify-flow.mjs +++ b/scripts/verify-flow.mjs @@ -94,6 +94,49 @@ async function clickBattleDeploymentStart(page) { await page.mouse.click(point.x, point.y); } +async function clickSceneBounds(page, sceneKey, bounds, options) { + const point = await page.evaluate(({ requestedSceneKey, logicalBounds }) => { + const scene = window.__HEROS_GAME__?.scene.getScene(requestedSceneKey); + const canvas = document.querySelector('canvas'); + const canvasBounds = canvas?.getBoundingClientRect(); + if (!scene || !canvas || !canvasBounds || !logicalBounds) { + return null; + } + return { + x: canvasBounds.left + (logicalBounds.x + logicalBounds.width / 2) * canvasBounds.width / scene.scale.width, + y: canvasBounds.top + (logicalBounds.y + logicalBounds.height / 2) * canvasBounds.height / scene.scale.height + }; + }, { requestedSceneKey: sceneKey, logicalBounds: bounds }); + if (!point || !Number.isFinite(point.x) || !Number.isFinite(point.y)) { + throw new Error(`Expected clickable battle bounds: ${JSON.stringify(bounds)}`); + } + await page.mouse.click(point.x, point.y, options); +} + +async function clickBattleBounds(page, bounds, options) { + await clickSceneBounds(page, 'BattleScene', bounds, options); +} + +function boundsOverlap(left, right) { + if (!left || !right) { + return false; + } + return !( + left.x + left.width <= right.x || + right.x + right.width <= left.x || + left.y + left.height <= right.y || + right.y + right.height <= left.y + ); +} + +function hasExactUnitIds(actual, expected) { + return ( + Array.isArray(actual) && + actual.length === expected.length && + expected.every((unitId) => actual.includes(unitId)) + ); +} + async function openBattleMapMenu(page) { const point = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); @@ -263,9 +306,9 @@ try { const page = await browser.newPage(desktopBrowserContextOptions); installLegacyUiClickScaling(page); - await page.goto(targetUrl, { waitUntil: 'domcontentloaded' }); + await page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 90000 }); await page.evaluate(() => window.localStorage.clear()); - await page.reload({ waitUntil: 'domcontentloaded' }); + await page.reload({ waitUntil: 'domcontentloaded', timeout: 90000 }); await page.waitForFunction(() => document.querySelector('canvas') !== null, undefined, { timeout: 90000 }); await page.waitForFunction(() => window.__HEROS_GAME__ !== undefined, undefined, { timeout: 90000 }); await page.waitForFunction(() => window.__HEROS_DEBUG__ !== undefined, undefined, { timeout: 90000 }); @@ -315,6 +358,258 @@ try { await startDeploymentIfNeeded(page, 'first-battle-zhuo-commandery'); + await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.firstBattleTutorial?.active === true, undefined, { timeout: 15000 }); + const tutorialStart = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); + const state = window.__HEROS_DEBUG__?.battle(); + const tutorial = state?.firstBattleTutorial; + const canvas = document.querySelector('canvas'); + const path = tutorial?.path; + const tileBounds = path && scene + ? [path.from, path.moveTile, path.targetTile].map((tile) => ({ + x: scene.tileTopLeftX(tile.x), + y: scene.tileTopLeftY(tile.y), + width: scene.layout.tileSize, + height: scene.layout.tileSize + })) + : []; + return { + tutorial, + tileBounds, + viewport: { width: window.innerWidth, height: window.innerHeight, dpr: window.devicePixelRatio }, + canvas: { width: canvas?.width ?? 0, height: canvas?.height ?? 0 }, + wrongAllyBounds: (() => { + const wrong = state?.units?.find((unit) => unit.faction === 'ally' && unit.id !== path?.unitId && unit.hp > 0); + return wrong && scene + ? { x: scene.tileTopLeftX(wrong.x), y: scene.tileTopLeftY(wrong.y), width: scene.layout.tileSize, height: scene.layout.tileSize } + : null; + })() + }; + }); + if ( + tutorialStart.viewport.width !== 1920 || + tutorialStart.viewport.height !== 1080 || + tutorialStart.viewport.dpr !== 1 || + tutorialStart.canvas.width !== 1920 || + tutorialStart.canvas.height !== 1080 + ) { + throw new Error(`First-battle tutorial must run at 1920x1080 DPR1: ${JSON.stringify(tutorialStart)}`); + } + if ( + tutorialStart.tutorial?.step !== 'select-unit' || + tutorialStart.tutorial.stepNumber !== 1 || + tutorialStart.tutorial.totalSteps !== 4 || + !tutorialStart.tutorial.path?.unitId || + !tutorialStart.tutorial.path?.targetId || + !tutorialStart.tutorial.cardBounds || + !tutorialStart.tutorial.highlightBounds || + !tutorialStart.tutorial.skipBounds || + tutorialStart.tileBounds.some((bounds) => boundsOverlap(tutorialStart.tutorial.cardBounds, bounds)) || + boundsOverlap(tutorialStart.tutorial.cardBounds, tutorialStart.tutorial.highlightBounds) + ) { + throw new Error(`Expected unobstructed 1/4 tutorial card, path, highlight, and skip bounds: ${JSON.stringify(tutorialStart)}`); + } + if (!tutorialStart.wrongAllyBounds) { + throw new Error(`Expected a non-recommended ally for tutorial wrong-click regression: ${JSON.stringify(tutorialStart)}`); + } + + await clickBattleBounds(page, tutorialStart.wrongAllyBounds); + await page.waitForFunction(() => { + const tutorial = window.__HEROS_DEBUG__?.battle()?.firstBattleTutorial; + return tutorial?.step === 'select-unit' && tutorial?.feedback?.length > 0; + }); + let tutorialState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.firstBattleTutorial); + await clickBattleBounds(page, tutorialState.highlightBounds); + await page.waitForFunction(() => { + const tutorial = window.__HEROS_DEBUG__?.battle()?.firstBattleTutorial; + return tutorial?.step === 'move' && tutorial?.feedback === ''; + }); + + const wrongMoveBounds = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); + const path = window.__HEROS_DEBUG__?.battle()?.firstBattleTutorial?.path; + return scene && path + ? { x: scene.tileTopLeftX(path.from.x), y: scene.tileTopLeftY(path.from.y), width: scene.layout.tileSize, height: scene.layout.tileSize } + : null; + }); + await clickBattleBounds(page, wrongMoveBounds); + await page.waitForFunction(() => { + const tutorial = window.__HEROS_DEBUG__?.battle()?.firstBattleTutorial; + return tutorial?.step === 'move' && tutorial?.feedback?.length > 0; + }); + tutorialState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.firstBattleTutorial); + await clickBattleBounds(page, tutorialState.highlightBounds); + await page.waitForFunction(() => { + const tutorial = window.__HEROS_DEBUG__?.battle()?.firstBattleTutorial; + return tutorial?.step === 'attack-command' && tutorial?.feedback === ''; + }); + + let attackStep = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); + const tutorial = window.__HEROS_DEBUG__?.battle()?.firstBattleTutorial; + const waitBounds = scene?.commandButtons?.find((button) => button.command === 'wait')?.background?.getBounds?.(); + return { + tutorial, + waitBounds: waitBounds ? { x: waitBounds.x, y: waitBounds.y, width: waitBounds.width, height: waitBounds.height } : null + }; + }); + if ( + !attackStep.waitBounds || + !attackStep.tutorial?.highlightBounds || + boundsOverlap(attackStep.tutorial.cardBounds, attackStep.tutorial.highlightBounds) + ) { + throw new Error(`Expected unobstructed attack command highlight and a wrong-command target: ${JSON.stringify(attackStep)}`); + } + await clickBattleBounds(page, attackStep.tutorial.highlightBounds, { button: 'right' }); + await page.waitForFunction(() => { + const battle = window.__HEROS_DEBUG__?.battle(); + return battle?.firstBattleTutorial?.step === 'move' && battle?.phase === 'moving'; + }); + await page.waitForFunction(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); + const tutorial = window.__HEROS_DEBUG__?.battle()?.firstBattleTutorial; + const unitId = tutorial?.path?.unitId; + const highlight = tutorial?.highlightBounds; + const hitZone = unitId ? scene?.unitViews?.get?.(unitId)?.hitZone : null; + if (!highlight || !hitZone?.active) { + return true; + } + return !hitZone.getBounds().contains( + highlight.x + highlight.width / 2, + highlight.y + highlight.height / 2 + ); + }); + tutorialState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.firstBattleTutorial); + await clickBattleBounds(page, tutorialState.highlightBounds); + await page.waitForFunction(() => { + const tutorial = window.__HEROS_DEBUG__?.battle()?.firstBattleTutorial; + return tutorial?.step === 'attack-command' && tutorial?.feedback === ''; + }); + attackStep = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); + const tutorial = window.__HEROS_DEBUG__?.battle()?.firstBattleTutorial; + const waitBounds = scene?.commandButtons?.find((button) => button.command === 'wait')?.background?.getBounds?.(); + return { + tutorial, + waitBounds: waitBounds ? { x: waitBounds.x, y: waitBounds.y, width: waitBounds.width, height: waitBounds.height } : null + }; + }); + await clickBattleBounds(page, attackStep.waitBounds); + await page.waitForFunction(() => { + const tutorial = window.__HEROS_DEBUG__?.battle()?.firstBattleTutorial; + return tutorial?.step === 'attack-command' && tutorial?.feedback?.length > 0; + }); + tutorialState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.firstBattleTutorial); + await clickBattleBounds(page, tutorialState.highlightBounds); + await page.waitForFunction(() => { + const tutorial = window.__HEROS_DEBUG__?.battle()?.firstBattleTutorial; + return tutorial?.step === 'target-preview' && tutorial?.feedback === ''; + }); + tutorialState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.firstBattleTutorial); + await clickBattleBounds(page, tutorialState.highlightBounds, { button: 'right' }); + await page.waitForFunction(() => { + const battle = window.__HEROS_DEBUG__?.battle(); + return battle?.firstBattleTutorial?.step === 'attack-command' && battle?.phase === 'command'; + }); + tutorialState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.firstBattleTutorial); + await clickBattleBounds(page, tutorialState.highlightBounds); + await page.waitForFunction(() => { + const tutorial = window.__HEROS_DEBUG__?.battle()?.firstBattleTutorial; + return tutorial?.step === 'target-preview' && tutorial?.feedback === ''; + }); + + const emptyTutorialTileBounds = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); + const tutorial = window.__HEROS_DEBUG__?.battle()?.firstBattleTutorial; + if (!scene || !tutorial?.cardBounds) { + return null; + } + for (let row = 0; row < scene.layout.visibleRows; row += 1) { + for (let column = 0; column < scene.layout.visibleColumns; column += 1) { + const x = scene.cameraTileX + column; + const y = scene.cameraTileY + row; + if (!scene.isInBounds(x, y) || scene.isOccupied(x, y)) { + continue; + } + const bounds = { + x: scene.tileTopLeftX(x), + y: scene.tileTopLeftY(y), + width: scene.layout.tileSize, + height: scene.layout.tileSize + }; + const card = tutorial.cardBounds; + const overlaps = !( + bounds.x + bounds.width <= card.x || + card.x + card.width <= bounds.x || + bounds.y + bounds.height <= card.y || + card.y + card.height <= bounds.y + ); + if (!overlaps) { + return bounds; + } + } + } + return null; + }); + await clickBattleBounds(page, emptyTutorialTileBounds); + await page.waitForFunction(() => { + const tutorial = window.__HEROS_DEBUG__?.battle()?.firstBattleTutorial; + return tutorial?.step === 'target-preview' && tutorial?.targetPreviewLocked === false && tutorial?.feedback?.length > 0; + }); + + tutorialState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.firstBattleTutorial); + if (boundsOverlap(tutorialState.cardBounds, tutorialState.highlightBounds)) { + throw new Error(`Tutorial card obscured the recommended target: ${JSON.stringify(tutorialState)}`); + } + await clickBattleBounds(page, tutorialState.highlightBounds); + await page.waitForFunction(() => { + const tutorial = window.__HEROS_DEBUG__?.battle()?.firstBattleTutorial; + return tutorial?.step === 'target-preview' && tutorial?.targetPreviewLocked === true; + }); + tutorialState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.firstBattleTutorial); + await clickBattleBounds(page, tutorialState.highlightBounds); + await page.waitForFunction(() => { + const battle = window.__HEROS_DEBUG__?.battle(); + const campaign = JSON.parse(window.localStorage.getItem('heros-web:campaign-state') ?? '{}'); + const slot = JSON.parse(window.localStorage.getItem('heros-web:campaign-state:slot-1') ?? '{}'); + return ( + battle?.firstBattleTutorial?.active === false && + battle?.firstBattleTutorial?.completed === true && + campaign.completedTutorialIds?.includes('first-battle-basic-controls') && + slot.completedTutorialIds?.includes('first-battle-basic-controls') + ); + }, undefined, { timeout: 30000 }); + await page.waitForFunction((expectedUnitId) => { + const battle = window.__HEROS_DEBUG__?.battle(); + return battle?.phase !== 'targeting' && battle?.selectedUnitId === expectedUnitId; + }, tutorialState.path.unitId, { timeout: 30000 }); + await page.waitForTimeout(500); + + await page.reload({ waitUntil: 'domcontentloaded', timeout: 90000 }); + await page.waitForFunction(() => { + const activeScenes = window.__HEROS_DEBUG__?.activeScenes?.() ?? []; + return window.__HEROS_GAME__ && activeScenes.includes('TitleScene'); + }, undefined, { timeout: 90000 }); + await page.evaluate(() => { + window.__HEROS_GAME__?.scene.getScene('TitleScene')?.continueGame?.(1); + }); + await page.waitForFunction(() => { + const state = window.__HEROS_DEBUG__?.battle(); + const activeScenes = window.__HEROS_DEBUG__?.activeScenes?.() ?? []; + return activeScenes.includes('BattleScene') && state?.battleId === 'first-battle-zhuo-commandery' && ['deployment', 'idle'].includes(state?.phase) && state?.mapBackgroundReady; + }, undefined, { timeout: 90000 }); + await startDeploymentIfNeeded(page, 'first-battle-zhuo-commandery'); + await page.waitForTimeout(4300); + const tutorialReentryState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); + if ( + tutorialReentryState?.firstBattleTutorial?.active !== false || + tutorialReentryState.firstBattleTutorial.completed !== true || + tutorialReentryState.firstBattleTutorial.cardBounds !== null || + tutorialReentryState.battleLog.some((line) => line.includes('첫 행동 ·')) + ) { + throw new Error(`Completed tutorial should not reappear or duplicate the legacy one-line guide: ${JSON.stringify(tutorialReentryState)}`); + } + await page.keyboard.press('F9'); await page.waitForTimeout(100); await page.screenshot({ path: 'dist/verification-battle.png', fullPage: true }); @@ -661,7 +956,7 @@ try { await page.screenshot({ path: 'dist/verification-battle-result-victory.png', fullPage: true }); await page.mouse.click(738, 642); - await waitForCampAfterBattleResult(page); + await waitForCampAfterBattleResult(page, { preserveArrivalReward: true }); await page.screenshot({ path: 'dist/verification-camp.png', fullPage: true }); const campState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); @@ -675,6 +970,19 @@ try { ) { throw new Error(`Expected first camp to expose first-battle dialogue set: ${JSON.stringify(campState)}`); } + const arrivalSupplyAction = campState.victoryRewardAcknowledgement?.actions?.find((action) => action.id === 'supplies'); + if ( + campState.victoryRewardAcknowledgement?.visible !== true || + !arrivalSupplyAction?.interactive || + !arrivalSupplyAction.bounds + ) { + throw new Error(`Expected first camp arrival reward modal with an interactive supplies deep link: ${JSON.stringify(campState.victoryRewardAcknowledgement)}`); + } + await clickSceneBounds(page, 'CampScene', arrivalSupplyAction.bounds); + await page.waitForFunction(() => { + const camp = window.__HEROS_DEBUG__?.camp(); + return camp?.victoryRewardAcknowledgement?.visible === false && camp?.activeTab === 'supplies'; + }, undefined, { timeout: 30000 }); await page.evaluate(() => { const campScene = window.__HEROS_GAME__?.scene.getScene('CampScene'); @@ -757,6 +1065,93 @@ try { throw new Error(`Expected sortie preparation overlay from next story button: ${JSON.stringify(campStateWithSortiePrep)}`); } assertSortieTacticalRoster(campStateWithSortiePrep, ['liu-bei', 'guan-yu', 'zhang-fei']); + const foundingSortieUnitIds = ['liu-bei', 'guan-yu', 'zhang-fei']; + const jianYongSortieUnitIds = ['liu-bei', 'zhang-fei', 'jian-yong']; + const firstFormationState = await ensureSortiePrepStep(page, 'formation'); + const firstFormationRosterIds = firstFormationState.sortieRoster?.map((unit) => unit.id) ?? []; + const firstFormationJianYong = firstFormationState.sortieRoster?.find((unit) => unit.id === 'jian-yong'); + if ( + firstFormationState.sortieRoster?.length !== 4 || + firstFormationState.sortieSelectionLimit !== 3 || + !hasExactUnitIds(firstFormationRosterIds, [...foundingSortieUnitIds, 'jian-yong']) || + !hasExactUnitIds(firstFormationState.selectedSortieUnitIds, foundingSortieUnitIds) || + firstFormationJianYong?.available !== true || + firstFormationJianYong.selected !== false + ) { + throw new Error(`Expected first victory to expose an available, initially unselected Jian Yong in a 4-of-3 roster: ${JSON.stringify(firstFormationState)}`); + } + + let firstFormationOrderState = firstFormationState; + if (firstFormationOrderState.sortieOperationOrder?.required && !firstFormationOrderState.sortieOperationOrder.complete) { + const eliteOrderCard = firstFormationOrderState.sortieOperationOrder.cards?.find((card) => card.id === 'elite'); + if (!eliteOrderCard?.cardBounds) { + throw new Error(`Expected an interactive first-sortie operation order card: ${JSON.stringify(firstFormationOrderState.sortieOperationOrder)}`); + } + await clickSceneBounds(page, 'CampScene', eliteOrderCard.cardBounds); + await page.waitForFunction(() => { + const order = window.__HEROS_DEBUG__?.camp()?.sortieOperationOrder; + return order?.selectedId === 'elite' && order?.complete === true; + }, undefined, { timeout: 30000 }); + firstFormationOrderState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + } + if (firstFormationOrderState.sortieOperationOrder?.open) { + const closeBounds = firstFormationOrderState.sortieOperationOrder.closeButtonBounds; + if (!closeBounds) { + throw new Error(`Expected the first-sortie operation order browser to expose a close button: ${JSON.stringify(firstFormationOrderState.sortieOperationOrder)}`); + } + await clickSceneBounds(page, 'CampScene', closeBounds); + await page.waitForFunction(() => { + const camp = window.__HEROS_DEBUG__?.camp(); + return camp?.sortieOperationOrder?.open === false && camp?.sortieRosterToggleBounds?.length === 4; + }, undefined, { timeout: 30000 }); + } + + await clickSortiePortraitToggle(page, 'guan-yu', false); + await clickSortiePortraitToggle(page, 'jian-yong', true); + const jianYongSelectionPersistence = await page.evaluate(() => { + const camp = window.__HEROS_DEBUG__?.camp(); + const globalRaw = window.localStorage.getItem('heros-web:campaign-state'); + const slotRaw = window.localStorage.getItem('heros-web:campaign-state:slot-1'); + return { + camp, + globalSave: globalRaw ? JSON.parse(globalRaw) : null, + slotSave: slotRaw ? JSON.parse(slotRaw) : null + }; + }); + const jianYongPreviewIds = jianYongSelectionPersistence.camp?.sortieDeploymentPreview?.map((entry) => entry.unitId) ?? []; + if ( + !hasExactUnitIds(jianYongSelectionPersistence.camp?.selectedSortieUnitIds, jianYongSortieUnitIds) || + !hasExactUnitIds(jianYongSelectionPersistence.camp?.campaign?.selectedSortieUnitIds, jianYongSortieUnitIds) || + !hasExactUnitIds(jianYongSelectionPersistence.globalSave?.selectedSortieUnitIds, jianYongSortieUnitIds) || + !hasExactUnitIds(jianYongSelectionPersistence.slotSave?.selectedSortieUnitIds, jianYongSortieUnitIds) || + !hasExactUnitIds(jianYongPreviewIds, jianYongSortieUnitIds) + ) { + throw new Error(`Expected actual portrait-card clicks to persist a three-unit sortie with Jian Yong: ${JSON.stringify(jianYongSelectionPersistence)}`); + } + await page.screenshot({ path: 'dist/verification-first-camp-jian-yong-sortie.png', fullPage: true }); + + await clickSortiePortraitToggle(page, 'jian-yong', false); + await clickSortiePortraitToggle(page, 'guan-yu', true); + const foundingSelectionPersistence = await page.evaluate(() => { + const camp = window.__HEROS_DEBUG__?.camp(); + const globalRaw = window.localStorage.getItem('heros-web:campaign-state'); + const slotRaw = window.localStorage.getItem('heros-web:campaign-state:slot-1'); + return { + camp, + globalSave: globalRaw ? JSON.parse(globalRaw) : null, + slotSave: slotRaw ? JSON.parse(slotRaw) : null + }; + }); + const foundingPreviewIds = foundingSelectionPersistence.camp?.sortieDeploymentPreview?.map((entry) => entry.unitId) ?? []; + if ( + !hasExactUnitIds(foundingSelectionPersistence.camp?.selectedSortieUnitIds, foundingSortieUnitIds) || + !hasExactUnitIds(foundingSelectionPersistence.camp?.campaign?.selectedSortieUnitIds, foundingSortieUnitIds) || + !hasExactUnitIds(foundingSelectionPersistence.globalSave?.selectedSortieUnitIds, foundingSortieUnitIds) || + !hasExactUnitIds(foundingSelectionPersistence.slotSave?.selectedSortieUnitIds, foundingSortieUnitIds) || + !hasExactUnitIds(foundingPreviewIds, foundingSortieUnitIds) + ) { + throw new Error(`Expected actual portrait-card clicks to restore and persist the founding trio: ${JSON.stringify(foundingSelectionPersistence)}`); + } await page.screenshot({ path: 'dist/verification-camp-sortie.png', fullPage: true }); await page.keyboard.press('Escape'); @@ -9905,9 +10300,42 @@ async function waitForCampReady(page, options = {}) { (!expected.step || campState?.campaign?.step === expected.step) ); }, options, { timeout: 90000 }); + if (!options.preserveArrivalReward) { + await dismissCampArrivalRewardIfVisible(page); + } } -async function waitForCampAfterBattleResult(page) { +async function dismissCampArrivalRewardIfVisible(page) { + const acknowledgement = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.victoryRewardAcknowledgement ?? null); + if (!acknowledgement?.visible) { + return; + } + const closeAction = acknowledgement.actions?.find((action) => action.id === 'close'); + if (!closeAction?.interactive || !closeAction.bounds) { + throw new Error(`Expected an interactive close action on the camp arrival reward modal: ${JSON.stringify(acknowledgement)}`); + } + await clickSceneBounds(page, 'CampScene', closeAction.bounds); + try { + await page.waitForFunction(() => window.__HEROS_DEBUG__?.camp()?.victoryRewardAcknowledgement?.visible === false, undefined, { timeout: 1800 }); + return; + } catch { + const afterPointer = await page.evaluate(() => ({ + activeScenes: window.__HEROS_DEBUG__?.activeScenes() ?? [], + acknowledgement: window.__HEROS_DEBUG__?.camp()?.victoryRewardAcknowledgement ?? null + })); + if (afterPointer.acknowledgement?.visible === false) { + return; + } + await page.keyboard.press('Escape'); + try { + await page.waitForFunction(() => window.__HEROS_DEBUG__?.camp()?.victoryRewardAcknowledgement?.visible === false, undefined, { timeout: 30000 }); + } catch (error) { + throw new Error(`Camp arrival reward could not be dismissed by its actual close action or Escape fallback: ${JSON.stringify(afterPointer)}\n${error instanceof Error ? error.message : String(error)}`); + } + } +} + +async function waitForCampAfterBattleResult(page, options = {}) { await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; return activeScenes.includes('CampScene') || activeScenes.includes('StoryScene'); @@ -9916,14 +10344,14 @@ async function waitForCampAfterBattleResult(page) { for (let i = 0; i < 48; i += 1) { const activeScenes = await page.evaluate(() => window.__HEROS_DEBUG__?.activeScenes() ?? []); if (activeScenes.includes('CampScene')) { - await waitForCampReady(page); + await waitForCampReady(page, options); return; } await page.keyboard.press('Space'); await page.waitForTimeout(240); } - await waitForCampReady(page); + await waitForCampReady(page, options); } function delay(ms) { @@ -10212,6 +10640,37 @@ async function launchSortieFromCurrentStep(page) { } } +async function clickSortiePortraitToggle(page, unitId, expectedSelected) { + const state = await ensureSortiePrepStep(page, 'formation'); + const card = state?.sortiePortraitRosterView?.cardBounds?.find((entry) => entry.unitId === unitId); + const firstSliceToggle = state?.firstSortiePrep + ? state.sortieRosterToggleBounds?.find((entry) => entry.unitId === unitId) + : null; + const toggleBounds = state?.sortiePortraitRosterView?.enabled === true + ? card?.toggleButtonBounds + : firstSliceToggle; + if (!toggleBounds) { + throw new Error(`Cannot click sortie toggle for ${unitId}: ${JSON.stringify({ + step: state?.sortiePrepStep, + firstSortiePrep: state?.firstSortiePrep, + portraitRosterView: state?.sortiePortraitRosterView, + firstSliceToggles: state?.sortieRosterToggleBounds + })}`); + } + + await clickSceneBounds(page, 'CampScene', toggleBounds); + await page.waitForFunction(({ targetUnitId, selected }) => { + const camp = window.__HEROS_DEBUG__?.camp(); + return ( + camp?.sortieVisible === true && + camp?.sortiePrepStep === 'formation' && + camp?.selectedSortieUnitIds?.includes(targetUnitId) === selected + ); + }, { targetUnitId: unitId, selected: expectedSelected }, { timeout: 30000 }); + + return page.evaluate(() => window.__HEROS_DEBUG__?.camp()); +} + async function clickSortieRosterUnit(page, unitId) { await ensureSortiePrepStep(page, 'formation'); let state = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); diff --git a/scripts/verify-release-candidate.mjs b/scripts/verify-release-candidate.mjs index f3f3077..5166709 100644 --- a/scripts/verify-release-candidate.mjs +++ b/scripts/verify-release-candidate.mjs @@ -48,6 +48,48 @@ async function clickBattleDeploymentStart(page) { await page.mouse.click(point.x, point.y); } +async function waitForBattleDeploymentConfirmation(page) { + await page.waitForFunction(() => { + const battle = window.__HEROS_DEBUG__?.battle(); + const combatAssets = battle?.combatAssets; + return ( + battle?.phase === 'idle' && + (combatAssets?.status === 'ready' || combatAssets?.status === 'degraded') && + combatAssets?.pendingDeploymentConfirmation === false && + battle?.battleHud?.deploymentPanel === null + ); + }, undefined, { timeout: 90000 }); +} + +async function readEquipmentSwapConfirmControlPoint(page) { + await page.waitForFunction(() => { + const comparison = window.__HEROS_DEBUG__?.camp()?.equipmentSwapComparison; + const bounds = comparison?.confirmButtonBounds; + return ( + comparison?.visible === true && + comparison.confirmInteractive === true && + Number.isFinite(bounds?.x) && + Number.isFinite(bounds?.y) && + Number.isFinite(bounds?.width) && + Number.isFinite(bounds?.height) && + bounds.width > 0 && + bounds.height > 0 + ); + }, undefined, { timeout: 30000 }); + await page.evaluate(() => new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(resolve)); + })); + const comparison = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.equipmentSwapComparison ?? null); + assert( + comparison?.visible === true && + comparison.confirmInteractive === true && + isFiniteBounds(comparison.confirmButtonBounds), + `Expected a stable interactive equipment confirmation control: ${JSON.stringify(comparison)}` + ); + const bounds = comparison.confirmButtonBounds; + return { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 }; +} + try { runCommand('node', ['scripts/verify-static-data.mjs']); mkdirSync(screenshotDir, { recursive: true }); @@ -2385,24 +2427,28 @@ try { }, 'first camp'); assert( firstCampProbe.state?.campRoster?.pageCount === 1 && - firstCampProbe.state.campRoster.totalCount === 3 && - firstCampProbe.state.campRoster.visibleUnitIds.length === 3 && - firstCampProbe.state.campRoster.rowBounds.length === 3 && + firstCampProbe.state.campRoster.totalCount === 4 && + sameJsonValue( + [...firstCampProbe.state.campRoster.visibleUnitIds].sort(), + ['liu-bei', 'guan-yu', 'zhang-fei', 'jian-yong'].sort() + ) && + firstCampProbe.state.campRoster.rowBounds.length === 4 && firstCampProbe.state.campRoster.rowBounds.every((row) => row.height >= 100) && firstCampProbe.state.campRoster.navigationBounds === null && firstCampProbe.state.campRoster.previousEnabled === false && firstCampProbe.state.campRoster.nextEnabled === false, - `Expected the three-officer first camp to retain spacious cards without redundant pagination: ${JSON.stringify(firstCampProbe.state?.campRoster)}` + `Expected the four-officer first camp to retain spacious cards without redundant pagination: ${JSON.stringify(firstCampProbe.state?.campRoster)}` ); assert( firstCampProbe.state?.nextSortieBattleId === 'second-battle-yellow-turban-pursuit', `Expected first camp sortie flow to prepare unlocked second battle: ${JSON.stringify(firstCampProbe.state)}` ); assert( - ['liu-bei', 'guan-yu', 'zhang-fei'].every((unitId) => - firstCampProbe.state?.sortieDeploymentPreview?.some((slot) => slot.unitId === unitId) + sameJsonValue( + (firstCampProbe.state?.sortieDeploymentPreview ?? []).map((slot) => slot.unitId).sort(), + ['liu-bei', 'guan-yu', 'zhang-fei'].sort() ), - `Expected first camp deployment preview to keep the founding trio assigned: ${JSON.stringify(firstCampProbe.state?.sortieDeploymentPreview)}` + `Expected first camp deployment preview to keep exactly the founding trio assigned while Jian Yong remains a selectable reserve: ${JSON.stringify(firstCampProbe.state?.sortieDeploymentPreview)}` ); assert(firstCampProbe.state?.sortieHasBattle === true, `Expected first camp to expose a playable next battle: ${JSON.stringify(firstCampProbe.state)}`); assert( @@ -3050,10 +3096,136 @@ try { equipmentPaginationFixture.panel.pageCount >= 2 && equipmentPaginationFixture.panel.visibleItemIds?.length === equipmentPaginationFixture.panel.pageSize && equipmentPaginationFixture.panel.nextEnabled === true && + equipmentPaginationFixture.panel.rows?.length === equipmentPaginationFixture.panel.visibleItemIds.length && + equipmentPaginationFixture.panel.rows.every( + (row) => boundsInside(row.bounds, equipmentPaginationFixture.panel.bounds) && + boundsInside(row.actionButtonBounds, row.bounds) + ) && boundsWithinFhdViewport(equipmentPaginationFixture.panel.bounds) && boundsInside(equipmentPaginationFixture.panel.nextButtonBounds, equipmentPaginationFixture.panel.bounds), `Expected the equipment manager to expose the first of multiple inventory pages: ${JSON.stringify(equipmentPaginationFixture)}` ); + const equipmentComparisonCandidate = equipmentPaginationFixture.panel.rows.find((row) => row.canEquip && row.interactive); + assert( + equipmentComparisonCandidate && boundsWithinFhdViewport(equipmentComparisonCandidate.actionButtonBounds), + `Expected a visible, interactive equipment candidate in the FHD inventory page: ${JSON.stringify(equipmentPaginationFixture.panel.rows)}` + ); + const equipmentBeforeComparison = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); + const unit = scene?.campaign?.roster?.find((candidate) => candidate.id === scene?.selectedUnitId); + return unit + ? { + unitId: unit.id, + equipment: structuredClone(unit.equipment), + inventory: structuredClone(scene.campaign.inventory) + } + : null; + }); + await page.mouse.click( + equipmentComparisonCandidate.actionButtonBounds.x + equipmentComparisonCandidate.actionButtonBounds.width / 2, + equipmentComparisonCandidate.actionButtonBounds.y + equipmentComparisonCandidate.actionButtonBounds.height / 2 + ); + await page.waitForFunction( + () => window.__HEROS_DEBUG__?.camp()?.equipmentSwapComparison?.visible === true, + undefined, + { timeout: 30000 } + ); + const equipmentComparison = await page.evaluate(() => { + const state = window.__HEROS_DEBUG__?.camp(); + const unit = state?.campaign?.roster?.find((candidate) => candidate.id === state?.equipmentSwapComparison?.unitId); + return { + comparison: state?.equipmentSwapComparison ?? null, + equipment: unit ? structuredClone(unit.equipment) : null, + inventory: structuredClone(state?.campaign?.inventory ?? {}) + }; + }); + assert( + equipmentBeforeComparison && + equipmentComparison.comparison?.candidateItemId === equipmentComparisonCandidate.itemId && + equipmentComparison.comparison.currentItemId !== equipmentComparison.comparison.candidateItemId && + equipmentComparison.comparison.confirmInteractive === true && + equipmentComparison.comparison.cancelInteractive === true && + boundsWithinFhdViewport(equipmentComparison.comparison.panelBounds) && + boundsInside(equipmentComparison.comparison.confirmButtonBounds, equipmentComparison.comparison.panelBounds) && + boundsInside(equipmentComparison.comparison.cancelButtonBounds, equipmentComparison.comparison.panelBounds) && + sameJsonValue(equipmentComparison.equipment, equipmentBeforeComparison.equipment) && + sameJsonValue(equipmentComparison.inventory, equipmentBeforeComparison.inventory), + `Expected candidate selection to open a non-destructive current/candidate comparison: ${JSON.stringify({ before: equipmentBeforeComparison, after: equipmentComparison })}` + ); + await page.screenshot({ path: `${screenshotDir}/rc-first-camp-equipment-comparison.png`, fullPage: true }); + await assertCanvasPainted(page, 'first camp equipment comparison'); + + await page.mouse.click( + equipmentComparison.comparison.cancelButtonBounds.x + equipmentComparison.comparison.cancelButtonBounds.width / 2, + equipmentComparison.comparison.cancelButtonBounds.y + equipmentComparison.comparison.cancelButtonBounds.height / 2 + ); + await page.waitForFunction( + () => window.__HEROS_DEBUG__?.camp()?.equipmentSwapComparison === null, + undefined, + { timeout: 30000 } + ); + const equipmentAfterCancel = await page.evaluate((unitId) => { + const state = window.__HEROS_DEBUG__?.camp(); + const unit = state?.campaign?.roster?.find((candidate) => candidate.id === unitId); + return { equipment: structuredClone(unit?.equipment ?? {}), inventory: structuredClone(state?.campaign?.inventory ?? {}) }; + }, equipmentBeforeComparison.unitId); + assert( + sameJsonValue(equipmentAfterCancel.equipment, equipmentBeforeComparison.equipment) && + sameJsonValue(equipmentAfterCancel.inventory, equipmentBeforeComparison.inventory), + `Expected cancel to leave equipment and inventory untouched: ${JSON.stringify({ before: equipmentBeforeComparison, after: equipmentAfterCancel })}` + ); + + await page.mouse.click( + equipmentComparisonCandidate.actionButtonBounds.x + equipmentComparisonCandidate.actionButtonBounds.width / 2, + equipmentComparisonCandidate.actionButtonBounds.y + equipmentComparisonCandidate.actionButtonBounds.height / 2 + ); + const equipmentConfirmPoint = await readEquipmentSwapConfirmControlPoint(page); + await page.mouse.click(equipmentConfirmPoint.x, equipmentConfirmPoint.y); + await page.waitForFunction( + ({ unitId, slot, candidateItemId }) => { + const state = window.__HEROS_DEBUG__?.camp(); + const unit = state?.campaign?.roster?.find((candidate) => candidate.id === unitId); + return state?.equipmentSwapComparison === null && unit?.equipment?.[slot]?.itemId === candidateItemId; + }, + { + unitId: equipmentComparison.comparison.unitId, + slot: equipmentComparison.comparison.slot, + candidateItemId: equipmentComparison.comparison.candidateItemId + }, + { timeout: 30000 } + ); + const confirmedEquipmentSwap = await page.evaluate(({ unitId, slot }) => { + const state = window.__HEROS_DEBUG__?.camp(); + return state?.campaign?.roster?.find((candidate) => candidate.id === unitId)?.equipment?.[slot]?.itemId ?? null; + }, { unitId: equipmentComparison.comparison.unitId, slot: equipmentComparison.comparison.slot }); + assert( + confirmedEquipmentSwap === equipmentComparison.comparison.candidateItemId, + `Expected the explicit confirm pointer to apply the candidate equipment: ${JSON.stringify({ comparison: equipmentComparison.comparison, confirmedEquipmentSwap })}` + ); + + await page.evaluate(({ unitId, slot, currentItemId }) => { + const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); + scene?.swapUnitEquipment?.(unitId, slot, currentItemId); + }, { + unitId: equipmentComparison.comparison.unitId, + slot: equipmentComparison.comparison.slot, + currentItemId: equipmentComparison.comparison.currentItemId + }); + const equipmentRestoreConfirmPoint = await readEquipmentSwapConfirmControlPoint(page); + await page.mouse.click(equipmentRestoreConfirmPoint.x, equipmentRestoreConfirmPoint.y); + await page.waitForFunction( + ({ unitId, slot, currentItemId }) => { + const state = window.__HEROS_DEBUG__?.camp(); + const unit = state?.campaign?.roster?.find((candidate) => candidate.id === unitId); + return state?.equipmentSwapComparison === null && unit?.equipment?.[slot]?.itemId === currentItemId; + }, + { + unitId: equipmentComparison.comparison.unitId, + slot: equipmentComparison.comparison.slot, + currentItemId: equipmentComparison.comparison.currentItemId + }, + { timeout: 30000 } + ); await page.evaluate(() => new Promise((resolve) => { requestAnimationFrame(() => requestAnimationFrame(resolve)); })); @@ -3135,20 +3307,27 @@ try { firstSortieBriefingState?.sortiePrimaryAction?.primary === true && firstSortieBriefingState?.sortiePrimaryAction?.interactive === true && firstSortieBriefingState?.sortiePrimaryAction?.lineWidth >= 2 && + firstSortieBriefingState?.sortieNextActionGuide?.label?.includes('무장 편성으로 이동') && + boundsWithinFhdViewport(firstSortieBriefingState?.sortieNextActionGuide?.bounds) && boundsWithinFhdViewport(firstSortieBriefingState?.sortiePrimaryAction?.bounds), `Expected the first sortie to open at the staged battlefield briefing: ${JSON.stringify(firstSortieBriefingState)}` ); await advanceSortiePrepStep(page, 'formation'); await page.screenshot({ path: `${screenshotDir}/rc-first-camp-sortie-formation.png`, fullPage: true }); await assertCanvasPainted(page, 'first camp sortie formation'); - const firstSortieSynergy = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.sortieSynergyPreview); + const firstSortieFormationState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + const firstSortieSynergy = firstSortieFormationState?.sortieSynergyPreview; assert( - firstSortieSynergy?.activeBondCount === 3 && + firstSortieFormationState?.sortiePrimaryAction?.kind === 'next' && + firstSortieFormationState.sortiePrimaryAction.nextStep === 'loadout' && + firstSortieFormationState?.sortieNextActionGuide?.label?.includes('장비·보급 점검으로 이동') && + boundsWithinFhdViewport(firstSortieFormationState?.sortieNextActionGuide?.bounds) && + firstSortieSynergy?.activeBondCount === 3 && firstSortieSynergy?.coveredRoleCount === 3 && firstSortieSynergy?.strongestBond?.damageBonus === 9 && firstSortieSynergy?.strongestBond?.chainRate === 18 && firstSortieSynergy?.firstPursuitTrinityConfigured === true, - `Expected first sortie to preview three bonds, the strongest effect, and trinity: ${JSON.stringify(firstSortieSynergy)}` + `Expected first sortie formation to expose its next action and preview three bonds, the strongest effect, and trinity: ${JSON.stringify(firstSortieFormationState)}` ); const expectedFirstSortiePursuitPairs = [ @@ -3746,6 +3925,7 @@ try { const battleKey = 'heros-web:battle:second-battle-yellow-turban-pursuit'; const storageKeys = [campaignCurrentKey, campaignSlotKey, battleKey, `${battleKey}:slot-1`, 'heros-web:first-battle-state']; const storageSnapshot = new Map(storageKeys.map((key) => [key, window.localStorage.getItem(key)])); + const loadDiagnostics = []; const captureUnits = () => { const byId = new Map((window.__HEROS_DEBUG__?.battle()?.units ?? []).map((unit) => [unit.id, unit])); return officerIds.map((id) => { @@ -3765,6 +3945,16 @@ try { campaign.sortieFormationAssignments = assignments; delete campaign.sortieResonanceSelection; window.localStorage.setItem(campaignSlotKey, JSON.stringify(campaign)); + const battleRaw = window.localStorage.getItem(`${battleKey}:slot-1`) ?? window.localStorage.getItem(battleKey); + const savedBattle = battleRaw ? JSON.parse(battleRaw) : null; + loadDiagnostics.push({ + assignments, + persistedAssignments: JSON.parse(window.localStorage.getItem(campaignSlotKey))?.sortieFormationAssignments ?? null, + battleSavePresent: Boolean(savedBattle), + battleSaveReadable: Boolean(scene.readBattleSaveState?.(1)), + tacticalCommand: savedBattle?.tacticalCommand ?? null, + sortieResonanceBondId: savedBattle?.sortieResonanceBondId ?? null + }); scene.launchSortieResonanceBondId = undefined; scene.coreResonancePursuitAttempts = 0; scene.coreResonancePursuitSuccesses = 0; @@ -3774,6 +3964,10 @@ try { }; scene.saveBattleState?.(1); + const savedBattleRaw = window.localStorage.getItem(`${battleKey}:slot-1`) ?? window.localStorage.getItem(battleKey); + const savedBattle = savedBattleRaw ? JSON.parse(savedBattleRaw) : null; + const battleSaveReadableAfterSave = Boolean(scene.readBattleSaveState?.(1)); + const savedBattleBonds = structuredClone(savedBattle?.bonds ?? []); const savedCampaignRaw = window.localStorage.getItem(campaignSlotKey); if (!savedCampaignRaw) { return { error: 'campaign slot save missing' }; @@ -3809,6 +4003,7 @@ try { const restoredCoreResonance = structuredClone( window.__HEROS_DEBUG__?.battle()?.coreSortieResonance ?? null ); + const reserveBondPreserved = scene.bondStates?.has('liu-bei__jian-yong') ?? false; storageSnapshot.forEach((value, key) => { if (value === null) { window.localStorage.removeItem(key); @@ -3816,8 +4011,31 @@ try { window.localStorage.setItem(key, value); } }); - return { changed, changedCoreResonance, allReserve, restored, restoredCoreResonance }; + return { + changed, + changedCoreResonance, + allReserve, + restored, + restoredCoreResonance, + loadDiagnostics, + battleSaveReadableAfterSave, + savedBattleBonds, + reserveBondPreserved + }; }); + assert( + roleSealResyncProbe?.battleSaveReadableAfterSave === true && + roleSealResyncProbe.savedBattleBonds.length > 0 && + roleSealResyncProbe.savedBattleBonds.every((bond) => ( + bond.unitIds.every((unitId) => secondBattleProbe.deployedAllyIds.includes(unitId)) + )) && + !roleSealResyncProbe.savedBattleBonds.some((bond) => bond.id === 'liu-bei__jian-yong') && + roleSealResyncProbe.reserveBondPreserved === true && + roleSealResyncProbe.loadDiagnostics.every((diagnostic) => ( + diagnostic.battleSavePresent === true && diagnostic.battleSaveReadable === true + )), + `Expected a three-officer battle save to exclude reserve bonds and remain readable across formation reloads: ${JSON.stringify(roleSealResyncProbe)}` + ); assert( JSON.stringify(roleSealResyncProbe?.changed) === JSON.stringify([ { id: 'liu-bei', role: 'front', effect: '전열 방호', seal: '전', sealPresent: true, sealVisible: true }, @@ -6430,12 +6648,26 @@ async function assertLargeRosterHud(browser, url) { if (scene?.phase === 'deployment') { scene.confirmPreBattleDeployment?.(); } + }); + await waitForBattleDeploymentConfirmation(page); + await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); scene?.hideBattleEventBanner?.(); scene?.renderSituationPanel?.(); }); await page.waitForFunction(() => { - const hud = window.__HEROS_DEBUG__?.battle()?.battleHud; - return hud?.miniMap?.visible === true && hud?.sidebar?.bounds && hud?.deploymentPanel === null; + const battle = window.__HEROS_DEBUG__?.battle(); + const hud = battle?.battleHud; + return ( + battle?.phase === 'idle' && + hud?.miniMap?.visible === true && + hud?.sidebar?.bounds && + hud?.deploymentPanel === null && + hud?.quickTabs?.visible === true && + hud?.quickTabs?.enabled === true && + hud?.quickTabs?.activeTab === 'situation' && + hud?.quickTabs?.currentView === 'situation' + ); }, undefined, { timeout: 30000 }); const situationProbe = await readLargeRosterHudProbe(page); @@ -6529,10 +6761,25 @@ async function assertLargeRosterHud(browser, url) { await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); scene?.confirmPreBattleDeployment?.(); + }); + await waitForBattleDeploymentConfirmation(page); + await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); scene?.hideBattleEventBanner?.(); scene?.renderRosterPanel?.('ally', '출전 위치 확정. 행동할 장수를 선택하세요.'); }); - await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.battleHud?.rosterPanel?.pageCount > 1, undefined, { timeout: 30000 }); + await page.waitForFunction(() => { + const battle = window.__HEROS_DEBUG__?.battle(); + const hud = battle?.battleHud; + return ( + battle?.phase === 'idle' && + hud?.rosterPanel?.pageCount > 1 && + hud?.quickTabs?.visible === true && + hud?.quickTabs?.enabled === true && + hud?.quickTabs?.activeTab === 'roster' && + hud?.quickTabs?.currentView === 'roster' + ); + }, undefined, { timeout: 30000 }); const restartedPage = await readLargeRosterHudProbe(page); assert( restartedPage.roster.page === 0, @@ -6560,6 +6807,25 @@ async function assertWebglBattleQuickTabSmoke(browser, url, battleId) { return battle?.phase === 'idle' && battle.battleHud?.quickTabs?.enabled === true; }, undefined, { timeout: 90000 }); + if (battleId === 'first-battle-zhuo-commandery') { + await page.waitForFunction( + () => window.__HEROS_DEBUG__?.battle()?.firstBattleTutorial?.active === true, + undefined, + { timeout: 30000 } + ); + await page.evaluate(() => { + window.__HEROS_GAME__?.scene.getScene('BattleScene')?.completeFirstBattleTutorial?.('skipped'); + }); + await page.waitForFunction(() => { + const battle = window.__HEROS_DEBUG__?.battle(); + return ( + battle?.firstBattleTutorial?.active === false && + battle.firstBattleTutorial.completion === 'skipped' && + battle.battleHud?.quickTabs?.enabled === true + ); + }, undefined, { timeout: 30000 }); + } + const rendererProbe = await page.evaluate(() => { const game = window.__HEROS_GAME__; const scene = game?.scene.getScene('BattleScene'); @@ -7663,9 +7929,15 @@ async function waitForStoryReady(page) { } async function assertFreshStoryAssetStreaming(page, requestedResourceUrls, requestStartIndex) { + let stableInitialBackground = null; for (let sampleIndex = 0; sampleIndex < 8; sampleIndex += 1) { const probe = await readStoryAssetStreamingProbe(page); assertStoryAssetStreamingProbe(probe, 0, 'fresh story page 1'); + stableInitialBackground ??= probe.story.background; + assert( + probe.story.background === stableInitialBackground, + `Expected repeated reads of the same story page to retain one deterministic background: ${JSON.stringify(probe)}` + ); assert( storyStreamingIndexes(probe.story.assetStreaming).every((pageIndex) => pageIndex <= 1), `Expected fresh story loading to stay within pages 1-2 before input: ${JSON.stringify(probe)}` @@ -7696,6 +7968,22 @@ async function assertFreshStoryAssetStreaming(page, requestedResourceUrls, reque `Expected every current cutscene unit base sheet to be loaded: ${JSON.stringify(probe)}` ); + const repeatedSortieBackgrounds = new Set(); + while (probe.story?.pageIndex < probe.story?.totalPages - 1) { + const targetPageIndex = probe.story.pageIndex + 1; + probe = await advanceOneStoryPage(page, targetPageIndex); + assertStoryAssetStreamingProbe(probe, targetPageIndex, `fresh story page ${targetPageIndex + 1}`); + if (probe.story.requestedBackground === 'story-sortie') { + repeatedSortieBackgrounds.add(probe.story.background); + } + } + assert( + repeatedSortieBackgrounds.size >= 2, + `Expected repeated sortie story pages to distribute deterministic background variants: ${JSON.stringify([ + ...repeatedSortieBackgrounds + ])}` + ); + const actionRequests = requestedResourceUrls .slice(requestStartIndex) .filter((url) => /\/unit-[^/?]+-actions(?:-[^/?]+)?\.png(?:[?#]|$)/i.test(decodeURIComponent(url))); diff --git a/scripts/verify-story-asset-data.mjs b/scripts/verify-story-asset-data.mjs index fedd310..12b0edf 100644 --- a/scripts/verify-story-asset-data.mjs +++ b/scripts/verify-story-asset-data.mjs @@ -1,3 +1,5 @@ +import { readdirSync } from 'node:fs'; +import { join } from 'node:path'; import { createServer } from 'vite'; const validCutsceneKinds = new Set(['muster', 'operation', 'victory']); @@ -15,7 +17,8 @@ const server = await createServer({ try { const scenarioModule = await server.ssrLoadModule('/src/game/data/scenario.ts'); const { battleScenarios } = await server.ssrLoadModule('/src/game/data/battles.ts'); - const { storyBackgroundAssets, storyBackgroundKeysFor } = await server.ssrLoadModule('/src/game/data/storyAssets.ts'); + const { storyBackgroundAssets, storyBackgroundKeyForPage, storyBackgroundKeysFor } = + await server.ssrLoadModule('/src/game/data/storyAssets.ts'); const { portraitAssetEntriesForKey, portraitKeyForSpeaker } = await server.ssrLoadModule('/src/game/data/portraitAssets.ts'); const { storyCutsceneActorProfileFor } = await server.ssrLoadModule('/src/game/data/storyCutscenes.ts'); const { hasUnitSheetAsset } = await server.ssrLoadModule('/src/game/data/unitAssets.ts'); @@ -24,6 +27,8 @@ try { const storySources = collectStorySources(scenarioModule, battleScenarios); const uniquePageIds = new Set(); const usedBackgroundKeys = new Set(); + const selectedBackgroundKeys = new Set(); + const selectedBackgroundUrlsByBase = new Map(); const usedPortraitKeys = new Set(); const usedCutsceneActorIds = new Set(); let pageCount = 0; @@ -44,9 +49,12 @@ try { index, page, usedBackgroundKeys, + selectedBackgroundKeys, + selectedBackgroundUrlsByBase, usedPortraitKeys, usedCutsceneActorIds, storyBackgroundAssets, + storyBackgroundKeyForPage, storyBackgroundKeysFor, portraitAssetEntriesForKey, portraitKeyForSpeaker, @@ -59,6 +67,9 @@ try { }); }); + validateStoryBackgroundVariantCoverage(errors, selectedBackgroundKeys); + validateRepeatedStoryBackgroundDistribution(errors, selectedBackgroundUrlsByBase); + if (errors.length) { console.error(`Story asset data verification failed with ${errors.length} issue(s):`); errors.slice(0, 100).forEach((error) => console.error(`- ${error}`)); @@ -69,7 +80,8 @@ try { } else { console.log( `Verified ${pageCount} story page references (${uniquePageIds.size} unique pages), ` + - `${cutsceneCount} cutscenes, ${usedBackgroundKeys.size} backgrounds, ` + + `${cutsceneCount} cutscenes, ${usedBackgroundKeys.size} background candidates, ` + + `${selectedBackgroundKeys.size} selected backgrounds, ` + `${usedPortraitKeys.size} portrait keys, and ${usedCutsceneActorIds.size} cutscene actor profiles.` ); } @@ -131,7 +143,16 @@ function validateStoryPage(context) { } function validateBackground(context, pageContext) { - const { errors, page, usedBackgroundKeys, storyBackgroundAssets, storyBackgroundKeysFor } = context; + const { + errors, + page, + usedBackgroundKeys, + selectedBackgroundKeys, + selectedBackgroundUrlsByBase, + storyBackgroundAssets, + storyBackgroundKeyForPage, + storyBackgroundKeysFor + } = context; if (!page.background) { return; } @@ -139,8 +160,71 @@ function validateBackground(context, pageContext) { const keys = storyBackgroundKeysFor(page.background).filter((key) => storyBackgroundAssets[key]); if (keys.length === 0) { errors.push(`${pageContext}: missing story background asset "${page.background}"`); + return; } keys.forEach((key) => usedBackgroundKeys.add(key)); + + const selectedKey = storyBackgroundKeyForPage(page.background, page.id); + const repeatedSelection = storyBackgroundKeyForPage(page.background, page.id); + if (selectedKey !== repeatedSelection) { + errors.push(`${pageContext}: story background selection must be deterministic`); + } + if (!keys.includes(selectedKey)) { + errors.push(`${pageContext}: selected background "${selectedKey}" is not a candidate for "${page.background}"`); + return; + } + + const selectedUrl = storyBackgroundAssets[selectedKey]; + if (!selectedUrl) { + errors.push(`${pageContext}: selected background "${selectedKey}" has no runtime asset URL`); + return; + } + + selectedBackgroundKeys.add(selectedKey); + const backgroundGroup = selectedBackgroundUrlsByBase.get(page.background) ?? { + pageCount: 0, + candidateUrls: new Set(), + selectedUrls: new Set() + }; + backgroundGroup.pageCount += 1; + keys.forEach((key) => backgroundGroup.candidateUrls.add(storyBackgroundAssets[key])); + backgroundGroup.selectedUrls.add(selectedUrl); + selectedBackgroundUrlsByBase.set(page.background, backgroundGroup); +} + +function validateStoryBackgroundVariantCoverage(errors, selectedBackgroundKeys) { + const firstVariantNumber = 33; + const lastVariantNumber = 134; + const expectedVariantCount = lastVariantNumber - firstVariantNumber + 1; + const variantKeys = readdirSync(join('src', 'assets', 'images', 'story'), { withFileTypes: true }) + .filter((entry) => entry.isFile()) + .map((entry) => entry.name.match(/^(\d+)-(.+)\.png$/i)) + .filter((match) => match && Number(match[1]) >= firstVariantNumber && Number(match[1]) <= lastVariantNumber) + .map((match) => `story-${match[2]}`) + .sort(); + + if (variantKeys.length !== expectedVariantCount) { + errors.push( + `story background variants ${firstVariantNumber}-${lastVariantNumber}: expected ${expectedVariantCount} files, ` + + `found ${variantKeys.length}` + ); + } + + variantKeys.forEach((key) => { + if (!selectedBackgroundKeys.has(key)) { + errors.push(`story background variant "${key}" is never selected by a campaign page`); + } + }); +} + +function validateRepeatedStoryBackgroundDistribution(errors, selectedBackgroundUrlsByBase) { + selectedBackgroundUrlsByBase.forEach(({ pageCount, candidateUrls, selectedUrls }, baseKey) => { + if (pageCount > 1 && candidateUrls.size > 1 && selectedUrls.size < 2) { + errors.push( + `${baseKey}: ${pageCount} repeated pages should use at least two of ${candidateUrls.size} background variants` + ); + } + }); } function validatePortrait(context, pageContext) { diff --git a/scripts/verify-visual-asset-data.mjs b/scripts/verify-visual-asset-data.mjs index d280fa6..f0acac6 100644 --- a/scripts/verify-visual-asset-data.mjs +++ b/scripts/verify-visual-asset-data.mjs @@ -1,4 +1,4 @@ -import { readFileSync, statSync } from 'node:fs'; +import { readFileSync, readdirSync, statSync } from 'node:fs'; import { createServer } from 'vite'; const server = await createServer({ @@ -16,7 +16,28 @@ const expectedV2IconFrames = { fire: 24, bean: 27, salve: 28, - wine: 29 + wine: 29, + confusion: 30, + benevolentCommand: 31, + azureDragonStrike: 32, + changbanRoar: 33, + singleRiderRescue: 34, + eastWindFire: 35, + hundredPacePierce: 36, + westernCavalryCharge: 37, + qilinStratagem: 38, + burn: 39 +}; + +const expectedDedicatedUsableIcons = { + benevolentCommand: 'benevolentCommand', + azureDragonStrike: 'azureDragonStrike', + changbanRoar: 'changbanRoar', + singleRiderRescue: 'singleRiderRescue', + eastWindFire: 'eastWindFire', + hundredPacePierce: 'hundredPacePierce', + westernCavalryCharge: 'westernCavalryCharge', + qilinStratagem: 'qilinStratagem' }; try { @@ -111,6 +132,9 @@ function validateBattleUiIconAtlas(errors, manifest) { } const frameLimit = manifest.columns * manifest.rows; + if (frameLimit !== 40) { + errors.push(`battle UI icon atlas must contain 40 frames, got ${frameLimit}`); + } Object.entries(manifest.frames).forEach(([key, frame]) => { assertNonEmptyString(errors, key, 'battle UI icon key'); if (!Number.isInteger(frame) || frame < 0 || frame >= frameLimit) { @@ -135,8 +159,8 @@ function validateBattleUiIconSources(errors, manifest) { } const entries = Object.entries(sources); - if (entries.length !== 8) { - errors.push(`battleUiIconManifest.v2Sources must contain 8 source icons, got ${entries.length}`); + if (entries.length !== 18) { + errors.push(`battleUiIconManifest.v2Sources must contain 18 source icons, got ${entries.length}`); } const sourceFrames = new Set(); @@ -190,6 +214,46 @@ function validateBattleUsableIcons(errors, usableCatalog, frames) { errors.push(`${context} must use the item's dedicated icon key "${id}", got "${usable.iconKey}"`); } }); + + const dedicatedIconKeys = []; + Object.entries(expectedDedicatedUsableIcons).forEach(([usableId, expectedIconKey]) => { + const usable = usableCatalog[usableId]; + if (!usable) { + errors.push(`usableCatalog.${usableId} must exist for its signature icon`); + return; + } + if (usable.iconKey !== expectedIconKey) { + errors.push(`usableCatalog.${usableId}.iconKey must use dedicated icon "${expectedIconKey}", got "${usable.iconKey}"`); + } + dedicatedIconKeys.push(usable.iconKey); + }); + + if (new Set(dedicatedIconKeys).size !== dedicatedIconKeys.length) { + errors.push('signature usable icon keys must not be duplicated'); + } + + const statusIconKeys = ['confusion', 'burn']; + const reservedGenericIconKeys = ['shout', 'fire']; + const premiumIconKeys = [...dedicatedIconKeys, ...statusIconKeys]; + if (new Set(premiumIconKeys).size !== premiumIconKeys.length) { + errors.push('signature and status icon keys must be unique'); + } + [...statusIconKeys, ...reservedGenericIconKeys].forEach((iconKey) => { + if (!(iconKey in frames)) { + errors.push(`battle UI icon manifest is missing dedicated status/generic icon "${iconKey}"`); + } + }); + if (frames.confusion === frames.shout) { + errors.push('confusion status icon frame must be distinct from shout'); + } + if (frames.burn === frames.fire) { + errors.push('burn status icon frame must be distinct from fire tactic'); + } + + const premiumFrames = premiumIconKeys.map((iconKey) => frames[iconKey]).filter(Number.isInteger); + if (new Set(premiumFrames).size !== premiumFrames.length) { + errors.push('signature and status icon frames must not be duplicated'); + } } function validateItemCatalog(errors, itemCatalog, equipmentSlots) { @@ -224,6 +288,7 @@ function validateItemCatalog(errors, itemCatalog, equipmentSlots) { function validateGeneratedEquipmentIcons(errors, itemCatalog) { const bootScene = readFileSync('src/game/scenes/BootScene.ts', 'utf8'); + const campScene = readFileSync('src/game/scenes/CampScene.ts', 'utf8'); const generatedItemIds = new Set([...bootScene.matchAll(/createItemIcon\('item-([^']+)'/g)].map((match) => match[1])); const catalogItemIds = new Set(Object.keys(itemCatalog)); @@ -241,6 +306,107 @@ function validateGeneratedEquipmentIcons(errors, itemCatalog) { errors.push(`BootScene generates item-${itemId}, but itemCatalog has no matching item`); } }); + + validateEquipmentIconDirectory(errors, catalogItemIds, 'src/assets/images/ui/equipment-icons/128', 128); + validateEquipmentIconDirectory(errors, catalogItemIds, 'src/assets/images/ui/equipment-icons/32', 32); + + const bootPreloadChecks = [ + { + pattern: /import\.meta\.glob\('\.\.\/\.\.\/assets\/images\/ui\/equipment-icons\/128\/\*\.png'/, + message: 'BootScene must import the 128px equipment icon glob' + }, + { + pattern: /import\.meta\.glob\('\.\.\/\.\.\/assets\/images\/ui\/equipment-icons\/32\/\*\.png'/, + message: 'BootScene must import the 32px equipment icon glob' + }, + { + pattern: /this\.preloadEquipmentIcons\(equipmentIcon128Modules,\s*''\)/, + message: 'BootScene must preload 128px equipment icons as item-${id}' + }, + { + pattern: /this\.preloadEquipmentIcons\(equipmentIcon32Modules,\s*'-micro'\)/, + message: 'BootScene must preload 32px equipment icons as item-${id}-micro' + }, + { + pattern: /this\.load\.image\(`item-\$\{itemId\}\$\{suffix\}`,\s*url\)/, + message: 'BootScene equipment icon loader must preserve item-${id}${suffix} texture keys' + } + ]; + bootPreloadChecks.forEach(({ pattern, message }) => { + if (!pattern.test(bootScene)) { + errors.push(message); + } + }); + + const smallCampEquipmentIcons = [ + ...campScene.matchAll( + /const icon = [^\n]*this\.add\.image\([^\n]*, `item-\$\{[^`]+?\}(-micro)?`\)\);[\s\S]{0,180}?icon\.setDisplaySize\(this\.campUiLength\((\d+)\)/g + ) + ]; + smallCampEquipmentIcons.forEach((match) => { + const suffix = match[1] ?? ''; + const displaySize = Number(match[2]); + if (displaySize <= 22 && suffix !== '-micro') { + errors.push(`CampScene equipment icon displayed at ${displaySize}px must use the -micro texture`); + } + }); + if (smallCampEquipmentIcons.filter((match) => Number(match[2]) <= 22 && match[1] === '-micro').length < 3) { + errors.push('CampScene must route its main 22px-or-smaller equipment slots through -micro textures'); + } + if ( + !/renderSortieEquipmentIcons\([^)]*frameSize = 18, iconSize = 14[^)]*\)[\s\S]{0,900}`item-\$\{item\.id\}-micro`/.test( + campScene + ) + ) { + errors.push('CampScene sortie equipment strip must use item-${id}-micro at its 14px default size'); + } +} + +function validateEquipmentIconDirectory(errors, catalogItemIds, directory, expectedSize) { + let filenames; + try { + filenames = readdirSync(directory); + } catch (error) { + errors.push(`equipment icon directory cannot be read at "${directory}": ${error.message}`); + return; + } + + const pngFilenames = filenames.filter((filename) => filename.endsWith('.png')); + filenames + .filter((filename) => !filename.endsWith('.png')) + .forEach((filename) => errors.push(`${directory} contains unexpected non-PNG asset "${filename}"`)); + + const assetIds = new Set(pngFilenames.map((filename) => filename.slice(0, -4))); + Array.from(catalogItemIds) + .sort() + .forEach((itemId) => { + if (!assetIds.has(itemId)) { + errors.push(`${directory} is missing ${itemId}.png`); + return; + } + const source = `${directory}/${itemId}.png`; + let dimensions; + try { + dimensions = readPngDimensions(source); + } catch (error) { + errors.push(`equipment icon cannot be read at "${source}": ${error.message}`); + return; + } + if (dimensions.width !== expectedSize || dimensions.height !== expectedSize) { + errors.push(`${source} must be exactly ${expectedSize}x${expectedSize}, got ${dimensions.width}x${dimensions.height}`); + } + if (dimensions.bitDepth !== 8 || dimensions.colorType !== 6) { + errors.push(`${source} must be an 8-bit RGBA PNG, got bitDepth=${dimensions.bitDepth} colorType=${dimensions.colorType}`); + } + }); + + Array.from(assetIds) + .sort() + .forEach((itemId) => { + if (!catalogItemIds.has(itemId)) { + errors.push(`${directory} contains extra icon ${itemId}.png with no matching itemCatalog entry`); + } + }); } function readPngDimensions(path) { diff --git a/src/assets/images/ui/battle-ui-icons-micro.png b/src/assets/images/ui/battle-ui-icons-micro.png index 2bd3b5e..0472e88 100644 Binary files a/src/assets/images/ui/battle-ui-icons-micro.png and b/src/assets/images/ui/battle-ui-icons-micro.png differ diff --git a/src/assets/images/ui/battle-ui-icons.png b/src/assets/images/ui/battle-ui-icons.png index 3d0d1d3..73332f8 100644 Binary files a/src/assets/images/ui/battle-ui-icons.png and b/src/assets/images/ui/battle-ui-icons.png differ diff --git a/src/assets/images/ui/equipment-icons/128/bravery-token.png b/src/assets/images/ui/equipment-icons/128/bravery-token.png new file mode 100644 index 0000000..eee5a46 Binary files /dev/null and b/src/assets/images/ui/equipment-icons/128/bravery-token.png differ diff --git a/src/assets/images/ui/equipment-icons/128/cloth-armor.png b/src/assets/images/ui/equipment-icons/128/cloth-armor.png new file mode 100644 index 0000000..ecf224d Binary files /dev/null and b/src/assets/images/ui/equipment-icons/128/cloth-armor.png differ diff --git a/src/assets/images/ui/equipment-icons/128/grain-pouch.png b/src/assets/images/ui/equipment-icons/128/grain-pouch.png new file mode 100644 index 0000000..4fd3517 Binary files /dev/null and b/src/assets/images/ui/equipment-icons/128/grain-pouch.png differ diff --git a/src/assets/images/ui/equipment-icons/128/green-dragon-glaive.png b/src/assets/images/ui/equipment-icons/128/green-dragon-glaive.png new file mode 100644 index 0000000..2b5ac95 Binary files /dev/null and b/src/assets/images/ui/equipment-icons/128/green-dragon-glaive.png differ diff --git a/src/assets/images/ui/equipment-icons/128/iron-spear.png b/src/assets/images/ui/equipment-icons/128/iron-spear.png new file mode 100644 index 0000000..65dc70e Binary files /dev/null and b/src/assets/images/ui/equipment-icons/128/iron-spear.png differ diff --git a/src/assets/images/ui/equipment-icons/128/lamellar-armor.png b/src/assets/images/ui/equipment-icons/128/lamellar-armor.png new file mode 100644 index 0000000..a69202a Binary files /dev/null and b/src/assets/images/ui/equipment-icons/128/lamellar-armor.png differ diff --git a/src/assets/images/ui/equipment-icons/128/leader-axe.png b/src/assets/images/ui/equipment-icons/128/leader-axe.png new file mode 100644 index 0000000..fbc3cf9 Binary files /dev/null and b/src/assets/images/ui/equipment-icons/128/leader-axe.png differ diff --git a/src/assets/images/ui/equipment-icons/128/mountain-guide-scroll.png b/src/assets/images/ui/equipment-icons/128/mountain-guide-scroll.png new file mode 100644 index 0000000..4148a78 Binary files /dev/null and b/src/assets/images/ui/equipment-icons/128/mountain-guide-scroll.png differ diff --git a/src/assets/images/ui/equipment-icons/128/oath-robe.png b/src/assets/images/ui/equipment-icons/128/oath-robe.png new file mode 100644 index 0000000..05ef69d Binary files /dev/null and b/src/assets/images/ui/equipment-icons/128/oath-robe.png differ diff --git a/src/assets/images/ui/equipment-icons/128/peach-charm.png b/src/assets/images/ui/equipment-icons/128/peach-charm.png new file mode 100644 index 0000000..f30c33e Binary files /dev/null and b/src/assets/images/ui/equipment-icons/128/peach-charm.png differ diff --git a/src/assets/images/ui/equipment-icons/128/rebel-vest.png b/src/assets/images/ui/equipment-icons/128/rebel-vest.png new file mode 100644 index 0000000..7b41b39 Binary files /dev/null and b/src/assets/images/ui/equipment-icons/128/rebel-vest.png differ diff --git a/src/assets/images/ui/equipment-icons/128/reinforced-lamellar.png b/src/assets/images/ui/equipment-icons/128/reinforced-lamellar.png new file mode 100644 index 0000000..ea8528c Binary files /dev/null and b/src/assets/images/ui/equipment-icons/128/reinforced-lamellar.png differ diff --git a/src/assets/images/ui/equipment-icons/128/serpent-spear.png b/src/assets/images/ui/equipment-icons/128/serpent-spear.png new file mode 100644 index 0000000..65f9b3f Binary files /dev/null and b/src/assets/images/ui/equipment-icons/128/serpent-spear.png differ diff --git a/src/assets/images/ui/equipment-icons/128/short-bow.png b/src/assets/images/ui/equipment-icons/128/short-bow.png new file mode 100644 index 0000000..25dc2ca Binary files /dev/null and b/src/assets/images/ui/equipment-icons/128/short-bow.png differ diff --git a/src/assets/images/ui/equipment-icons/128/sky-piercer-halberd.png b/src/assets/images/ui/equipment-icons/128/sky-piercer-halberd.png new file mode 100644 index 0000000..042f0bb Binary files /dev/null and b/src/assets/images/ui/equipment-icons/128/sky-piercer-halberd.png differ diff --git a/src/assets/images/ui/equipment-icons/128/training-sword.png b/src/assets/images/ui/equipment-icons/128/training-sword.png new file mode 100644 index 0000000..1a00fa2 Binary files /dev/null and b/src/assets/images/ui/equipment-icons/128/training-sword.png differ diff --git a/src/assets/images/ui/equipment-icons/128/twin-oath-blades.png b/src/assets/images/ui/equipment-icons/128/twin-oath-blades.png new file mode 100644 index 0000000..26bf737 Binary files /dev/null and b/src/assets/images/ui/equipment-icons/128/twin-oath-blades.png differ diff --git a/src/assets/images/ui/equipment-icons/128/war-manual.png b/src/assets/images/ui/equipment-icons/128/war-manual.png new file mode 100644 index 0000000..cfbe17f Binary files /dev/null and b/src/assets/images/ui/equipment-icons/128/war-manual.png differ diff --git a/src/assets/images/ui/equipment-icons/128/western-cavalry-spear.png b/src/assets/images/ui/equipment-icons/128/western-cavalry-spear.png new file mode 100644 index 0000000..011cf71 Binary files /dev/null and b/src/assets/images/ui/equipment-icons/128/western-cavalry-spear.png differ diff --git a/src/assets/images/ui/equipment-icons/128/white-feather-fan.png b/src/assets/images/ui/equipment-icons/128/white-feather-fan.png new file mode 100644 index 0000000..bf5265c Binary files /dev/null and b/src/assets/images/ui/equipment-icons/128/white-feather-fan.png differ diff --git a/src/assets/images/ui/equipment-icons/128/wind-quiver.png b/src/assets/images/ui/equipment-icons/128/wind-quiver.png new file mode 100644 index 0000000..1d0b3e3 Binary files /dev/null and b/src/assets/images/ui/equipment-icons/128/wind-quiver.png differ diff --git a/src/assets/images/ui/equipment-icons/128/yellow-scarf-charm.png b/src/assets/images/ui/equipment-icons/128/yellow-scarf-charm.png new file mode 100644 index 0000000..8e1f00b Binary files /dev/null and b/src/assets/images/ui/equipment-icons/128/yellow-scarf-charm.png differ diff --git a/src/assets/images/ui/equipment-icons/128/yellow-turban-saber.png b/src/assets/images/ui/equipment-icons/128/yellow-turban-saber.png new file mode 100644 index 0000000..1a50476 Binary files /dev/null and b/src/assets/images/ui/equipment-icons/128/yellow-turban-saber.png differ diff --git a/src/assets/images/ui/equipment-icons/32/bravery-token.png b/src/assets/images/ui/equipment-icons/32/bravery-token.png new file mode 100644 index 0000000..2f77681 Binary files /dev/null and b/src/assets/images/ui/equipment-icons/32/bravery-token.png differ diff --git a/src/assets/images/ui/equipment-icons/32/cloth-armor.png b/src/assets/images/ui/equipment-icons/32/cloth-armor.png new file mode 100644 index 0000000..14db60e Binary files /dev/null and b/src/assets/images/ui/equipment-icons/32/cloth-armor.png differ diff --git a/src/assets/images/ui/equipment-icons/32/grain-pouch.png b/src/assets/images/ui/equipment-icons/32/grain-pouch.png new file mode 100644 index 0000000..d618328 Binary files /dev/null and b/src/assets/images/ui/equipment-icons/32/grain-pouch.png differ diff --git a/src/assets/images/ui/equipment-icons/32/green-dragon-glaive.png b/src/assets/images/ui/equipment-icons/32/green-dragon-glaive.png new file mode 100644 index 0000000..1646aa8 Binary files /dev/null and b/src/assets/images/ui/equipment-icons/32/green-dragon-glaive.png differ diff --git a/src/assets/images/ui/equipment-icons/32/iron-spear.png b/src/assets/images/ui/equipment-icons/32/iron-spear.png new file mode 100644 index 0000000..d02e829 Binary files /dev/null and b/src/assets/images/ui/equipment-icons/32/iron-spear.png differ diff --git a/src/assets/images/ui/equipment-icons/32/lamellar-armor.png b/src/assets/images/ui/equipment-icons/32/lamellar-armor.png new file mode 100644 index 0000000..962b5e4 Binary files /dev/null and b/src/assets/images/ui/equipment-icons/32/lamellar-armor.png differ diff --git a/src/assets/images/ui/equipment-icons/32/leader-axe.png b/src/assets/images/ui/equipment-icons/32/leader-axe.png new file mode 100644 index 0000000..9a99a24 Binary files /dev/null and b/src/assets/images/ui/equipment-icons/32/leader-axe.png differ diff --git a/src/assets/images/ui/equipment-icons/32/mountain-guide-scroll.png b/src/assets/images/ui/equipment-icons/32/mountain-guide-scroll.png new file mode 100644 index 0000000..feb084a Binary files /dev/null and b/src/assets/images/ui/equipment-icons/32/mountain-guide-scroll.png differ diff --git a/src/assets/images/ui/equipment-icons/32/oath-robe.png b/src/assets/images/ui/equipment-icons/32/oath-robe.png new file mode 100644 index 0000000..f2c046f Binary files /dev/null and b/src/assets/images/ui/equipment-icons/32/oath-robe.png differ diff --git a/src/assets/images/ui/equipment-icons/32/peach-charm.png b/src/assets/images/ui/equipment-icons/32/peach-charm.png new file mode 100644 index 0000000..36b6560 Binary files /dev/null and b/src/assets/images/ui/equipment-icons/32/peach-charm.png differ diff --git a/src/assets/images/ui/equipment-icons/32/rebel-vest.png b/src/assets/images/ui/equipment-icons/32/rebel-vest.png new file mode 100644 index 0000000..876e5fd Binary files /dev/null and b/src/assets/images/ui/equipment-icons/32/rebel-vest.png differ diff --git a/src/assets/images/ui/equipment-icons/32/reinforced-lamellar.png b/src/assets/images/ui/equipment-icons/32/reinforced-lamellar.png new file mode 100644 index 0000000..e99c095 Binary files /dev/null and b/src/assets/images/ui/equipment-icons/32/reinforced-lamellar.png differ diff --git a/src/assets/images/ui/equipment-icons/32/serpent-spear.png b/src/assets/images/ui/equipment-icons/32/serpent-spear.png new file mode 100644 index 0000000..94b0c95 Binary files /dev/null and b/src/assets/images/ui/equipment-icons/32/serpent-spear.png differ diff --git a/src/assets/images/ui/equipment-icons/32/short-bow.png b/src/assets/images/ui/equipment-icons/32/short-bow.png new file mode 100644 index 0000000..3450d0b Binary files /dev/null and b/src/assets/images/ui/equipment-icons/32/short-bow.png differ diff --git a/src/assets/images/ui/equipment-icons/32/sky-piercer-halberd.png b/src/assets/images/ui/equipment-icons/32/sky-piercer-halberd.png new file mode 100644 index 0000000..0c68d0f Binary files /dev/null and b/src/assets/images/ui/equipment-icons/32/sky-piercer-halberd.png differ diff --git a/src/assets/images/ui/equipment-icons/32/training-sword.png b/src/assets/images/ui/equipment-icons/32/training-sword.png new file mode 100644 index 0000000..b8b828e Binary files /dev/null and b/src/assets/images/ui/equipment-icons/32/training-sword.png differ diff --git a/src/assets/images/ui/equipment-icons/32/twin-oath-blades.png b/src/assets/images/ui/equipment-icons/32/twin-oath-blades.png new file mode 100644 index 0000000..1c5819d Binary files /dev/null and b/src/assets/images/ui/equipment-icons/32/twin-oath-blades.png differ diff --git a/src/assets/images/ui/equipment-icons/32/war-manual.png b/src/assets/images/ui/equipment-icons/32/war-manual.png new file mode 100644 index 0000000..b07b3f4 Binary files /dev/null and b/src/assets/images/ui/equipment-icons/32/war-manual.png differ diff --git a/src/assets/images/ui/equipment-icons/32/western-cavalry-spear.png b/src/assets/images/ui/equipment-icons/32/western-cavalry-spear.png new file mode 100644 index 0000000..0bdacc2 Binary files /dev/null and b/src/assets/images/ui/equipment-icons/32/western-cavalry-spear.png differ diff --git a/src/assets/images/ui/equipment-icons/32/white-feather-fan.png b/src/assets/images/ui/equipment-icons/32/white-feather-fan.png new file mode 100644 index 0000000..ea95436 Binary files /dev/null and b/src/assets/images/ui/equipment-icons/32/white-feather-fan.png differ diff --git a/src/assets/images/ui/equipment-icons/32/wind-quiver.png b/src/assets/images/ui/equipment-icons/32/wind-quiver.png new file mode 100644 index 0000000..7646144 Binary files /dev/null and b/src/assets/images/ui/equipment-icons/32/wind-quiver.png differ diff --git a/src/assets/images/ui/equipment-icons/32/yellow-scarf-charm.png b/src/assets/images/ui/equipment-icons/32/yellow-scarf-charm.png new file mode 100644 index 0000000..7c066bc Binary files /dev/null and b/src/assets/images/ui/equipment-icons/32/yellow-scarf-charm.png differ diff --git a/src/assets/images/ui/equipment-icons/32/yellow-turban-saber.png b/src/assets/images/ui/equipment-icons/32/yellow-turban-saber.png new file mode 100644 index 0000000..6236c63 Binary files /dev/null and b/src/assets/images/ui/equipment-icons/32/yellow-turban-saber.png differ diff --git a/src/assets/images/ui/icon-sources/battle-ui-v2/azure-dragon-strike-v2.png b/src/assets/images/ui/icon-sources/battle-ui-v2/azure-dragon-strike-v2.png new file mode 100644 index 0000000..68c0640 Binary files /dev/null and b/src/assets/images/ui/icon-sources/battle-ui-v2/azure-dragon-strike-v2.png differ diff --git a/src/assets/images/ui/icon-sources/battle-ui-v2/benevolent-command-v2.png b/src/assets/images/ui/icon-sources/battle-ui-v2/benevolent-command-v2.png new file mode 100644 index 0000000..ed44b92 Binary files /dev/null and b/src/assets/images/ui/icon-sources/battle-ui-v2/benevolent-command-v2.png differ diff --git a/src/assets/images/ui/icon-sources/battle-ui-v2/burn-v2.png b/src/assets/images/ui/icon-sources/battle-ui-v2/burn-v2.png new file mode 100644 index 0000000..59b835b Binary files /dev/null and b/src/assets/images/ui/icon-sources/battle-ui-v2/burn-v2.png differ diff --git a/src/assets/images/ui/icon-sources/battle-ui-v2/changban-roar-v2.png b/src/assets/images/ui/icon-sources/battle-ui-v2/changban-roar-v2.png new file mode 100644 index 0000000..a30c751 Binary files /dev/null and b/src/assets/images/ui/icon-sources/battle-ui-v2/changban-roar-v2.png differ diff --git a/src/assets/images/ui/icon-sources/battle-ui-v2/confusion-v2.png b/src/assets/images/ui/icon-sources/battle-ui-v2/confusion-v2.png new file mode 100644 index 0000000..a897446 Binary files /dev/null and b/src/assets/images/ui/icon-sources/battle-ui-v2/confusion-v2.png differ diff --git a/src/assets/images/ui/icon-sources/battle-ui-v2/east-wind-fire-v2.png b/src/assets/images/ui/icon-sources/battle-ui-v2/east-wind-fire-v2.png new file mode 100644 index 0000000..b7d5840 Binary files /dev/null and b/src/assets/images/ui/icon-sources/battle-ui-v2/east-wind-fire-v2.png differ diff --git a/src/assets/images/ui/icon-sources/battle-ui-v2/hundred-pace-pierce-v2.png b/src/assets/images/ui/icon-sources/battle-ui-v2/hundred-pace-pierce-v2.png new file mode 100644 index 0000000..e903824 Binary files /dev/null and b/src/assets/images/ui/icon-sources/battle-ui-v2/hundred-pace-pierce-v2.png differ diff --git a/src/assets/images/ui/icon-sources/battle-ui-v2/qilin-stratagem-v2.png b/src/assets/images/ui/icon-sources/battle-ui-v2/qilin-stratagem-v2.png new file mode 100644 index 0000000..47a00cd Binary files /dev/null and b/src/assets/images/ui/icon-sources/battle-ui-v2/qilin-stratagem-v2.png differ diff --git a/src/assets/images/ui/icon-sources/battle-ui-v2/single-rider-rescue-v2.png b/src/assets/images/ui/icon-sources/battle-ui-v2/single-rider-rescue-v2.png new file mode 100644 index 0000000..ca423a4 Binary files /dev/null and b/src/assets/images/ui/icon-sources/battle-ui-v2/single-rider-rescue-v2.png differ diff --git a/src/assets/images/ui/icon-sources/battle-ui-v2/western-cavalry-charge-v2.png b/src/assets/images/ui/icon-sources/battle-ui-v2/western-cavalry-charge-v2.png new file mode 100644 index 0000000..a9f50bf Binary files /dev/null and b/src/assets/images/ui/icon-sources/battle-ui-v2/western-cavalry-charge-v2.png differ diff --git a/src/game/data/battleUiIcons.ts b/src/game/data/battleUiIcons.ts index ec2ac54..8023b22 100644 --- a/src/game/data/battleUiIcons.ts +++ b/src/game/data/battleUiIcons.ts @@ -37,11 +37,20 @@ export const battleUiIconFrames = { focus: 23, fire: 24, shout: 25, - confusion: 25, failure: 26, bean: 27, salve: 28, - wine: 29 + wine: 29, + confusion: 30, + benevolentCommand: 31, + azureDragonStrike: 32, + changbanRoar: 33, + singleRiderRescue: 34, + eastWindFire: 35, + hundredPacePierce: 36, + westernCavalryCharge: 37, + qilinStratagem: 38, + burn: 39 } as const; export type BattleUiIconKey = keyof typeof battleUiIconFrames; @@ -54,7 +63,17 @@ export const battleUiIconV2Sources = { fire: { frame: battleUiIconFrames.fire, source: 'src/assets/images/ui/icon-sources/battle-ui-v2/fire-v2.png' }, bean: { frame: battleUiIconFrames.bean, source: 'src/assets/images/ui/icon-sources/battle-ui-v2/bean-v2.png' }, salve: { frame: battleUiIconFrames.salve, source: 'src/assets/images/ui/icon-sources/battle-ui-v2/salve-v2.png' }, - wine: { frame: battleUiIconFrames.wine, source: 'src/assets/images/ui/icon-sources/battle-ui-v2/wine-v2.png' } + wine: { frame: battleUiIconFrames.wine, source: 'src/assets/images/ui/icon-sources/battle-ui-v2/wine-v2.png' }, + confusion: { frame: battleUiIconFrames.confusion, source: 'src/assets/images/ui/icon-sources/battle-ui-v2/confusion-v2.png' }, + benevolentCommand: { frame: battleUiIconFrames.benevolentCommand, source: 'src/assets/images/ui/icon-sources/battle-ui-v2/benevolent-command-v2.png' }, + azureDragonStrike: { frame: battleUiIconFrames.azureDragonStrike, source: 'src/assets/images/ui/icon-sources/battle-ui-v2/azure-dragon-strike-v2.png' }, + changbanRoar: { frame: battleUiIconFrames.changbanRoar, source: 'src/assets/images/ui/icon-sources/battle-ui-v2/changban-roar-v2.png' }, + singleRiderRescue: { frame: battleUiIconFrames.singleRiderRescue, source: 'src/assets/images/ui/icon-sources/battle-ui-v2/single-rider-rescue-v2.png' }, + eastWindFire: { frame: battleUiIconFrames.eastWindFire, source: 'src/assets/images/ui/icon-sources/battle-ui-v2/east-wind-fire-v2.png' }, + hundredPacePierce: { frame: battleUiIconFrames.hundredPacePierce, source: 'src/assets/images/ui/icon-sources/battle-ui-v2/hundred-pace-pierce-v2.png' }, + westernCavalryCharge: { frame: battleUiIconFrames.westernCavalryCharge, source: 'src/assets/images/ui/icon-sources/battle-ui-v2/western-cavalry-charge-v2.png' }, + qilinStratagem: { frame: battleUiIconFrames.qilinStratagem, source: 'src/assets/images/ui/icon-sources/battle-ui-v2/qilin-stratagem-v2.png' }, + burn: { frame: battleUiIconFrames.burn, source: 'src/assets/images/ui/icon-sources/battle-ui-v2/burn-v2.png' } } as const satisfies Partial>; export const battleUiIconManifest = { @@ -68,7 +87,7 @@ export const battleUiIconManifest = { microFrameHeight: battleUiIconMicroFrameSize, microMaxDisplaySize: battleUiIconMicroMaxDisplaySize, columns: 5, - rows: 6, + rows: 8, frames: battleUiIconFrames, v2Sources: battleUiIconV2Sources } as const; diff --git a/src/game/data/battleUsables.ts b/src/game/data/battleUsables.ts index 9b4d8d1..d28ac99 100644 --- a/src/game/data/battleUsables.ts +++ b/src/game/data/battleUsables.ts @@ -97,7 +97,7 @@ export const usableCatalog: Record = { }, benevolentCommand: { id: 'benevolentCommand', - iconKey: 'focus', + iconKey: 'benevolentCommand', command: 'strategy', name: '인덕의 호령', target: 'ally', @@ -114,7 +114,7 @@ export const usableCatalog: Record = { }, azureDragonStrike: { id: 'azureDragonStrike', - iconKey: 'strategy', + iconKey: 'azureDragonStrike', command: 'strategy', name: '청룡일섬', target: 'enemy', @@ -129,7 +129,7 @@ export const usableCatalog: Record = { }, changbanRoar: { id: 'changbanRoar', - iconKey: 'confusion', + iconKey: 'changbanRoar', command: 'strategy', name: '장판교 대갈', target: 'enemy', @@ -147,7 +147,7 @@ export const usableCatalog: Record = { }, singleRiderRescue: { id: 'singleRiderRescue', - iconKey: 'heal', + iconKey: 'singleRiderRescue', command: 'strategy', name: '단기구원', target: 'ally', @@ -160,7 +160,7 @@ export const usableCatalog: Record = { }, eastWindFire: { id: 'eastWindFire', - iconKey: 'fire', + iconKey: 'eastWindFire', command: 'strategy', name: '동남풍 화계', target: 'enemy', @@ -178,7 +178,7 @@ export const usableCatalog: Record = { }, hundredPacePierce: { id: 'hundredPacePierce', - iconKey: 'strategy', + iconKey: 'hundredPacePierce', command: 'strategy', name: '백보천양', target: 'enemy', @@ -193,7 +193,7 @@ export const usableCatalog: Record = { }, westernCavalryCharge: { id: 'westernCavalryCharge', - iconKey: 'confusion', + iconKey: 'westernCavalryCharge', command: 'strategy', name: '서량철기', target: 'enemy', @@ -211,7 +211,7 @@ export const usableCatalog: Record = { }, qilinStratagem: { id: 'qilinStratagem', - iconKey: 'confusion', + iconKey: 'qilinStratagem', command: 'strategy', name: '기린지계', target: 'enemy', diff --git a/src/game/data/battles.ts b/src/game/data/battles.ts index 57f464a..1ec0c3b 100644 --- a/src/game/data/battles.ts +++ b/src/game/data/battles.ts @@ -530,10 +530,10 @@ export const firstBattleScenario: BattleScenarioDefinition = { supplies: ['콩 1', '탁주 1'], equipment: ['연습검 1'], reputation: ['의용군 명성 +1'], - recruits: ['guan-yu', 'zhang-fei'], + recruits: ['guan-yu', 'zhang-fei', 'jian-yong'], unlockBattleId: 'second-battle-yellow-turban-pursuit', unlockLabel: '황건 잔당 추격 개방', - note: '도원 형제의 의용군이 정식으로 출전 가능한 부대로 정리됩니다.' + note: '간옹이 합류합니다. 다음 전투부터 유비·관우·장비·간옹 4명 중 최대 3명을 골라 편성하며, 추천 편성은 세 형제입니다.' }, victoryPages: firstBattleVictoryPages, nextCampScene: 'CampScene' @@ -1181,10 +1181,10 @@ export const seventhBattleScenario: BattleScenarioDefinition = { supplies: ['콩 2', '상처약 2', '탁주 1'], equipment: ['병법서 1'], reputation: ['서주 민심 +1'], - recruits: ['jian-yong', 'mi-zhu'], + recruits: ['mi-zhu'], unlockBattleId: 'eighth-battle-xiaopei-supply-road', unlockLabel: '소패 보급로 방위전 개방', - note: '서주 구원으로 간옹과 미축이 합류합니다. 다음 전투부터 책사/보급관 선택지가 출전 준비에 표시됩니다.' + note: '서주 구원으로 미축이 합류합니다. 탁현부터 함께한 간옹의 책략과 미축의 보급을 조합하는 선택지가 출전 준비에 추가됩니다.' }, victoryPages: seventhBattleVictoryPages, nextCampScene: 'CampScene' @@ -1197,8 +1197,8 @@ export const eighthBattleScenario: BattleScenarioDefinition = { defeatConditionLabel: '유비 퇴각', openingObjectiveLines: [ '여포군의 척후와 습격병이 소패 보급로의 창고를 노리고 있습니다. 고순을 격파하면 서주의 첫 방위선을 지킬 수 있습니다.', - '간옹은 병법서와 책략으로 길목 압박을 늦추고, 미축은 창고 주변 보급과 회복을 맡기 좋습니다. 마을과 창고 지형을 중심으로 전열을 잡으십시오.', - '35턴 이내 승리하면 서주 보급망과 새 합류 무장의 명성이 크게 오릅니다.' + '탁현부터 함께한 간옹은 병법서와 책략으로 길목 압박을 늦추고, 새로 합류한 미축은 창고 주변 보급과 회복을 맡기 좋습니다. 마을과 창고 지형을 중심으로 전열을 잡으십시오.', + '35턴 이내 승리하면 서주 보급망과 미축의 군영 명성이 크게 오릅니다.' ], tacticalGuide: { summary: '소패 보급로 교두보를 먼저 안정시키고, 관우가 길목을 버티는 동안 간옹과 미축이 후방에서 회복과 책략 보조를 이어갑니다.', @@ -1243,7 +1243,7 @@ export const eighthBattleScenario: BattleScenarioDefinition = { { role: 'flank', unitId: 'zhang-fei', x: 2, y: 18, label: '남쪽 압박' }, { role: 'support', unitId: 'liu-bei', x: 2, y: 17, label: '후방 지휘' } ], - note: '소패 보급로는 새로 합류한 간옹과 미축을 시험하기 좋은 전장입니다. 관우가 길목을 막고, 간옹은 책략, 미축은 보급을 맡는 전열이 기본입니다.' + note: '소패 보급로는 기존 책사 간옹과 새 보급관 미축의 조합을 시험하기 좋은 전장입니다. 관우가 길목을 막고, 간옹은 책략, 미축은 보급을 맡는 전열이 기본입니다.' }, map: eighthBattleMap, units: eighthBattleUnits, diff --git a/src/game/data/scenario.ts b/src/game/data/scenario.ts index 6139cc8..e4f3736 100644 --- a/src/game/data/scenario.ts +++ b/src/game/data/scenario.ts @@ -364,15 +364,16 @@ const firstBattleStoryOverrides: Record> = { 'first-victory-next': { chapter: '다음 길', speaker: '전황', - text: '세 형제의 이름은 탁현 일대에 퍼지기 시작했다. 이제 황건 잔당의 퇴로를 끊어야 한다.', + text: '탁현에서부터 유비를 도와 온 간옹이 의용군의 군정과 책략을 맡기로 했다. 다음 전투부터 유비·관우·장비·간옹 네 명 중 최대 세 명을 골라 전장에 맞는 편성을 꾸릴 수 있다.', cutscene: { kind: 'operation', - title: '다음 전투 해금', - subtitle: '황건 잔당 추격으로 이어집니다.', + title: '간옹 합류 · 첫 편성 선택', + subtitle: '4명 중 최대 3명을 골라 황건 잔당 추격에 나섭니다.', actors: [ - { unitId: 'liu-bei', x: 0.25, y: 0.54, size: 108, direction: 'east', label: '유비' }, - { unitId: 'guan-yu', x: 0.34, y: 0.53, size: 112, direction: 'east', label: '관우' }, - { unitId: 'zhang-fei', x: 0.43, y: 0.54, size: 112, direction: 'east', label: '장비' } + { unitId: 'liu-bei', x: 0.22, y: 0.54, size: 108, direction: 'east', label: '유비' }, + { unitId: 'guan-yu', x: 0.32, y: 0.53, size: 112, direction: 'east', label: '관우' }, + { unitId: 'zhang-fei', x: 0.42, y: 0.54, size: 112, direction: 'east', label: '장비' }, + { unitId: 'jian-yong', x: 0.52, y: 0.55, size: 106, direction: 'west', label: '간옹' } ], markers: [ { x: 0.24, y: 0.68, label: '탁현', side: 'ally' }, @@ -381,10 +382,11 @@ const firstBattleStoryOverrides: Record> = { ], briefing: { title: '정비 후 출전', - lines: ['보급품 배정', '장비 교체', '출전 무장 선택'] + lines: ['신규 무장 간옹 확인', '4명 중 최대 3명 편성', '추천 편성은 유비·관우·장비'] }, rewards: [ - { label: '군영에서 보상 확인', tone: 'reward' }, + { label: '신규 무장: 간옹', tone: 'reward' }, + { label: '편성: 4명 중 최대 3명', tone: 'bond' }, { label: '다음: 황건 잔당 추격', tone: 'next' } ] } @@ -841,7 +843,7 @@ export const seventhBattleVictoryPages: StoryPage[] = [ bgm: 'militia-theme', chapter: '서주의 문', background: 'story-xuzhou', - text: '조조군 선봉이 물러서자 서주 성문 앞 피난민들의 숨통이 트였다. 도겸은 유비의 군기를 맞이하며 깊이 고개를 숙였다.' + text: '조조군 선봉이 물러서자 서주 성문 앞 피난민들의 숨통이 트였다. 도겸은 유비의 군기를 맞이하며 깊이 고개를 숙였고, 미축은 보급 장부를 들고 유비군에 합류했다.' }, { id: 'seventh-tao-qian-entrusts', @@ -860,7 +862,7 @@ export const eighthBattleIntroPages: StoryPage[] = [ bgm: 'militia-theme', chapter: '서주의 새 군영', background: 'story-xuzhou', - text: '도겸은 병든 몸을 일으켜 서주의 장부와 군량 창고를 유비에게 넘겼다. 간옹과 미축은 백성의 청원과 보급로를 정리하며 새 군영의 기초를 세웠다.' + text: '도겸은 병든 몸을 일으켜 서주의 장부와 군량 창고를 유비에게 넘겼다. 오래 함께해 온 간옹은 백성의 청원을 정리하고, 새로 합류한 미축은 보급로와 창고를 맡아 새 군영의 기초를 세웠다.' }, { id: 'eighth-lu-bu-shadow', @@ -885,7 +887,7 @@ export const eighthBattleIntroPages: StoryPage[] = [ chapter: '첫 방위전', background: 'story-xuzhou', speaker: '간옹', - text: '현덕, 이제 함께 갈 장수를 고르는 일도 전술이 되었네. 칼이 필요한 곳과 말이 필요한 곳, 그리고 말보다 장부가 필요한 곳을 구분해야 하네.' + text: '현덕, 탁현에서 시작한 편성 선택이 이제 더 넓어졌네. 칼이 필요한 곳과 책략이 필요한 곳, 그리고 말보다 장부가 필요한 곳을 구분해 미축까지 알맞게 배치해야 하네.' } ]; @@ -5144,7 +5146,7 @@ export const firstBattleUnits: UnitData[] = [ accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 } }, x: 5, - y: 13 + y: 14 }, { id: 'rebel-b', @@ -5358,15 +5360,44 @@ export const firstBattleUnits: UnitData[] = [ } ]; +// 간옹은 첫 승리 직후 합류하므로 2전 데이터가 평가되기 전에 공용 템플릿을 선언한다. +// 후반 서주 합류 배열도 이 템플릿을 재사용해 같은 무장이 두 번 정의되지 않게 한다. +export const jianYongRecruitUnit: UnitData = { + id: 'jian-yong', + name: '간옹', + faction: 'ally', + className: '책사', + classKey: 'strategist', + level: 4, + exp: 12, + hp: 26, + maxHp: 26, + attack: 7, + move: 4, + stats: { might: 42, intelligence: 78, leadership: 66, agility: 64, luck: 72 }, + equipment: { + weapon: { itemId: 'training-sword', level: 1, exp: 8 }, + armor: { itemId: 'cloth-armor', level: 1, exp: 6 }, + accessory: { itemId: 'war-manual', level: 1, exp: 0 } + }, + x: 1, + y: 17 +}; + +const earlyCampaignAllies: UnitData[] = [ + ...firstBattleUnits.filter((unit) => unit.faction === 'ally'), + jianYongRecruitUnit +]; + const secondBattleAllyPositions: Record = { 'liu-bei': { x: 2, y: 15 }, 'guan-yu': { x: 3, y: 15 }, - 'zhang-fei': { x: 2, y: 16 } + 'zhang-fei': { x: 2, y: 16 }, + 'jian-yong': { x: 1, y: 16 } }; export const secondBattleUnits: UnitData[] = [ - ...firstBattleUnits - .filter((unit) => unit.faction === 'ally') + ...earlyCampaignAllies .map((unit) => placeScenarioUnit(unit, secondBattleAllyPositions[unit.id] ?? { x: unit.x, y: unit.y })), { id: 'pursuit-raider-a', @@ -5604,12 +5635,12 @@ export const secondBattleUnits: UnitData[] = [ const thirdBattleAllyPositions: Record = { 'liu-bei': { x: 2, y: 16 }, 'guan-yu': { x: 3, y: 16 }, - 'zhang-fei': { x: 2, y: 17 } + 'zhang-fei': { x: 2, y: 17 }, + 'jian-yong': { x: 1, y: 17 } }; export const thirdBattleUnits: UnitData[] = [ - ...firstBattleUnits - .filter((unit) => unit.faction === 'ally') + ...earlyCampaignAllies .map((unit) => placeScenarioUnit(unit, thirdBattleAllyPositions[unit.id] ?? { x: unit.x, y: unit.y })), { id: 'guangzong-scout-a', @@ -5868,12 +5899,12 @@ export const thirdBattleUnits: UnitData[] = [ const fourthBattleAllyPositions: Record = { 'liu-bei': { x: 2, y: 16 }, 'guan-yu': { x: 3, y: 16 }, - 'zhang-fei': { x: 2, y: 17 } + 'zhang-fei': { x: 2, y: 17 }, + 'jian-yong': { x: 1, y: 17 } }; export const fourthBattleUnits: UnitData[] = [ - ...firstBattleUnits - .filter((unit) => unit.faction === 'ally') + ...earlyCampaignAllies .map((unit) => placeScenarioUnit(unit, fourthBattleAllyPositions[unit.id] ?? { x: unit.x, y: unit.y })), { id: 'guangzong-main-vanguard-a', @@ -6132,12 +6163,12 @@ export const fourthBattleUnits: UnitData[] = [ const fifthBattleAllyPositions: Record = { 'liu-bei': { x: 2, y: 16 }, 'guan-yu': { x: 3, y: 16 }, - 'zhang-fei': { x: 2, y: 17 } + 'zhang-fei': { x: 2, y: 17 }, + 'jian-yong': { x: 1, y: 17 } }; export const fifthBattleUnits: UnitData[] = [ - ...firstBattleUnits - .filter((unit) => unit.faction === 'ally') + ...earlyCampaignAllies .map((unit) => placeScenarioUnit(unit, fifthBattleAllyPositions[unit.id] ?? { x: unit.x, y: unit.y })), { id: 'sishui-vanguard-a', @@ -6375,12 +6406,12 @@ export const fifthBattleUnits: UnitData[] = [ const sixthBattleAllyPositions: Record = { 'liu-bei': { x: 2, y: 15 }, 'guan-yu': { x: 3, y: 15 }, - 'zhang-fei': { x: 2, y: 16 } + 'zhang-fei': { x: 2, y: 16 }, + 'jian-yong': { x: 1, y: 16 } }; export const sixthBattleUnits: UnitData[] = [ - ...firstBattleUnits - .filter((unit) => unit.faction === 'ally') + ...earlyCampaignAllies .map((unit) => placeScenarioUnit(unit, sixthBattleAllyPositions[unit.id] ?? { x: unit.x, y: unit.y })), { id: 'jieqiao-skirmisher-a', @@ -6639,12 +6670,12 @@ export const sixthBattleUnits: UnitData[] = [ const seventhBattleAllyPositions: Record = { 'liu-bei': { x: 2, y: 15 }, 'guan-yu': { x: 3, y: 15 }, - 'zhang-fei': { x: 2, y: 16 } + 'zhang-fei': { x: 2, y: 16 }, + 'jian-yong': { x: 1, y: 16 } }; export const seventhBattleUnits: UnitData[] = [ - ...firstBattleUnits - .filter((unit) => unit.faction === 'ally') + ...earlyCampaignAllies .map((unit) => placeScenarioUnit(unit, seventhBattleAllyPositions[unit.id] ?? { x: unit.x, y: unit.y })), { id: 'xuzhou-raider-a', @@ -6901,27 +6932,7 @@ export const seventhBattleUnits: UnitData[] = [ ]; export const xuzhouRecruitUnits: UnitData[] = [ - { - id: 'jian-yong', - name: '간옹', - faction: 'ally', - className: '책사', - classKey: 'strategist', - level: 4, - exp: 12, - hp: 26, - maxHp: 26, - attack: 7, - move: 4, - stats: { might: 42, intelligence: 78, leadership: 66, agility: 64, luck: 72 }, - equipment: { - weapon: { itemId: 'training-sword', level: 1, exp: 8 }, - armor: { itemId: 'cloth-armor', level: 1, exp: 6 }, - accessory: { itemId: 'war-manual', level: 1, exp: 0 } - }, - x: 3, - y: 17 - }, + jianYongRecruitUnit, { id: 'mi-zhu', name: '미축', @@ -19141,15 +19152,17 @@ export const firstBattleBonds: BattleBond[] = [ } ]; +export const jianYongRecruitBond: BattleBond = { + id: 'liu-bei__jian-yong', + unitIds: ['liu-bei', 'jian-yong'], + title: '오랜 벗', + level: 48, + exp: 0, + description: '간옹은 유비가 흔들릴 때 현실적인 말로 길을 정리해 주는 오랜 벗이다.' +}; + export const xuzhouRecruitBonds: BattleBond[] = [ - { - id: 'liu-bei__jian-yong', - unitIds: ['liu-bei', 'jian-yong'], - title: '오랜 벗', - level: 48, - exp: 0, - description: '간옹은 유비가 흔들릴 때 현실적인 말로 길을 정리해 주는 오랜 벗이다.' - }, + jianYongRecruitBond, { id: 'liu-bei__mi-zhu', unitIds: ['liu-bei', 'mi-zhu'], @@ -19781,12 +19794,12 @@ export const jingRearCrisisBonds: BattleBond[] = [ } ]; -export const secondBattleBonds: BattleBond[] = firstBattleBonds.map(cloneBattleBondForScenario); -export const thirdBattleBonds: BattleBond[] = firstBattleBonds.map(cloneBattleBondForScenario); -export const fourthBattleBonds: BattleBond[] = firstBattleBonds.map(cloneBattleBondForScenario); -export const fifthBattleBonds: BattleBond[] = firstBattleBonds.map(cloneBattleBondForScenario); -export const sixthBattleBonds: BattleBond[] = firstBattleBonds.map(cloneBattleBondForScenario); -export const seventhBattleBonds: BattleBond[] = firstBattleBonds.map(cloneBattleBondForScenario); +export const secondBattleBonds: BattleBond[] = [...firstBattleBonds, jianYongRecruitBond].map(cloneBattleBondForScenario); +export const thirdBattleBonds: BattleBond[] = [...firstBattleBonds, jianYongRecruitBond].map(cloneBattleBondForScenario); +export const fourthBattleBonds: BattleBond[] = [...firstBattleBonds, jianYongRecruitBond].map(cloneBattleBondForScenario); +export const fifthBattleBonds: BattleBond[] = [...firstBattleBonds, jianYongRecruitBond].map(cloneBattleBondForScenario); +export const sixthBattleBonds: BattleBond[] = [...firstBattleBonds, jianYongRecruitBond].map(cloneBattleBondForScenario); +export const seventhBattleBonds: BattleBond[] = [...firstBattleBonds, jianYongRecruitBond].map(cloneBattleBondForScenario); export const eighthBattleBonds: BattleBond[] = [...firstBattleBonds, ...xuzhouRecruitBonds].map(cloneBattleBondForScenario); export const ninthBattleBonds: BattleBond[] = eighthBattleBonds.map(cloneBattleBondForScenario); export const tenthBattleBonds: BattleBond[] = ninthBattleBonds.map(cloneBattleBondForScenario); diff --git a/src/game/data/storyAssets.ts b/src/game/data/storyAssets.ts index 599be1d..cbe59c8 100644 --- a/src/game/data/storyAssets.ts +++ b/src/game/data/storyAssets.ts @@ -26,6 +26,35 @@ const storyAssetAliases: Record = { 'title-taoyuan': 'story-taoyuan-oath-title' }; +// A handful of later background variants use a scene name that is related to, +// but not prefixed by, the background alias referenced by scenario data. Keep +// those relationships explicit so page planning can still discover them +// without making the runtime depend on file-system order. +const storyAssetCandidateAliases: Record = { + 'story-chengdu-surrender': ['story-chengdu-outer-gate-surrender'], + 'story-yiling-fire-attack': ['story-yiling-fire-forest-corridor'] +}; + +// Hashing provides a stable default. These sparse assignments cover single-page +// families and hash collisions so every purpose-built 33-134 scene variant is +// actually shown somewhere in the campaign; the static verifier guards that +// coverage as story pages evolve. +const storyBackgroundPageAssignments: Record = { + 'yellow-turban-chaos': 'story-chaos-market-panic', + 'militia-rises': 'story-militia-training-yard', + 'fifth-liu-bei-choice': 'story-anti-dong-hulao-pass', + 'thirty-third-chengdu-encircled': 'story-chengdu-surrender-court', + 'thirty-third-final-plan': 'story-chengdu-outer-gate-surrender', + 'thirty-third-sortie': 'story-chengdu-surrender-hall', + 'forty-sixth-camps-stretch': 'story-yiling-fire-attack-river-ashes', + 'forty-sixth-lu-xun-fire': 'story-yiling-fire-attack-riverbank', + 'forty-sixth-liu-bei-orders': 'story-yiling-fire-forest-corridor', + 'forty-seventh-southern-unrest': 'story-nanzhong-forest-council', + 'fifty-sixth-tianshui-rumor': 'story-tianshui-front-market-command', + 'sixty-second-renewed-qishan-council': 'story-qishan-renewed-frozen-scout-line', + 'sixty-third-zhanghe-rear-raid': 'story-lucheng-pursuit-ravine-signal' +}; + function storyKeyFromPath(path: string) { const fileName = path.split('/').pop() ?? path; const slug = fileName.replace(/\.png$/i, '').replace(/^\d+-/, ''); @@ -47,9 +76,34 @@ Object.entries(storyAssetAliases).forEach(([alias, target]) => { export function storyBackgroundKeysFor(baseKey: string) { const prefix = `${baseKey}-`; - const keys = Object.keys(storyBackgroundAssets) + const prefixedKeys = Object.keys(storyBackgroundAssets) .filter((key) => key === baseKey || key.startsWith(prefix)) .sort(); + const candidateAliases = storyAssetCandidateAliases[baseKey] ?? []; + const keys = [...prefixedKeys, ...candidateAliases].filter((key) => Boolean(storyBackgroundAssets[key])); + return Array.from(new Set(keys)); } + +export function storyBackgroundKeyForPage(baseKey: string, pageId: string) { + const keys = storyBackgroundKeysFor(baseKey); + if (keys.length === 0) { + return baseKey; + } + + const assignedKey = storyBackgroundPageAssignments[pageId]; + if (assignedKey && keys.includes(assignedKey)) { + return assignedKey; + } + + return keys[stableStoryHash(pageId) % keys.length]; +} + +function stableStoryHash(value: string) { + let hash = 0; + for (let index = 0; index < value.length; index += 1) { + hash = (hash * 31 + value.charCodeAt(index)) >>> 0; + } + return hash; +} diff --git a/src/game/data/storyCutscenes.ts b/src/game/data/storyCutscenes.ts index e20133a..d0accde 100644 --- a/src/game/data/storyCutscenes.ts +++ b/src/game/data/storyCutscenes.ts @@ -75,6 +75,14 @@ const storyCutsceneActorProfiles: Record = { portraitKey: 'zhangFei', color: 0xd46a4c }, + 'jian-yong': { + unitId: 'jian-yong', + name: '간옹', + roleLabel: '군정·책략 보좌', + unitTextureKey: 'unit-shu-strategist', + portraitKey: 'jian-yong', + color: 0x7ea6b8 + }, 'rebel-leader': { unitId: 'rebel-leader', name: '황건 두령', diff --git a/src/game/data/unitAssets.ts b/src/game/data/unitAssets.ts index 581c197..865a114 100644 --- a/src/game/data/unitAssets.ts +++ b/src/game/data/unitAssets.ts @@ -21,6 +21,20 @@ type UnitSheetAsset = { actionUrl: string; }; +export type UnitActionSheetLoadResult = { + readyKeys: string[]; + missingKeys: string[]; + failedKeys: string[]; + timedOutKeys: string[]; + timedOut: boolean; +}; + +export type UnitActionSheetLoadOptions = { + watchdogMs?: number; +}; + +const defaultUnitActionSheetWatchdogMs = 15_000; + const unitSheetModules = import.meta.glob('../../assets/images/units/unit-*.png', { eager: true, import: 'default' @@ -116,38 +130,100 @@ export function loadUnitBaseSheets(scene: Phaser.Scene, keys: Iterable, export function loadUnitSheets(scene: Phaser.Scene, keys: Iterable, onReady: () => void) { const uniqueKeys = Array.from(new Set(keys)); - if (unitSheetsReady(scene, uniqueKeys)) { - onReady(); + loadUnitBaseSheets(scene, uniqueKeys, () => { + loadUnitActionSheets(scene, uniqueKeys, () => onReady()); + }); +} + +export function unitActionSheetsReady(scene: Phaser.Scene, keys: Iterable) { + return Array.from(keys).every((key) => scene.textures.exists(`${key}-actions`)); +} + +export function loadUnitActionSheets( + scene: Phaser.Scene, + keys: Iterable, + onComplete: (result: UnitActionSheetLoadResult) => void, + options: UnitActionSheetLoadOptions = {} +) { + const uniqueKeys = Array.from(new Set(keys)); + if (unitActionSheetsReady(scene, uniqueKeys)) { + onComplete({ readyKeys: uniqueKeys, missingKeys: [], failedKeys: [], timedOutKeys: [], timedOut: false }); return; } const pendingLoads: Array<{ key: string; url: string }> = []; uniqueKeys.forEach((key) => { const sheet = unitSheetAssetsByKey.get(key) ?? missingUnitSheet(key); - if (!scene.textures.exists(sheet.key) && !pendingUnitTextureKeys.has(sheet.key)) { - pendingUnitTextureKeys.add(sheet.key); - pendingLoads.push({ key: sheet.key, url: sheet.url }); - } if (!scene.textures.exists(sheet.actionKey) && !pendingUnitTextureKeys.has(sheet.actionKey)) { pendingUnitTextureKeys.add(sheet.actionKey); pendingLoads.push({ key: sheet.actionKey, url: sheet.actionUrl }); } }); - if (pendingLoads.length <= 0) { - waitForUnitSheets(scene, uniqueKeys, onReady); - return; - } - + const actionKeys = new Set(uniqueKeys.map((key) => `${key}-actions`)); + const failedActionKeys = new Set(); + const watchdogMs = normalizeUnitSheetWatchdogMs(options.watchdogMs); + let settled = false; + let pollTimer: ReturnType | undefined; + let watchdogTimer: ReturnType | undefined; const clearPendingLoads = () => { pendingLoads.forEach(({ key }) => pendingUnitTextureKeys.delete(key)); }; - - scene.load.once('complete', () => { + const cleanup = () => { + scene.load.off('complete', handleComplete); + scene.load.off('loaderror', handleLoadError); + scene.events.off('shutdown', handleShutdown); clearPendingLoads(); - waitForUnitSheets(scene, uniqueKeys, onReady); + if (pollTimer !== undefined) { + clearInterval(pollTimer); + pollTimer = undefined; + } + if (watchdogTimer !== undefined) { + clearTimeout(watchdogTimer); + watchdogTimer = undefined; + } + }; + const settle = (notify = true, timedOut = false) => { + if (settled) { + return; + } + settled = true; + cleanup(); + if (notify) { + finishUnitActionSheetLoad(scene, uniqueKeys, failedActionKeys, timedOut, onComplete); + } + }; + const allRequestsSettled = () => uniqueKeys.every((key) => { + const actionKey = `${key}-actions`; + return scene.textures.exists(actionKey) || failedActionKeys.has(actionKey); }); - scene.load.once('loaderror', clearPendingLoads); + const handleComplete = () => settle(); + const handleLoadError = (file: Phaser.Loader.File) => { + const key = String(file.key); + if (!actionKeys.has(key)) { + return; + } + failedActionKeys.add(key); + if (allRequestsSettled()) { + settle(); + } + }; + const handleShutdown = () => settle(false); + + scene.events.once('shutdown', handleShutdown); + scene.load.on('complete', handleComplete); + scene.load.on('loaderror', handleLoadError); + pollTimer = setInterval(() => { + if (unitActionSheetsReady(scene, uniqueKeys)) { + settle(); + } + }, 50); + watchdogTimer = setTimeout(() => settle(true, true), watchdogMs); + + if (pendingLoads.length <= 0) { + return; + } + pendingLoads.forEach(({ key, url }) => { scene.load.spritesheet(key, url, { frameWidth: unitSheetFrameSize, @@ -157,8 +233,10 @@ export function loadUnitSheets(scene: Phaser.Scene, keys: Iterable, onRe scene.load.start(); } -function unitSheetsReady(scene: Phaser.Scene, keys: Iterable) { - return Array.from(keys).every((key) => scene.textures.exists(key) && scene.textures.exists(`${key}-actions`)); +function normalizeUnitSheetWatchdogMs(value: number | undefined) { + return Number.isFinite(value) + ? Math.max(250, Math.min(defaultUnitActionSheetWatchdogMs, Math.round(value!))) + : defaultUnitActionSheetWatchdogMs; } function waitForUnitBaseSheetsAttempt(scene: Phaser.Scene, keys: string[], onReady: () => void, attempts = 0) { @@ -177,18 +255,29 @@ function waitForUnitBaseSheetsAttempt(scene: Phaser.Scene, keys: string[], onRea scene.time.delayedCall(50, () => waitForUnitBaseSheetsAttempt(scene, keys, onReady, attempts + 1)); } -function waitForUnitSheets(scene: Phaser.Scene, keys: string[], onReady: () => void, attempts = 0) { - if (unitSheetsReady(scene, keys)) { - onReady(); - return; +function finishUnitActionSheetLoad( + scene: Phaser.Scene, + keys: string[], + failedActionKeys: ReadonlySet, + timedOut: boolean, + onComplete: (result: UnitActionSheetLoadResult) => void +) { + const missingKeys = keys.filter((key) => !scene.textures.exists(`${key}-actions`)); + const failedKeys = missingKeys.filter((key) => failedActionKeys.has(`${key}-actions`) || !timedOut); + const timedOutKeys = timedOut ? missingKeys.filter((key) => !failedKeys.includes(key)) : []; + if (failedKeys.length > 0) { + console.warn(`Failed to load unit action sheets; base-sheet fallback will be used: ${failedKeys.join(', ')}`); } - - if (attempts > 200) { - const missing = keys.filter((key) => !scene.textures.exists(key) || !scene.textures.exists(`${key}-actions`)); - throw new Error(`Timed out waiting for unit sheet textures: ${missing.join(', ')}`); + if (timedOutKeys.length > 0) { + console.info(`Unit action sheet loading exceeded the startup budget; base-sheet fallback is active while loading continues: ${timedOutKeys.join(', ')}`); } - - scene.time.delayedCall(50, () => waitForUnitSheets(scene, keys, onReady, attempts + 1)); + onComplete({ + readyKeys: keys.filter((key) => !missingKeys.includes(key)), + missingKeys, + failedKeys, + timedOutKeys, + timedOut: timedOutKeys.length > 0 + }); } export function ensureUnitAnimations(scene: Phaser.Scene, keys: Iterable) { diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 384e866..25993d8 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -16,7 +16,8 @@ import { getTerrainRule, getUnitClass, type TerrainType, type UnitClassKey } fro import { ensureUnitAnimations, hasUnitSheetAsset, - loadUnitSheets, + loadUnitActionSheets, + loadUnitBaseSheets, unitBaseFramesPerDirection, unitTextureVariantKeys as unitAssetTextureVariantKeys, type UnitDirection @@ -95,7 +96,9 @@ import { campaignSortiePresetIds, campaignSaveSlotCount, cloneCampaignSortieRecommendationSnapshot, + completeCampaignTutorial, getCampaignState, + hasCompletedCampaignTutorial, listCampaignSaveSlots, loadCampaignState, markCampaignStep, @@ -106,6 +109,7 @@ import { setFirstBattleReport, type CampaignRewardSnapshot, type CampaignState, + type CampaignTutorialId, type CampaignSortieItemAssignments, type CampaignSortieOrderResultSnapshot, type CampaignSortiePerformanceSnapshot, @@ -128,6 +132,7 @@ import { startLazyScene } from './lazyScenes'; const battleReferenceWidth = 1280; const battleReferenceHeight = 720; const battleFhdUiScale = 1.5; +const firstBattleTutorialId: CampaignTutorialId = 'first-battle-basic-controls'; const unitTexture: Record = { 'liu-bei': 'unit-liu-bei', @@ -1350,6 +1355,7 @@ type UnitActionPose = 'attack' | 'strategy' | 'item' | 'hurt' | 'celebrate'; const levelUpCelebrationStepOffsets = [0, -7, -2, -9, 0, -6, -3, -8]; type BattlePhase = 'deployment' | 'idle' | 'moving' | 'command' | 'targeting' | 'animating' | 'resolved'; +type ScenarioCombatAssetStatus = 'idle' | 'loading' | 'ready' | 'degraded'; type BattleCommand = 'attack' | UsableCommand | 'wait'; type DamageCommand = Exclude; type SpecialDamageMotionKind = 'item' | 'arcane' | 'fire' | 'roar'; @@ -1475,6 +1481,18 @@ type PendingMove = { intentAfterMove: EnemyIntentSnapshot[]; }; +type FirstBattleTutorialStep = 'select-unit' | 'move' | 'attack-command' | 'target-preview'; + +type FirstBattleTutorialPath = { + unitId: string; + from: { x: number; y: number }; + moveTile: { x: number; y: number }; + targetId: string; + targetTile: { x: number; y: number }; +}; + +type FirstBattleTutorialCompletion = 'completed' | 'skipped'; + type AttackIntent = { attackerId: string; targetId: string; @@ -3445,6 +3463,15 @@ export class BattleScene extends Phaser.Scene { private readonly pendingUiContainerSorts = new WeakSet(); private layout!: BattleLayout; private phase: BattlePhase = 'idle'; + private scenarioCombatAssetStatus: ScenarioCombatAssetStatus = 'idle'; + private scenarioCombatAssetCallbacks: Array<() => void> = []; + private scenarioCombatAssetMissingActionKeys: string[] = []; + private scenarioCombatAssetMissingPortraitKeys: string[] = []; + private scenarioCombatAssetLoadStartedAt?: number; + private scenarioCombatAssetLoadCompletedAt?: number; + private scenarioCombatAssetLoadGeneration = 0; + private scenarioCombatAssetSettlementCount = 0; + private pendingDeploymentConfirmation = false; private turnNumber = 1; private activeFaction: ActiveFaction = 'ally'; private selectedUnit?: UnitData; @@ -3602,6 +3629,15 @@ export class BattleScene extends Phaser.Scene { private pointerFeedbackHintText?: Phaser.GameObjects.Text; private lastPointerFeedbackKey?: string; private suppressNextLeftClick = false; + private firstBattleTutorialStep?: FirstBattleTutorialStep; + private firstBattleTutorialPath?: FirstBattleTutorialPath; + private firstBattleTutorialFeedback = ''; + private firstBattleTutorialTargetPreviewLocked = false; + private firstBattleTutorialCompletion?: FirstBattleTutorialCompletion; + private firstBattleTutorialObjects: Phaser.GameObjects.GameObject[] = []; + private firstBattleTutorialHighlight?: Phaser.GameObjects.Rectangle; + private firstBattleTutorialCard?: Phaser.GameObjects.Rectangle; + private firstBattleTutorialSkipButton?: Phaser.GameObjects.Rectangle; private approachPathCostCache = new Map>(); private launchSortieUnitIds: string[] = []; private launchSortieFormationAssignments: SortieFormationAssignments = {}; @@ -3620,6 +3656,15 @@ export class BattleScene extends Phaser.Scene { init(data?: BattleSceneData) { configureBattleScenario(getBattleScenario(data?.battleId)); + this.scenarioCombatAssetLoadGeneration += 1; + this.scenarioCombatAssetStatus = 'idle'; + this.scenarioCombatAssetCallbacks = []; + this.scenarioCombatAssetMissingActionKeys = []; + this.scenarioCombatAssetMissingPortraitKeys = []; + this.scenarioCombatAssetLoadStartedAt = undefined; + this.scenarioCombatAssetLoadCompletedAt = undefined; + this.scenarioCombatAssetSettlementCount = 0; + this.pendingDeploymentConfirmation = false; this.rosterTab = 'ally'; this.rosterPage = 0; this.bondPage = 0; @@ -3637,6 +3682,15 @@ export class BattleScene extends Phaser.Scene { this.sidePanelTransitionCompletedRevision = 0; this.sidePanelTransitionActive = false; this.sidePanelTransitionLast = undefined; + this.firstBattleTutorialStep = undefined; + this.firstBattleTutorialPath = undefined; + this.firstBattleTutorialFeedback = ''; + this.firstBattleTutorialTargetPreviewLocked = false; + this.firstBattleTutorialCompletion = undefined; + this.firstBattleTutorialObjects = []; + this.firstBattleTutorialHighlight = undefined; + this.firstBattleTutorialCard = undefined; + this.firstBattleTutorialSkipButton = undefined; const campaign = getCampaignState(); const selectedOrderId = campaign.sortieOrderSelection?.battleId === battleScenario.id ? campaign.sortieOrderSelection.orderId @@ -3681,6 +3735,10 @@ export class BattleScene extends Phaser.Scene { this.tacticalCommandCutInLast = undefined; this.bondChainJudgementLast = undefined; this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => { + this.scenarioCombatAssetLoadGeneration += 1; + this.scenarioCombatAssetCallbacks = []; + this.pendingDeploymentConfirmation = false; + this.hideFirstBattleTutorial(); this.hideTacticalCommandCutIn(); this.hideCombatCutIn(); this.mapResultPopupObjects.forEach((object) => object.active && object.destroy()); @@ -3891,8 +3949,12 @@ export class BattleScene extends Phaser.Scene { this.updateTurnText(); if (this.shouldShowPreBattleDeployment()) { this.showPreBattleDeployment(); + this.time.delayedCall(250, () => this.ensureScenarioCombatAssets()); } else { - this.time.delayedCall(180, () => this.showOpeningBattleEvent()); + this.time.delayedCall(180, () => { + this.showOpeningBattleEvent(); + this.scheduleFirstBattleTutorial(); + }); this.renderRosterPanel('ally', '행동할 장수를 선택하세요.'); this.refreshEnemyIntentForecast(); } @@ -3902,7 +3964,13 @@ export class BattleScene extends Phaser.Scene { private ensureScenarioAssets(onReady: () => void) { this.ensureScenarioMapTexture(() => loadBattleUiIcons(this, () => - this.ensureScenarioUnitTextures(() => this.ensureScenarioCombatPortraits(onReady)) + this.ensureScenarioUnitBaseTextures(() => { + if (this.shouldShowPreBattleDeployment()) { + onReady(); + return; + } + this.ensureScenarioCombatAssets(onReady); + }) ) ); } @@ -3942,9 +4010,13 @@ export class BattleScene extends Phaser.Scene { this.load.start(); } - private ensureScenarioUnitTextures(onReady: () => void) { - const textureKeys = Array.from(new Set([...battleUnits.map((unit) => this.unitTextureKey(unit)), ...this.debugSpritePreviewKeys()])); - const texturesReady = textureKeys.every((key) => this.textures.exists(key) && this.textures.exists(`${key}-actions`)); + private scenarioUnitTextureKeys() { + return Array.from(new Set([...battleUnits.map((unit) => this.unitTextureKey(unit)), ...this.debugSpritePreviewKeys()])); + } + + private ensureScenarioUnitBaseTextures(onReady: () => void) { + const textureKeys = this.scenarioUnitTextureKeys(); + const texturesReady = textureKeys.every((key) => this.textures.exists(key)); if (texturesReady) { ensureUnitAnimations(this, textureKeys); @@ -3967,7 +4039,7 @@ export class BattleScene extends Phaser.Scene { }; this.load.once('loaderror', handleLoadError); - loadUnitSheets(this, textureKeys, () => { + loadUnitBaseSheets(this, textureKeys, () => { this.load.off('loaderror', handleLoadError); loadingText.destroy(); ensureUnitAnimations(this, textureKeys); @@ -3975,7 +4047,65 @@ export class BattleScene extends Phaser.Scene { }); } - private ensureScenarioCombatPortraits(onReady: () => void) { + private ensureScenarioCombatAssets(onReady?: () => void) { + if (onReady) { + this.scenarioCombatAssetCallbacks.push(onReady); + } + if (this.scenarioCombatAssetStatus === 'ready' || this.scenarioCombatAssetStatus === 'degraded') { + this.flushScenarioCombatAssetCallbacks(); + return; + } + if (this.scenarioCombatAssetStatus === 'loading') { + return; + } + + this.scenarioCombatAssetStatus = 'loading'; + this.scenarioCombatAssetLoadStartedAt = Date.now(); + this.scenarioCombatAssetLoadCompletedAt = undefined; + const generation = this.scenarioCombatAssetLoadGeneration; + const watchdogMs = this.scenarioCombatAssetWatchdogMs(); + const textureKeys = this.scenarioUnitTextureKeys(); + loadUnitActionSheets(this, textureKeys, (actionResult) => { + if (generation !== this.scenarioCombatAssetLoadGeneration) { + return; + } + this.scenarioCombatAssetMissingActionKeys = [...actionResult.missingKeys]; + this.ensureScenarioCombatPortraits(() => { + if (generation !== this.scenarioCombatAssetLoadGeneration) { + return; + } + this.scenarioCombatAssetLoadCompletedAt = Date.now(); + this.scenarioCombatAssetStatus = + actionResult.missingKeys.length > 0 || this.scenarioCombatAssetMissingPortraitKeys.length > 0 + ? 'degraded' + : 'ready'; + this.scenarioCombatAssetSettlementCount += 1; + this.flushScenarioCombatAssetCallbacks(); + if (this.pendingDeploymentConfirmation && this.phase === 'deployment') { + this.pendingDeploymentConfirmation = false; + this.confirmPreBattleDeployment(); + } + }, watchdogMs, generation); + }, { watchdogMs }); + } + + private scenarioCombatAssetWatchdogMs() { + const fallback = 15_000; + if (!this.debugToolsEnabled() || typeof window === 'undefined') { + return fallback; + } + const requested = Number(new URLSearchParams(window.location.search).get('debugCombatAssetWatchdogMs')); + return Number.isFinite(requested) + ? Phaser.Math.Clamp(Math.round(requested), 250, fallback) + : fallback; + } + + private flushScenarioCombatAssetCallbacks() { + const callbacks = this.scenarioCombatAssetCallbacks.splice(0); + callbacks.forEach((callback) => callback()); + } + + private ensureScenarioCombatPortraits(onReady: () => void, watchdogMs: number, generation: number) { const portraitEntries = Array.from( new Map( battleUnits @@ -3987,6 +4117,7 @@ export class BattleScene extends Phaser.Scene { const missingEntries = portraitEntries.filter((entry) => !this.textures.exists(entry.textureKey)); if (missingEntries.length === 0) { + this.scenarioCombatAssetMissingPortraitKeys = []; onReady(); return; } @@ -4004,12 +4135,52 @@ export class BattleScene extends Phaser.Scene { const handleLoadError = () => { loadingText.setText('일부 장수 초상을 불러오지 못했습니다.'); }; - this.load.on('loaderror', handleLoadError); - this.load.once('complete', () => { + loadingText.setVisible(false); + let settled = false; + let pollTimer: ReturnType | undefined; + let watchdogTimer: ReturnType | undefined; + const handleComplete = () => settle(); + const handleShutdown = () => settle(false); + const cleanup = () => { + this.load.off('complete', handleComplete); this.load.off('loaderror', handleLoadError); - loadingText.destroy(); + this.events.off(Phaser.Scenes.Events.SHUTDOWN, handleShutdown); + if (pollTimer !== undefined) { + clearInterval(pollTimer); + pollTimer = undefined; + } + if (watchdogTimer !== undefined) { + clearTimeout(watchdogTimer); + watchdogTimer = undefined; + } + }; + const settle = (notify = true) => { + if (settled) { + return; + } + settled = true; + cleanup(); + if (loadingText.active) { + loadingText.destroy(); + } + if (!notify || generation !== this.scenarioCombatAssetLoadGeneration) { + return; + } + this.scenarioCombatAssetMissingPortraitKeys = missingEntries + .map((entry) => entry.textureKey) + .filter((key) => !this.textures.exists(key)); onReady(); - }); + }; + + this.load.on('loaderror', handleLoadError); + this.load.on('complete', handleComplete); + this.events.once(Phaser.Scenes.Events.SHUTDOWN, handleShutdown); + pollTimer = setInterval(() => { + if (missingEntries.every((entry) => this.textures.exists(entry.textureKey))) { + settle(); + } + }, 50); + watchdogTimer = setTimeout(() => settle(), watchdogMs); missingEntries.forEach((entry) => this.load.image(entry.textureKey, entry.url)); this.load.start(); } @@ -5688,7 +5859,7 @@ export class BattleScene extends Phaser.Scene { const portraitKey = this.combatPortraitKey(actor); const portrait = portraitKey && this.textures.exists(portraitKey) ? this.fitPortraitToFrame(this.add.image(92, height / 2, portraitKey), 132, 166) - : this.add.sprite(92, height / 2, this.unitActionTexture(actor), this.unitActionFrameIndex('south', 'strategy')) + : this.add.sprite(92, height / 2, this.unitActionTexture(actor), this.unitActionFrame(actor, 'south', 'strategy')) .setDisplaySize(132, 132); const iconFrame = this.add.rectangle(202, 42, 54, 54, 0x0a0f14, 0.96); @@ -5961,11 +6132,15 @@ export class BattleScene extends Phaser.Scene { idle50.setDepth(depth + 1); this.applyBaseSpriteBlend(idle50); + const actionTextureKey = `${textureKey}-actions`; + const actionTextureReady = this.textures.exists(actionTextureKey); const action = this.add.sprite( left + 196, top + 76, - `${textureKey}-actions`, - this.unitActionFrameIndex('east', 'attack', this.unitActionImpactFrame('attack')) + actionTextureReady ? actionTextureKey : textureKey, + actionTextureReady + ? this.unitActionFrameIndex('east', 'attack', this.unitActionImpactFrame('attack')) + : this.unitFrameIndex('east') ); action.setDisplaySize(78, 78); action.setDepth(depth + 1); @@ -6250,6 +6425,7 @@ export class BattleScene extends Phaser.Scene { this.positionBondChainReadyMarkers(); this.positionObjectiveMapMarkers(); + this.positionFirstBattleTutorialHighlight(); battleUnits.forEach((unit) => this.positionUnitView(unit)); this.positionDeploymentOverlayObjects(); this.updateMiniMap(); @@ -6817,6 +6993,31 @@ export class BattleScene extends Phaser.Scene { return; } + const tutorialPath = this.firstBattleTutorialPath; + const tutorialStep = this.firstBattleTutorialStep; + const completingTutorialSelect = Boolean( + tutorialPath && tutorialStep === 'select-unit' && unit.id === tutorialPath.unitId + ); + if (tutorialPath && tutorialStep === 'select-unit' && unit.id !== tutorialPath.unitId) { + const expected = battleUnits.find((candidate) => candidate.id === tutorialPath.unitId); + this.showFirstBattleTutorialFeedback(`${unit.name}이 아니라 빛나는 ${expected?.name ?? '아군'}을 먼저 선택하세요.`); + return; + } + if (tutorialPath && tutorialStep === 'move' && unit.id !== tutorialPath.unitId) { + this.showFirstBattleTutorialFeedback('선택한 아군의 이동을 먼저 마치세요. 빛나는 이동칸을 클릭하면 됩니다.'); + return; + } + if (tutorialPath && tutorialStep === 'attack-command') { + this.showFirstBattleTutorialFeedback('다른 부대를 선택하기 전에 열린 명령 카드에서 공격을 선택하세요.'); + return; + } + if (tutorialPath && tutorialStep === 'target-preview' && unit.id !== tutorialPath.targetId) { + const expected = battleUnits.find((candidate) => candidate.id === tutorialPath.targetId); + this.showFirstBattleTutorialFeedback(`${unit.name}은 지금의 권장 대상이 아닙니다. 빛나는 ${expected?.name ?? '적'}을 선택하세요.`); + this.renderCurrentTargetingGuideNotice('튜토리얼: 빛나는 적을 선택해 공격 예측을 확인하세요.'); + return; + } + this.hideMapMenu(); this.hideSaveSlotPanel(); this.hideTurnEndPrompt(); @@ -6904,6 +7105,10 @@ export class BattleScene extends Phaser.Scene { this.updateMiniMap(); this.refreshUnitLegibilityStyles(); this.renderUnitDetail(unit, '이동할 파란 칸을 선택하세요.'); + if (completingTutorialSelect) { + this.suppressNextLeftClick = true; + this.setFirstBattleTutorialStep('move'); + } } private showMoveRange(unit: UnitData) { @@ -6942,6 +7147,17 @@ export class BattleScene extends Phaser.Scene { } private moveSelectedUnit(x: number, y: number, pointer?: Phaser.Input.Pointer) { + if (this.isFirstBattleTutorialActive()) { + const path = this.firstBattleTutorialPath!; + if (this.firstBattleTutorialStep !== 'move') { + this.showFirstBattleTutorialFeedback('현재 빛나는 단계의 조작을 먼저 완료하세요.'); + return; + } + if (this.selectedUnit?.id !== path.unitId || x !== path.moveTile.x || y !== path.moveTile.y) { + this.showFirstBattleTutorialFeedback('이 칸에서는 권장 적을 공격할 수 없습니다. 빛나는 이동칸을 선택하세요.'); + return; + } + } if (!this.selectedUnit || !this.canMoveTo(this.selectedUnit, x, y)) { return; } @@ -6985,6 +7201,10 @@ export class BattleScene extends Phaser.Scene { this.showCommandMenu(unit, commandX, commandY); this.renderUnitDetail(unit, '명령 선택 · 우클릭은 이동 취소'); this.hideTurnEndPrompt(); + if (this.firstBattleTutorialStep === 'move') { + this.suppressNextLeftClick = true; + this.setFirstBattleTutorialStep('attack-command'); + } } private showPostMoveTurnEndPrompt(unit: UnitData, commandX = this.tileCenterX(unit.x), commandY = this.tileCenterY(unit.y)) { @@ -8035,6 +8255,14 @@ export class BattleScene extends Phaser.Scene { return; } + if (this.scenarioCombatAssetStatus !== 'ready' && this.scenarioCombatAssetStatus !== 'degraded') { + this.pendingDeploymentConfirmation = true; + this.deploymentNotice = '\uC804\uD22C \uB3D9\uC791\uC744 \uC900\uBE44\uD558\uACE0 \uC788\uC2B5\uB2C8\uB2E4. \uC900\uBE44\uAC00 \uB05D\uB098\uBA74 \uC790\uB3D9\uC73C\uB85C \uC804\uD22C\uB97C \uC2DC\uC791\uD569\uB2C8\uB2E4.'; + this.renderDeploymentPanel(); + this.ensureScenarioCombatAssets(); + return; + } + this.clearDeploymentObjects(); this.clearPointerFeedback(); this.deploymentSelectedUnitId = undefined; @@ -8046,6 +8274,7 @@ export class BattleScene extends Phaser.Scene { this.renderRosterPanel('ally', '출전 위치 확정. 행동할 장수를 선택하세요.'); this.refreshEnemyIntentForecast(); this.showOpeningBattleEvent(); + this.scheduleFirstBattleTutorial(); soundDirector.playSelect(); } @@ -8924,6 +9153,9 @@ export class BattleScene extends Phaser.Scene { } if (choicePointer.leftButtonDown()) { this.suppressNextLeftClick = true; + if (!this.acceptFirstBattleTutorialCommand(command)) { + return; + } this.hideTurnEndPrompt(); if (command === 'strategy' || command === 'item') { this.showUsableMenu(unit, command, choicePointer.x, choicePointer.y); @@ -8932,6 +9164,9 @@ export class BattleScene extends Phaser.Scene { if (command !== 'wait') { this.beginDamageTargeting(unit, command); + if (command === 'attack' && this.phase === 'targeting' && this.firstBattleTutorialStep === 'attack-command') { + this.setFirstBattleTutorialStep('target-preview'); + } return; } @@ -10378,6 +10613,18 @@ export class BattleScene extends Phaser.Scene { usable?: BattleUsable, preview?: CombatPreview ) { + const tutorialPath = this.firstBattleTutorialPath; + const tutorialTargetStep = this.firstBattleTutorialStep === 'target-preview'; + if (tutorialPath && tutorialTargetStep) { + if (action !== 'attack' || usable || attacker.id !== tutorialPath.unitId || target.id !== tutorialPath.targetId) { + const expected = battleUnits.find((candidate) => candidate.id === tutorialPath.targetId); + this.clearLockedTargetPreview(); + this.firstBattleTutorialTargetPreviewLocked = false; + this.showFirstBattleTutorialFeedback(`${target.name}은 권장 대상이 아닙니다. 빛나는 ${expected?.name ?? '적'}을 선택하세요.`); + this.renderCurrentTargetingGuideNotice('튜토리얼: 빛나는 적을 선택해 공격 예측을 확인하세요.'); + return; + } + } if (!this.canUseDamageCommand(attacker, target, action, usable)) { this.clearLockedTargetPreview(); this.renderCurrentTargetingGuideNotice(this.damageTargetFailureMessage(attacker, target, action, usable)); @@ -10385,11 +10632,19 @@ export class BattleScene extends Phaser.Scene { } if (this.isLockedDamageTarget(attacker, target, action, usable)) { + if (tutorialTargetStep) { + this.completeFirstBattleTutorial('completed'); + } void this.tryResolveDamageTarget(attacker, target, action, usable); return; } this.lockDamageTargetPreview(attacker, target, action, usable, preview ?? this.combatPreview(attacker, target, action, usable)); + if (tutorialTargetStep) { + this.firstBattleTutorialTargetPreviewLocked = true; + this.firstBattleTutorialFeedback = ''; + this.renderFirstBattleTutorial(); + } } private chooseSupportTarget(user: UnitData, target: UnitData, usable: BattleUsable, preview?: SupportPreview) { @@ -14291,7 +14546,7 @@ export class BattleScene extends Phaser.Scene { let sprite: Phaser.GameObjects.Sprite | undefined; if (animation.unit) { sprite = this.trackResultSettlementTransientObject( - this.add.sprite(spriteX, spriteY + 2, this.unitActionTexture(animation.unit), this.unitActionFrameIndex('south', this.celebrationPose(animation.unit))) + this.add.sprite(spriteX, spriteY + 2, this.unitActionTexture(animation.unit), this.unitActionFrame(animation.unit, 'south', this.celebrationPose(animation.unit))) ); sprite.setDisplaySize(70, 70); sprite.setDepth(depth + 6); @@ -14356,7 +14611,7 @@ export class BattleScene extends Phaser.Scene { } if (animation.unit) { - const sprite = this.add.sprite(visualX, 8, this.unitActionTexture(animation.unit), this.unitActionFrameIndex('south', this.celebrationPose(animation.unit))); + const sprite = this.add.sprite(visualX, 8, this.unitActionTexture(animation.unit), this.unitActionFrame(animation.unit, 'south', this.celebrationPose(animation.unit))); sprite.setDisplaySize(82, 82); container.add(sprite); void this.playUnitActionFrames(sprite, animation.unit, 'south', this.celebrationPose(animation.unit), 105, 2); @@ -15626,6 +15881,495 @@ export class BattleScene extends Phaser.Scene { }; } + private scheduleFirstBattleTutorial() { + if ( + battleScenario.id !== 'first-battle-zhuo-commandery' || + hasCompletedCampaignTutorial(firstBattleTutorialId) || + this.firstBattleTutorialStep + ) { + return; + } + + const attemptStart = (attemptsRemaining: number) => { + if ( + this.scene.key !== 'BattleScene' || + this.battleOutcome || + hasCompletedCampaignTutorial(firstBattleTutorialId) || + this.firstBattleTutorialStep + ) { + return; + } + if (this.phase === 'idle' && this.activeFaction === 'ally' && this.turnNumber === 1) { + this.startFirstBattleTutorial(); + return; + } + if (attemptsRemaining > 0) { + this.time.delayedCall(180, () => attemptStart(attemptsRemaining - 1)); + } + }; + attemptStart(12); + } + + private startFirstBattleTutorial() { + const path = this.createFirstBattleTutorialPath(); + if (!path) { + return; + } + + this.firstBattleTutorialPath = path; + this.firstBattleTutorialStep = 'select-unit'; + this.firstBattleTutorialFeedback = ''; + this.firstBattleTutorialTargetPreviewLocked = false; + this.firstBattleTutorialCompletion = undefined; + this.ensureFirstBattleTutorialPathVisible(); + this.renderFirstBattleTutorial(); + } + + private createFirstBattleTutorialPath(): FirstBattleTutorialPath | undefined { + const allyPriority = ['guan-yu', 'zhang-fei', 'liu-bei']; + const targetPriority = ['rebel-a']; + const allies = battleUnits + .filter((unit) => unit.faction === 'ally' && unit.hp > 0 && !this.actedUnitIds.has(unit.id)) + .sort((left, right) => { + const leftRank = allyPriority.indexOf(left.id); + const rightRank = allyPriority.indexOf(right.id); + return (leftRank < 0 ? allyPriority.length : leftRank) - (rightRank < 0 ? allyPriority.length : rightRank); + }); + const enemies = battleUnits + .filter((unit) => unit.faction === 'enemy' && unit.hp > 0) + .sort((left, right) => { + const leftRank = targetPriority.indexOf(left.id); + const rightRank = targetPriority.indexOf(right.id); + return (leftRank < 0 ? targetPriority.length : leftRank) - (rightRank < 0 ? targetPriority.length : rightRank); + }); + + const candidates = allies.flatMap((unit, allyIndex) => + this.reachableTiles(unit) + .filter((tile) => tile.x !== unit.x || tile.y !== unit.y) + .flatMap((tile) => enemies + .filter((target) => this.tileDistanceTo(tile.x, tile.y, target.x, target.y) <= this.attackRange(unit)) + .map((target, targetIndex) => ({ + unit, + tile, + target, + score: + (target.id === 'rebel-a' ? 10000 : 0) + + (unit.id === 'guan-yu' ? 1000 : unit.id === 'zhang-fei' ? 500 : 0) - + allyIndex * 20 - + targetIndex * 5 + + tile.cost + }))) + ).sort((left, right) => right.score - left.score); + const selected = candidates[0]; + if (!selected) { + return undefined; + } + + return { + unitId: selected.unit.id, + from: { x: selected.unit.x, y: selected.unit.y }, + moveTile: { x: selected.tile.x, y: selected.tile.y }, + targetId: selected.target.id, + targetTile: { x: selected.target.x, y: selected.target.y } + }; + } + + private ensureFirstBattleTutorialPathVisible() { + const path = this.firstBattleTutorialPath; + if (!path) { + return; + } + const minX = Math.min(path.from.x, path.moveTile.x, path.targetTile.x); + const maxX = Math.max(path.from.x, path.moveTile.x, path.targetTile.x); + const minY = Math.min(path.from.y, path.moveTile.y, path.targetTile.y); + const maxY = Math.max(path.from.y, path.moveTile.y, path.targetTile.y); + if ( + minX < this.cameraTileX || + maxX >= this.cameraTileX + this.layout.visibleColumns || + minY < this.cameraTileY || + maxY >= this.cameraTileY + this.layout.visibleRows + ) { + this.centerCameraOnTile(Math.floor((minX + maxX) / 2), Math.floor((minY + maxY) / 2)); + } + } + + private isFirstBattleTutorialActive() { + return Boolean(this.firstBattleTutorialStep && this.firstBattleTutorialPath); + } + + private firstBattleTutorialStepNumber() { + switch (this.firstBattleTutorialStep) { + case 'select-unit': + return 1; + case 'move': + return 2; + case 'attack-command': + return 3; + case 'target-preview': + return 4; + default: + return 0; + } + } + + private firstBattleTutorialCopy() { + const path = this.firstBattleTutorialPath; + const unit = path ? battleUnits.find((candidate) => candidate.id === path.unitId) : undefined; + const target = path ? battleUnits.find((candidate) => candidate.id === path.targetId) : undefined; + switch (this.firstBattleTutorialStep) { + case 'select-unit': + return { + title: '아군 선택', + body: `${unit?.name ?? '빛나는 아군'}을 클릭해 행동을 시작하세요.` + }; + case 'move': + return { + title: '권장 칸으로 이동', + body: `${unit?.name ?? '선택한 아군'}을 빛나는 이동 칸으로 전진시키세요.` + }; + case 'attack-command': + return { + title: '공격 명령', + body: '열린 명령 카드에서 공격을 선택하세요.' + }; + case 'target-preview': + return this.firstBattleTutorialTargetPreviewLocked + ? { + title: '공격 확정', + body: `피해 예측을 확인했습니다. ${target?.name ?? '같은 대상'}을 한 번 더 클릭해 실행하세요.` + } + : { + title: '공격 대상 확인', + body: `${target?.name ?? '빛나는 적'}을 클릭해 피해·명중 예측을 먼저 확인하세요.` + }; + default: + return { title: '', body: '' }; + } + } + + private firstBattleTutorialCardPosition(width: number, height: number) { + const inset = 18; + const candidates = [ + { left: this.layout.mapX + inset, top: this.layout.mapY + inset }, + { left: this.layout.mapX + this.layout.mapWidth - width - inset, top: this.layout.mapY + inset }, + { left: this.layout.mapX + inset, top: this.layout.mapY + this.layout.mapHeight - height - inset }, + { + left: this.layout.mapX + this.layout.mapWidth - width - inset, + top: this.layout.mapY + this.layout.mapHeight - height - inset + } + ]; + const path = this.firstBattleTutorialPath; + const tileBounds = path + ? [path.from, path.moveTile, path.targetTile].map((tile) => ({ + x: this.tileTopLeftX(tile.x) - 10, + y: this.tileTopLeftY(tile.y) - 10, + width: this.layout.tileSize + 20, + height: this.layout.tileSize + 20 + })) + : []; + const commandBounds = this.gameObjectCollectionBoundsDebug(this.commandMenuObjects); + const openingBounds = this.gameObjectCollectionBoundsDebug(this.battleEventObjects); + const obstacles = [ + ...tileBounds, + ...(commandBounds ? [commandBounds] : []), + ...(openingBounds ? [openingBounds] : []) + ]; + const pathCenter = path + ? { + x: (this.tileCenterX(path.from.x) + this.tileCenterX(path.moveTile.x) + this.tileCenterX(path.targetTile.x)) / 3, + y: (this.tileCenterY(path.from.y) + this.tileCenterY(path.moveTile.y) + this.tileCenterY(path.targetTile.y)) / 3 + } + : { x: this.layout.mapX, y: this.layout.mapY }; + const intersects = ( + left: { x: number; y: number; width: number; height: number }, + right: { x: number; y: number; width: number; height: number } + ) => !( + left.x + left.width <= right.x || + right.x + right.width <= left.x || + left.y + left.height <= right.y || + right.y + right.height <= left.y + ); + + return candidates + .map((candidate) => { + const bounds = { x: candidate.left, y: candidate.top, width, height }; + const overlapCount = obstacles.filter((obstacle) => intersects(bounds, obstacle)).length; + const centerX = candidate.left + width / 2; + const centerY = candidate.top + height / 2; + const distanceScore = (centerX - pathCenter.x) ** 2 + (centerY - pathCenter.y) ** 2; + return { ...candidate, score: distanceScore - overlapCount * 10_000_000 }; + }) + .sort((left, right) => right.score - left.score)[0]; + } + + private renderFirstBattleTutorial() { + if (!this.isFirstBattleTutorialActive()) { + this.hideFirstBattleTutorial(); + return; + } + + this.hideFirstBattleTutorial(); + const stepNumber = this.firstBattleTutorialStepNumber(); + const copy = this.firstBattleTutorialCopy(); + const width = Math.min(540, this.layout.mapWidth - 36); + const height = 148; + const position = this.firstBattleTutorialCardPosition(width, height); + const left = position.left; + const top = position.top; + const depth = 52; + const panel = this.trackFirstBattleTutorialObject(this.add.rectangle(left, top, width, height, 0x09131b, 0.98)); + panel.setName('first-battle-tutorial-card'); + panel.setOrigin(0); + panel.setDepth(depth); + panel.setStrokeStyle(2, palette.gold, 0.94); + panel.setInteractive(); + this.firstBattleTutorialCard = panel; + + const stepBadge = this.trackFirstBattleTutorialObject(this.add.rectangle(left + 22, top + 25, 72, 34, 0x5b211d, 0.98)); + stepBadge.setDepth(depth + 1); + stepBadge.setStrokeStyle(1, 0xf0c778, 0.92); + const stepText = this.trackFirstBattleTutorialObject(this.add.text(left + 22, top + 25, `${stepNumber}/4`, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '16px', + color: '#fff0bd', + fontStyle: '700' + })); + stepText.setOrigin(0.5); + stepText.setDepth(depth + 2); + + const title = this.trackFirstBattleTutorialObject(this.add.text(left + 72, top + 12, copy.title, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '23px', + color: '#f4dfad', + fontStyle: '700', + stroke: '#05070a', + strokeThickness: 3 + })); + title.setDepth(depth + 1); + const body = this.trackFirstBattleTutorialObject(this.add.text(left + 22, top + 60, copy.body, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '16px', + color: '#e1e8ef', + fontStyle: '700', + wordWrap: { width: width - 44, useAdvancedWrap: true } + })); + body.setDepth(depth + 1); + const feedback = this.trackFirstBattleTutorialObject(this.add.text( + left + 22, + top + 111, + this.firstBattleTutorialFeedback || '빛나는 표시를 따라 실제로 조작하세요. · 우클릭/ESC는 이전 단계', + { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '12px', + color: this.firstBattleTutorialFeedback ? '#ffb08e' : '#9fb0bf', + fontStyle: '700', + fixedWidth: width - 44 + } + )); + feedback.setDepth(depth + 1); + + const skipWidth = 96; + const skipHeight = 32; + const skipX = left + width - skipWidth - 18; + const skipY = top + 16; + const skip = this.trackFirstBattleTutorialObject(this.add.rectangle(skipX, skipY, skipWidth, skipHeight, 0x17232e, 0.98)); + skip.setName('first-battle-tutorial-skip'); + skip.setOrigin(0); + skip.setDepth(depth + 2); + skip.setStrokeStyle(1, 0x7f8994, 0.82); + skip.setInteractive({ useHandCursor: true }); + const skipText = this.trackFirstBattleTutorialObject(this.add.text(skipX + skipWidth / 2, skipY + skipHeight / 2, '건너뛰기', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '13px', + color: '#d4dce6', + fontStyle: '700' + })); + skipText.setOrigin(0.5); + skipText.setDepth(depth + 3); + skipText.setInteractive({ useHandCursor: true }); + const skipTutorial = () => { + this.suppressNextLeftClick = true; + soundDirector.playSelect(); + this.completeFirstBattleTutorial('skipped'); + }; + skip.on('pointerdown', skipTutorial); + skipText.on('pointerdown', skipTutorial); + this.firstBattleTutorialSkipButton = skip; + this.renderFirstBattleTutorialHighlight(); + } + + private renderFirstBattleTutorialHighlight() { + const path = this.firstBattleTutorialPath; + if (!path || !this.firstBattleTutorialStep) { + return; + } + + let bounds: { x: number; y: number; width: number; height: number } | undefined; + let tile: { x: number; y: number } | undefined; + if (this.firstBattleTutorialStep === 'select-unit') { + tile = path.from; + } else if (this.firstBattleTutorialStep === 'move') { + tile = path.moveTile; + } else if (this.firstBattleTutorialStep === 'target-preview') { + tile = path.targetTile; + } else { + const attackButton = this.commandButtons.find((button) => button.command === 'attack')?.background; + const buttonBounds = attackButton?.active ? attackButton.getBounds() : undefined; + if (buttonBounds) { + bounds = { x: buttonBounds.x, y: buttonBounds.y, width: buttonBounds.width, height: buttonBounds.height }; + } + } + + if (tile) { + bounds = { + x: this.tileTopLeftX(tile.x), + y: this.tileTopLeftY(tile.y), + width: this.layout.tileSize, + height: this.layout.tileSize + }; + } + if (!bounds) { + return; + } + + const highlight = this.trackFirstBattleTutorialObject( + this.add.rectangle(bounds.x, bounds.y, bounds.width, bounds.height, palette.gold, 0.12) + ); + highlight.setName('first-battle-tutorial-highlight'); + highlight.setOrigin(0); + highlight.setDepth(49); + highlight.setStrokeStyle(4, 0xffec9d, 1); + if (tile) { + highlight.setData('tutorialTileX', tile.x); + highlight.setData('tutorialTileY', tile.y); + if (this.mapMask) { + highlight.setMask(this.mapMask); + } + highlight.setVisible(this.isTileVisible(tile.x, tile.y)); + } + this.tweens.add({ + targets: highlight, + alpha: 0.34, + duration: 540, + yoyo: true, + repeat: -1, + ease: 'Sine.easeInOut' + }); + this.firstBattleTutorialHighlight = highlight; + } + + private positionFirstBattleTutorialHighlight() { + const highlight = this.firstBattleTutorialHighlight; + if (!highlight?.active) { + return; + } + const tileX = highlight.getData('tutorialTileX') as number | undefined; + const tileY = highlight.getData('tutorialTileY') as number | undefined; + if (tileX === undefined || tileY === undefined) { + return; + } + highlight.setPosition(this.tileTopLeftX(tileX), this.tileTopLeftY(tileY)); + highlight.setVisible(this.isTileVisible(tileX, tileY)); + } + + private trackFirstBattleTutorialObject(object: T) { + this.firstBattleTutorialObjects.push(object); + return object; + } + + private hideFirstBattleTutorial() { + this.firstBattleTutorialObjects.forEach((object) => { + this.tweens?.killTweensOf(object); + if (object.active) { + object.destroy(); + } + }); + this.firstBattleTutorialObjects = []; + this.firstBattleTutorialHighlight = undefined; + this.firstBattleTutorialCard = undefined; + this.firstBattleTutorialSkipButton = undefined; + } + + private setFirstBattleTutorialStep(step: FirstBattleTutorialStep, feedback = '') { + if (!this.firstBattleTutorialPath) { + return; + } + this.firstBattleTutorialStep = step; + this.firstBattleTutorialFeedback = feedback; + if (step !== 'target-preview') { + this.firstBattleTutorialTargetPreviewLocked = false; + } + this.ensureFirstBattleTutorialPathVisible(); + this.renderFirstBattleTutorial(); + } + + private showFirstBattleTutorialFeedback(message: string) { + if (!this.isFirstBattleTutorialActive()) { + return; + } + this.firstBattleTutorialFeedback = message; + this.renderFirstBattleTutorial(); + } + + private completeFirstBattleTutorial(completion: FirstBattleTutorialCompletion) { + if (!this.isFirstBattleTutorialActive()) { + return; + } + this.firstBattleTutorialCompletion = completion; + this.firstBattleTutorialStep = undefined; + this.firstBattleTutorialFeedback = ''; + this.firstBattleTutorialTargetPreviewLocked = false; + this.hideFirstBattleTutorial(); + completeCampaignTutorial(firstBattleTutorialId); + } + + private acceptFirstBattleTutorialCommand(command: BattleCommand) { + if (!this.isFirstBattleTutorialActive()) { + return true; + } + if (this.firstBattleTutorialStep !== 'attack-command') { + this.showFirstBattleTutorialFeedback('지금은 빛나는 단계의 조작을 먼저 완료하세요.'); + return false; + } + if (command !== 'attack') { + this.showFirstBattleTutorialFeedback(`${commandLabels[command]} 대신 먼저 공격을 선택해 기본 전투 흐름을 익혀 보세요.`); + return false; + } + return true; + } + + private handleFirstBattleTutorialUnhandledLeftClick(pointer: Phaser.Input.Pointer) { + const path = this.firstBattleTutorialPath; + const step = this.firstBattleTutorialStep; + if (!path || !step) { + return false; + } + const tile = this.pointerToTile(pointer); + if (step === 'select-unit') { + if (tile?.x === path.from.x && tile.y === path.from.y) { + return false; + } + this.showFirstBattleTutorialFeedback('먼저 빛나는 아군을 선택해야 이동 범위가 열립니다.'); + return true; + } + if (step === 'move') { + if (tile?.x === path.moveTile.x && tile.y === path.moveTile.y) { + return false; + } + this.showFirstBattleTutorialFeedback('이 칸은 권장 이동칸이 아닙니다. 빛나는 칸으로 이동해 적을 공격 범위에 넣으세요.'); + return true; + } + if (step === 'attack-command') { + const inCommandButton = this.commandButtons.some(({ background }) => background.getBounds().contains(pointer.x, pointer.y)); + if (inCommandButton) { + return false; + } + this.showFirstBattleTutorialFeedback('열린 명령 카드에서 빛나는 공격 버튼을 선택하세요.'); + return true; + } + return false; + } + private showOpeningBattleEvent() { const guide = battleScenario.tacticalGuide; const objectiveLines = guide ? [guide.summary, `진군: ${guide.route}`, guide.focus] : battleScenario.openingObjectiveLines; @@ -16123,6 +16867,11 @@ export class BattleScene extends Phaser.Scene { return; } + if (this.firstBattleTutorialStep === 'select-unit' || this.firstBattleTutorialStep === 'move') { + this.showFirstBattleTutorialFeedback('이 단계에서는 빛나는 표시를 선택하세요. 필요하면 건너뛰기를 사용할 수 있습니다.'); + return; + } + if (this.phase === 'deployment') { this.cancelDeploymentSelection(true); return; @@ -16182,6 +16931,10 @@ export class BattleScene extends Phaser.Scene { return; } + if (this.handleFirstBattleTutorialUnhandledLeftClick(pointer)) { + return; + } + const mapMenuAction = this.mapMenuActionAt(pointer.x, pointer.y); if (mapMenuAction) { this.runMapMenuAction(mapMenuAction); @@ -16224,6 +16977,10 @@ export class BattleScene extends Phaser.Scene { } this.clearLockedTargetPreview(); + if (this.firstBattleTutorialStep === 'target-preview') { + this.firstBattleTutorialTargetPreviewLocked = false; + this.showFirstBattleTutorialFeedback('빈 칸에는 공격할 대상이 없습니다. 빛나는 적 부대를 선택하세요.'); + } const unit = this.selectedUnit; const usable = this.selectedUsable; const range = this.actionRange(unit, this.targetingAction, usable); @@ -16756,6 +17513,7 @@ export class BattleScene extends Phaser.Scene { } private createBattleSaveState(): BattleSaveState { + const deployedUnitIds = new Set(battleUnits.map((unit) => unit.id)); return { version: 1, battleId: battleScenario.id, @@ -16788,10 +17546,12 @@ export class BattleScene extends Phaser.Scene { direction: this.unitViews.get(unit.id)?.direction, equipment: this.cloneEquipment(unit.equipment) })), - bonds: Array.from(this.bondStates.values()).map((bond) => ({ - ...bond, - unitIds: [...bond.unitIds] as [string, string] - })), + bonds: Array.from(this.bondStates.values()) + .filter((bond) => bond.unitIds.every((unitId) => deployedUnitIds.has(unitId))) + .map((bond) => ({ + ...bond, + unitIds: [...bond.unitIds] as [string, string] + })), itemStocks: this.serializeItemStocks(), battleBuffs: Array.from(this.battleBuffs.values()).map((buff) => ({ ...buff })), battleStatuses: this.serializeBattleStatuses(), @@ -16835,16 +17595,15 @@ export class BattleScene extends Phaser.Scene { this.actedUnitIds = new Set(state.actedUnitIds ?? []); this.attackIntents = (state.attackIntents ?? []).map((intent) => ({ ...intent })); this.battleLog = [...(state.battleLog ?? [])]; - this.bondStates = new Map( - (state.bonds ?? []).map((bond) => [ - bond.id, - { - ...bond, - unitIds: [...bond.unitIds] as [string, string], - battleExp: bond.battleExp ?? 0 - } - ]) - ); + const restoredBondStates = new Map(this.bondStates); + (state.bonds ?? []).forEach((bond) => { + restoredBondStates.set(bond.id, { + ...bond, + unitIds: [...bond.unitIds] as [string, string], + battleExp: bond.battleExp ?? 0 + }); + }); + this.bondStates = restoredBondStates; this.itemStocks = this.deserializeItemStocks(state.itemStocks); this.battleBuffs = new Map((state.battleBuffs ?? []).map((buff) => [buff.unitId, { ...buff }])); this.battleStatuses = this.deserializeBattleStatuses(state.battleStatuses); @@ -17943,7 +18702,7 @@ export class BattleScene extends Phaser.Scene { attackerX, groundY, this.unitActionTexture(result.attacker), - this.unitActionFrameIndex(attackerDirection, this.actionPoseForCommand(result.action)) + this.unitActionFrame(result.attacker, attackerDirection, this.actionPoseForCommand(result.action)) ) ); attackerSprite.setDepth(depth + 6); @@ -18581,7 +19340,7 @@ export class BattleScene extends Phaser.Scene { const supportPose: UnitActionPose = result.usable.command === 'strategy' ? 'strategy' : 'item'; const userSprite = this.trackCombatObject( - this.add.sprite(left + 160, supportSpriteY, this.unitActionTexture(result.user), this.unitActionFrameIndex('east', supportPose)) + this.add.sprite(left + 160, supportSpriteY, this.unitActionTexture(result.user), this.unitActionFrame(result.user, 'east', supportPose)) ); userSprite.setDepth(depth + 8); this.applyCombatSpriteDisplaySize(userSprite, result.user, true); @@ -19185,7 +19944,7 @@ export class BattleScene extends Phaser.Scene { } if (celebrant) { - const sprite = this.add.sprite(visualX, 8, this.unitActionTexture(celebrant), this.unitActionFrameIndex('south', this.celebrationPose(celebrant))); + const sprite = this.add.sprite(visualX, 8, this.unitActionTexture(celebrant), this.unitActionFrame(celebrant, 'south', this.celebrationPose(celebrant))); sprite.setDisplaySize(82, 82); const burst = this.add.star(visualX, -12, 8, 12, 34, 0xffdf7b, 0.72); container.add([burst, sprite]); @@ -19336,7 +20095,7 @@ export class BattleScene extends Phaser.Scene { private createGrowthCelebrationSprite(unit: UnitData, x: number, y: number, depth: number) { const sprite = this.trackCombatObject( - this.add.sprite(x, y, this.unitActionTexture(unit), this.unitActionFrameIndex('south', this.celebrationPose(unit))) + this.add.sprite(x, y, this.unitActionTexture(unit), this.unitActionFrame(unit, 'south', this.celebrationPose(unit))) ); sprite.setDisplaySize(82, 82); this.applyActionSpriteBlend(sprite); @@ -20238,7 +20997,7 @@ export class BattleScene extends Phaser.Scene { entryX, groundY, this.unitActionTexture(partner), - this.unitActionFrameIndex('east', this.actionPoseForCommand('attack')) + this.unitActionFrame(partner, 'east', this.actionPoseForCommand('attack')) ) ); supporterSprite.setDepth(depth); @@ -21407,7 +22166,7 @@ export class BattleScene extends Phaser.Scene { } private statusEffectIcon(kind: BattleStatusKind): BattleUiIconKey { - return kind === 'burn' ? 'fire' : 'confusion'; + return kind === 'burn' ? 'burn' : 'confusion'; } private statusEffectTone(kind: BattleStatusKind) { @@ -21544,6 +22303,9 @@ export class BattleScene extends Phaser.Scene { soundDirector.playSelect(); this.showCommandMenu(unit, view?.sprite.x ?? this.tileCenterX(unit.x), view?.sprite.y ?? this.tileCenterY(unit.y)); this.renderUnitDetail(unit, '대상 선택을 취소했습니다.\n공격, 책략, 도구, 대기 중 하나를 다시 선택하세요.\n우클릭하면 이동 취소로 돌아갑니다.'); + if (this.firstBattleTutorialStep === 'target-preview') { + this.setFirstBattleTutorialStep('attack-command', '대상 선택을 취소해 공격 명령 단계로 돌아왔습니다.'); + } return true; } @@ -21573,6 +22335,9 @@ export class BattleScene extends Phaser.Scene { unit, `이동을 취소했습니다.\n${toX + 1}, ${toY + 1}에서 원래 위치 ${fromX + 1}, ${fromY + 1}로 되돌렸습니다.` ); + if (this.firstBattleTutorialStep === 'attack-command') { + this.setFirstBattleTutorialStep('move', '이동을 취소해 권장 이동칸 단계로 돌아왔습니다.'); + } return true; } @@ -22093,12 +22858,18 @@ export class BattleScene extends Phaser.Scene { return unitFrameRows[direction] * unitActionFramesPerDirection + unitActionColumnOffsets[pose] + safeFrame; } + private unitActionFrame(unit: UnitData, direction: UnitDirection, pose: UnitActionPose, frame = 0) { + return this.textures.exists(`${this.unitTextureKey(unit)}-actions`) + ? this.unitActionFrameIndex(direction, pose, frame) + : this.unitFrameIndex(direction); + } + private unitActionImpactFrame(pose: UnitActionPose) { return Math.max(0, Math.floor(unitActionFrameCounts[pose] * 0.55)); } private setUnitActionFrame(sprite: Phaser.GameObjects.Sprite, unit: UnitData, direction: UnitDirection, pose: UnitActionPose, frame = 0) { - sprite.setTexture(this.unitActionTexture(unit), this.unitActionFrameIndex(direction, pose, frame)); + sprite.setTexture(this.unitActionTexture(unit), this.unitActionFrame(unit, direction, pose, frame)); this.applyActionSpriteBlend(sprite); } @@ -25447,6 +26218,34 @@ export class BattleScene extends Phaser.Scene { }; } + private firstBattleTutorialDebugState() { + const path = this.firstBattleTutorialPath; + return { + id: firstBattleTutorialId, + eligibleBattle: battleScenario.id === 'first-battle-zhuo-commandery', + active: this.isFirstBattleTutorialActive(), + completed: hasCompletedCampaignTutorial(firstBattleTutorialId), + completion: this.firstBattleTutorialCompletion ?? null, + step: this.firstBattleTutorialStep ?? null, + stepNumber: this.firstBattleTutorialStepNumber(), + totalSteps: 4, + targetPreviewLocked: this.firstBattleTutorialTargetPreviewLocked, + feedback: this.firstBattleTutorialFeedback, + path: path + ? { + unitId: path.unitId, + from: { ...path.from }, + moveTile: { ...path.moveTile }, + targetId: path.targetId, + targetTile: { ...path.targetTile } + } + : null, + cardBounds: this.gameObjectBoundsDebug(this.firstBattleTutorialCard), + highlightBounds: this.gameObjectBoundsDebug(this.firstBattleTutorialHighlight), + skipBounds: this.gameObjectBoundsDebug(this.firstBattleTutorialSkipButton) + }; + } + getDebugState() { const campaign = getCampaignState(); const effectiveSortieUnitIds = this.effectiveSortieUnitIds(campaign); @@ -25469,6 +26268,14 @@ export class BattleScene extends Phaser.Scene { const sortieOrderDebug = this.resultSortieOrderDebugState(); const sortieResonanceDebug = this.coreSortieResonanceDebugState(); const combatCutInDebug = this.combatCutInStageDebugState(); + const scenarioUnitTextureKeys = this.scenarioUnitTextureKeys(); + const scenarioCombatPortraitKeys = Array.from( + new Set( + battleUnits + .map((unit) => this.combatPortraitAssetEntry(unit)?.textureKey) + .filter((key): key is string => Boolean(key)) + ) + ); return { scene: this.scene.key, @@ -25487,6 +26294,7 @@ export class BattleScene extends Phaser.Scene { ? cloneCampaignSortieRecommendationSnapshot(this.launchSortieRecommendation) : null, battleHud: this.battleHudDebugState(), + firstBattleTutorial: this.firstBattleTutorialDebugState(), combatCutIn: combatCutInDebug, combatCutInStage: combatCutInDebug, sortieDoctrine: this.debugSortieDoctrine(), @@ -25505,6 +26313,23 @@ export class BattleScene extends Phaser.Scene { turnNumber: this.turnNumber, activeFaction: this.activeFaction, phase: this.phase, + combatAssets: { + status: this.scenarioCombatAssetStatus, + deferredDuringDeployment: this.shouldShowPreBattleDeployment(), + pendingDeploymentConfirmation: this.pendingDeploymentConfirmation, + settlementCount: this.scenarioCombatAssetSettlementCount, + watchdogMs: this.scenarioCombatAssetWatchdogMs(), + baseReady: scenarioUnitTextureKeys.every((key) => this.textures.exists(key)), + actionReady: scenarioUnitTextureKeys.every((key) => this.textures.exists(`${key}-actions`)), + portraitReady: scenarioCombatPortraitKeys.every((key) => this.textures.exists(key)), + unitTextureKeys: scenarioUnitTextureKeys, + missingActionKeys: [...this.scenarioCombatAssetMissingActionKeys], + missingPortraitKeys: [...this.scenarioCombatAssetMissingPortraitKeys], + loadDurationMs: + this.scenarioCombatAssetLoadStartedAt === undefined + ? null + : (this.scenarioCombatAssetLoadCompletedAt ?? Date.now()) - this.scenarioCombatAssetLoadStartedAt + }, battleOutcome: this.battleOutcome ?? null, mapTextureKey: battleScenario.mapTextureKey, mapTextureReady: this.textures.exists(battleScenario.mapTextureKey), diff --git a/src/game/scenes/BootScene.ts b/src/game/scenes/BootScene.ts index 24d8f53..0b51d38 100644 --- a/src/game/scenes/BootScene.ts +++ b/src/game/scenes/BootScene.ts @@ -1,6 +1,16 @@ import Phaser from 'phaser'; import titleBackgroundUrl from '../../assets/images/taoyuan-oath-title.png'; +const equipmentIcon128Modules = import.meta.glob('../../assets/images/ui/equipment-icons/128/*.png', { + eager: true, + import: 'default' +}) as Record; + +const equipmentIcon32Modules = import.meta.glob('../../assets/images/ui/equipment-icons/32/*.png', { + eager: true, + import: 'default' +}) as Record; + const itemIconSourceSize = 28; const itemIconTextureSize = 64; @@ -11,6 +21,8 @@ export class BootScene extends Phaser.Scene { preload() { this.load.image('title-taoyuan', titleBackgroundUrl); + this.preloadEquipmentIcons(equipmentIcon128Modules, ''); + this.preloadEquipmentIcons(equipmentIcon32Modules, '-micro'); } create() { @@ -29,6 +41,15 @@ export class BootScene extends Phaser.Scene { graphics.destroy(); } + private preloadEquipmentIcons(modules: Record, suffix: string) { + Object.entries(modules).forEach(([path, url]) => { + const itemId = path.match(/\/([^/]+)\.png$/)?.[1]; + if (itemId) { + this.load.image(`item-${itemId}${suffix}`, url); + } + }); + } + private createEquipmentIconTextures() { this.createItemIcon('item-training-sword', (graphics) => this.drawTrainingSwordIcon(graphics)); this.createItemIcon('item-twin-oath-blades', (graphics) => { diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts index 9a44387..970885b 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -108,6 +108,8 @@ import { hanzhongMainRecruitUnits, hanzhongScoutRecruitBonds, hanzhongScoutRecruitUnits, + jianYongRecruitBond, + jianYongRecruitUnit, jiangWeiRecruitBonds, jiangWeiRecruitUnits, mengHuoFourthCaptureBonds, @@ -272,6 +274,20 @@ type CampActionButtonView = { hovered: boolean; }; +type EquipmentSwapRequest = { + unitId: string; + slot: EquipmentSlot; + candidateItemId: string; + returnToSortie: boolean; +}; + +type EquipmentInventoryRowView = { + itemId: string; + canEquip: boolean; + background: Phaser.GameObjects.Rectangle; + actionButton: Phaser.GameObjects.Rectangle; +}; + type CampRosterBounds = { x: number; y: number; @@ -305,7 +321,7 @@ type SortiePortraitRosterLayout = { visibleUnitIds: string[]; previousButtonBounds: CampRosterBounds; nextButtonBounds: CampRosterBounds; - cardBounds: Array; + cardBounds: Array; previousEnabled: boolean; nextEnabled: boolean; }; @@ -11261,6 +11277,12 @@ export class CampScene extends Phaser.Scene { private equipmentInventoryPreviousButton?: Phaser.GameObjects.Rectangle; private equipmentInventoryNextButton?: Phaser.GameObjects.Rectangle; private equipmentInventoryVisibleItemIds: string[] = []; + private equipmentInventoryRowViews: EquipmentInventoryRowView[] = []; + private equipmentSwapConfirmObjects: Phaser.GameObjects.GameObject[] = []; + private pendingEquipmentSwap?: EquipmentSwapRequest; + private equipmentSwapConfirmPanel?: Phaser.GameObjects.Rectangle; + private equipmentSwapConfirmButton?: Phaser.GameObjects.Rectangle; + private equipmentSwapCancelButton?: Phaser.GameObjects.Rectangle; private suppliesPanelBackground?: Phaser.GameObjects.Rectangle; private suppliesEquipmentBoxes: Phaser.GameObjects.Rectangle[] = []; private suppliesTrophyBoxes: Phaser.GameObjects.Rectangle[] = []; @@ -11292,6 +11314,8 @@ export class CampScene extends Phaser.Scene { private reportFormationHistoryUndoState?: ReportFormationHistoryUndoState; private tabButtons: CampTabButtonView[] = []; private sortiePrimaryActionButton?: CampActionButtonView; + private sortieNextActionGuideBackground?: Phaser.GameObjects.Rectangle; + private sortieNextActionGuideText?: Phaser.GameObjects.Text; private visitedTabs = new Set(); private selectedSortieUnitIds: string[] = []; private sortieFormationAssignments: SortieFormationAssignments = {}; @@ -11327,6 +11351,7 @@ export class CampScene extends Phaser.Scene { private sortieRosterScroll = 0; private sortiePortraitRosterPage = 0; private sortiePortraitRosterLayout?: SortiePortraitRosterLayout; + private sortieRosterToggleBounds: Array = []; private sortiePlanFeedback = ''; private sortiePrepStep: SortiePrepStep = 'briefing'; private terrainCountCache = new Map(); @@ -11361,6 +11386,7 @@ export class CampScene extends Phaser.Scene { this.sortieRosterScroll = 0; this.sortiePortraitRosterPage = 0; this.sortiePortraitRosterLayout = undefined; + this.sortieRosterToggleBounds = []; this.sortiePinnedSwapCandidateUnitId = undefined; this.sortieSwapUndoState = undefined; this.sortieFormationPanelMode = 'roster'; @@ -11388,6 +11414,11 @@ export class CampScene extends Phaser.Scene { this.victoryRewardObjects = []; this.victoryRewardCards = []; this.victoryRewardActions = []; + this.equipmentSwapConfirmObjects = []; + this.pendingEquipmentSwap = undefined; + this.equipmentSwapConfirmPanel = undefined; + this.equipmentSwapConfirmButton = undefined; + this.equipmentSwapCancelButton = undefined; this.reportFormationReviewObjects = []; this.reportFormationReviewVisible = false; this.reportFormationReviewFeedback = ''; @@ -11405,6 +11436,8 @@ export class CampScene extends Phaser.Scene { this.reportFormationHistoryUndoState = undefined; this.tabButtons = []; this.sortiePrimaryActionButton = undefined; + this.sortieNextActionGuideBackground = undefined; + this.sortieNextActionGuideText = undefined; this.visitedTabs = new Set(['status']); this.campaign = getCampaignState(); this.report = this.campaign.firstBattleReport ?? getFirstBattleReport() ?? this.createFallbackReport(); @@ -11652,6 +11685,19 @@ export class CampScene extends Phaser.Scene { return; } + if ( + battleId === campBattleIds.first || + battleId === campBattleIds.second || + battleId === campBattleIds.third || + battleId === campBattleIds.fourth || + battleId === campBattleIds.fifth || + battleId === campBattleIds.sixth + ) { + this.campaign = ensureCampaignRosterUnits([jianYongRecruitUnit], [jianYongRecruitBond]); + this.report = this.campaign.firstBattleReport ?? this.report; + return; + } + if ( battleId === campBattleIds.fortieth || battleId === campBattleIds.fortyFirst || @@ -13150,6 +13196,12 @@ export class CampScene extends Phaser.Scene { return; } + if (this.equipmentSwapConfirmObjects.length > 0) { + soundDirector.playSelect(); + this.hideEquipmentSwapComparison(); + return; + } + if (this.saveSlotConfirmObjects.length > 0) { soundDirector.playSelect(); this.hideCampSaveConfirm(); @@ -13191,6 +13243,7 @@ export class CampScene extends Phaser.Scene { if ( this.sortieObjects.length === 0 || this.victoryRewardObjects.length > 0 || + this.equipmentSwapConfirmObjects.length > 0 || this.saveSlotObjects.length > 0 || this.saveSlotConfirmObjects.length > 0 || !this.usesStagedSortiePrep() @@ -13426,6 +13479,17 @@ export class CampScene extends Phaser.Scene { this.renderFirstSortieLoadoutStep(contentX, contentY, contentWidth, contentHeight, depth + 2, checklist); } + const stepIndex = firstSortiePrepSteps.findIndex((step) => step.id === this.sortiePrepStep); + const nextStep = firstSortiePrepSteps[stepIndex + 1]?.id; + this.renderSortieNextActionGuide( + x + 324, + y + height - 66, + 548, + 28, + nextStep, + depth + 3 + ); + this.addSortieButton('저장', x + 82, y + height - 42, 96, () => { soundDirector.playSelect(); this.showCampSaveSlotPanel(); @@ -13435,12 +13499,10 @@ export class CampScene extends Phaser.Scene { this.hideSortiePrep(); }, depth + 3); - const stepIndex = firstSortiePrepSteps.findIndex((step) => step.id === this.sortiePrepStep); if (stepIndex > 0) { const previousStep = firstSortiePrepSteps[stepIndex - 1].id; this.addSortieButton('이전', x + width - 250, y + height - 42, 104, () => this.setSortiePrepStep(previousStep), depth + 3); } - const nextStep = firstSortiePrepSteps[stepIndex + 1]?.id; const primaryActionLabel = nextStep ? (nextStep === 'formation' ? '편성하기' : '장비·보급') : '출진'; this.addSortieButton(primaryActionLabel, x + width - 134, y + height - 42, 128, () => { if (nextStep) { @@ -13768,7 +13830,19 @@ export class CampScene extends Phaser.Scene { const rowIndex = Math.floor(index / 2); const cardX = x + 18 + column * (cardWidth + columnGap); const cardY = cardTop + rowIndex * (cardHeight + rowGap); - cardBounds.push({ unitId: unit.id, x: cardX, y: cardY, width: cardWidth, height: cardHeight }); + cardBounds.push({ + unitId: unit.id, + x: cardX, + y: cardY, + width: cardWidth, + height: cardHeight, + toggleButtonBounds: { + x: cardX + cardWidth - 94, + y: cardY + cardHeight - 32, + width: 84, + height: 24 + } + }); const selected = this.isSortieSelected(unit.id); const focused = this.sortieFocusedUnitId === unit.id; const pinnedSwapCandidate = this.sortiePinnedSwapCandidateUnitId === unit.id; @@ -13926,9 +14000,10 @@ export class CampScene extends Phaser.Scene { visibleUnitIds: visibleUnits.map((unit) => unit.id), previousButtonBounds: this.scaleCampUiBounds(previousButtonBounds), nextButtonBounds: this.scaleCampUiBounds(nextButtonBounds), - cardBounds: cardBounds.map(({ unitId, ...bounds }) => ({ + cardBounds: cardBounds.map(({ unitId, toggleButtonBounds, ...bounds }) => ({ unitId, - ...this.scaleCampUiBounds(bounds) + ...this.scaleCampUiBounds(bounds), + toggleButtonBounds: this.scaleCampUiBounds(toggleButtonBounds) })), previousEnabled, nextEnabled @@ -14988,13 +15063,21 @@ export class CampScene extends Phaser.Scene { this.trackSortie(this.add.text(x + 190, y + 19, '초상을 고른 뒤 역할을 바꾸십시오.', this.textStyle(12, '#9fb0bf'))).setDepth(depth + 1); this.renderFirstSortieInlineButton('추천 편성', x + width - 134, y + 8, 116, 38, this.canApplyRecommendedSortiePlan(), false, () => this.applyRecommendedSortiePlan(), depth + 1); - const units = this.sortieRosterDisplayUnits(this.sortieRosterUnits()).slice(0, 3); - const gap = 12; - const cardWidth = Math.floor((width - 36 - gap * 2) / 3); - const cardY = y + 54; - const cardHeight = height - 70; + this.sortieRosterToggleBounds = []; + const units = this.sortieRosterDisplayUnits(this.sortieRosterUnits()); + const compactGrid = units.length > 3; + const columns = compactGrid ? 2 : 3; + const rows = Math.max(1, Math.ceil(units.length / columns)); + const columnGap = 12; + const rowGap = compactGrid ? 10 : 0; + const cardWidth = Math.floor((width - 36 - columnGap * (columns - 1)) / columns); + const cardTop = y + 54; + const cardHeight = Math.floor((height - 70 - rowGap * (rows - 1)) / rows); units.forEach((unit, index) => { - const cardX = x + 18 + index * (cardWidth + gap); + const column = index % columns; + const rowIndex = Math.floor(index / columns); + const cardX = x + 18 + column * (cardWidth + columnGap); + const cardY = cardTop + rowIndex * (cardHeight + rowGap); const selected = this.isSortieSelected(unit.id); const focused = this.sortieFocusedUnitId === unit.id; const required = this.isRequiredSortieUnit(unit.id); @@ -15013,35 +15096,55 @@ export class CampScene extends Phaser.Scene { this.showSortiePrep(); }); - this.renderFirstSortiePortrait(cardX + cardWidth / 2, cardY + 88, 132, unit, depth + 2, selected ? 1 : 0.5); - this.trackSortie(this.add.text(cardX + 14, cardY + 162, `${unit.name} Lv${unit.level}`, this.textStyle(20, selected ? '#f2e3bf' : '#87919c', true))).setDepth(depth + 2); - this.trackSortie(this.add.text(cardX + 14, cardY + 190, `${unit.className} · HP ${unit.hp}/${unit.maxHp}`, this.textStyle(12, selected ? '#d4dce6' : '#77818c', true))).setDepth(depth + 2); - this.drawSortieBar(cardX + 14, cardY + 211, cardWidth - 28, 7, unit.hp / unit.maxHp, selected ? palette.green : 0x53606c, depth + 2); - const recommendationText = recommendation ? this.conciseSortieText(recommendation.reason) : `${this.sortieFormationRoleLabel(role)} 역할로 전장에 합류합니다.`; - this.trackSortie( - this.add.text(cardX + 14, cardY + 230, recommendationText, { - ...this.textStyle(12, recommendation ? '#ffdf7b' : '#9fb0bf', recommendation ? true : false), - wordWrap: { width: cardWidth - 28, useAdvancedWrap: true }, - lineSpacing: 2 - }) - ).setDepth(depth + 2); - this.trackSortie(this.add.text(cardX + 14, cardY + 278, this.compactText(this.sortieBondLine(unit), 26), this.textStyle(10, selected ? '#a8ffd0' : '#77818c', true))).setDepth(depth + 2); - this.trackSortie(this.add.text(cardX + 14, cardY + 294, this.compactText(this.sortieStrategyLine(unit.id), 24), this.textStyle(10, selected ? '#9fc8e8' : '#77818c', true))).setDepth(depth + 2); + if (compactGrid) { + this.renderFirstSortiePortrait(cardX + 50, cardY + 55, 72, unit, depth + 2, selected ? 1 : 0.5); + this.trackSortie(this.add.text(cardX + 96, cardY + 10, `${unit.name} Lv${unit.level}`, this.textStyle(16, selected ? '#f2e3bf' : '#87919c', true))).setDepth(depth + 2); + this.trackSortie(this.add.text(cardX + 96, cardY + 36, `${unit.className} · HP ${unit.hp}/${unit.maxHp}`, this.textStyle(10, selected ? '#d4dce6' : '#77818c', true))).setDepth(depth + 2); + this.drawSortieBar(cardX + 96, cardY + 56, cardWidth - 110, 6, unit.hp / unit.maxHp, selected ? palette.green : 0x53606c, depth + 2); + this.trackSortie(this.add.text(cardX + 96, cardY + 68, this.compactText(recommendationText, 28), this.textStyle(9, recommendation ? '#ffdf7b' : '#9fb0bf', Boolean(recommendation)))).setDepth(depth + 2); + this.trackSortie(this.add.text(cardX + 14, cardY + 98, this.compactText(this.sortieBondLine(unit), 36), this.textStyle(9, selected ? '#a8ffd0' : '#77818c', true))).setDepth(depth + 2); + this.trackSortie(this.add.text(cardX + 14, cardY + 113, this.compactText(this.sortieStrategyLine(unit.id), 34), this.textStyle(9, selected ? '#9fc8e8' : '#77818c', true))).setDepth(depth + 2); + } else { + this.renderFirstSortiePortrait(cardX + cardWidth / 2, cardY + 88, 132, unit, depth + 2, selected ? 1 : 0.5); + this.trackSortie(this.add.text(cardX + 14, cardY + 162, `${unit.name} Lv${unit.level}`, this.textStyle(20, selected ? '#f2e3bf' : '#87919c', true))).setDepth(depth + 2); + this.trackSortie(this.add.text(cardX + 14, cardY + 190, `${unit.className} · HP ${unit.hp}/${unit.maxHp}`, this.textStyle(12, selected ? '#d4dce6' : '#77818c', true))).setDepth(depth + 2); + this.drawSortieBar(cardX + 14, cardY + 211, cardWidth - 28, 7, unit.hp / unit.maxHp, selected ? palette.green : 0x53606c, depth + 2); + this.trackSortie( + this.add.text(cardX + 14, cardY + 230, recommendationText, { + ...this.textStyle(12, recommendation ? '#ffdf7b' : '#9fb0bf', Boolean(recommendation)), + wordWrap: { width: cardWidth - 28, useAdvancedWrap: true }, + lineSpacing: 2 + }) + ).setDepth(depth + 2); + this.trackSortie(this.add.text(cardX + 14, cardY + 278, this.compactText(this.sortieBondLine(unit), 26), this.textStyle(10, selected ? '#a8ffd0' : '#77818c', true))).setDepth(depth + 2); + this.trackSortie(this.add.text(cardX + 14, cardY + 294, this.compactText(this.sortieStrategyLine(unit.id), 24), this.textStyle(10, selected ? '#9fc8e8' : '#77818c', true))).setDepth(depth + 2); + } - this.renderFirstSortieInlineButton( - required ? '필수 출전' : selected ? '출전 확정' : '대기', + const toggleButton = this.renderFirstSortieInlineButton( + required ? '필수 출전' : selected ? '출전 해제' : '출전 등록', cardX + cardWidth - 90, - cardY + 12, + cardY + 10, 78, 30, - false, + !required, selected, - () => undefined, + () => { + this.sortieFocusedUnitId = unit.id; + this.toggleSortieUnit(unit.id); + }, depth + 3 ); + const toggleBounds = toggleButton.getBounds(); + this.sortieRosterToggleBounds.push({ + unitId: unit.id, + x: toggleBounds.x, + y: toggleBounds.y, + width: toggleBounds.width, + height: toggleBounds.height + }); const roles = sortieFormationSlotDefinitions.filter((definition) => definition.role !== 'reserve'); const roleGap = 6; @@ -15050,9 +15153,9 @@ export class CampScene extends Phaser.Scene { this.renderFirstSortieInlineButton( definition.label, cardX + 14 + roleIndex * (roleButtonWidth + roleGap), - cardY + cardHeight - 52, + cardY + cardHeight - (compactGrid ? 38 : 52), roleButtonWidth, - 40, + compactGrid ? 28 : 40, selected, role === definition.role, () => this.assignFirstSortieRole(unit.id, definition.role), @@ -15400,6 +15503,7 @@ export class CampScene extends Phaser.Scene { text.on('pointerout', () => setHovered(false)); text.on('pointerdown', action); } + return bg; } private renderSortieHeaderSummary(x: number, y: number, width: number, height: number, depth: number) { @@ -15512,6 +15616,7 @@ export class CampScene extends Phaser.Scene { depth: number, interaction: 'toggle' | 'focus' = 'toggle' ) { + this.sortieRosterToggleBounds = []; const bg = this.trackSortie(this.add.rectangle(x, y, width, height, 0x0d141c, 0.92)); bg.setOrigin(0); bg.setDepth(depth); @@ -15567,6 +15672,16 @@ export class CampScene extends Phaser.Scene { row.setDepth(depth + 1); row.setStrokeStyle(1, disabled ? 0x53606c : selected ? palette.green : recommendedReserve ? palette.gold : 0x53606c, disabled ? 0.22 : selected ? 0.7 : recommendedReserve ? 0.54 : 0.36); row.setInteractive({ useHandCursor: true }); + const rowBounds = row.getBounds(); + if (interaction === 'toggle') { + this.sortieRosterToggleBounds.push({ + unitId: unit.id, + x: rowBounds.x, + y: rowBounds.y, + width: rowBounds.width, + height: rowBounds.height + }); + } row.on('wheel', handleRosterWheel); row.on('pointerover', () => { this.sortieFocusedUnitId = unit.id; @@ -16168,7 +16283,7 @@ export class CampScene extends Phaser.Scene { this.trackSortie(this.add.text(x + 174, rowY + 2, this.compactText(this.itemBonusText(item), 14), this.textStyle(10, '#9fb0bf', true))).setDepth(depth + 1); this.drawSortieBar(x + 46, rowY + 15, 104, 5, state.exp / equipmentExpToNext(state.level), slot === 'weapon' ? palette.gold : slot === 'armor' ? palette.blue : palette.green, depth + 1); const canSwap = this.equipmentInventoryEntries(slot).some((entry) => entry.item.id !== item.id); - this.renderSortiePanelButton(canSwap ? '교체' : '없음', x + width - 58, rowY, 40, 17, canSwap, false, () => this.swapUnitEquipment(unit.id, slot, undefined, true), depth + 1); + this.renderSortiePanelButton(canSwap ? '비교' : '없음', x + width - 58, rowY, 40, 17, canSwap, false, () => this.swapUnitEquipment(unit.id, slot, undefined, true), depth + 1); }); this.renderSortieSupplyAssignment(unit, x + 18, y + 236, width - 36, depth + 1); @@ -16304,7 +16419,7 @@ export class CampScene extends Phaser.Scene { const iconFrame = this.trackSortie(this.add.rectangle(x + index * spacing, y, frameSize, frameSize, selected && item.rank === 'treasure' ? 0x2c2630 : 0x0a1017, 0.92)); iconFrame.setStrokeStyle(1, item.rank === 'treasure' ? palette.gold : 0x53606c, selected ? 0.62 : 0.34); iconFrame.setDepth(depth); - const icon = this.trackSortie(this.add.image(x + index * spacing, y, `item-${item.id}`)); + const icon = this.trackSortie(this.add.image(x + index * spacing, y, `item-${item.id}-micro`)); icon.setDisplaySize(this.campUiLength(iconSize), this.campUiLength(iconSize)); icon.setAlpha(selected ? 1 : 0.52); icon.setDepth(depth + 1); @@ -19284,6 +19399,34 @@ export class CampScene extends Phaser.Scene { return view; } + private renderSortieNextActionGuide( + x: number, + y: number, + width: number, + height: number, + nextStep: SortiePrepStep | undefined, + depth: number + ) { + const actionLabel = nextStep === 'formation' + ? '무장 편성으로 이동' + : nextStep === 'loadout' + ? '장비·보급 점검으로 이동' + : '필수 조건을 확인하고 출진'; + const bg = this.trackSortie(this.add.rectangle(x, y, width, height, 0x151f2a, 0.94)); + bg.setOrigin(0); + bg.setDepth(depth); + bg.setStrokeStyle(1, palette.gold, 0.58); + const label = this.trackSortie( + this.add.text(x + 14, y + 6, `다음 행동 · ${actionLabel}`, this.textStyle(11, '#ffdf7b', true)) + ); + label.setDepth(depth + 1); + const shortcut = this.trackSortie(this.add.text(x + width - 14, y + 6, 'Enter', this.textStyle(10, '#9fb0bf', true))); + shortcut.setOrigin(1, 0); + shortcut.setDepth(depth + 1); + this.sortieNextActionGuideBackground = bg; + this.sortieNextActionGuideText = label; + } + private startVictoryStory() { const flow = this.currentSortieFlow(); if (flow.nextBattleId && !this.hasCurrentSortieOrderSelection()) { @@ -19396,7 +19539,10 @@ export class CampScene extends Phaser.Scene { this.sortieObjects.forEach((object) => object.destroy()); this.sortieObjects = []; this.sortiePrimaryActionButton = undefined; + this.sortieNextActionGuideBackground = undefined; + this.sortieNextActionGuideText = undefined; this.sortiePortraitRosterLayout = undefined; + this.sortieRosterToggleBounds = []; this.sortieHoveredUnitId = undefined; if (clearPinnedSwap) { this.sortiePinnedSwapCandidateUnitId = undefined; @@ -21749,11 +21895,12 @@ export class CampScene extends Phaser.Scene { this.equipmentPanelBackground = bg; this.equipmentInventoryPreviousButton = undefined; this.equipmentInventoryNextButton = undefined; + this.equipmentInventoryRowViews = []; bg.setOrigin(0); bg.setStrokeStyle(1, palette.gold, 0.58); this.track(this.add.text(x + 24, y + 22, '장비 관리', this.textStyle(24, '#f2e3bf', true))); - this.track(this.add.text(x + 24, y + 58, '선택 장수의 무기·방어구·보조구 효과와 성장도를 비교하고 바로 교체합니다.', this.textStyle(14, '#d4dce6'))); + this.track(this.add.text(x + 24, y + 58, '후보를 고르면 현재 장비와 수치를 비교합니다. 교체 확정 전에는 장부에 반영되지 않습니다.', this.textStyle(14, '#d4dce6'))); this.renderEquipmentUnitSummary(unit, x + 24, y + 86, width - 48, 48); @@ -21770,14 +21917,14 @@ export class CampScene extends Phaser.Scene { this.track(this.add.text( x + 24, y + 266, - `보유 장비 ${inventoryEntries.length}${inventoryPageCount > 1 ? ` · ${this.equipmentInventoryPage + 1}/${inventoryPageCount}` : ''}`, + `보유 장비 전체 ${inventoryEntries.length}종${inventoryPageCount > 1 ? ` · ${this.equipmentInventoryPage + 1}/${inventoryPageCount}쪽` : ''}`, this.textStyle(18, '#f2e3bf', true) )); if (inventoryPageCount > 1) { this.equipmentInventoryPreviousButton = this.renderEquipmentInventoryPageButton( - x + 424, + x + 400, y + 278, - '‹', + '이전', this.equipmentInventoryPage > 0, () => { this.equipmentInventoryPage -= 1; @@ -21787,7 +21934,7 @@ export class CampScene extends Phaser.Scene { this.equipmentInventoryNextButton = this.renderEquipmentInventoryPageButton( x + 462, y + 278, - '›', + '다음', this.equipmentInventoryPage < inventoryPageCount - 1, () => { this.equipmentInventoryPage += 1; @@ -21820,9 +21967,9 @@ export class CampScene extends Phaser.Scene { enabled: boolean, action: () => void ) { - const bg = this.track(this.add.rectangle(x, y, 30, 24, enabled ? 0x1a2630 : 0x111820, enabled ? 0.96 : 0.68)); + const bg = this.track(this.add.rectangle(x, y, 56, 26, enabled ? 0x1a2630 : 0x111820, enabled ? 0.96 : 0.68)); bg.setStrokeStyle(1, enabled ? palette.gold : 0x53606c, enabled ? 0.72 : 0.34); - const text = this.track(this.add.text(x, y - 1, label, this.textStyle(18, enabled ? '#f2e3bf' : '#6f7882', true))); + const text = this.track(this.add.text(x, y - 1, label, this.textStyle(11, enabled ? '#f2e3bf' : '#6f7882', true))); text.setOrigin(0.5); if (!enabled) { return bg; @@ -21876,7 +22023,7 @@ export class CampScene extends Phaser.Scene { const iconFrame = this.track(this.add.rectangle(x + 20, y + height / 2, 28, 28, 0x0a1017, 0.92)); iconFrame.setStrokeStyle(1, entry.item.rank === 'treasure' ? palette.gold : 0x53606c, 0.54); - const icon = this.track(this.add.image(x + 20, y + height / 2, `item-${entry.item.id}`)); + const icon = this.track(this.add.image(x + 20, y + height / 2, `item-${entry.item.id}-micro`)); icon.setDisplaySize(this.campUiLength(22), this.campUiLength(22)); icon.setAlpha(canEquip ? 1 : 0.52); @@ -21887,7 +22034,7 @@ export class CampScene extends Phaser.Scene { const button = this.track(this.add.rectangle(x + width - 34, y + height / 2, 52, 24, canEquip ? 0x1a2630 : 0x121922, canEquip ? 0.96 : 0.62)); button.setStrokeStyle(1, canEquip ? palette.gold : 0x53606c, canEquip ? 0.68 : 0.3); - const buttonText = this.track(this.add.text(x + width - 34, y + height / 2 - 7, canEquip ? '교체' : '착용', this.textStyle(11, canEquip ? '#f2e3bf' : '#87919c', true))); + const buttonText = this.track(this.add.text(x + width - 34, y + height / 2 - 7, canEquip ? '비교' : '착용', this.textStyle(11, canEquip ? '#f2e3bf' : '#87919c', true))); buttonText.setOrigin(0.5, 0); if (canEquip) { @@ -21901,6 +22048,12 @@ export class CampScene extends Phaser.Scene { buttonText.setInteractive({ useHandCursor: true }); buttonText.on('pointerdown', action); } + this.equipmentInventoryRowViews.push({ + itemId: entry.item.id, + canEquip, + background: bg, + actionButton: button + }); } private renderEquipmentEffectSummary(unit: UnitData, x: number, y: number, width: number, height: number) { @@ -21912,7 +22065,7 @@ export class CampScene extends Phaser.Scene { const equipped = equipmentSlots.map((slot) => ({ slot, state: unit.equipment[slot], item: getItem(unit.equipment[slot].itemId) })); equipped.forEach((entry, index) => { const rowY = y + 42 + index * 30; - const icon = this.track(this.add.image(x + 20, rowY + 8, `item-${entry.item.id}`)); + const icon = this.track(this.add.image(x + 20, rowY + 8, `item-${entry.item.id}-micro`)); icon.setDisplaySize(this.campUiLength(20), this.campUiLength(20)); this.track(this.add.text(x + 36, rowY, `${equipmentSlotLabels[entry.slot]} Lv${entry.state.level}`, this.textStyle(11, '#d8b15f', true))); this.track(this.add.text(x + 108, rowY, this.compactText(this.itemBonusText(entry.item), 18), this.textStyle(11, '#d4dce6', true))); @@ -22058,7 +22211,7 @@ export class CampScene extends Phaser.Scene { bg.setStrokeStyle(1, entry.item.rank === 'treasure' ? palette.gold : palette.blue, canEquip ? 0.58 : 0.28); const iconFrame = this.track(this.add.rectangle(x + 18, y + 18, 28, 28, 0x0a1017, 0.92)); iconFrame.setStrokeStyle(1, entry.item.rank === 'treasure' ? palette.gold : 0x53606c, 0.54); - const icon = this.track(this.add.image(x + 18, y + 18, `item-${entry.item.id}`)); + const icon = this.track(this.add.image(x + 18, y + 18, `item-${entry.item.id}-micro`)); icon.setDisplaySize(this.campUiLength(21), this.campUiLength(21)); this.track(this.add.text(x + 38, y + 7, this.compactText(`${entry.item.name} x${entry.amount}`, 8), this.textStyle(11, '#f2e3bf', true))); this.track(this.add.text(x + 38, y + 23, this.compactText(`${equipmentSlotLabels[entry.item.slot]} · ${this.itemBonusText(entry.item)}`, 11), this.textStyle(9, '#9fb0bf', true))); @@ -22253,7 +22406,7 @@ export class CampScene extends Phaser.Scene { const canSwap = alternatives.length > 0; const button = this.track(this.add.rectangle(x + width - 36, y + 16, 54, 22, canSwap ? 0x1a2630 : 0x121922, canSwap ? 0.96 : 0.66)); button.setStrokeStyle(1, canSwap ? palette.gold : 0x53606c, canSwap ? 0.7 : 0.3); - const buttonText = this.track(this.add.text(x + width - 36, y + 16, canSwap ? '교체' : '없음', this.textStyle(10, canSwap ? '#f2e3bf' : '#7f8994', true))); + const buttonText = this.track(this.add.text(x + width - 36, y + 16, canSwap ? '비교' : '없음', this.textStyle(10, canSwap ? '#f2e3bf' : '#7f8994', true))); buttonText.setOrigin(0.5); if (canSwap) { const action = () => this.swapUnitEquipment(unit.id, slot); @@ -22821,12 +22974,219 @@ export class CampScene extends Phaser.Scene { return; } + soundDirector.playSelect(); + this.showEquipmentSwapComparison( + { + unitId, + slot, + candidateItemId: nextItem.id, + returnToSortie + }, + target, + currentItem, + nextItem + ); + } + + private showEquipmentSwapComparison( + request: EquipmentSwapRequest, + target: UnitData, + currentItem: ItemDefinition, + candidateItem: ItemDefinition + ) { + this.hideEquipmentSwapComparison(); + this.pendingEquipmentSwap = request; + + const depth = 92; + const width = 636; + const height = 304; + const x = Math.floor((campLegacyCanvasWidth - width) / 2); + const y = Math.floor((campLegacyCanvasHeight - height) / 2); + const shade = this.trackEquipmentSwapConfirm( + this.add.rectangle(0, 0, campLegacyCanvasWidth, campLegacyCanvasHeight, 0x020406, 0.76) + ); + shade.setOrigin(0); + shade.setDepth(depth); + shade.setInteractive(); + + const panel = this.trackEquipmentSwapConfirm(this.add.rectangle(x, y, width, height, 0x0f1720, 0.99)); + panel.setOrigin(0); + panel.setDepth(depth + 1); + panel.setStrokeStyle(3, palette.gold, 0.94); + panel.setInteractive(); + this.equipmentSwapConfirmPanel = panel; + + const title = this.trackEquipmentSwapConfirm( + this.add.text(x + 26, y + 20, '장비 교체 비교', this.textStyle(24, '#f2e3bf', true)) + ); + title.setDepth(depth + 2); + const guide = this.trackEquipmentSwapConfirm( + this.add.text( + x + 26, + y + 53, + `${target.name} · ${equipmentSlotLabels[request.slot]} · 아래 수치를 확인한 뒤 확정하십시오.`, + this.textStyle(13, '#c8d2dd') + ) + ); + guide.setDepth(depth + 2); + + const cardWidth = 280; + const cardHeight = 116; + const cardY = y + 82; + this.renderEquipmentSwapComparisonCard( + '현재 장비', + currentItem, + target.equipment[request.slot].level, + x + 26, + cardY, + cardWidth, + cardHeight, + depth + 2, + false + ); + this.renderEquipmentSwapComparisonCard( + '교체 후보', + candidateItem, + target.equipment[request.slot].level, + x + width - 26 - cardWidth, + cardY, + cardWidth, + cardHeight, + depth + 2, + true + ); + + const delta = this.trackEquipmentSwapConfirm( + this.add.rectangle(x + 26, y + 212, width - 52, 34, 0x172a22, 0.92) + ); + delta.setOrigin(0); + delta.setDepth(depth + 2); + delta.setStrokeStyle(1, palette.green, 0.54); + const deltaText = this.trackEquipmentSwapConfirm( + this.add.text( + x + 42, + y + 220, + `교체 변화 · ${this.equipmentDeltaText(candidateItem, currentItem)} · 성장도 Lv${target.equipment[request.slot].level} 유지`, + this.textStyle(12, '#a8ffd0', true) + ) + ); + deltaText.setDepth(depth + 3); + + const cancel = this.trackEquipmentSwapConfirm(this.add.rectangle(x + width - 212, y + 274, 116, 36, 0x1a2630, 0.98)); + cancel.setDepth(depth + 2); + cancel.setStrokeStyle(1, palette.blue, 0.82); + const cancelText = this.trackEquipmentSwapConfirm( + this.add.text(x + width - 212, y + 274, '취소', this.textStyle(14, '#d4dce6', true)) + ); + cancelText.setOrigin(0.5); + cancelText.setDepth(depth + 3); + this.equipmentSwapCancelButton = cancel; + + const confirm = this.trackEquipmentSwapConfirm(this.add.rectangle(x + width - 82, y + 274, 128, 36, 0x4a371d, 0.99)); + confirm.setDepth(depth + 2); + confirm.setStrokeStyle(2, palette.gold, 0.96); + const confirmText = this.trackEquipmentSwapConfirm( + this.add.text(x + width - 82, y + 274, '교체 확정', this.textStyle(14, '#fff2b8', true)) + ); + confirmText.setOrigin(0.5); + confirmText.setDepth(depth + 3); + this.equipmentSwapConfirmButton = confirm; + + const setCancelHovered = (hovered: boolean) => cancel.setFillStyle(hovered ? 0x283947 : 0x1a2630, 0.99); + const setConfirmHovered = (hovered: boolean) => confirm.setFillStyle(hovered ? 0x654c25 : 0x4a371d, 0.99); + [cancel, cancelText].forEach((object) => { + object.setInteractive({ useHandCursor: true }); + object.on('pointerover', () => setCancelHovered(true)); + object.on('pointerout', () => setCancelHovered(false)); + object.on('pointerdown', () => { + soundDirector.playSelect(); + this.hideEquipmentSwapComparison(); + }); + }); + [confirm, confirmText].forEach((object) => { + object.setInteractive({ useHandCursor: true }); + object.on('pointerover', () => setConfirmHovered(true)); + object.on('pointerout', () => setConfirmHovered(false)); + object.on('pointerdown', () => this.confirmEquipmentSwap()); + }); + } + + private renderEquipmentSwapComparisonCard( + eyebrow: string, + item: ItemDefinition, + level: number, + x: number, + y: number, + width: number, + height: number, + depth: number, + candidate: boolean + ) { + const bg = this.trackEquipmentSwapConfirm( + this.add.rectangle(x, y, width, height, candidate ? 0x1d2b22 : 0x151f2a, 0.96) + ); + bg.setOrigin(0); + bg.setDepth(depth); + bg.setStrokeStyle(candidate ? 2 : 1, candidate ? palette.gold : palette.blue, candidate ? 0.84 : 0.48); + const iconFrame = this.trackEquipmentSwapConfirm(this.add.rectangle(x + 34, y + 62, 54, 54, 0x090f16, 0.96)); + iconFrame.setDepth(depth + 1); + iconFrame.setStrokeStyle(1, item.rank === 'treasure' ? palette.gold : 0x53606c, 0.7); + const icon = this.trackEquipmentSwapConfirm(this.add.image(x + 34, y + 62, `item-${item.id}`)); + icon.setDisplaySize(this.campUiLength(40), this.campUiLength(40)); + icon.setDepth(depth + 2); + const eyebrowText = this.trackEquipmentSwapConfirm( + this.add.text(x + 14, y + 10, eyebrow, this.textStyle(11, candidate ? '#ffdf7b' : '#9fb0bf', true)) + ); + eyebrowText.setDepth(depth + 1); + const name = this.trackEquipmentSwapConfirm( + this.add.text(x + 66, y + 32, `${item.name} · Lv${level}`, this.textStyle(17, '#f2e3bf', true)) + ); + name.setDepth(depth + 1); + const bonus = this.trackEquipmentSwapConfirm( + this.add.text(x + 66, y + 60, this.itemBonusText(item), this.textStyle(12, candidate ? '#a8ffd0' : '#d4dce6', true)) + ); + bonus.setDepth(depth + 1); + const effect = this.trackEquipmentSwapConfirm( + this.add.text(x + 66, y + 84, this.compactText(item.effects[0] ?? item.description, 28), this.textStyle(10, '#9fb0bf')) + ); + effect.setDepth(depth + 1); + } + + private confirmEquipmentSwap() { + const request = this.pendingEquipmentSwap; + if (!request) { + this.hideEquipmentSwapComparison(); + return; + } + this.hideEquipmentSwapComparison(); + this.applyUnitEquipmentSwap(request); + } + + private applyUnitEquipmentSwap(request: EquipmentSwapRequest) { + const campaign = this.campaign ?? getCampaignState(); + const target = campaign.roster.find((unit) => unit.id === request.unitId); + if (!target) { + this.showCampNotice('장비를 바꿀 장수 장부를 찾지 못했습니다.'); + return; + } + const currentState = target.equipment[request.slot]; + const currentItem = getItem(currentState.itemId); + const nextItem = getItem(request.candidateItemId); + if (nextItem.slot !== request.slot || nextItem.id === currentItem.id) { + this.showCampNotice('장비 상태가 달라졌습니다. 후보를 다시 비교하십시오.'); + return; + } + if ((campaign.inventory[nextItem.name] ?? 0) <= 0) { + this.showCampNotice(`${nextItem.name} 보유량이 없습니다.`); + return; + } + campaign.inventory[nextItem.name] -= 1; if (campaign.inventory[nextItem.name] <= 0) { delete campaign.inventory[nextItem.name]; } campaign.inventory[currentItem.name] = (campaign.inventory[currentItem.name] ?? 0) + 1; - target.equipment[slot] = { + target.equipment[request.slot] = { itemId: nextItem.id, level: currentState.level, exp: currentState.exp @@ -22836,14 +23196,23 @@ export class CampScene extends Phaser.Scene { this.campaign = saveCampaignState(campaign); this.report = this.campaign.firstBattleReport ?? this.report; soundDirector.playSelect(); - this.showCampNotice(`${target.name} ${equipmentSlotLabels[slot]} 교체 · ${currentItem.name} → ${nextItem.name} (${this.itemBonusText(nextItem)})`); - if (returnToSortie || this.sortieObjects.length > 0) { + this.showCampNotice(`${target.name} ${equipmentSlotLabels[request.slot]} 교체 · ${currentItem.name} → ${nextItem.name} (${this.itemBonusText(nextItem)})`); + if (request.returnToSortie || this.sortieObjects.length > 0) { this.showSortiePrep(); } else { this.render(); } } + private hideEquipmentSwapComparison() { + this.equipmentSwapConfirmObjects.forEach((object) => object.destroy()); + this.equipmentSwapConfirmObjects = []; + this.pendingEquipmentSwap = undefined; + this.equipmentSwapConfirmPanel = undefined; + this.equipmentSwapConfirmButton = undefined; + this.equipmentSwapCancelButton = undefined; + } + private nextEquipmentItemForSlot(slot: EquipmentSlot, currentItemId: string) { return this.equipmentInventoryEntries(slot).find((entry) => entry.item.id !== currentItemId)?.item; } @@ -22944,6 +23313,12 @@ export class CampScene extends Phaser.Scene { return object; } + private trackEquipmentSwapConfirm(object: T) { + this.scaleLegacyCampUi(object); + this.equipmentSwapConfirmObjects.push(object); + return object; + } + private track(object: T) { this.scaleLegacyCampUi(object); this.contentObjects.push(object); @@ -22965,6 +23340,7 @@ export class CampScene extends Phaser.Scene { this.equipmentInventoryPreviousButton = undefined; this.equipmentInventoryNextButton = undefined; this.equipmentInventoryVisibleItemIds = []; + this.equipmentInventoryRowViews = []; this.suppliesPanelBackground = undefined; this.suppliesEquipmentBoxes = []; this.suppliesTrophyBoxes = []; @@ -23089,6 +23465,15 @@ export class CampScene extends Phaser.Scene { ); const equipmentInventory = this.equipmentInventoryEntries(); const equipmentInventoryPageCount = Math.max(1, Math.ceil(equipmentInventory.length / equipmentInventoryPageSize)); + const equipmentSwapTarget = this.pendingEquipmentSwap + ? this.campaign?.roster.find((unit) => unit.id === this.pendingEquipmentSwap?.unitId) + : undefined; + const equipmentSwapCurrentItem = equipmentSwapTarget && this.pendingEquipmentSwap + ? getItem(equipmentSwapTarget.equipment[this.pendingEquipmentSwap.slot].itemId) + : undefined; + const equipmentSwapCandidateItem = this.pendingEquipmentSwap + ? getItem(this.pendingEquipmentSwap.candidateItemId) + : undefined; return { scene: this.scene.key, victoryRewardAcknowledgement: { @@ -23107,6 +23492,24 @@ export class CampScene extends Phaser.Scene { interactive: Boolean(action.button.input?.enabled) })) }, + equipmentSwapComparison: this.pendingEquipmentSwap && equipmentSwapTarget && equipmentSwapCurrentItem && equipmentSwapCandidateItem + ? { + visible: Boolean(this.equipmentSwapConfirmPanel?.active), + unitId: equipmentSwapTarget.id, + unitName: equipmentSwapTarget.name, + slot: this.pendingEquipmentSwap.slot, + currentItemId: equipmentSwapCurrentItem.id, + currentItemName: equipmentSwapCurrentItem.name, + candidateItemId: equipmentSwapCandidateItem.id, + candidateItemName: equipmentSwapCandidateItem.name, + deltaText: this.equipmentDeltaText(equipmentSwapCandidateItem, equipmentSwapCurrentItem), + panelBounds: this.sortieObjectBoundsDebug(this.equipmentSwapConfirmPanel), + confirmButtonBounds: this.sortieObjectBoundsDebug(this.equipmentSwapConfirmButton), + cancelButtonBounds: this.sortieObjectBoundsDebug(this.equipmentSwapCancelButton), + confirmInteractive: Boolean(this.equipmentSwapConfirmButton?.input?.enabled), + cancelInteractive: Boolean(this.equipmentSwapCancelButton?.input?.enabled) + } + : null, activeTab: this.activeTab, campTabs: this.tabButtons.map((button) => ({ id: button.tab, @@ -23501,6 +23904,12 @@ export class CampScene extends Phaser.Scene { lineWidth: this.sortiePrimaryActionButton.background.lineWidth } : null, + sortieNextActionGuide: this.sortieNextActionGuideBackground?.active + ? { + label: this.sortieNextActionGuideText?.text ?? '', + bounds: this.sortieObjectBoundsDebug(this.sortieNextActionGuideBackground) + } + : null, sortiePortraitRosterView: { enabled: stagedSortiePrep && !this.isFirstSortiePrepSlice(), total: portraitRosterUnits.length, @@ -23521,8 +23930,12 @@ export class CampScene extends Phaser.Scene { nextButtonBounds: this.sortiePortraitRosterLayout ? { ...this.sortiePortraitRosterLayout.nextButtonBounds } : null, - cardBounds: this.sortiePortraitRosterLayout?.cardBounds.map((bounds) => ({ ...bounds })) ?? [] + cardBounds: this.sortiePortraitRosterLayout?.cardBounds.map(({ toggleButtonBounds, ...bounds }) => ({ + ...bounds, + toggleButtonBounds: { ...toggleButtonBounds } + })) ?? [] }, + sortieRosterToggleBounds: this.sortieRosterToggleBounds.map((bounds) => ({ ...bounds })), sortiePortraitRoster: portraitRosterUnits.map((unit) => { const availability = this.sortieUnitAvailability(unit); const portraitKey = campaignPortraitKeysByUnitId[unit.id]; @@ -23605,6 +24018,7 @@ export class CampScene extends Phaser.Scene { selectedUnitId: this.selectedUnitId, selectedDialogueId: this.selectedDialogueId, selectedVisitId: this.selectedVisitId, + sortieSelectionLimit: this.sortieMaxUnits(), selectedSortieUnitIds: [...this.selectedSortieUnitIds], sortieFormationAssignments: { ...this.sortieFormationAssignments }, reserveTrainingAssignments: { ...reserveTrainingAssignments }, @@ -23825,7 +24239,14 @@ export class CampScene extends Phaser.Scene { previousButtonBounds: this.sortieObjectBoundsDebug(this.equipmentInventoryPreviousButton), nextButtonBounds: this.sortieObjectBoundsDebug(this.equipmentInventoryNextButton), previousEnabled: Boolean(this.equipmentInventoryPreviousButton?.input?.enabled), - nextEnabled: Boolean(this.equipmentInventoryNextButton?.input?.enabled) + nextEnabled: Boolean(this.equipmentInventoryNextButton?.input?.enabled), + rows: this.equipmentInventoryRowViews.map((row) => ({ + itemId: row.itemId, + canEquip: row.canEquip, + bounds: this.sortieObjectBoundsDebug(row.background), + actionButtonBounds: this.sortieObjectBoundsDebug(row.actionButton), + interactive: Boolean(row.background.input?.enabled && row.actionButton.input?.enabled) + })) } }, equipmentInventory: equipmentInventory.map((entry) => ({ @@ -23987,7 +24408,11 @@ export class CampScene extends Phaser.Scene { id: unit.id, name: unit.name, hp: unit.hp, - maxHp: unit.maxHp + maxHp: unit.maxHp, + equipment: equipmentSlots.reduce((equipment, slot) => { + equipment[slot] = { ...unit.equipment[slot] }; + return equipment; + }, {} as UnitData['equipment']) })), completedCampDialogues: this.campaign.completedCampDialogues, completedCampVisits: this.campaign.completedCampVisits diff --git a/src/game/scenes/StoryScene.ts b/src/game/scenes/StoryScene.ts index 524639e..da555b9 100644 --- a/src/game/scenes/StoryScene.ts +++ b/src/game/scenes/StoryScene.ts @@ -8,7 +8,7 @@ import { type BattleUiIconKey } from '../data/battleUiIcons'; import { portraitAssetEntriesForKey, portraitKeyForSpeaker, type PortraitAssetEntry } from '../data/portraitAssets'; -import { storyBackgroundAssets, storyBackgroundKeysFor } from '../data/storyAssets'; +import { storyBackgroundAssets, storyBackgroundKeyForPage } from '../data/storyAssets'; import { ensureUnitAnimations, loadUnitBaseSheets, @@ -701,12 +701,8 @@ export class StoryScene extends Phaser.Scene { } private pageBackgroundAssetKey(page: StoryPage) { - const keys = storyBackgroundKeysFor(page.background).filter((key) => Boolean(storyBackgroundAssets[key])); - if (keys.length <= 1) { - return keys[0] ?? page.background; - } - - return keys[this.hashString(page.id) % keys.length]; + const key = storyBackgroundKeyForPage(page.background, page.id); + return storyBackgroundAssets[key] ? key : page.background; } private applyBackground(page: StoryPage) { diff --git a/src/game/state/campaignState.ts b/src/game/state/campaignState.ts index 67958b2..82d8874 100644 --- a/src/game/state/campaignState.ts +++ b/src/game/state/campaignState.ts @@ -1,7 +1,13 @@ import { equipmentExpToNext, equipmentSlots, itemCatalog, type EquipmentSlot } from '../data/battleItems'; import { unitClasses } from '../data/battleRules'; import { battleScenarios, type BattleScenarioId } from '../data/battles'; -import { campaignRecruitUnits, type BattleBond, type UnitData } from '../data/scenario'; +import { + campaignRecruitUnits, + jianYongRecruitBond, + jianYongRecruitUnit, + type BattleBond, + type UnitData +} from '../data/scenario'; import { normalizeSortieFormationAssignments, type SortieFormationAssignments } from '../data/sortieDeployment'; import { coreSortieResonanceMinLevel } from '../data/sortieSynergy'; import { @@ -438,6 +444,10 @@ export type CampaignSortieOrderHistory = Partial< Record>> >; +export const campaignTutorialIds = ['first-battle-basic-controls'] as const; +export type CampaignTutorialId = (typeof campaignTutorialIds)[number]; +const campaignTutorialIdSet = new Set(campaignTutorialIds); + export type CampaignState = { version: 1; updatedAt: string; @@ -458,6 +468,7 @@ export type CampaignState = { claimedSortieOrderRewardIds: string[]; reserveTrainingFocus: CampaignReserveTrainingFocusId; reserveTrainingAssignments: CampaignReserveTrainingAssignments; + completedTutorialIds: CampaignTutorialId[]; completedCampDialogues: string[]; completedCampVisits: string[]; acknowledgedVictoryRewardBattleIds: string[]; @@ -663,6 +674,7 @@ export function createInitialCampaignState(): CampaignState { claimedSortieOrderRewardIds: [], reserveTrainingFocus: defaultCampaignReserveTrainingFocusId, reserveTrainingAssignments: {}, + completedTutorialIds: [], completedCampDialogues: [], completedCampVisits: [], acknowledgedVictoryRewardBattleIds: [], @@ -749,6 +761,20 @@ export function markCampaignStep(step: CampaignStep) { return cloneCampaignState(state); } +export function hasCompletedCampaignTutorial(id: CampaignTutorialId, state = ensureCampaignState()) { + return state.completedTutorialIds.includes(id); +} + +export function completeCampaignTutorial(id: CampaignTutorialId) { + const state = ensureCampaignState(); + if (!state.completedTutorialIds.includes(id)) { + state.completedTutorialIds.push(id); + state.updatedAt = new Date().toISOString(); + persistCampaignState(state); + } + return cloneCampaignState(state); +} + export function setCampaignSortieOrderSelection(battleId: BattleScenarioId, orderId?: CampaignSortiePresetId) { const state = ensureCampaignState(); if (!(battleId in battleScenarios)) { @@ -957,6 +983,7 @@ export function setFirstBattleReport(report: FirstBattleReport) { .filter((unit): unit is UnitData => Boolean(unit)); state.roster = mergeRosterProgress(state.roster, [...alliedReportUnits, ...recruitedUnits]); state.bonds = reportClone.bonds.map(cloneBondSnapshot); + backfillEarlyJianYongRecruit(state); const reserveTraining = previousSettlement || reportClone.outcome !== 'victory' ? previousSettlement?.reserveTraining ?? [] : applyReserveTrainingProgress( @@ -1207,6 +1234,89 @@ export function ensureCampaignRosterUnits(units: UnitData[], bonds: BattleBond[] return cloneCampaignState(state); } +const earlyJianYongRecruitBattleId: BattleScenarioId = 'first-battle-zhuo-commandery'; + +/** + * 첫 승리 보상에 간옹이 추가되기 전에 만들어진 저장도 동일한 4인 편성 풀을 갖게 한다. + * 반복 호출해도 roster, reward, report, bond에 중복 항목을 만들지 않는다. + */ +function backfillEarlyJianYongRecruit(state: CampaignState) { + const firstSettlement = state.battleHistory[earlyJianYongRecruitBattleId]; + const firstReport = state.firstBattleReport?.battleId === earlyJianYongRecruitBattleId + ? state.firstBattleReport + : undefined; + const firstVictoryRecorded = firstSettlement?.outcome === 'victory' || firstReport?.outcome === 'victory'; + + if (!firstVictoryRecorded) { + return; + } + + if (!state.roster.some((unit) => unit.id === jianYongRecruitUnit.id)) { + state.roster.push(cloneUnit(jianYongRecruitUnit)); + } + if (!state.bonds.some((bond) => bond.id === jianYongRecruitBond.id)) { + // 전역 인연 진행도는 전투 보고서의 96개 스냅샷 제한 대상이 아니다. + // 후반 저장을 마이그레이션할 때 기존 인연을 지우지 않고 누락 항목만 추가한다. + state.bonds.push(createCampBondSnapshot(jianYongRecruitBond)); + } + + if (firstReport?.outcome === 'victory') { + if (!firstReport.bonds.some((bond) => bond.id === jianYongRecruitBond.id)) { + appendRequiredLimitedMigrationEntry(firstReport.bonds, createCampBondSnapshot(jianYongRecruitBond), maxCampaignBattleBondEntries); + } + firstReport.campaignRewards = backfillEarlyJianYongReward(firstReport.campaignRewards); + } + + if (firstSettlement?.outcome === 'victory') { + if (!firstSettlement.bonds.some((bond) => bond.id === jianYongRecruitBond.id)) { + appendRequiredLimitedMigrationEntry(firstSettlement.bonds, { + id: jianYongRecruitBond.id, + title: jianYongRecruitBond.title, + level: jianYongRecruitBond.level, + exp: jianYongRecruitBond.exp, + battleExp: 0 + }, maxCampaignBattleBondEntries); + } + firstSettlement.campaignRewards = backfillEarlyJianYongReward(firstSettlement.campaignRewards); + } +} + +function backfillEarlyJianYongReward(existing?: CampaignRewardSnapshot): CampaignRewardSnapshot { + const rewardDefinition = battleScenarios[earlyJianYongRecruitBattleId].campaignReward; + const snapshot = cloneCampaignRewardSnapshot(existing, earlyJianYongRecruitBattleId) ?? { + supplies: [...(rewardDefinition?.supplies ?? [])], + equipment: [...(rewardDefinition?.equipment ?? [])], + reputation: [...(rewardDefinition?.reputation ?? [])], + recruits: (rewardDefinition?.recruits ?? []).map((unitId) => ({ + unitId, + name: campaignRecruitUnitById.get(unitId)?.name ?? battleScenarios[earlyJianYongRecruitBattleId].units.find((unit) => unit.id === unitId)?.name ?? unitId + })), + unlocks: rewardDefinition?.unlockBattleId + ? [{ + battleId: rewardDefinition.unlockBattleId, + title: rewardDefinition.unlockLabel ?? battleScenarios[rewardDefinition.unlockBattleId].title + }] + : [], + ...(rewardDefinition?.note ? { note: rewardDefinition.note } : {}) + }; + + if (!snapshot.recruits.some((recruit) => recruit.unitId === jianYongRecruitUnit.id)) { + appendRequiredLimitedMigrationEntry( + snapshot.recruits, + { unitId: jianYongRecruitUnit.id, name: jianYongRecruitUnit.name }, + maxCampaignStringListEntries + ); + } + return snapshot; +} + +function appendRequiredLimitedMigrationEntry(entries: T[], entry: T, limit: number) { + if (entries.length >= limit) { + entries.splice(limit - 1); + } + entries.push(entry); +} + function ensureCampaignState() { if (!campaignState) { campaignState = readStoredCampaignState() ?? createInitialCampaignState(); @@ -1232,9 +1342,14 @@ function normalizeCampaignState(state: Partial): CampaignState { .filter((bond): bond is CampBondSnapshot => Boolean(bond)); normalized.completedCampDialogues = uniqueStrings(normalized.completedCampDialogues); normalized.completedCampVisits = uniqueStrings(normalized.completedCampVisits); + normalized.completedTutorialIds = uniqueStrings(normalized.completedTutorialIds) + .filter((id): id is CampaignTutorialId => campaignTutorialIdSet.has(id)); normalized.acknowledgedVictoryRewardBattleIds = uniqueStrings(normalized.acknowledgedVictoryRewardBattleIds) .filter((battleId) => battleId in battleScenarios); normalized.inventory = normalizeInventory(normalized.inventory); + normalized.battleHistory = normalizeBattleHistory(normalized.battleHistory); + normalized.firstBattleReport = normalizeFirstBattleReport(normalized.firstBattleReport); + backfillEarlyJianYongRecruit(normalized); const rosterUnitIds = normalizedRosterUnitIds(normalized.roster); const sortieUnitFilter = rosterUnitIds.size > 0 ? rosterUnitIds : undefined; if (sortieUnitFilter) { @@ -1279,7 +1394,6 @@ function normalizeCampaignState(state: Partial): CampaignState { ); normalized.reserveTrainingFocus = normalizeReserveTrainingFocusId(normalized.reserveTrainingFocus); normalized.reserveTrainingAssignments = normalizeCampaignReserveTrainingAssignments(recordOrEmpty(normalized.reserveTrainingAssignments), sortieUnitFilter); - normalized.battleHistory = normalizeBattleHistory(normalized.battleHistory); if (sortieUnitFilter) { normalized.battleHistory = filterBattleHistoryRosterReferences( normalized.battleHistory, @@ -1288,7 +1402,6 @@ function normalizeCampaignState(state: Partial): CampaignState { ); } normalized.latestBattleId = normalizeLatestBattleId(normalized.latestBattleId, normalized.battleHistory); - normalized.firstBattleReport = normalizeFirstBattleReport(normalized.firstBattleReport); if (normalized.firstBattleReport && sortieUnitFilter) { normalized.firstBattleReport = filterFirstBattleReportRosterReferences(normalized.firstBattleReport, sortieUnitFilter); }